Compare commits

...
77 Commits
Author SHA1 Message Date
Florian Sylvain c050db78d2 docs: release notes for v3.7.0-beta 2026-10-30 20:20:51 +01:00
Florian Sylvain 10a66cd979 feat: change email, export my data, delete account (with data anonymization) 2026-10-30 20:19:07 +01:00
Florian Sylvain bb191c389a chore: gdpr features design groundwork 2026-10-29 23:24:15 +01:00
Florian Sylvain c6d49db95e chore: updated reference design to app changes and fixes 2026-10-29 18:07:06 +01:00
Florian Sylvain 8cdd70bd81 fix: french buttons labels overflowing 2026-10-29 17:16:19 +01:00
Florian Sylvain b40a792d02 fix: hide date separators when date picker field idle, to prevent overlapping labels 2026-10-29 17:14:02 +01:00
Florian Sylvain 7a66318a73 fix: sticky footer on role editor 2026-10-29 17:11:40 +01:00
Florian Sylvain 89da2f6b14 fix: "used in" media badge color when selected readabiltiy issue 2026-10-29 17:09:17 +01:00
Florian Sylvain fda2793888 fix: add details to media panel when image is used in author or site 2026-10-29 17:05:25 +01:00
Florian Sylvain 63c0aec444 fix: visual issues (footer floating, table elements overflowing each others) 2026-10-29 16:57:39 +01:00
Florian Sylvain b39a41be88 refac: DELETE /v1/user/{id}?expected_role_id=N, user.remove audit event, 409 target_role_not_found, GetAll returns its error, Own-scope visibility filter moved into repos, JSON tags removed from use-case types 2026-10-29 16:28:25 +01:00
Florian Sylvain 5d6f378fce refac: stale test entries, failing web tests, Postgre backup bug, Hardcoded MySQL table count, Stale Postgre connection in test, own-scope visibility filter, GetAll ignoring its error, JSON tags in use-case types, 31 copies of DESKTOP_QUERY, ProfileView endpoint paths 2026-10-28 17:54:10 +01:00
Florian Sylvain efecdee200 docs: release notes for v3.6.0-beta 2026-10-28 00:13:30 +01:00
Florian Sylvain 34dffe222f feat: roles SPA Users, Profile, API keys, Activity 2026-10-28 00:10:15 +01:00
Florian Sylvain 3db490a615 feat: SPA Roles screens 2026-10-27 23:05:04 +01:00
Florian Sylvain 907949aa12 feat: roles SPA foundations 2026-10-27 22:21:12 +01:00
Florian Sylvain 80f7037d3a feat: Users API, ownership transfer and removal of the legacy role string 2026-10-27 03:08:15 +01:00
Florian Sylvain 094df63a7f feat: /v1/role list, get, create, update and delete. Reassign members on delete. Write-time validation (ValidateGrants, duplicates, typed keys against real content types). Name uniqueness (role_name_taken), role_locked, role_has_members, role_conflict on version mismatch. No-escalation: nobody grants a permission they don't hold, and roles.edit_owner_only guards the Owner role. Activity events for role changes. Typed-key exposure in /auth/me. Cleanup of typed rows when a content type is deleted. | Backend only. Will carry the deferred ListGrants decision. 2026-10-27 00:45:24 +01:00
Florian Sylvain a043f7f41a refac: debt on role backend 2026-10-26 23:34:35 +01:00
Florian Sylvain de142f6c5f feat: more role backend and admin/writer roles additions and adjustments 2026-10-26 18:15:26 +01:00
Florian Sylvain 8c6c6456da feat: roles system backend 2026-10-26 05:02:49 +01:00
Florian Sylvain 44cca46d5e chore: roles management groundwork 2026-10-26 02:53:33 +01:00
Florian Sylvain 7c0ee21906 feat: updated integration page with missing API routes 2026-10-26 02:18:35 +01:00
Florian Sylvain 25d9c30b8a feat: integration search bar 2026-10-25 21:57:43 +01:00
Florian Sylvain a8fea8d900 fix: changed "Jump to" so it scrolls only the page's own scroll container 2026-10-25 20:26:52 +01:00
Florian Sylvain a0fdf03be0 docs: release notes for v3.5.3-beta 2026-10-23 01:57:29 +02:00
Florian Sylvain 9ab98fd50c fix: response schema is now inferred from each endpoint's sample JSON 2026-10-23 01:56:35 +02:00
Florian Sylvain d2af39da61 docs: release notes for v3.5.2-beta 2026-10-23 01:31:51 +02:00
Florian Sylvain 4cff43fefb feat: llms.txt and llms-full.txt, openapi.json, MCP server 2026-10-23 01:29:59 +02:00
Florian Sylvain daba3c04c0 fix: relation picker page size refused by entry list 2026-10-23 00:48:10 +02:00
Florian Sylvain f2cdbcd3cb docs: release notes for v3.5.0-beta 2026-10-23 00:38:22 +02:00
Florian Sylvain 37a77c9c22 feat: relation field and picker 2026-10-23 00:13:42 +02:00
Florian Sylvain b45c99be3b fix: reduced SPA build chunk sizes 2026-10-22 10:15:32 +02:00
Florian Sylvain fc51ab8c10 fix: editor's right side panel vertical overflow 2026-10-22 03:04:25 +02:00
Florian Sylvain 07f5c5d1ee docs: release notes for v3.4.2-beta 2026-10-22 00:50:33 +02:00
Florian Sylvain ea98c5ec05 fix: security & privacy (rate limiter fix, csrf session cookie, logout on server, drafts stale token passw, login timing, password over 72b, JWT_SECRET complexity, SMTP TLS, CSV export neutralised, fonts bundled, upload metadata, hourly cleanup job, privacy.md report) 2026-10-22 00:49:19 +02:00
Florian Sylvain e2812ee0b9 docs: release notes for v3.4.1-beta 2026-10-21 23:31:01 +02:00
Florian Sylvain aa5d3582f8 fix: updated go dependencies with latest security fixes 2026-10-21 23:28:41 +02:00
Florian Sylvain c68e9a7858 refac: removed 7 direct and 12 indirect go modules for custom code instead 2026-10-21 23:13:46 +02:00
Florian Sylvain cfcf108488 fix: web dependencies security updates 2026-10-21 22:55:37 +02:00
Florian Sylvain b43cc6861f docs: removed coding standards from version control 2026-10-21 22:48:31 +02:00
Florian Sylvain 074944c99a refac: atomic merge, lost-update race, RunPurge cost, duplicated walks 2026-10-21 22:47:46 +02:00
Florian Sylvain 0fc3511e2b feat(SPA): media library indications for custom medial field type usage 2026-10-21 16:17:09 +02:00
Florian Sylvain 3e7fc9a4a5 feat(SPA): media field type 2026-10-21 15:17:15 +02:00
Florian Sylvain a2d596f4ca feat(API): media field type 2026-10-21 05:07:36 +02:00
Florian Sylvain ad5faf3d19 docs: release notes for v3.4.0-beta 2026-10-21 01:46:15 +02:00
Florian Sylvain 89051b3f1f refac: duplicates, crap, dry etc. on last features commits 2026-10-21 00:52:14 +02:00
Florian Sylvain fba20f83c5 feat: entry editor at /content/:key/new and /content/:key/:id 2026-10-21 00:12:34 +02:00
Florian Sylvain 91d916fb74 feat: entry list at /content/:key 2026-10-20 21:05:03 +02:00
Florian Sylvain b4cbcb0395 refac: split useSchemaBuilder & SchemaDeleteControls 2026-10-20 18:12:29 +02:00
Florian Sylvain 5404291af2 feat: delete a custom type 2026-10-20 17:56:44 +02:00
Florian Sylvain 76e9e1220c fix: accessibility issues for builder's design 2026-10-20 05:11:14 +02:00
Florian Sylvain 8e344b2500 feat: edit an existing custom type 2026-10-20 04:42:50 +02:00
Florian Sylvain 083cc9e23e feat: schema builder's create flow 2026-10-20 00:29:01 +02:00
Florian Sylvain f514146fed feat: built-in type read-only view,s chema list, states, spec files 2026-10-19 22:31:13 +02:00
Florian Sylvain 3c67cd2314 feat(SPA): schema list and the sidebar Content group 2026-10-19 19:34:45 +02:00
Florian Sylvain 73a75d5cb9 feat: custom content type icons, built-in types, user lookup 2026-10-19 18:22:55 +02:00
Florian Sylvain e6e088c963 chore: custom content types design groundwork 2026-10-19 17:38:41 +02:00
Florian Sylvain 58d58f5a04 feat: custom content types backend 2026-10-19 17:01:37 +02:00
Florian Sylvain ed67b7e70d docs: release notes for v3.3.0-beta 2026-10-19 04:10:29 +02:00
Florian Sylvain 23e88190d9 fix: route every toggle switch in the SPA through one new component, for one correct geometry 2026-10-19 04:06:34 +02:00
Florian Sylvain 340a0ad461 feat: pagination in the SPA for the Sites and Authors lists 2026-10-19 04:00:47 +02:00
Florian Sylvain 500ad59458 fix: authors profile pictute all accross the SPA 2026-10-19 00:46:16 +02:00
Florian Sylvain 18600ab49d chore: fix pagination for authors and site 2026-10-19 00:30:19 +02:00
Florian Sylvain daf3594316 fix: technical debts left by the Sites, Authors and SEO slices, plus backend support for the new Sites and Authors pagination 2026-10-19 00:25:31 +02:00
Florian Sylvain 9f4ce426a8 chore: groundwork for SEO fixes and tech&vis debt 2026-10-18 17:30:12 +02:00
Florian Sylvain 02fb8751fd feat: SEO SPA Sites, Articles and Integration (4/4) 2026-10-18 15:51:28 +02:00
Florian Sylvain cb737b7ae3 fix: SEO SPA editor last fixes (3/4) 2026-10-16 16:31:17 +02:00
Florian Sylvain 470584400f feat: SEO SPA editor integration 2026-10-16 03:06:57 +02:00
Florian Sylvain eb9876fde3 feat: SEO backend output (2/4) 2026-10-15 21:48:55 +02:00
Florian Sylvain 0eb46b0d74 feat: SEO backend data (1/4) 2026-10-15 19:30:29 +02:00
Florian Sylvain cb1410f0b0 chore: grounwork for SEO features 2026-10-15 15:48:57 +02:00
Florian Sylvain 6b8990c55e feat: sites and authors integration into existing screens (& formated all frontend files kinda accidentally) 2026-10-15 05:25:44 +02:00
Florian Sylvain 3d81d2c717 feat(SPA): screens for Sites and Authors 2026-10-14 23:00:50 +02:00
Florian Sylvain b34a173d83 feat: Authors feature backend (and upgraded deprecated mods) 2026-10-14 17:45:00 +02:00
Florian Sylvain a11338094e feat: Sites feature backend 2026-10-14 03:56:50 +02:00
Florian Sylvain 45fcbd890d chore: groundwork for sites & authors 2026-10-14 00:33:52 +02:00
1508 changed files with 182636 additions and 5966 deletions

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ CORS_ALLOWED_ORIGINS=
AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
AUTH_RATE_LIMIT_WINDOW_SECONDS=60
TRUSTED_PROXIES=
SMTP_ALLOW_INSECURE=false
UPDATE_FEED_URL=https://git.floriansylvain.fr
RENEWCMS_CONTAINER=false
IMAGE_VARIANT_MAX_SOURCE_BYTES=10485760
+5
View File
@@ -28,3 +28,8 @@ CLAUDE.md
.agents/
AGENTS.md
codex-workflow.md
CODING_STANDARDS.md
# Generated LLM docs
internal/infrastructure/llmdocs/generated/*
!internal/infrastructure/llmdocs/generated/.gitkeep
-100
View File
@@ -1,100 +0,0 @@
# Coding Standards
These rules apply to every file in this repo , for humans and agents alike.
## General rules
1. Follow standard conventions.
2. Keep it simple stupid. Simpler is always better. Reduce complexity as much as possible.
3. Boy scout rule. Leave the campground cleaner than you found it.
4. Always find root cause. Always look for the root cause of a problem.
## Design rules
1. Keep configurable data at high levels.
2. Prefer polymorphism to if/else or switch/case.
3. Separate multi-threading code.
4. Prevent over-configurability.
5. Use dependency injection.
6. Follow Law of Demeter. A class should know only its direct dependencies.
## Understandability tips
1. Be consistent. If you do something a certain way, do all similar things in the same way.
2. Use explanatory variables.
3. Encapsulate boundary conditions. Boundary conditions are hard to keep track of. Put the processing for them in one place.
4. Prefer dedicated value objects to primitive types.
5. Avoid logical dependency. Don't write methods which work correctly only depending on something else in the same class.
6. Avoid negative conditionals.
## Names rules
1. Choose descriptive and unambiguous names.
2. Make meaningful distinctions.
3. Use pronounceable names.
4. Use searchable names.
5. Replace magic numbers with named constants.
6. Avoid encodings. Don't append prefixes or type information.
## Functions rules
1. Small.
2. Do one thing.
3. Use descriptive names.
4. Prefer fewer arguments.
5. Have no side effects.
6. Don't use flag arguments. Split the method into several independent methods that can be called from the client without the flag.
## Comments rules
1. No comments in the codebase. This covers Go, TypeScript/Vue, HTML templates and config files (YAML, TOML, `.env.example`, `.gitattributes`, …).
2. Explain yourself in code instead: descriptive names, small functions, named constants, explanatory variables.
3. The only exceptions are tool directives the code needs to work, such as `//go:build` and `//go:embed`.
4. Don't comment out code; just remove it.
5. Prose belongs in `docs/` and `README.md`, not in source files.
## Source code structure
1. Separate concepts vertically.
2. Related code should appear vertically dense.
3. Declare variables close to their usage.
4. Dependent functions should be close.
5. Similar functions should be close.
6. Place functions in the downward direction (caller above callee).
7. Keep lines short.
8. Don't use horizontal alignment.
9. Use white space to associate related things and disassociate weakly related ones.
10. Don't break indentation.
## Objects and data structures
1. Hide internal structure.
2. Prefer data structures when there's no behavior to attach.
3. Avoid hybrid structures (half object, half data).
4. Keep classes small.
5. Each class should do one thing.
6. Keep the number of instance variables small.
7. Base classes should know nothing about their derivatives.
8. Prefer many small functions over one function with a parameter that selects behavior.
9. Prefer non-static methods to static methods.
## Tests
1. One assert per test.
2. Readable.
3. Fast.
4. Independent.
5. Repeatable.
## Code smells to watch for
1. **Rigidity** — the software is difficult to change; a small change causes a cascade of subsequent changes.
2. **Fragility** — the software breaks in many places due to a single change.
3. **Immobility** — you cannot reuse parts of the code in other projects because of the risk and effort involved.
4. **Needless complexity**.
5. **Needless repetition**.
6. **Opacity** — the code is hard to understand.
## Credits
Thanks to [@wojtekluwojteklu/clean_code.md](https://gist.github.com/wojteklu/73c6914cc446146b8b533c0988cf8d29)
+6 -1
View File
@@ -4,8 +4,12 @@ COPY web/package.json web/bun.lock ./
RUN bun install --frozen-lockfile
COPY web/ ./
RUN bun run build-only
WORKDIR /src
COPY docs/ ./docs/
COPY scripts/generate-llms.ts ./scripts/generate-llms.ts
RUN bun scripts/generate-llms.ts
FROM golang:1.27.1-alpine AS server
FROM golang:1.27.3-alpine AS server
ARG VERSION=dev
ENV CGO_ENABLED=0
WORKDIR /src
@@ -13,6 +17,7 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=web /src/internal/infrastructure/assets/dist ./internal/infrastructure/assets/dist
COPY --from=web /src/internal/infrastructure/llmdocs/generated ./internal/infrastructure/llmdocs/generated
RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/renewcms ./cmd/server
FROM alpine:3.22
+3
View File
@@ -47,9 +47,12 @@ task dev # API with hot reload + Vite HMR → http://localhost:51
| [docs/deployment.md](docs/deployment.md) | Building release binaries, running as a service, HTTPS, backups, upgrades |
| [docs/docker.md](docs/docker.md) | Running RenewCMS in Docker: image, Compose, data volume, backups, reverse proxy, tags, upgrades, publishing |
| [docs/configuration.md](docs/configuration.md) | Every environment variable |
| [docs/privacy.md](docs/privacy.md) | Personal data stored, retention, cookies, outbound connections and GDPR requests |
| [docs/architecture.md](docs/architecture.md) | Backend layering and how the SPA is embedded |
| [docs/performance.md](docs/performance.md) | Measured costs, such as the `?seo=` article filter |
| [docs/api.md](docs/api.md) | API reference: authentication, API keys, routes and the activity log |
| [docs/api/articles.md](docs/api/articles.md) | Reading articles: pagination, search, `format` and body schemas |
| [docs/custom-content-types.md](docs/custom-content-types.md) | Custom content types: layering, phase 1 limitations and their fix path |
| [docs/image-variants.md](docs/image-variants.md) | Image variants and their cache |
| [docs/release-notes.md](docs/release-notes.md) | Release notes format, the published releases and how to publish one |
+18 -2
View File
@@ -3,6 +3,7 @@ version: '3'
vars:
BINARY: renewcms
DIST_DIR: internal/infrastructure/assets/dist
LLMS_DIR: internal/infrastructure/llmdocs/generated
VERSION:
sh: git describe --tags --always --dirty
LDFLAGS: -s -w -X main.version={{.VERSION}}
@@ -32,6 +33,7 @@ tasks:
dev:api:
desc: Run the API with hot reload on :8080 (SPA not embedded)
deps: [build:llms]
cmds:
- air
@@ -231,22 +233,36 @@ tasks:
desc: Type-check and build the SPA into {{.DIST_DIR}}
dir: web
deps: [web:install]
sources: [src/**/*, public/**/*, index.html, vite.config.ts, tsconfig*.json, package.json, bun.lock]
sources: [src/**/*, public/**/*, vite/**/*, index.html, vite.config.ts, tsconfig*.json, package.json, bun.lock]
generates: ['../{{.DIST_DIR}}/index.html']
cmds:
- bun run build
build:llms:
desc: Generate llms.txt, llms-full.txt and openapi.json from the route catalogue and docs/ → {{.LLMS_DIR}}
deps: [web:install]
sources: [scripts/generate-llms.ts, web/src/data/apiCatalogue.ts, docs/api.md, docs/api/*.md, docs/llm/*.md, docs/configuration.md]
generates: ['{{.LLMS_DIR}}/llms-full.txt']
cmds:
- bun scripts/generate-llms.ts
build:
desc: Production binary for this machine → bin/{{.BINARY}}
deps: [build:web]
deps: [build:web, build:llms]
cmds:
- go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}{{exeExt}} ./cmd/server
build:mcp:
desc: MCP server (stdio) for this machine → bin/{{.BINARY}}-mcp
cmds:
- go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}-mcp{{exeExt}} ./cmd/mcp
release:
desc: Cross-compile and sign production binaries for all release targets → bin/ (needs RELEASE_SIGNING_KEY)
cmds:
- task: release:check
- task: build:web
- task: build:llms
- for: { var: RELEASE_TARGETS }
task: build:target
vars:
+142 -23
View File
@@ -1,13 +1,21 @@
package api
import (
"RenewCMS/api/pkg/authtoken"
domainAPIKey "RenewCMS/internal/domain/apikey"
domainArticle "RenewCMS/internal/domain/article"
domainAudit "RenewCMS/internal/domain/audit"
domainAuthor "RenewCMS/internal/domain/author"
domainContentType "RenewCMS/internal/domain/contenttype"
domainEntry "RenewCMS/internal/domain/entry"
domainImage "RenewCMS/internal/domain/image"
domainInvite "RenewCMS/internal/domain/invite"
domainMail "RenewCMS/internal/domain/mail"
domainPurge "RenewCMS/internal/domain/purge"
domainRole "RenewCMS/internal/domain/role"
domainSession "RenewCMS/internal/domain/session"
domainSite "RenewCMS/internal/domain/site"
domainTransaction "RenewCMS/internal/domain/transaction"
domainUpdate "RenewCMS/internal/domain/update"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/auditing"
@@ -32,21 +40,56 @@ import (
"runtime"
"sync"
"time"
"github.com/go-chi/jwtauth/v5"
)
type UseCases struct {
apiKeyAuthOnce sync.Once
apiKeyAuthMiddleware func(http.Handler) http.Handler
Token *jwtauth.JWTAuth
Token *authtoken.Auth
PurgeScheduler *scheduler.Runner
ActivityCleanupScheduler *scheduler.Runner
RetentionScheduler *scheduler.Runner
CreateArticleUseCase *useCases.CreateArticleUseCase
GetArticleUseCase *useCases.GetArticleUseCase
ListArticlesUseCase *useCases.ListArticlesUseCase
UpdateArticleUseCase *useCases.UpdateArticleUseCase
ResolveArticleSEOUseCase *useCases.ResolveArticleSEOUseCase
CheckArticleSEOUseCase *useCases.CheckArticleSEOUseCase
ListSitemapUseCase *useCases.ListSitemapUseCase
DeleteArticleUseCase *useCases.DeleteArticleUseCase
ListAuthorsUseCase *useCases.ListAuthorsUseCase
CreateAuthorUseCase *useCases.CreateAuthorUseCase
CreateMyAuthorUseCase *useCases.CreateMyAuthorUseCase
UpdateAuthorUseCase *useCases.UpdateAuthorUseCase
DeleteAuthorUseCase *useCases.DeleteAuthorUseCase
GetMyAuthorUseCase *useCases.GetMyAuthorUseCase
GetMyAccessUseCase *useCases.GetMyAccessUseCase
CheckPermissionUseCase *useCases.CheckPermissionUseCase
ListPermissionsUseCase *useCases.ListPermissionsUseCase
ListSitesUseCase *useCases.ListSitesUseCase
CreateSiteUseCase *useCases.CreateSiteUseCase
UpdateSiteUseCase *useCases.UpdateSiteUseCase
SetDefaultSiteUseCase *useCases.SetDefaultSiteUseCase
DeleteSiteUseCase *useCases.DeleteSiteUseCase
CreateContentTypeUseCase *useCases.CreateContentTypeUseCase
ListContentTypesUseCase *useCases.ListContentTypesUseCase
GetContentTypeUseCase *useCases.GetContentTypeUseCase
UpdateContentTypeUseCase *useCases.UpdateContentTypeUseCase
DeleteContentTypeUseCase *useCases.DeleteContentTypeUseCase
ListRolesUseCase *useCases.ListRolesUseCase
GetRoleUseCase *useCases.GetRoleUseCase
CreateRoleUseCase *useCases.CreateRoleUseCase
UpdateRoleUseCase *useCases.UpdateRoleUseCase
DeleteRoleUseCase *useCases.DeleteRoleUseCase
ReassignRoleMembersUseCase *useCases.ReassignRoleMembersUseCase
ListBuiltinTypesUseCase *useCases.ListBuiltinTypesUseCase
GetBuiltinTypeUseCase *useCases.GetBuiltinTypeUseCase
LookupUserNamesUseCase *useCases.LookupUserNamesUseCase
CreateEntryUseCase *useCases.CreateEntryUseCase
GetEntryUseCase *useCases.GetEntryUseCase
ListEntriesUseCase *useCases.ListEntriesUseCase
UpdateEntryUseCase *useCases.UpdateEntryUseCase
DeleteEntryUseCase *useCases.DeleteEntryUseCase
GetUserUseCase *useCases.GetUserUseCase
CreateUserUseCase *useCases.CreateUserUseCase
AuthenticateUserUseCase *useCases.AuthenticateUserUseCase
@@ -55,11 +98,20 @@ type UseCases struct {
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
ResetPasswordUseCase *useCases.ResetPasswordUseCase
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
RequestEmailChangeUseCase *useCases.RequestEmailChangeUseCase
ResendEmailChangeUseCase *useCases.ResendEmailChangeUseCase
CancelEmailChangeUseCase *useCases.CancelEmailChangeUseCase
ConfirmEmailChangeUseCase *useCases.ConfirmEmailChangeUseCase
ListUsersUseCase *useCases.ListUsersUseCase
CreateInviteUseCase *useCases.CreateInviteUseCase
CancelInviteUseCase *useCases.CancelInviteUseCase
RenewInviteUseCase *useCases.RenewInviteUseCase
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
ChangeUserRoleUseCase *useCases.ChangeUserRoleUseCase
RemoveUserUseCase *useCases.RemoveUserUseCase
ExportUserDataUseCase *useCases.ExportUserDataUseCase
DeleteOwnAccountUseCase *useCases.DeleteOwnAccountUseCase
TransferOwnershipUseCase *useCases.TransferOwnershipUseCase
GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase
SendMailUseCase *useCases.SendMailUseCase
@@ -108,7 +160,7 @@ type UseCases struct {
func (c *UseCases) RunSchedulers(ctx context.Context) {
var running sync.WaitGroup
for _, runner := range []*scheduler.Runner{c.PurgeScheduler, c.ActivityCleanupScheduler} {
for _, runner := range []*scheduler.Runner{c.PurgeScheduler, c.ActivityCleanupScheduler, c.RetentionScheduler} {
if runner == nil {
continue
}
@@ -123,8 +175,15 @@ func (c *UseCases) RunSchedulers(ctx context.Context) {
type Repositories struct {
ArticleRepo domainArticle.Repository
SiteRepo domainSite.Repository
ContentTypeRepo domainContentType.Repository
EntryRepo domainEntry.Repository
AuthorRepo domainAuthor.Repository
UserRepo domainUser.Repository
UserEraser domainUser.Eraser
RoleRepo domainRole.Repository
ImageRepo domainImage.Repository
Transactor domainTransaction.Transactor
VariantRepo domainImage.VariantRepository
VariantFileRepo domainImage.VariantFileRepository
VariantCacheRepo domainImage.VariantCacheRepository
@@ -132,6 +191,7 @@ type Repositories struct {
VariantRenderer domainImage.VariantRenderer
InviteRepo domainInvite.Repository
APIKeyRepo domainAPIKey.Repository
SessionRepo domainSession.Repository
PurgeSettingsRepo domainPurge.Repository
AuditRepo domainAudit.Repository
AuditSettingsRepo domainAudit.SettingsRepository
@@ -155,6 +215,8 @@ const (
updateHelperFileName = "update-helper.json"
updatesDirName = "updates"
backupsDirName = "backups"
maxConcurrentDataExports = 2
)
func imageStorageConfig(settings config.Settings) storage.Config {
@@ -241,7 +303,7 @@ func openDatabase(settings config.Settings) *database.Connection {
if err != nil {
panic("Unable to open the database: " + err.Error())
}
if err := database.Migrate(connection.DB); err != nil {
if err := database.Migrate(connection.DB, settings.PublicURL); err != nil {
panic("Failed to migrate database: " + err.Error())
}
return connection
@@ -261,8 +323,15 @@ func getRepositories(connection *database.Connection, settings config.Settings)
variantFiles := persistence.NewVariantFileRepository(db)
return &Repositories{
ArticleRepo: persistence.NewArticleRepository(db),
SiteRepo: persistence.NewSiteRepository(db),
ContentTypeRepo: persistence.NewContentTypeRepository(db),
EntryRepo: persistence.NewEntryRepository(db),
AuthorRepo: persistence.NewAuthorRepository(db),
UserRepo: persistence.NewUserRepository(db),
UserEraser: persistence.NewAccountEraser(db),
RoleRepo: persistence.NewRoleRepository(db),
ImageRepo: persistence.NewImageRepository(db),
Transactor: persistence.NewTransactor(db),
VariantRepo: persistence.NewVariantRepository(db),
VariantFileRepo: variantFiles,
VariantCacheRepo: variantFiles,
@@ -270,6 +339,7 @@ func getRepositories(connection *database.Connection, settings config.Settings)
VariantRenderer: imaging.NewVariantRenderer(),
InviteRepo: persistence.NewInviteRepository(db),
APIKeyRepo: persistence.NewAPIKeyRepository(db),
SessionRepo: persistence.NewRevokedSessionRepository(db),
PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db),
AuditRepo: persistence.NewAuditEventRepository(db),
AuditSettingsRepo: persistence.NewAuditSettingsRepository(db),
@@ -290,12 +360,15 @@ func getRepositories(connection *database.Connection, settings config.Settings)
func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings config.Settings) *UseCases {
hasher := hashing.NewBcryptHasher()
mailDispatcher := mailer.NewAsyncDispatcher()
sessionRevocation := useCases.NewSessionRevocationUseCase(repos.SessionRepo, time.Now)
renderer := markdown.NewConverter()
keyGenerator := hashing.NewSHA256KeyGenerator()
recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default())
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, repos.VariantFileRepo, recorder)
mediaUsage := useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo)
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, repos.VariantFileRepo, recorder, repos.ContentTypeRepo, repos.EntryRepo, repos.Transactor, log.Default())
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder)
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder, repos.SiteRepo, repos.AuthorRepo)
purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge, recorder)
activityCleanup := useCases.NewCleanupActivityUseCase(repos.AuditRepo, repos.AuditSettingsRepo, recorder)
@@ -305,38 +378,84 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
return &UseCases{
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder),
RetentionScheduler: scheduler.NewSystemRetentionRunner(useCases.NewEnforceRetentionUseCase(repos.UserRepo, repos.InviteRepo, repos.SessionRepo), log.Default()),
Token: authtoken.New([]byte(settings.JWTSecret), authtoken.WithRevocationStore(sessionRevocation)),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ImageRepo),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder),
ResolveArticleSEOUseCase: useCases.NewResolveArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo),
CheckArticleSEOUseCase: useCases.NewCheckArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo),
ListSitemapUseCase: useCases.NewListSitemapUseCase(repos.ArticleRepo, repos.SiteRepo),
ListAuthorsUseCase: useCases.NewListAuthorsUseCase(repos.AuthorRepo, repos.UserRepo),
CreateAuthorUseCase: useCases.NewCreateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder),
CreateMyAuthorUseCase: useCases.NewCreateMyAuthorUseCase(repos.AuthorRepo, repos.UserRepo, recorder),
UpdateAuthorUseCase: useCases.NewUpdateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder),
DeleteAuthorUseCase: useCases.NewDeleteAuthorUseCase(repos.AuthorRepo, recorder),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(repos.AuthorRepo),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(repos.RoleRepo, repos.UserRepo),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(repos.RoleRepo),
ListPermissionsUseCase: useCases.NewListPermissionsUseCase(),
ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo),
CreateSiteUseCase: useCases.NewCreateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
SetDefaultSiteUseCase: useCases.NewSetDefaultSiteUseCase(repos.SiteRepo, recorder),
DeleteSiteUseCase: useCases.NewDeleteSiteUseCase(repos.SiteRepo, recorder),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(repos.ContentTypeRepo, repos.RoleRepo, time.Now),
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(repos.ContentTypeRepo),
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(repos.ContentTypeRepo),
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo, time.Now),
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.RoleRepo),
ListRolesUseCase: useCases.NewListRolesUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo),
GetRoleUseCase: useCases.NewGetRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo),
CreateRoleUseCase: useCases.NewCreateRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder),
UpdateRoleUseCase: useCases.NewUpdateRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder, time.Now),
DeleteRoleUseCase: useCases.NewDeleteRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder),
ReassignRoleMembersUseCase: useCases.NewReassignRoleMembersUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder),
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
LookupUserNamesUseCase: useCases.NewLookupUserNamesUseCase(repos.UserRepo),
CreateEntryUseCase: useCases.NewCreateEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.ImageRepo, time.Now),
GetEntryUseCase: useCases.NewGetEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.ImageRepo),
ListEntriesUseCase: useCases.NewListEntriesUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.ImageRepo),
UpdateEntryUseCase: useCases.NewUpdateEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.ImageRepo, time.Now),
DeleteEntryUseCase: useCases.NewDeleteEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, repos.RoleRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder),
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher, recorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder),
RequestEmailChangeUseCase: useCases.NewRequestEmailChangeUseCase(repos.UserRepo, hasher, repos.MailRepo, mailDispatcher, settings.PublicURL, time.Now, recorder),
ResendEmailChangeUseCase: useCases.NewResendEmailChangeUseCase(repos.UserRepo, hasher, repos.MailRepo, mailDispatcher, settings.PublicURL, time.Now, recorder),
CancelEmailChangeUseCase: useCases.NewCancelEmailChangeUseCase(repos.UserRepo, recorder),
ConfirmEmailChangeUseCase: useCases.NewConfirmEmailChangeUseCase(repos.UserRepo, hasher, time.Now, recorder),
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, repos.ContentTypeRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder),
RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo),
RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, repos.ContentTypeRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo),
ChangeUserRoleUseCase: useCases.NewChangeUserRoleUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder),
RemoveUserUseCase: useCases.NewRemoveUserUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder),
ExportUserDataUseCase: useCases.NewExportUserDataUseCase(repos.UserRepo, repos.RoleRepo, repos.AuthorRepo, repos.APIKeyRepo, repos.ArticleRepo, repos.EntryRepo, repos.ContentTypeRepo, repos.ImageRepo, repos.AuditRepo, time.Now, recorder, maxConcurrentDataExports),
DeleteOwnAccountUseCase: useCases.NewDeleteOwnAccountUseCase(repos.UserRepo, repos.RoleRepo, hasher, repos.UserEraser, sessionRevocation, repos.MailRepo, mailDispatcher, settings.PublicURL, recorder, repos.AuthorRepo, repos.ImageRepo, mediaUsage, imageStorage, repos.VariantFileRepo),
TransferOwnershipUseCase: useCases.NewTransferOwnershipUseCase(repos.UserRepo, repos.RoleRepo, recorder),
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, recorder),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, hasher, recorder),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder, imaging.NewMetadataStripper()),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo, repos.VariantFileRepo),
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo, repos.VariantFileRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ArticleRepo),
MergeMediaUseCase: mergeMedia,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
GetMediaUsageUseCase: mediaUsage,
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo),
RunPurgeUseCase: runPurge,
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now),
@@ -346,9 +465,9 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now, recorder),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, time.Now, recorder),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now),
ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now),
ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now, repos.SiteRepo, repos.AuthorRepo),
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now),
ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, time.Now),
+13 -7
View File
@@ -1,11 +1,12 @@
package activity
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
@@ -29,7 +30,7 @@ type Handler struct {
type filterQuery struct {
Q string `validate:"max=200"`
Actor string `validate:"max=40"`
Category string `validate:"omitempty,oneof=articles media users api_keys auth system"`
Category string `validate:"omitempty,oneof=articles media users api_keys auth system sites authors"`
Outcome string `validate:"omitempty,oneof=all success failed"`
Security string `validate:"omitempty,oneof=true"`
Range string `validate:"omitempty,oneof=today 7d 30d all custom"`
@@ -134,9 +135,8 @@ func writeActivityError(w http.ResponseWriter, err error) {
case errors.Is(err, audit.ErrInvalidFilter):
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
case errors.Is(err, audit.ErrActorNotAllowed):
http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden)
case errors.Is(err, user.ErrUserNotFound):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
access.WriteMissingPermission(w, role.KeyActivityView)
case access.WriteIfMissingPermission(w, err):
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
@@ -156,7 +156,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
activityQuery := toActivityQuery(query.filterQuery)
activityQuery.Page, activityQuery.Size = query.Page, query.Size
page, err := h.ListUseCase.ListActivity(useCases.ListActivityCommand{ViewerID: viewerId, Query: activityQuery})
page, err := h.ListUseCase.ListActivity(useCases.ListActivityCommand{ViewerID: viewerId, Grants: access.Grants(r), Query: activityQuery})
if err != nil {
writeActivityError(w, err)
return
@@ -220,7 +220,13 @@ func (h *Handler) Export(w http.ResponseWriter, r *http.Request) {
return
}
prepared, err := h.ExportUseCase.Prepare(toActivityQuery(query))
viewerId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
prepared, err := h.ExportUseCase.Prepare(useCases.ExportActivityCommand{ViewerID: viewerId, Grants: access.Grants(r), Query: toActivityQuery(query)})
if err != nil {
writeActivityError(w, err)
return
+7 -2
View File
@@ -1,6 +1,7 @@
package apikey
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
@@ -109,8 +110,11 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
return
}
entries, err := h.ListUseCase.ListAPIKeys(userId)
entries, err := h.ListUseCase.ListAPIKeys(userId, access.Grants(r))
if err != nil {
if access.WriteIfMissingPermission(w, err) {
return
}
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
@@ -161,8 +165,9 @@ func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.RevokeUseCase.RevokeAPIKey(r.Context(), userId, uint32(id)); err != nil {
if err := h.RevokeUseCase.RevokeAPIKey(r.Context(), userId, uint32(id), access.Grants(r)); err != nil {
switch {
case access.WriteIfMissingPermission(w, err):
case errors.Is(err, domain.ErrAPIKeyNotFound):
http.Error(w, apperr.APIKeyNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, user.ErrUserNotFound):
+324 -79
View File
@@ -1,31 +1,36 @@
package article
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
playground "github.com/go-playground/validator/v10"
)
type Handler struct {
CreateUseCase *useCases.CreateArticleUseCase
GetUseCase *useCases.GetArticleUseCase
ListUseCase *useCases.ListArticlesUseCase
DeleteUseCase *useCases.DeleteArticleUseCase
UpdateUseCase *useCases.UpdateArticleUseCase
GetUserUseCase *useCases.GetUserUseCase
CreateUseCase *useCases.CreateArticleUseCase
GetUseCase *useCases.GetArticleUseCase
ListUseCase *useCases.ListArticlesUseCase
DeleteUseCase *useCases.DeleteArticleUseCase
UpdateUseCase *useCases.UpdateArticleUseCase
}
type PostArticle struct {
@@ -33,49 +38,124 @@ type PostArticle struct {
Body string `json:"body"`
}
func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase, getUser *useCases.GetUserUseCase) *Handler {
func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase) *Handler {
return &Handler{
CreateUseCase: create,
GetUseCase: get,
ListUseCase: list,
DeleteUseCase: delete,
UpdateUseCase: update,
GetUserUseCase: getUser,
CreateUseCase: create,
GetUseCase: get,
ListUseCase: list,
DeleteUseCase: delete,
UpdateUseCase: update,
}
}
type articleResponse struct {
type baseArticleResponse struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Body json.RawMessage `json:"body"`
BodyFormat domain.BodyFormat `json:"body_format"`
IsOnline bool `json:"is_online"`
SiteID uint32 `json:"site_id"`
CreatedBy *uint32 `json:"created_by,omitempty"`
Authors []author.Byline `json:"authors"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func toArticleResponse(article domain.Article, format domain.BodyFormat) (articleResponse, error) {
type articleResponse struct {
baseArticleResponse
Slug string `json:"slug"`
Excerpt string `json:"excerpt"`
SEOTitle string `json:"seo_title"`
SEODescription string `json:"seo_description"`
CoverImageID *uint32 `json:"cover_image_id"`
CoverImageAlt string `json:"cover_image_alt"`
SocialTitle string `json:"social_title"`
SocialDescription string `json:"social_description"`
SocialImageID *uint32 `json:"social_image_id"`
CanonicalURL string `json:"canonical_url"`
Noindex bool `json:"noindex"`
SEODismissed []string `json:"seo_dismissed"`
}
type listItemResponse struct {
baseArticleResponse
Excerpt string `json:"excerpt"`
Slug string `json:"slug"`
SEOStatus string `json:"seo_status"`
SEOSuggestions int `json:"seo_suggestions"`
}
func creatorFor(article domain.Article, viewer access.Caller) *uint32 {
if viewer.UserID == 0 {
return nil
}
createdBy := article.CreatedBy
return &createdBy
}
func toBaseResponse(article domain.Article, format domain.BodyFormat, viewer access.Caller) (baseArticleResponse, error) {
body := json.RawMessage(article.Body)
if format != domain.BodyFormatJSON {
encoded, err := json.Marshal(article.Body)
if err != nil {
return articleResponse{}, err
return baseArticleResponse{}, err
}
body = encoded
}
return articleResponse{
authors := article.Authors
if authors == nil {
authors = []author.Byline{}
}
return baseArticleResponse{
ID: article.ID,
Title: article.Title,
Body: body,
BodyFormat: format,
IsOnline: article.IsOnline,
SiteID: article.SiteID,
CreatedBy: creatorFor(article, viewer),
Authors: authors,
CreatedAt: article.CreatedAt,
UpdatedAt: article.UpdatedAt,
}, nil
}
func encodeArticle(article domain.Article, format domain.BodyFormat) ([]byte, error) {
response, err := toArticleResponse(article, format)
func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer access.Caller) (articleResponse, error) {
base, err := toBaseResponse(article, format, viewer)
if err != nil {
return articleResponse{}, err
}
dismissed := article.SEODismissed
if dismissed == nil {
dismissed = []string{}
}
return articleResponse{
baseArticleResponse: base,
Slug: article.Slug,
Excerpt: article.Excerpt,
SEOTitle: article.SEOTitle,
SEODescription: article.SEODescription,
CoverImageID: article.CoverImageID,
CoverImageAlt: article.CoverImageAlt,
SocialTitle: article.SocialTitle,
SocialDescription: article.SocialDescription,
SocialImageID: article.SocialImageID,
CanonicalURL: article.CanonicalURL,
Noindex: article.Noindex,
SEODismissed: dismissed,
}, nil
}
func toListItemResponse(article domain.Article, summary domain.SEOSummary, format domain.BodyFormat, viewer access.Caller) (listItemResponse, error) {
base, err := toBaseResponse(article, format, viewer)
if err != nil {
return listItemResponse{}, err
}
return listItemResponse{baseArticleResponse: base, Excerpt: article.Excerpt, Slug: article.Slug, SEOStatus: summary.Status, SEOSuggestions: summary.Suggestions}, nil
}
func encodeArticle(article domain.Article, format domain.BodyFormat, viewer access.Caller) ([]byte, error) {
response, err := toArticleResponse(article, format, viewer)
if err != nil {
return nil, err
}
@@ -108,12 +188,7 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
return
}
var article domain.Article
if h.callerIsVerified(r) {
article, err = h.GetUseCase.GetArticle(id, format)
} else {
article, err = h.GetUseCase.GetOnlineArticle(id, format)
}
article, err := h.GetUseCase.GetArticle(id, format, access.CallerOf(r))
if err != nil {
if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
@@ -123,7 +198,7 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
return
}
articleJson, err := encodeArticle(article, format)
articleJson, err := encodeArticle(article, format, access.CallerOf(r))
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
@@ -155,29 +230,86 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
Title: localArticle.Title,
Body: localArticle.Body,
Format: format,
Caller: access.CallerOf(r),
})
if err != nil {
if errors.Is(err, domain.ErrInvalidTitle) {
http.Error(w, apperr.ArticleTitleErrorMessage, http.StatusBadRequest)
return
}
http.Error(w, "Failed to create article", http.StatusInternalServerError)
if !writeKnownArticleError(w, err) {
http.Error(w, "Failed to create article", http.StatusInternalServerError)
}
return
}
writeArticleJsonResponse(w, http.StatusCreated, createdArticle, format)
writeArticleJsonResponse(w, r, http.StatusCreated, createdArticle, format)
}
func writeArticleErrorResponse(w http.ResponseWriter, err error) {
if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return
if !writeKnownArticleError(w, err) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
func writeArticleJsonResponse(w http.ResponseWriter, status int, article domain.Article, format domain.BodyFormat) {
articleJson, err := encodeArticle(article, format)
func writeKnownArticleError(w http.ResponseWriter, err error) bool {
if access.WriteIfMissingPermission(w, err) {
return true
}
if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return true
}
if errors.Is(err, site.ErrSiteNotFound) {
http.Error(w, apperr.SiteNotFoundErrorMessage, http.StatusNotFound)
return true
}
if errors.Is(err, author.ErrAuthorNotFound) {
http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound)
return true
}
if errors.Is(err, domain.ErrSlugTaken) {
writeSlugTaken(w, err)
return true
}
if errors.Is(err, domain.ErrInvalidSlug) {
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
return true
}
if errors.Is(err, domain.ErrInvalidCanonicalURL) {
http.Error(w, apperr.InvalidURLErrorMessage, http.StatusUnprocessableEntity)
return true
}
if errors.Is(err, image.ErrImageNotFound) {
http.Error(w, apperr.ArticleImageNotFoundErrorMessage, http.StatusBadRequest)
return true
}
return false
}
func writeSlugTaken(w http.ResponseWriter, err error) {
var conflict *domain.SlugTakenError
if errors.As(err, &conflict) && conflict.Suggestion != "" {
w.Header().Set(apperr.SlugSuggestionHeader, conflict.Suggestion)
}
http.Error(w, apperr.ArticleSlugTakenErrorMessage, http.StatusConflict)
}
func writeValidationError(w http.ResponseWriter, err error) {
var failures playground.ValidationErrors
if errors.As(err, &failures) {
for _, failure := range failures {
if failure.Tag() == validator.ArticleSlugTag {
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
return
}
}
}
http.Error(w, err.Error(), http.StatusBadRequest)
}
func writeArticleJsonResponse(w http.ResponseWriter, r *http.Request, status int, article domain.Article, format domain.BodyFormat) {
articleJson, err := encodeArticle(article, format, access.CallerOf(r))
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
@@ -203,17 +335,108 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b
return
}
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(r.Context(), id, isOnline, format)
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(r.Context(), id, access.CallerOf(r), isOnline, format)
if err != nil {
writeArticleErrorResponse(w, err)
return
}
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format)
}
type optionalImageID struct {
Set bool
Value *uint32 `validate:"omitempty,gte=1"`
}
func (o *optionalImageID) UnmarshalJSON(data []byte) error {
o.Set = true
if string(data) == "null" {
o.Value = nil
return nil
}
var value uint32
if err := json.Unmarshal(data, &value); err != nil {
return err
}
o.Value = &value
return nil
}
func (o optionalImageID) toNullable() domain.Nullable[uint32] {
return domain.Nullable[uint32]{Set: o.Set, Value: o.Value}
}
type UpdateArticleBody struct {
Body string `json:"body"`
Body *string `json:"body"`
SiteID *uint32 `json:"site_id" validate:"omitempty,gte=1"`
AuthorIDs *[]uint32 `json:"author_ids" validate:"omitempty,dive,gte=1"`
Slug *string `json:"slug" validate:"omitempty,articleslug"`
Excerpt *string `json:"excerpt" validate:"omitempty,max=300"`
SEOTitle *string `json:"seo_title" validate:"omitempty,max=120"`
SEODescription *string `json:"seo_description" validate:"omitempty,max=320"`
CoverImageID optionalImageID `json:"cover_image_id"`
CoverImageAlt *string `json:"cover_image_alt" validate:"omitempty,max=200"`
SocialTitle *string `json:"social_title" validate:"omitempty,max=120"`
SocialDescription *string `json:"social_description" validate:"omitempty,max=320"`
SocialImageID optionalImageID `json:"social_image_id"`
CanonicalURL *string `json:"canonical_url" validate:"omitempty,max=2048"`
Noindex *bool `json:"noindex"`
SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,oneof=title_long title_empty desc_short desc_long cover_alt cover_none slug_long no_au no_h body_alt short"`
}
var nonNullableArticleFields = []string{"body", "site_id", "author_ids", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"}
var nullableArticleFields = []string{"cover_image_id", "social_image_id"}
func hasDuplicates(ids []uint32) bool {
seen := make(map[uint32]bool, len(ids))
for _, id := range ids {
if seen[id] {
return true
}
seen[id] = true
}
return false
}
func countKnownFields(fields map[string]json.RawMessage, nonNullable []string) (int, bool) {
known := 0
for name, value := range fields {
switch {
case slices.Contains(nonNullable, name):
if string(value) == "null" {
return 0, false
}
known++
case slices.Contains(nullableArticleFields, name):
known++
}
}
return known, true
}
func readUpdateArticleBody(r *http.Request) (UpdateArticleBody, bool) {
raw, err := io.ReadAll(r.Body)
if err != nil {
return UpdateArticleBody{}, false
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
return UpdateArticleBody{}, false
}
known, ok := countKnownFields(fields, nonNullableArticleFields)
if !ok || known == 0 {
return UpdateArticleBody{}, false
}
var request UpdateArticleBody
if err := json.Unmarshal(raw, &request); err != nil {
return UpdateArticleBody{}, false
}
if request.AuthorIDs != nil && hasDuplicates(*request.AuthorIDs) {
return UpdateArticleBody{}, false
}
return request, true
}
func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
@@ -228,24 +451,41 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
return
}
var localArticle UpdateArticleBody
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
request, ok := readUpdateArticleBody(r)
if !ok {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(localArticle); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
if err := validator.Validate.Struct(request); err != nil {
writeValidationError(w, err)
return
}
updatedArticle, err := h.UpdateUseCase.UpdateBody(r.Context(), id, localArticle.Body, format)
updatedArticle, err := h.UpdateUseCase.Update(r.Context(), id, access.CallerOf(r), useCases.UpdateArticleCommand{
Body: request.Body,
SiteID: request.SiteID,
AuthorIDs: request.AuthorIDs,
Slug: request.Slug,
Excerpt: request.Excerpt,
SEOTitle: request.SEOTitle,
SEODescription: request.SEODescription,
CoverImageID: request.CoverImageID.toNullable(),
CoverImageAlt: request.CoverImageAlt,
SocialTitle: request.SocialTitle,
SocialDescription: request.SocialDescription,
SocialImageID: request.SocialImageID.toNullable(),
CanonicalURL: request.CanonicalURL,
Noindex: request.Noindex,
SEODismissed: request.SEODismissed,
Format: format,
})
if err != nil {
writeArticleErrorResponse(w, err)
return
}
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format)
}
func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) {
@@ -256,31 +496,19 @@ func (h *Handler) UnpublishArticle(w http.ResponseWriter, r *http.Request) {
h.setIsOnline(w, r, false)
}
func (h *Handler) callerIsVerified(r *http.Request) bool {
userId, ok := claims.UserId(r)
if !ok {
return false
}
currentUser, err := h.GetUserUseCase.GetUser(userId)
if err != nil {
return false
}
return currentUser.IsVerified
}
const (
defaultListPage = 1
defaultListSize = 25
)
type listQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"oneof=5 25 50 100"`
Q string `validate:"max=200"`
Status string `validate:"omitempty,oneof=online draft"`
Sort string `validate:"oneof=title:asc title:desc status:asc status:desc created_at:asc created_at:desc updated_at:asc updated_at:desc"`
Page int `validate:"gte=1"`
Size int `validate:"oneof=5 25 50 100"`
Q string `validate:"max=200"`
Status string `validate:"omitempty,oneof=online draft"`
Site *string `validate:"omitempty,min=1"`
SEO string `validate:"omitempty,oneof=ready suggestions hidden"`
Sort string `validate:"oneof=title:asc title:desc status:asc status:desc created_at:asc created_at:desc updated_at:asc updated_at:desc"`
}
const defaultListSort = "updated_at:desc"
@@ -291,11 +519,11 @@ func parseSort(value string) domain.Sort {
}
type listResponse struct {
Items []articleResponse `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
Counts domain.Counts `json:"counts"`
Items []listItemResponse `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
Counts domain.Counts `json:"counts"`
}
func intQueryParam(values url.Values, name string, fallback int) (int, error) {
@@ -322,7 +550,19 @@ func readListQuery(values url.Values) (listQuery, error) {
if values.Has("sort") {
sortValue = values.Get("sort")
}
return listQuery{Page: page, Size: size, Q: values.Get("q"), Status: status, Sort: sortValue}, nil
var siteKey *string
if values.Has("site") {
key := values.Get("site")
if key == "" {
return listQuery{}, errors.New("empty site")
}
siteKey = &key
}
seoStatus := values.Get("seo")
if values.Has("seo") && seoStatus == "" {
return listQuery{}, errors.New("empty seo")
}
return listQuery{Page: page, Size: size, Q: values.Get("q"), Status: status, Sort: sortValue, Site: siteKey, SEO: seoStatus}, nil
}
func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
@@ -338,22 +578,24 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
}
page, err := h.ListUseCase.ListArticlesPage(useCases.ListArticlesCommand{
Format: format,
Verified: h.callerIsVerified(r),
Page: query.Page,
Size: query.Size,
Q: query.Q,
Status: query.Status,
Sort: parseSort(query.Sort),
Format: format,
Caller: access.CallerOf(r),
Page: query.Page,
Size: query.Size,
Q: query.Q,
Status: query.Status,
Sort: parseSort(query.Sort),
SiteKey: query.Site,
SEO: query.SEO,
})
if err != nil {
http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError)
return
}
responses := make([]articleResponse, 0, len(page.Items))
responses := make([]listItemResponse, 0, len(page.Items))
for _, article := range page.Items {
response, err := toArticleResponse(article, format)
response, err := toListItemResponse(article, page.SEO[article.ID], format, access.CallerOf(r))
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
@@ -379,11 +621,14 @@ func (h *Handler) DeleteArticle(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.DeleteUseCase.DeleteArticle(r.Context(), id); err != nil {
if err := h.DeleteUseCase.DeleteArticle(r.Context(), id, access.CallerOf(r)); err != nil {
if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return
}
if access.WriteIfMissingPermission(w, err) {
return
}
http.Error(w, "Failed to delete article", http.StatusInternalServerError)
return
}
+127
View File
@@ -0,0 +1,127 @@
package article
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
"io"
"log"
"net/http"
)
type SEOHandler struct {
ResolveUseCase *useCases.ResolveArticleSEOUseCase
CheckUseCase *useCases.CheckArticleSEOUseCase
}
func NewSEOHandler(resolve *useCases.ResolveArticleSEOUseCase, check *useCases.CheckArticleSEOUseCase) *SEOHandler {
return &SEOHandler{ResolveUseCase: resolve, CheckUseCase: check}
}
type CheckArticleSEOBody struct {
Body *string `json:"body"`
Slug *string `json:"slug" validate:"omitempty,articleslug"`
Excerpt *string `json:"excerpt" validate:"omitempty,max=300"`
SEOTitle *string `json:"seo_title" validate:"omitempty,max=120"`
SEODescription *string `json:"seo_description" validate:"omitempty,max=320"`
CoverImageID optionalImageID `json:"cover_image_id"`
CoverImageAlt *string `json:"cover_image_alt" validate:"omitempty,max=200"`
SocialTitle *string `json:"social_title" validate:"omitempty,max=120"`
SocialDescription *string `json:"social_description" validate:"omitempty,max=320"`
SocialImageID optionalImageID `json:"social_image_id"`
CanonicalURL *string `json:"canonical_url" validate:"omitempty,max=2048"`
Noindex *bool `json:"noindex"`
SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,oneof=title_long title_empty desc_short desc_long cover_alt cover_none slug_long no_au no_h body_alt short"`
}
var checkNonNullableFields = []string{"body", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"}
func readCheckBody(r *http.Request) (CheckArticleSEOBody, bool) {
raw, err := io.ReadAll(r.Body)
if err != nil {
return CheckArticleSEOBody{}, false
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil || fields == nil {
return CheckArticleSEOBody{}, false
}
known, ok := countKnownFields(fields, checkNonNullableFields)
if !ok || known != len(fields) {
return CheckArticleSEOBody{}, false
}
var request CheckArticleSEOBody
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
return CheckArticleSEOBody{}, false
}
return request, true
}
func (h *SEOHandler) Resolve(w http.ResponseWriter, r *http.Request) {
id, err := parseArticleId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
resolved, err := h.ResolveUseCase.Resolve(id, access.CallerOf(r))
if err != nil {
writeArticleErrorResponse(w, err)
return
}
writeSEOJson(w, resolved)
}
func (h *SEOHandler) Check(w http.ResponseWriter, r *http.Request) {
id, err := parseArticleId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
request, ok := readCheckBody(r)
if !ok {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
writeValidationError(w, err)
return
}
checked, err := h.CheckUseCase.Check(id, access.CallerOf(r), useCases.CheckArticleSEOCommand{
Body: request.Body,
Slug: request.Slug,
Excerpt: request.Excerpt,
SEOTitle: request.SEOTitle,
SEODescription: request.SEODescription,
CoverImageID: request.CoverImageID.toNullable(),
CoverImageAlt: request.CoverImageAlt,
SocialTitle: request.SocialTitle,
SocialDescription: request.SocialDescription,
SocialImageID: request.SocialImageID.toNullable(),
CanonicalURL: request.CanonicalURL,
Noindex: request.Noindex,
SEODismissed: request.SEODismissed,
})
if err != nil {
writeArticleErrorResponse(w, err)
return
}
writeSEOJson(w, checked)
}
func writeSEOJson(w http.ResponseWriter, payload any) {
encoded, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if _, err := w.Write(encoded); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
+5 -4
View File
@@ -2,6 +2,7 @@ package auth
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user"
@@ -9,12 +10,10 @@ import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/jwtauth/v5"
)
type AcceptInviteHandler struct {
Token *jwtauth.JWTAuth
Token *authtoken.Auth
GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase
IsProduction bool
@@ -30,7 +29,7 @@ type inviteEmailResponse struct {
Email string `json:"email"`
}
func NewAcceptInviteHandler(getInvite *useCases.GetInviteUseCase, acceptInvite *useCases.AcceptInviteUseCase, token *jwtauth.JWTAuth, isProduction bool) *AcceptInviteHandler {
func NewAcceptInviteHandler(getInvite *useCases.GetInviteUseCase, acceptInvite *useCases.AcceptInviteUseCase, token *authtoken.Auth, isProduction bool) *AcceptInviteHandler {
return &AcceptInviteHandler{
Token: token,
GetInviteUseCase: getInvite,
@@ -96,6 +95,8 @@ func writeAcceptInviteError(w http.ResponseWriter, err error) {
http.Error(w, apperr.UsernameTakenErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrEmailHasAccount):
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
case errors.Is(err, user.ErrPasswordTooLong):
http.Error(w, apperr.PasswordTooLongErrorMessage, http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
+151
View File
@@ -0,0 +1,151 @@
package auth
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
)
type EmailChangeHandler struct {
RequestUseCase *useCases.RequestEmailChangeUseCase
ResendUseCase *useCases.ResendEmailChangeUseCase
CancelUseCase *useCases.CancelEmailChangeUseCase
ConfirmUseCase *useCases.ConfirmEmailChangeUseCase
}
type RequestEmailChangeCredentials struct {
NewEmail string `json:"new_email" validate:"required,max=255,inviteemail"`
CurrentPassword string `json:"current_password" validate:"required"`
}
type ConfirmEmailChangeCredentials struct {
Code string `json:"code" validate:"required"`
}
func NewEmailChangeHandler(request *useCases.RequestEmailChangeUseCase, resend *useCases.ResendEmailChangeUseCase, cancel *useCases.CancelEmailChangeUseCase, confirm *useCases.ConfirmEmailChangeUseCase) *EmailChangeHandler {
return &EmailChangeHandler{
RequestUseCase: request,
ResendUseCase: resend,
CancelUseCase: cancel,
ConfirmUseCase: confirm,
}
}
func (h *EmailChangeHandler) Request(w http.ResponseWriter, r *http.Request) {
var credentials RequestEmailChangeCredentials
if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(credentials); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
pending, err := h.RequestUseCase.RequestEmailChange(r.Context(), useCases.RequestEmailChangeCommand{
UserID: userId,
NewEmail: credentials.NewEmail,
CurrentPassword: credentials.CurrentPassword,
})
if err != nil {
writeEmailChangeError(w, err)
return
}
writePendingEmail(w, pending)
}
func (h *EmailChangeHandler) Resend(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
pending, err := h.ResendUseCase.ResendEmailChange(r.Context(), userId)
if err != nil {
writeEmailChangeError(w, err)
return
}
writePendingEmail(w, pending)
}
func (h *EmailChangeHandler) Cancel(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
if err := h.CancelUseCase.CancelEmailChange(r.Context(), userId); err != nil {
writeEmailChangeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *EmailChangeHandler) Confirm(w http.ResponseWriter, r *http.Request) {
var credentials ConfirmEmailChangeCredentials
if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(credentials); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
email, err := h.ConfirmUseCase.ConfirmEmailChange(r.Context(), userId, credentials.Code)
if err != nil {
writeEmailChangeError(w, err)
return
}
body, err := json.Marshal(map[string]string{"email": email})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
_, _ = w.Write(body)
}
func writePendingEmail(w http.ResponseWriter, pending string) {
body, err := json.Marshal(map[string]string{"pending_email": pending})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
_, _ = w.Write(body)
}
func writeEmailChangeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, user.ErrUserNotFound):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
case errors.Is(err, user.ErrWrongPassword):
http.Error(w, apperr.WrongPasswordErrorCode, http.StatusForbidden)
case errors.Is(err, user.ErrSameEmail):
http.Error(w, apperr.SameEmailErrorCode, http.StatusBadRequest)
case errors.Is(err, user.ErrNoPendingEmailChange):
http.Error(w, apperr.NoPendingEmailChangeErrorCode, http.StatusNotFound)
case errors.Is(err, user.ErrInvalidEmailChangeCode):
http.Error(w, apperr.InvalidEmailChangeCodeErrorCode, http.StatusForbidden)
case errors.Is(err, user.ErrEmailUnavailable):
http.Error(w, apperr.EmailUnavailableErrorCode, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+26 -19
View File
@@ -2,6 +2,7 @@ package auth
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
@@ -9,12 +10,10 @@ import (
"errors"
"net/http"
"time"
"github.com/go-chi/jwtauth/v5"
)
type LoginHandler struct {
Token *jwtauth.JWTAuth
Token *authtoken.Auth
CreateUseCase *useCases.CreateUserUseCase
AuthenticateUseCase *useCases.AuthenticateUserUseCase
IsProduction bool
@@ -31,7 +30,7 @@ type LoginCredentials struct {
Password string `json:"password" validate:"required,min=8"`
}
func NewLoginHandler(token *jwtauth.JWTAuth, create *useCases.CreateUserUseCase, authenticate *useCases.AuthenticateUserUseCase, isProduction bool) *LoginHandler {
func NewLoginHandler(token *authtoken.Auth, create *useCases.CreateUserUseCase, authenticate *useCases.AuthenticateUserUseCase, isProduction bool) *LoginHandler {
return &LoginHandler{
Token: token,
CreateUseCase: create,
@@ -41,28 +40,21 @@ func NewLoginHandler(token *jwtauth.JWTAuth, create *useCases.CreateUserUseCase,
}
func removeJwtCookie(w http.ResponseWriter, isProduction bool) {
http.SetCookie(w, &http.Cookie{
Name: "jwt",
Value: "",
Expires: time.Now(),
MaxAge: -1,
Secure: isProduction,
HttpOnly: true,
Path: "/",
})
http.SetCookie(w, authtoken.ExpiredCookie(isProduction))
}
func setJwtCookie(w http.ResponseWriter, token *jwtauth.JWTAuth, isProduction bool, u user.User) error {
_, tokenString, err := token.Encode(map[string]any{"user_id": u.ID, "token_version": u.TokenVersion})
func setJwtCookie(w http.ResponseWriter, token *authtoken.Auth, isProduction bool, u user.User) error {
tokenString, err := token.Encode(map[string]any{"user_id": u.ID, "token_version": u.TokenVersion})
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: "jwt",
Name: authtoken.CookieName,
Value: tokenString,
Expires: time.Now().Add(24 * time.Hour),
Expires: time.Now().Add(authtoken.SessionLifetime),
Secure: isProduction,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
})
return nil
@@ -123,6 +115,10 @@ func (h *LoginHandler) Register(w http.ResponseWriter, r *http.Request) {
http.Error(w, apperr.RegistrationClosedErrorMessage, http.StatusForbidden)
return
}
if errors.Is(err, user.ErrPasswordTooLong) {
http.Error(w, apperr.PasswordTooLongErrorMessage, http.StatusBadRequest)
return
}
http.Error(w, apperr.RegistrationFailedErrorMessage, http.StatusInternalServerError)
return
}
@@ -134,14 +130,25 @@ func (h *LoginHandler) Register(w http.ResponseWriter, r *http.Request) {
}
func (h *LoginHandler) RegistrationStatus(w http.ResponseWriter, _ *http.Request) {
body, _ := json.Marshal(map[string]bool{"open": h.CreateUseCase.IsRegistrationOpen()})
open, err := h.CreateUseCase.IsRegistrationOpen()
if err != nil {
http.Error(w, apperr.RegistrationFailedErrorMessage, http.StatusInternalServerError)
return
}
body, _ := json.Marshal(map[string]bool{"open": open})
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
func (h *LoginHandler) Logout(w http.ResponseWriter, r *http.Request) {
h.AuthenticateUseCase.Logout(r.Context())
removeJwtCookie(w, h.IsProduction)
if claims, err := authtoken.FromContext(r.Context()); err == nil {
if err := h.Token.Revoke(claims); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
h.AuthenticateUseCase.Logout(r.Context())
message, _ := json.Marshal(map[string]any{"message": "User logged out! HTTPonly jwt cookie deleted"})
_, _ = w.Write(message)
}
+63 -4
View File
@@ -3,7 +3,10 @@ package auth
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/payload"
"RenewCMS/api/pkg/publicuser"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
@@ -12,15 +15,59 @@ import (
)
type MeHandler struct {
GetUseCase *useCases.GetUserUseCase
GetUseCase *useCases.GetUserUseCase
GetAuthorUseCase *useCases.GetMyAuthorUseCase
GetAccessUseCase *useCases.GetMyAccessUseCase
}
func NewMeHandler(getUser *useCases.GetUserUseCase) *MeHandler {
func NewMeHandler(getUser *useCases.GetUserUseCase, getAuthor *useCases.GetMyAuthorUseCase, getAccess *useCases.GetMyAccessUseCase) *MeHandler {
return &MeHandler{
GetUseCase: getUser,
GetUseCase: getUser,
GetAuthorUseCase: getAuthor,
GetAccessUseCase: getAccess,
}
}
type meAuthor struct {
author.Byline
ArticleCount int `json:"article_count"`
}
type meAccess struct {
Role payload.RoleRef `json:"role"`
Permissions []role.Grant `json:"permissions"`
RoleAssignedBy *payload.RoleAssigner `json:"role_assigned_by"`
}
type meResponse struct {
publicuser.PublicUser
PendingEmail *string `json:"pending_email"`
Author *meAuthor `json:"author"`
Access meAccess `json:"access"`
}
func toMeAccess(access useCases.Access) meAccess {
return meAccess{
Role: payload.RoleRefOf(useCases.NewRoleRef(access.Role)),
Permissions: access.Permissions,
RoleAssignedBy: payload.RoleAssignerPtrOf(access.RoleAssignedBy),
}
}
func toMeAuthor(found *author.Author) *meAuthor {
if found == nil {
return nil
}
return &meAuthor{Byline: found.Byline(), ArticleCount: found.ArticleCount}
}
func pendingEmailOf(account user.User) *string {
if account.PendingEmail == "" {
return nil
}
return &account.PendingEmail
}
func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
@@ -38,7 +85,19 @@ func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
return
}
body, err := json.Marshal(publicuser.New(loadedUser))
linked, err := h.GetAuthorUseCase.Get(loadedUser.ID)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
access, err := h.GetAccessUseCase.Get(loadedUser)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
body, err := json.Marshal(meResponse{PublicUser: publicuser.New(loadedUser), PendingEmail: pendingEmailOf(loadedUser), Author: toMeAuthor(linked), Access: toMeAccess(access)})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
@@ -72,6 +72,10 @@ func (h *PasswordResetHandler) ResetPassword(w http.ResponseWriter, r *http.Requ
http.Error(w, apperr.PasswordResetFailedErrorMessage, http.StatusForbidden)
return
}
if errors.Is(err, user.ErrPasswordTooLong) {
http.Error(w, apperr.PasswordTooLongErrorMessage, http.StatusBadRequest)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
+383
View File
@@ -0,0 +1,383 @@
package author
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"net/url"
"strconv"
"github.com/go-chi/chi/v5"
playground "github.com/go-playground/validator/v10"
)
const (
defaultListPage = 1
defaultListSize = 25
reassignParam = "reassign_to"
reassignNone = "none"
)
type Handler struct {
ListUseCase *useCases.ListAuthorsUseCase
CreateUseCase *useCases.CreateAuthorUseCase
CreateMyUseCase *useCases.CreateMyAuthorUseCase
UpdateUseCase *useCases.UpdateAuthorUseCase
DeleteUseCase *useCases.DeleteAuthorUseCase
}
func NewHandler(list *useCases.ListAuthorsUseCase, create *useCases.CreateAuthorUseCase, createMy *useCases.CreateMyAuthorUseCase, update *useCases.UpdateAuthorUseCase, deleteUseCase *useCases.DeleteAuthorUseCase) *Handler {
return &Handler{ListUseCase: list, CreateUseCase: create, CreateMyUseCase: createMy, UpdateUseCase: update, DeleteUseCase: deleteUseCase}
}
type socialLinkRequest struct {
Type string `json:"type" validate:"socialtype"`
URL string `json:"url"`
}
func toLinks(requests []socialLinkRequest) []domain.SocialLink {
links := make([]domain.SocialLink, 0, len(requests))
for _, request := range requests {
links = append(links, domain.SocialLink{Type: request.Type, URL: request.URL})
}
return links
}
type PostAuthor struct {
Name string `json:"name" validate:"authorname"`
Slug string `json:"slug" validate:"omitempty,authorslug"`
Bio string `json:"bio"`
AvatarID *uint32 `json:"avatar_id" validate:"omitempty,gte=1"`
Website *string `json:"website"`
SocialLinks []socialLinkRequest `json:"social_links" validate:"omitempty,dive"`
UserID *uint32 `json:"user_id" validate:"omitempty,gte=1"`
}
type optional[T any] struct {
Set bool
Value *T
}
func (o *optional[T]) UnmarshalJSON(data []byte) error {
o.Set = true
if string(data) == "null" {
o.Value = nil
return nil
}
var value T
if err := json.Unmarshal(data, &value); err != nil {
return err
}
o.Value = &value
return nil
}
type PatchAuthor struct {
Name *string `json:"name" validate:"authorname"`
Slug *string `json:"slug" validate:"authorslug"`
Bio *string `json:"bio"`
AvatarID optional[uint32] `json:"avatar_id"`
Website optional[string] `json:"website"`
SocialLinks *[]socialLinkRequest `json:"social_links" validate:"omitempty,dive"`
UserID optional[uint32] `json:"user_id"`
}
func (p PatchAuthor) toPatch() domain.Patch {
patch := domain.Patch{
Name: p.Name,
Slug: p.Slug,
Bio: p.Bio,
AvatarID: domain.Nullable[uint32]{Set: p.AvatarID.Set, Value: p.AvatarID.Value},
Website: domain.Nullable[string]{Set: p.Website.Set, Value: p.Website.Value},
UserID: domain.Nullable[uint32]{Set: p.UserID.Set, Value: p.UserID.Value},
}
if p.SocialLinks != nil {
links := toLinks(*p.SocialLinks)
patch.SocialLinks = &links
}
return patch
}
var requiredNonNullFields = []string{"name", "slug", "bio", "social_links"}
var nullableFields = []string{"avatar_id", "website", "user_id"}
func readPatchAuthor(r *http.Request) (PatchAuthor, bool) {
raw, err := io.ReadAll(r.Body)
if err != nil {
return PatchAuthor{}, false
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
return PatchAuthor{}, false
}
known := false
for _, name := range requiredNonNullFields {
value, present := fields[name]
if !present {
continue
}
if string(value) == "null" {
return PatchAuthor{}, false
}
known = true
}
for _, name := range nullableFields {
if _, present := fields[name]; present {
known = true
}
}
if !known {
return PatchAuthor{}, false
}
var request PatchAuthor
if err := json.Unmarshal(raw, &request); err != nil {
return PatchAuthor{}, false
}
return request, true
}
func parseAuthorId(r *http.Request) (uint32, error) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
return uint32(id), err
}
type ownAuthorResponse struct {
domain.Author
SlugAdjusted bool `json:"slug_adjusted"`
}
func presentAuthor(a domain.Author) domain.Author {
if a.SocialLinks == nil {
a.SocialLinks = []domain.SocialLink{}
}
return a
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if status != http.StatusOK {
w.WriteHeader(status)
}
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
func writeAuthorError(w http.ResponseWriter, err error) {
switch {
case access.WriteIfMissingPermission(w, err):
case errors.Is(err, domain.ErrAuthorNotFound):
http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrSlugTaken):
http.Error(w, apperr.AuthorSlugTakenErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrAuthorHasArticles):
http.Error(w, apperr.AuthorHasArticlesErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrUserAlreadyLinked):
http.Error(w, apperr.UserAlreadyLinkedErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrAuthorAlreadyLinked):
http.Error(w, apperr.AuthorAlreadyLinkedErrorMessage, http.StatusConflict)
case errors.Is(err, user.ErrUserNotFound):
http.Error(w, apperr.UserNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrAuthorRights):
http.Error(w, apperr.AuthorRightsErrorMessage, http.StatusForbidden)
case errors.Is(err, domain.ErrBioTooLong):
http.Error(w, apperr.BioTooLongErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domain.ErrInvalidURL):
http.Error(w, apperr.InvalidURLErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domain.ErrTooManyLinks):
http.Error(w, apperr.TooManyLinksErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domain.ErrInvalidSocialType):
http.Error(w, apperr.InvalidSocialTypeErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidSlug):
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidReassign):
http.Error(w, apperr.InvalidReassignErrorMessage, http.StatusBadRequest)
case errors.Is(err, image.ErrImageNotFound):
http.Error(w, apperr.AuthorImageNotFoundErrorMessage, http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func writeValidationError(w http.ResponseWriter, err error) {
var failures playground.ValidationErrors
if errors.As(err, &failures) {
for _, failure := range failures {
if failure.Tag() == validator.AuthorSlugTag {
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
return
}
}
}
http.Error(w, err.Error(), http.StatusBadRequest)
}
type listQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"oneof=5 25 50 100"`
Q string `validate:"max=200"`
}
type listResponse struct {
Items []domain.Author `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
func intQueryParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
func readListQuery(values url.Values) (listQuery, error) {
page, err := intQueryParam(values, "page", defaultListPage)
if err != nil {
return listQuery{}, err
}
size, err := intQueryParam(values, "size", defaultListSize)
if err != nil {
return listQuery{}, err
}
return listQuery{Page: page, Size: size, Q: values.Get("q")}, nil
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
query, err := readListQuery(r.URL.Query())
if err != nil || validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
page, err := h.ListUseCase.List(useCases.ListAuthorsCommand{Q: query.Q, Page: query.Page, Size: query.Size, Grants: access.Grants(r)})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
items := make([]domain.Author, 0, len(page.Items))
for _, item := range page.Items {
items = append(items, presentAuthor(item))
}
w.Header().Set("X-Total-Count", strconv.Itoa(page.Total))
writeJSON(w, http.StatusOK, listResponse{Items: items, Total: page.Total, Page: page.Page, Size: page.Size})
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var request PostAuthor
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
writeValidationError(w, err)
return
}
created, err := h.CreateUseCase.Create(r.Context(), useCases.CreateAuthorCommand{
Name: request.Name,
Slug: request.Slug,
Bio: request.Bio,
AvatarID: request.AvatarID,
Website: request.Website,
SocialLinks: toLinks(request.SocialLinks),
UserID: request.UserID,
})
if err != nil {
writeAuthorError(w, err)
return
}
writeJSON(w, http.StatusCreated, presentAuthor(created))
}
func (h *Handler) CreateMine(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
created, err := h.CreateMyUseCase.Create(r.Context(), userId)
if err != nil {
writeAuthorError(w, err)
return
}
writeJSON(w, http.StatusCreated, ownAuthorResponse{Author: presentAuthor(created.Author), SlugAdjusted: created.SlugAdjusted})
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseAuthorId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
request, ok := readPatchAuthor(r)
if !ok {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
writeValidationError(w, err)
return
}
callerID, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
updated, err := h.UpdateUseCase.Update(r.Context(), id, useCases.AuthorCaller{UserID: callerID, Grants: access.Grants(r)}, request.toPatch())
if err != nil {
writeAuthorError(w, err)
return
}
writeJSON(w, http.StatusOK, presentAuthor(updated))
}
func readReassignment(r *http.Request) useCases.Reassignment {
query := r.URL.Query()
if !query.Has(reassignParam) {
return useCases.Reassignment{Mode: useCases.ReassignAbsent}
}
value := query.Get(reassignParam)
if value == reassignNone {
return useCases.Reassignment{Mode: useCases.ReassignNone}
}
parsed, err := strconv.ParseUint(value, 10, 32)
if err != nil {
return useCases.Reassignment{Mode: useCases.ReassignMalformed}
}
return useCases.Reassignment{Mode: useCases.ReassignToAuthor, Destination: uint32(parsed)}
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseAuthorId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
if err := h.DeleteUseCase.Delete(r.Context(), id, readReassignment(r)); err != nil {
writeAuthorError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
@@ -0,0 +1,38 @@
package builtintype
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/response"
domain "RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
)
type Handler struct {
ListUseCase *useCases.ListBuiltinTypesUseCase
GetUseCase *useCases.GetBuiltinTypeUseCase
}
func NewHandler(list *useCases.ListBuiltinTypesUseCase, get *useCases.GetBuiltinTypeUseCase) *Handler {
return &Handler{ListUseCase: list, GetUseCase: get}
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, http.StatusOK, h.ListUseCase.List())
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
found, err := h.GetUseCase.Get(chi.URLParam(r, "key"))
if errors.Is(err, domain.ErrNotFound) {
http.Error(w, apperr.BuiltinTypeNotFoundErrorMessage, http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
response.WriteJSON(w, http.StatusOK, found)
}
@@ -0,0 +1,184 @@
package contenttype
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/response"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
type Handler struct {
CreateUseCase *useCases.CreateContentTypeUseCase
ListUseCase *useCases.ListContentTypesUseCase
GetUseCase *useCases.GetContentTypeUseCase
UpdateUseCase *useCases.UpdateContentTypeUseCase
DeleteUseCase *useCases.DeleteContentTypeUseCase
}
func NewHandler(create *useCases.CreateContentTypeUseCase, list *useCases.ListContentTypesUseCase, get *useCases.GetContentTypeUseCase, update *useCases.UpdateContentTypeUseCase, deleteUseCase *useCases.DeleteContentTypeUseCase) *Handler {
return &Handler{CreateUseCase: create, ListUseCase: list, GetUseCase: get, UpdateUseCase: update, DeleteUseCase: deleteUseCase}
}
type iconInput struct {
value *string
invalid bool
}
func (i *iconInput) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
return nil
}
var text string
if err := json.Unmarshal(data, &text); err != nil {
i.invalid = true
return nil
}
i.value = &text
return nil
}
type PostContentType struct {
Key string `json:"key" validate:"required"`
Name string `json:"name" validate:"required,max=100"`
Icon iconInput `json:"icon"`
Fields []domain.Field `json:"fields"`
}
type PutContentType struct {
Key string `json:"key"`
Name string `json:"name" validate:"required,max=100"`
Icon iconInput `json:"icon"`
Fields []domain.Field `json:"fields"`
}
type contentTypeResponse struct {
ID uint32 `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Icon *string `json:"icon"`
Fields []domain.Field `json:"fields"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func toResponse(contentType domain.ContentType) contentTypeResponse {
fields := contentType.Fields
if fields == nil {
fields = []domain.Field{}
}
return contentTypeResponse{
ID: contentType.ID,
Key: contentType.Key,
Name: contentType.Name,
Icon: contentType.Icon,
Fields: fields,
CreatedAt: contentType.CreatedAt,
UpdatedAt: contentType.UpdatedAt,
}
}
func writeContentTypeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domain.ErrContentTypeNotFound):
http.Error(w, apperr.ContentTypeNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrKeyTaken):
http.Error(w, apperr.ContentTypeKeyTakenErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrHasEntries):
http.Error(w, apperr.ContentTypeHasEntriesErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrReferenced):
http.Error(w, apperr.ContentTypeReferencedErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrSchemaIncompatible):
http.Error(w, apperr.SchemaIncompatibleErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrInvalidName):
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidIcon):
http.Error(w, apperr.InvalidIconErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidKey):
http.Error(w, apperr.InvalidContentTypeKeyErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidField):
http.Error(w, apperr.InvalidFieldErrorMessage, http.StatusUnprocessableEntity)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
found, err := h.ListUseCase.List()
if err != nil {
writeContentTypeError(w, err)
return
}
responses := make([]contentTypeResponse, 0, len(found))
for _, contentType := range found {
responses = append(responses, toResponse(contentType))
}
response.WriteJSON(w, http.StatusOK, responses)
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
found, err := h.GetUseCase.Get(chi.URLParam(r, "key"))
if err != nil {
writeContentTypeError(w, err)
return
}
response.WriteJSON(w, http.StatusOK, toResponse(found))
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var request PostContentType
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if request.Icon.invalid {
writeContentTypeError(w, domain.ErrInvalidIcon)
return
}
created, err := h.CreateUseCase.Create(useCases.CreateContentTypeCommand{Key: request.Key, Name: request.Name, Icon: request.Icon.value, Fields: request.Fields})
if err != nil {
writeContentTypeError(w, err)
return
}
response.WriteJSON(w, http.StatusCreated, toResponse(created))
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
var request PutContentType
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if request.Icon.invalid {
writeContentTypeError(w, domain.ErrInvalidIcon)
return
}
updated, err := h.UpdateUseCase.Update(useCases.UpdateContentTypeCommand{Key: chi.URLParam(r, "key"), Name: request.Name, Icon: request.Icon.value, Fields: request.Fields})
if err != nil {
writeContentTypeError(w, err)
return
}
response.WriteJSON(w, http.StatusOK, toResponse(updated))
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
if err := h.DeleteUseCase.Delete(chi.URLParam(r, "key")); err != nil {
writeContentTypeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+291
View File
@@ -0,0 +1,291 @@
package entry
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/response"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/contenttype"
domain "RenewCMS/internal/domain/entry"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"time"
"github.com/go-chi/chi/v5"
)
type Handler struct {
CreateUseCase *useCases.CreateEntryUseCase
GetUseCase *useCases.GetEntryUseCase
ListUseCase *useCases.ListEntriesUseCase
UpdateUseCase *useCases.UpdateEntryUseCase
DeleteUseCase *useCases.DeleteEntryUseCase
}
func NewHandler(create *useCases.CreateEntryUseCase, get *useCases.GetEntryUseCase, list *useCases.ListEntriesUseCase, update *useCases.UpdateEntryUseCase, deleteUseCase *useCases.DeleteEntryUseCase) *Handler {
return &Handler{CreateUseCase: create, GetUseCase: get, ListUseCase: list, UpdateUseCase: update, DeleteUseCase: deleteUseCase}
}
type EntryBody struct {
Data map[string]any `json:"data" validate:"required"`
}
type entryResponse struct {
ID uint32 `json:"id"`
ContentType string `json:"content_type"`
Data map[string]any `json:"data"`
CreatedBy uint32 `json:"created_by"`
UpdatedBy uint32 `json:"updated_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Media map[string]any `json:"media"`
Relations map[string]any `json:"relations"`
}
type firstFieldBody struct {
Key string `json:"key"`
Type contenttype.FieldType `json:"type"`
Value any `json:"value"`
}
type relationBody struct {
ID uint32 `json:"id"`
ContentType string `json:"content_type"`
FirstField firstFieldBody `json:"first_field"`
}
type missingRelationBody struct {
ID uint32 `json:"id"`
Missing bool `json:"missing"`
}
func toRelationItem(item useCases.EntryRelation) any {
if item.Missing {
return missingRelationBody{ID: item.ID, Missing: true}
}
return relationBody{ID: item.ID, ContentType: item.ContentType, FirstField: firstFieldBody{Key: item.FirstField.Key, Type: item.FirstField.Type, Value: item.FirstField.Value}}
}
func toRelationsResponse(relations map[string]useCases.EntryRelations) map[string]any {
body := make(map[string]any, len(relations))
for key, held := range relations {
if !held.Many {
if len(held.Items) > 0 {
body[key] = toRelationItem(held.Items[0])
}
continue
}
items := make([]any, 0, len(held.Items))
for _, item := range held.Items {
items = append(items, toRelationItem(item))
}
body[key] = items
}
return body
}
type mediaBody struct {
ID uint32 `json:"id"`
Filename string `json:"filename"`
Width *int `json:"width"`
Height *int `json:"height"`
Size int64 `json:"size"`
ThumbnailURL string `json:"thumbnail_url"`
}
type missingMediaBody struct {
ID uint32 `json:"id"`
Missing bool `json:"missing"`
}
func toMediaResponse(media map[string]useCases.EntryMedia) map[string]any {
body := make(map[string]any, len(media))
for key, item := range media {
if item.Missing {
body[key] = missingMediaBody{ID: item.ID, Missing: true}
continue
}
body[key] = mediaBody{ID: item.ID, Filename: item.Filename, Width: item.Width, Height: item.Height, Size: item.Size, ThumbnailURL: item.ThumbnailURL}
}
return body
}
func toResponse(typed useCases.TypedEntry) entryResponse {
return entryResponse{
ID: typed.ID,
ContentType: typed.ContentTypeKey,
Data: typed.Data,
CreatedBy: typed.CreatedBy,
UpdatedBy: typed.UpdatedBy,
CreatedAt: typed.CreatedAt,
UpdatedAt: typed.UpdatedAt,
Media: toMediaResponse(typed.Media),
Relations: toRelationsResponse(typed.Relations),
}
}
type pageResponse struct {
Items []entryResponse `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
const (
defaultListPage = 1
defaultListSize = 25
)
type listQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"oneof=5 25 50 100"`
Sort domain.Sort
}
func intQueryParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
func readListQuery(values url.Values) (listQuery, error) {
page, err := intQueryParam(values, "page", defaultListPage)
if err != nil {
return listQuery{}, err
}
size, err := intQueryParam(values, "size", defaultListSize)
if err != nil {
return listQuery{}, err
}
order := domain.DefaultSort()
if values.Has("sort") {
order, err = domain.ParseSort(values.Get("sort"))
if err != nil {
return listQuery{}, err
}
}
return listQuery{Page: page, Size: size, Sort: order}, nil
}
func parseEntryId(r *http.Request) (uint32, error) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
return uint32(id), err
}
func writeEntryError(w http.ResponseWriter, err error) {
if access.WriteIfMissingPermission(w, err) {
return
}
var fieldError domain.FieldError
switch {
case errors.Is(err, contenttype.ErrContentTypeNotFound):
http.Error(w, apperr.ContentTypeNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrEntryNotFound):
http.Error(w, apperr.EntryNotFoundErrorMessage, http.StatusNotFound)
case errors.As(err, &fieldError):
http.Error(w, apperr.InvalidEntryErrorMessage+": "+fieldError.Error(), fieldErrorStatus(err))
case errors.Is(err, domain.ErrInvalidPage), errors.Is(err, domain.ErrInvalidSort):
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidEntry):
http.Error(w, apperr.InvalidEntryErrorMessage, http.StatusUnprocessableEntity)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func fieldErrorStatus(err error) int {
if errors.Is(err, domain.ErrUniqueViolation) {
return http.StatusConflict
}
return http.StatusUnprocessableEntity
}
func readBody(w http.ResponseWriter, r *http.Request) (EntryBody, bool) {
var body EntryBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || validator.Validate.Struct(body) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return EntryBody{}, false
}
return body, true
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
query, err := readListQuery(r.URL.Query())
if err != nil || validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
page, err := h.ListUseCase.ListPage(useCases.ListEntriesCommand{ContentTypeKey: chi.URLParam(r, "key"), Page: query.Page, Size: query.Size, Sort: query.Sort, Query: r.URL.Query().Get("q"), Caller: access.CallerOf(r)})
if err != nil {
writeEntryError(w, err)
return
}
items := make([]entryResponse, 0, len(page.Items))
for _, typed := range page.Items {
items = append(items, toResponse(typed))
}
response.WriteJSON(w, http.StatusOK, pageResponse{Items: items, Total: page.Total, Page: page.Page, Size: page.Size})
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
id, err := parseEntryId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
found, err := h.GetUseCase.Get(chi.URLParam(r, "key"), id, access.CallerOf(r))
if err != nil {
writeEntryError(w, err)
return
}
response.WriteJSON(w, http.StatusOK, toResponse(found))
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
body, ok := readBody(w, r)
if !ok {
return
}
created, err := h.CreateUseCase.Create(useCases.CreateEntryCommand{ContentTypeKey: chi.URLParam(r, "key"), Data: body.Data, Caller: access.CallerOf(r)})
if err != nil {
writeEntryError(w, err)
return
}
response.WriteJSON(w, http.StatusCreated, toResponse(created))
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseEntryId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
body, ok := readBody(w, r)
if !ok {
return
}
updated, err := h.UpdateUseCase.Update(useCases.UpdateEntryCommand{ContentTypeKey: chi.URLParam(r, "key"), ID: id, Data: body.Data, Caller: access.CallerOf(r)})
if err != nil {
writeEntryError(w, err)
return
}
response.WriteJSON(w, http.StatusOK, toResponse(updated))
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseEntryId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
if err := h.DeleteUseCase.Delete(chi.URLParam(r, "key"), id, access.CallerOf(r)); err != nil {
writeEntryError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+76
View File
@@ -0,0 +1,76 @@
package llms
import (
"io/fs"
"net/http"
"path"
"strings"
"github.com/go-chi/chi/v5"
)
const (
versionPlaceholder = "{{VERSION}}"
baseURLPlaceholder = "{{BASE_URL}}"
pageSuffix = ".md"
cacheControl = "public, max-age=300"
notBuiltNotice = "The LLM documentation was not generated for this build. Run `task build:llms`."
)
type Handler struct {
docs fs.FS
version string
baseURL string
}
func NewHandler(docs fs.FS, version string, publicURL string) *Handler {
return &Handler{
docs: docs,
version: version,
baseURL: strings.TrimRight(publicURL, "/"),
}
}
func (h *Handler) Register(r chi.Router) {
r.Get("/llms.txt", h.serve("llms.txt", "text/plain; charset=utf-8"))
r.Get("/llms-full.txt", h.serve("llms-full.txt", "text/plain; charset=utf-8"))
r.Get("/openapi.json", h.serve("openapi.json", "application/json"))
r.Get("/llms/{page}", h.page)
}
func (h *Handler) page(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "page")
if !strings.HasSuffix(name, pageSuffix) || strings.ContainsAny(name, "/\\") {
h.notFound(w)
return
}
h.write(w, path.Join("llms", name), "text/markdown; charset=utf-8")
}
func (h *Handler) serve(name string, contentType string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
h.write(w, name, contentType)
}
}
func (h *Handler) write(w http.ResponseWriter, name string, contentType string) {
if h.docs == nil {
h.notFound(w)
return
}
raw, err := fs.ReadFile(h.docs, name)
if err != nil {
h.notFound(w)
return
}
text := strings.NewReplacer(versionPlaceholder, h.version, baseURLPlaceholder, h.baseURL).Replace(string(raw))
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", cacheControl)
_, _ = w.Write([]byte(text))
}
func (h *Handler) notFound(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(notBuiltNotice))
}
+43 -8
View File
@@ -90,13 +90,35 @@ func intQueryParam(values url.Values, name string, fallback int) (int, error) {
}
type usageResponse struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Published bool `json:"published"`
Type string `json:"type"`
ID uint32 `json:"id"`
Title string `json:"title,omitempty"`
Published *bool `json:"published,omitempty"`
Fields []string `json:"fields"`
ContentTypeKey string `json:"content_type_key,omitempty"`
ContentTypeName string `json:"content_type_name,omitempty"`
FirstField *usageFirstFieldBody `json:"first_field,omitempty"`
}
type usageFirstFieldBody struct {
Key string `json:"key"`
Type string `json:"type"`
Value any `json:"value"`
}
func toUsageResponse(usage useCases.MediaUsage) usageResponse {
body := usageResponse{Type: usage.Type, ID: usage.ID, Title: usage.Title, Published: usage.Published, Fields: usage.Fields, ContentTypeKey: usage.ContentTypeKey, ContentTypeName: usage.ContentTypeName}
if usage.FirstField != nil {
body.FirstField = &usageFirstFieldBody{Key: usage.FirstField.Key, Type: string(usage.FirstField.Type), Value: usage.FirstField.Value}
}
return body
}
type mergeResponse struct {
ArticlesUpdated int `json:"articles_updated"`
SitesUpdated int `json:"sites_updated"`
AuthorsUpdated int `json:"authors_updated"`
Merged int `json:"merged"`
}
@@ -246,6 +268,19 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
item, err := h.ListUseCase.Get(id)
if err != nil {
writeImageError(w, err)
return
}
writeJSON(w, http.StatusOK, toResponse(item))
}
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
userId, _ := claims.UserId(r)
input, closeFile, ok := upload.ReadImageInput(w, r, userId)
@@ -322,15 +357,15 @@ func (h *Handler) Usage(w http.ResponseWriter, r *http.Request) {
return
}
articles, err := h.UsageUseCase.Usage(id)
usages, err := h.UsageUseCase.Usage(id)
if err != nil {
writeImageError(w, err)
return
}
response := make([]usageResponse, 0, len(articles))
for _, a := range articles {
response = append(response, usageResponse{ID: a.ID, Title: a.Title, Published: a.IsOnline})
response := make([]usageResponse, 0, len(usages))
for _, usage := range usages {
response = append(response, toUsageResponse(usage))
}
writeJSON(w, http.StatusOK, response)
}
@@ -352,7 +387,7 @@ func (h *Handler) Merge(w http.ResponseWriter, r *http.Request) {
return
}
writeJSON(w, http.StatusOK, mergeResponse{ArticlesUpdated: result.ArticlesUpdated, Merged: result.Merged})
writeJSON(w, http.StatusOK, mergeResponse{ArticlesUpdated: result.ArticlesUpdated, SitesUpdated: result.SitesUpdated, AuthorsUpdated: result.AuthorsUpdated, Merged: result.Merged})
}
func writeMergeError(w http.ResponseWriter, err error) {
+4
View File
@@ -53,6 +53,8 @@ type purgeRunResponse struct {
GroupsPurged int `json:"groups_purged"`
Merged int `json:"merged"`
ArticlesUpdated int `json:"articles_updated"`
SitesUpdated int `json:"sites_updated"`
AuthorsUpdated int `json:"authors_updated"`
FailedGroups int `json:"failed_groups"`
}
@@ -164,6 +166,8 @@ func (h *Handler) RunPurge(w http.ResponseWriter, r *http.Request) {
GroupsPurged: result.GroupsPurged,
Merged: result.Merged,
ArticlesUpdated: result.ArticlesUpdated,
SitesUpdated: result.SitesUpdated,
AuthorsUpdated: result.AuthorsUpdated,
FailedGroups: result.FailedGroups,
})
}
@@ -0,0 +1,69 @@
package ownership
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/payload"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
)
type Handler struct {
TransferUseCase *useCases.TransferOwnershipUseCase
}
type TransferRequest struct {
UserID uint32 `json:"user_id" validate:"required"`
ConfirmEmail string `json:"confirm_email" validate:"required"`
}
func NewHandler(transfer *useCases.TransferOwnershipUseCase) *Handler {
return &Handler{TransferUseCase: transfer}
}
func (h *Handler) Transfer(w http.ResponseWriter, r *http.Request) {
var request TransferRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
transferred, err := h.TransferUseCase.Transfer(r.Context(), useCases.TransferOwnershipCommand{
Caller: access.CallerOf(r),
UserID: request.UserID,
ConfirmEmail: request.ConfirmEmail,
})
if err != nil {
writeTransferError(w, err)
return
}
body, err := json.Marshal(payload.OwnershipTransferOf(transferred))
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
func writeTransferError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, user.ErrInvalidTransfer):
http.Error(w, apperr.InvalidTransferErrorMessage, http.StatusBadRequest)
case errors.Is(err, user.ErrConfirmationMismatch):
http.Error(w, apperr.ConfirmationMismatchErrorMessage, http.StatusBadRequest)
case errors.Is(err, user.ErrRoleChanged):
http.Error(w, apperr.UserRoleChangedErrorMessage, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
@@ -0,0 +1,53 @@
package permission
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"net/http"
)
type Handler struct {
ListUseCase *useCases.ListPermissionsUseCase
}
func NewHandler(list *useCases.ListPermissionsUseCase) *Handler {
return &Handler{ListUseCase: list}
}
type permissionItem struct {
Key string `json:"key"`
ScopeKind role.Kind `json:"scope_kind"`
OwnerOnly bool `json:"owner_only"`
PerType bool `json:"per_type"`
}
type permissionGroup struct {
Key string `json:"key"`
Items []permissionItem `json:"items"`
}
type permissionResponse struct {
Groups []permissionGroup `json:"groups"`
}
func toResponse(groups []useCases.PermissionGroup) permissionResponse {
response := permissionResponse{Groups: make([]permissionGroup, 0, len(groups))}
for _, group := range groups {
items := make([]permissionItem, 0, len(group.Items))
for _, permission := range group.Items {
items = append(items, permissionItem{Key: permission.Key, ScopeKind: permission.Kind, OwnerOnly: permission.OwnerOnly, PerType: permission.PerType()})
}
response.Groups = append(response.Groups, permissionGroup{Key: group.Key, Items: items})
}
return response
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
body, err := json.Marshal(toResponse(h.ListUseCase.List()))
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
_, _ = w.Write(body)
}
+251
View File
@@ -0,0 +1,251 @@
package role
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/payload"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"log"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
type Handler struct {
ListUseCase *useCases.ListRolesUseCase
GetUseCase *useCases.GetRoleUseCase
CreateUseCase *useCases.CreateRoleUseCase
UpdateUseCase *useCases.UpdateRoleUseCase
DeleteUseCase *useCases.DeleteRoleUseCase
ReassignUseCase *useCases.ReassignRoleMembersUseCase
}
func NewHandler(list *useCases.ListRolesUseCase, get *useCases.GetRoleUseCase, create *useCases.CreateRoleUseCase, update *useCases.UpdateRoleUseCase, deleteUseCase *useCases.DeleteRoleUseCase, reassign *useCases.ReassignRoleMembersUseCase) *Handler {
return &Handler{ListUseCase: list, GetUseCase: get, CreateUseCase: create, UpdateUseCase: update, DeleteUseCase: deleteUseCase, ReassignUseCase: reassign}
}
type GrantRequest struct {
Key string `json:"key" validate:"required,max=100"`
Scope string `json:"scope"`
}
type PostRole struct {
Name string `json:"name" validate:"required,max=100"`
Description string `json:"description" validate:"max=255"`
Grants []GrantRequest `json:"grants" validate:"dive"`
}
type PutRole struct {
Name string `json:"name" validate:"required,max=100"`
Description string `json:"description" validate:"max=255"`
Grants []GrantRequest `json:"grants" validate:"dive"`
Version uint32 `json:"version" validate:"required,min=1"`
}
type ResetRole struct {
Version uint32 `json:"version" validate:"required,min=1"`
}
type ReassignRole struct {
TargetRoleID uint32 `json:"target_role_id" validate:"required"`
}
func toGrants(requested []GrantRequest) []domain.Grant {
grants := make([]domain.Grant, 0, len(requested))
for _, grant := range requested {
grants = append(grants, domain.Grant{Key: grant.Key, Scope: domain.Scope(grant.Scope)})
}
return grants
}
func parseRoleId(r *http.Request) (uint32, error) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
return uint32(id), err
}
func decodeRequest(w http.ResponseWriter, r *http.Request, request any) bool {
if err := json.NewDecoder(r.Body).Decode(request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return false
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return false
}
return true
}
func readId(w http.ResponseWriter, r *http.Request) (uint32, bool) {
id, err := parseRoleId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return 0, false
}
return id, true
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if status != http.StatusOK {
w.WriteHeader(status)
}
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
type refusal struct {
cause error
status int
message string
}
var refusals = []refusal{
{domain.ErrRoleNotFound, http.StatusNotFound, apperr.RoleNotFoundErrorMessage},
{domain.ErrRoleLocked, http.StatusForbidden, apperr.RoleLockedErrorMessage},
{domain.ErrBuiltInRole, http.StatusConflict, apperr.RoleBuiltInErrorMessage},
{domain.ErrBuiltInName, http.StatusBadRequest, apperr.BuiltInNameErrorMessage},
{domain.ErrNotBuiltIn, http.StatusBadRequest, apperr.NotBuiltInErrorMessage},
{domain.ErrOwnRole, http.StatusForbidden, apperr.OwnRoleErrorMessage},
{domain.ErrRoleNameTaken, http.StatusConflict, apperr.RoleNameTakenErrorMessage},
{domain.ErrRoleConflict, http.StatusConflict, apperr.RoleConflictErrorMessage},
{domain.ErrRoleHasMembers, http.StatusConflict, apperr.RoleHasMembersErrorMessage},
{domain.ErrReassignTargetNotFound, http.StatusConflict, apperr.ReassignTargetNotFoundErrorMessage},
{domain.ErrInvalidReassign, http.StatusBadRequest, apperr.InvalidReassignErrorMessage},
{domain.ErrInvalidRoleName, http.StatusBadRequest, apperr.InvalidRoleNameErrorMessage},
{domain.ErrUnknownPermission, http.StatusBadRequest, apperr.UnknownPermissionErrorMessage},
{domain.ErrInvalidScope, http.StatusBadRequest, apperr.InvalidScopeErrorMessage},
{domain.ErrOwnerOnlyGrant, http.StatusBadRequest, apperr.OwnerOnlyPermissionErrorMessage},
{domain.ErrDuplicateGrant, http.StatusBadRequest, apperr.DuplicatePermissionErrorMessage},
{domain.ErrUnknownContentType, http.StatusBadRequest, apperr.UnknownContentTypeErrorMessage},
}
func writeRoleError(w http.ResponseWriter, err error) {
if access.WriteIfMissingPermission(w, err) {
return
}
var escalation domain.EscalationError
if errors.As(err, &escalation) {
w.Header().Set(apperr.EscalationHeader, escalation.Key)
http.Error(w, apperr.EscalationErrorMessage, http.StatusForbidden)
return
}
for _, candidate := range refusals {
if errors.Is(err, candidate.cause) {
http.Error(w, candidate.message, candidate.status)
return
}
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
func (h *Handler) List(w http.ResponseWriter, _ *http.Request) {
listed, err := h.ListUseCase.List()
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusOK, payload.RoleViewsOf(listed))
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
id, ok := readId(w, r)
if !ok {
return
}
found, err := h.GetUseCase.Get(id)
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusOK, payload.RoleViewOf(found))
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var request PostRole
if !decodeRequest(w, r, &request) {
return
}
created, err := h.CreateUseCase.Create(r.Context(), useCases.CreateRoleCommand{
Caller: access.CallerOf(r), Name: request.Name, Description: request.Description, Grants: toGrants(request.Grants),
})
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusCreated, payload.RoleViewOf(created))
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
id, ok := readId(w, r)
if !ok {
return
}
var request PutRole
if !decodeRequest(w, r, &request) {
return
}
updated, err := h.UpdateUseCase.Update(r.Context(), useCases.UpdateRoleCommand{
Caller: access.CallerOf(r), ID: id, Name: request.Name, Description: request.Description, Grants: toGrants(request.Grants), Version: request.Version,
})
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusOK, payload.RoleViewOf(updated))
}
func (h *Handler) Reset(w http.ResponseWriter, r *http.Request) {
id, ok := readId(w, r)
if !ok {
return
}
var request ResetRole
if !decodeRequest(w, r, &request) {
return
}
restored, err := h.UpdateUseCase.Reset(r.Context(), useCases.ResetRoleCommand{Caller: access.CallerOf(r), ID: id, Version: request.Version})
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusOK, payload.RoleViewOf(restored))
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
id, ok := readId(w, r)
if !ok {
return
}
if err := h.DeleteUseCase.Delete(r.Context(), useCases.DeleteRoleCommand{Caller: access.CallerOf(r), ID: id}); err != nil {
writeRoleError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Reassign(w http.ResponseWriter, r *http.Request) {
id, ok := readId(w, r)
if !ok {
return
}
var request ReassignRole
if !decodeRequest(w, r, &request) {
return
}
moved, err := h.ReassignUseCase.Reassign(r.Context(), useCases.ReassignRoleMembersCommand{Caller: access.CallerOf(r), SourceID: id, TargetID: request.TargetRoleID})
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]int{"moved": moved})
}
+296
View File
@@ -0,0 +1,296 @@
package site
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/image"
domain "RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"log"
"net/http"
"net/url"
"strconv"
"github.com/go-chi/chi/v5"
)
type Handler struct {
ListUseCase *useCases.ListSitesUseCase
CreateUseCase *useCases.CreateSiteUseCase
UpdateUseCase *useCases.UpdateSiteUseCase
SetDefaultUseCase *useCases.SetDefaultSiteUseCase
DeleteUseCase *useCases.DeleteSiteUseCase
}
func NewHandler(list *useCases.ListSitesUseCase, create *useCases.CreateSiteUseCase, update *useCases.UpdateSiteUseCase, setDefault *useCases.SetDefaultSiteUseCase, deleteUseCase *useCases.DeleteSiteUseCase) *Handler {
return &Handler{ListUseCase: list, CreateUseCase: create, UpdateUseCase: update, SetDefaultUseCase: setDefault, DeleteUseCase: deleteUseCase}
}
type PostSite struct {
Name string `json:"name" validate:"required,sitename"`
Key string `json:"key" validate:"required,sitekey"`
BaseURL string `json:"base_url" validate:"required"`
DefaultRobots string `json:"default_robots" validate:"omitempty,oneof=index noindex"`
Locale *string `json:"locale"`
DefaultSocialImageID *uint32 `json:"default_social_image_id"`
IsDefault bool `json:"is_default"`
SiteName string `json:"site_name" validate:"max=100"`
TitleTemplate *string `json:"title_template" validate:"omitempty,max=200"`
DefaultDescription string `json:"default_description" validate:"max=320"`
}
type optional[T any] struct {
Set bool
Value *T
}
func (o *optional[T]) UnmarshalJSON(data []byte) error {
o.Set = true
if string(data) == "null" {
o.Value = nil
return nil
}
var value T
if err := json.Unmarshal(data, &value); err != nil {
return err
}
o.Value = &value
return nil
}
type PatchSite struct {
Name *string `json:"name" validate:"omitempty,sitename"`
Key *string `json:"key" validate:"omitempty,sitekey"`
BaseURL *string `json:"base_url"`
DefaultRobots *string `json:"default_robots" validate:"omitempty,oneof=index noindex"`
Locale optional[string] `json:"locale"`
DefaultSocialImageID optional[uint32] `json:"default_social_image_id"`
IsDefault *bool `json:"is_default"`
SiteName *string `json:"site_name" validate:"omitempty,max=100"`
TitleTemplate *string `json:"title_template" validate:"omitempty,max=200"`
DefaultDescription *string `json:"default_description" validate:"omitempty,max=320"`
}
func (p PatchSite) toPatch() domain.Patch {
return domain.Patch{
Name: p.Name,
Key: p.Key,
BaseURL: p.BaseURL,
DefaultRobots: p.DefaultRobots,
Locale: domain.Nullable[string]{Set: p.Locale.Set, Value: p.Locale.Value},
DefaultSocialImageID: domain.Nullable[uint32]{Set: p.DefaultSocialImageID.Set, Value: p.DefaultSocialImageID.Value},
IsDefault: p.IsDefault,
SiteName: p.SiteName,
TitleTemplate: p.TitleTemplate,
DefaultDescription: p.DefaultDescription,
}
}
func parseSiteId(r *http.Request) (uint32, error) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
return uint32(id), err
}
func writeSite(w http.ResponseWriter, status int, s domain.Site) {
body, err := json.Marshal(s)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if status != http.StatusOK {
w.WriteHeader(status)
}
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
func writeSiteError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domain.ErrSiteNotFound):
http.Error(w, apperr.SiteNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrSiteKeyTaken):
http.Error(w, apperr.SiteKeyTakenErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrSiteHasArticles):
http.Error(w, apperr.SiteHasArticlesErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrDefaultSite):
http.Error(w, apperr.DefaultSiteErrorMessage, http.StatusForbidden)
case errors.Is(err, domain.ErrKeyImmutable):
http.Error(w, apperr.SiteKeyImmutableErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidSiteURL):
http.Error(w, apperr.InvalidSiteURLErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidTitleTemplate):
http.Error(w, apperr.InvalidTitleTemplateErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidLocale):
http.Error(w, apperr.InvalidLocaleErrorMessage, http.StatusBadRequest)
case errors.Is(err, image.ErrImageNotFound):
http.Error(w, apperr.SiteImageNotFoundErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrUseSetDefault):
http.Error(w, apperr.UseSetDefaultErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidReassign):
http.Error(w, apperr.InvalidReassignErrorMessage, http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
const (
defaultListPage = 1
defaultListSize = 25
)
type listQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"omitempty,oneof=5 25 50 100"`
Q string `validate:"max=200"`
}
func intQueryParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
func readListQuery(values url.Values) (listQuery, error) {
paged := values.Has("page") || values.Has("size")
page, err := intQueryParam(values, "page", defaultListPage)
if err != nil {
return listQuery{}, err
}
size := 0
if paged {
size, err = intQueryParam(values, "size", defaultListSize)
if err != nil {
return listQuery{}, err
}
if size == 0 {
return listQuery{}, errors.New("invalid size")
}
}
return listQuery{Page: page, Size: size, Q: values.Get("q")}, nil
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
query, err := readListQuery(r.URL.Query())
if err != nil || validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
page, err := h.ListUseCase.ListPage(useCases.ListSitesCommand{Q: query.Q, Page: query.Page, Size: query.Size})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
body, err := json.Marshal(page.Items)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
w.Header().Set("X-Total-Count", strconv.Itoa(page.Total))
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var request PostSite
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
created, err := h.CreateUseCase.Create(r.Context(), useCases.CreateSiteCommand{
Name: request.Name,
Key: request.Key,
BaseURL: request.BaseURL,
DefaultRobots: request.DefaultRobots,
Locale: request.Locale,
DefaultSocialImageID: request.DefaultSocialImageID,
IsDefault: request.IsDefault,
SiteName: request.SiteName,
TitleTemplate: request.TitleTemplate,
DefaultDescription: request.DefaultDescription,
})
if err != nil {
writeSiteError(w, err)
return
}
writeSite(w, http.StatusCreated, created)
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseSiteId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
var request PatchSite
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
updated, err := h.UpdateUseCase.Update(r.Context(), id, request.toPatch())
if err != nil {
writeSiteError(w, err)
return
}
writeSite(w, http.StatusOK, updated)
}
func (h *Handler) SetDefault(w http.ResponseWriter, r *http.Request) {
id, err := parseSiteId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
updated, err := h.SetDefaultUseCase.SetDefault(r.Context(), id)
if err != nil {
writeSiteError(w, err)
return
}
writeSite(w, http.StatusOK, updated)
}
const reassignQueryParam = "reassign_to"
func readReassign(r *http.Request) (*uint32, bool) {
query := r.URL.Query()
if !query.Has(reassignQueryParam) {
return nil, false
}
parsed, err := strconv.ParseUint(query.Get(reassignQueryParam), 10, 32)
if err != nil {
return nil, true
}
destination := uint32(parsed)
return &destination, false
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseSiteId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
reassignTo, invalid := readReassign(r)
if err := h.DeleteUseCase.Delete(r.Context(), useCases.DeleteSiteCommand{ID: id, ReassignTo: reassignTo, ReassignInvalid: invalid}); err != nil {
writeSiteError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+87
View File
@@ -0,0 +1,87 @@
package site
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"log"
"net/http"
"net/url"
"strconv"
"time"
"github.com/go-chi/chi/v5"
)
const (
defaultSitemapPage = 1
defaultSitemapSize = 100
totalCountHeader = "X-Total-Count"
)
type SitemapHandler struct {
ListUseCase *useCases.ListSitemapUseCase
}
func NewSitemapHandler(list *useCases.ListSitemapUseCase) *SitemapHandler {
return &SitemapHandler{ListUseCase: list}
}
type sitemapQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"oneof=25 50 100 500"`
}
type sitemapItem struct {
URL string `json:"url"`
ModifiedAt string `json:"modified_at"`
Noindex bool `json:"noindex"`
}
func sitemapIntParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
func readSitemapQuery(values url.Values) (sitemapQuery, error) {
page, err := sitemapIntParam(values, "page", defaultSitemapPage)
if err != nil {
return sitemapQuery{}, err
}
size, err := sitemapIntParam(values, "size", defaultSitemapSize)
if err != nil {
return sitemapQuery{}, err
}
return sitemapQuery{Page: page, Size: size}, nil
}
func (h *SitemapHandler) Sitemap(w http.ResponseWriter, r *http.Request) {
query, err := readSitemapQuery(r.URL.Query())
if err != nil || validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
page, err := h.ListUseCase.List(chi.URLParam(r, "key"), query.Page, query.Size)
if err != nil {
writeSiteError(w, err)
return
}
items := make([]sitemapItem, 0, len(page.Entries))
for _, entry := range page.Entries {
items = append(items, sitemapItem{URL: entry.URL, ModifiedAt: entry.ModifiedAt.UTC().Format(time.RFC3339), Noindex: entry.Noindex})
}
body, err := json.Marshal(items)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
w.Header().Set(totalCountHeader, strconv.Itoa(page.Total))
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
+7 -30
View File
@@ -1,11 +1,12 @@
package update
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/role"
domain "RenewCMS/internal/domain/update"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
@@ -21,29 +22,23 @@ type Handler struct {
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
RequestRestartUseCase *useCases.RequestRestartUseCase
GetUserUseCase *useCases.GetUserUseCase
}
type restartRequest struct {
Kind *string `json:"kind" validate:"omitempty,oneof=update restart"`
}
func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase, startDownload *useCases.StartUpdateDownloadUseCase, requestRestart *useCases.RequestRestartUseCase, getUser *useCases.GetUserUseCase) *Handler {
func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase, startDownload *useCases.StartUpdateDownloadUseCase, requestRestart *useCases.RequestRestartUseCase) *Handler {
return &Handler{
GetUpdateStatusUseCase: getStatus,
CheckForUpdateUseCase: checkForUpdate,
StartUpdateDownloadUseCase: startDownload,
RequestRestartUseCase: requestRestart,
GetUserUseCase: getUser,
}
}
func (h *Handler) GetStatus(w http.ResponseWriter, r *http.Request) {
isOwner, ok := h.isOwner(w, r)
if !ok {
return
}
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner))
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(canApply(r)))
}
func (h *Handler) Check(w http.ResponseWriter, r *http.Request) {
@@ -51,11 +46,7 @@ func (h *Handler) Check(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
isOwner, ok := h.isOwner(w, r)
if !ok {
return
}
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner))
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(canApply(r)))
}
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
@@ -105,22 +96,8 @@ func decodeRestartKind(w http.ResponseWriter, r *http.Request) (string, bool) {
return *request.Kind, true
}
func (h *Handler) isOwner(w http.ResponseWriter, r *http.Request) (bool, bool) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return false, false
}
currentUser, err := h.GetUserUseCase.GetUser(userId)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return false, false
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return false, false
}
return currentUser.Role == user.RoleOwner, true
func canApply(r *http.Request) bool {
return access.Grants(r).Holds(role.KeySystemApplyUpdates)
}
func writeDownloadError(w http.ResponseWriter, err error) {
+81
View File
@@ -0,0 +1,81 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"time"
)
type DeleteAccountHandler struct {
DeleteUseCase *useCases.DeleteOwnAccountUseCase
IsProduction bool
}
type DeleteAccountRequest struct {
CurrentPassword string `json:"current_password" validate:"required"`
Confirmation string `json:"confirmation" validate:"required,eq=DELETE"`
}
func NewDeleteAccountHandler(deleteAccount *useCases.DeleteOwnAccountUseCase, isProduction bool) *DeleteAccountHandler {
return &DeleteAccountHandler{DeleteUseCase: deleteAccount, IsProduction: isProduction}
}
func (h *DeleteAccountHandler) Delete(w http.ResponseWriter, r *http.Request) {
var request DeleteAccountRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
command := useCases.DeleteOwnAccountCommand{UserID: userId, CurrentPassword: request.CurrentPassword}
command.SessionID, command.SessionExpiresAt = sessionOf(r)
if err := h.DeleteUseCase.Delete(r.Context(), command); err != nil {
writeDeleteAccountError(w, err)
return
}
http.SetCookie(w, authtoken.ExpiredCookie(h.IsProduction))
w.WriteHeader(http.StatusNoContent)
}
func sessionOf(r *http.Request) (string, time.Time) {
tokenClaims, err := authtoken.FromContext(r.Context())
if err != nil {
return "", time.Time{}
}
id, _ := tokenClaims["jti"].(string)
expiry, _ := tokenClaims["exp"].(float64)
return id, time.Unix(int64(expiry), 0)
}
func writeDeleteAccountError(w http.ResponseWriter, err error) {
var lastAdmin domain.LastAdminError
switch {
case errors.Is(err, domain.ErrUserNotFound):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
case errors.Is(err, domain.ErrWrongPassword):
http.Error(w, apperr.WrongPasswordErrorCode, http.StatusForbidden)
case errors.As(err, &lastAdmin):
w.Header().Set(apperr.LastRoleHeader, lastAdmin.Role)
http.Error(w, apperr.LastAdminErrorCode, http.StatusConflict)
case errors.Is(err, domain.ErrAccountNotDeletable):
http.Error(w, apperr.AccountNotDeletableErrorCode, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+44
View File
@@ -0,0 +1,44 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"net/http"
"strconv"
)
type ExportHandler struct {
ExportUseCase *useCases.ExportUserDataUseCase
}
func NewExportHandler(export *useCases.ExportUserDataUseCase) *ExportHandler {
return &ExportHandler{ExportUseCase: export}
}
func (h *ExportHandler) Export(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
archive, err := h.ExportUseCase.Export(r.Context(), userId)
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "Service unavailable", http.StatusServiceUnavailable)
return
}
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Disposition", "attachment; filename=\""+archive.Filename+"\"")
w.Header().Set("Content-Length", strconv.Itoa(len(archive.Content)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(archive.Content)
}
+58 -36
View File
@@ -1,8 +1,10 @@
package user
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/payload"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user"
@@ -24,35 +26,39 @@ type InviteHandler struct {
}
type CreateInviteRequest struct {
Email string `json:"email" validate:"required,inviteemail"`
Email string `json:"email" validate:"required,inviteemail"`
RoleID uint32 `json:"role_id"`
}
type inviteResponse struct {
ID uint32 `json:"id"`
Kind string `json:"kind"`
Email string `json:"email"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
ID uint32 `json:"id"`
Kind string `json:"kind"`
Email string `json:"email"`
Status string `json:"status"`
Role payload.RoleRef `json:"role"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type inviteListItem struct {
ID uint32 `json:"id"`
Kind string `json:"kind"`
Email string `json:"email"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
ID uint32 `json:"id"`
Kind string `json:"kind"`
Email string `json:"email"`
Role *payload.RoleRef `json:"role"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
type userListItem struct {
ID uint32 `json:"id"`
Kind string `json:"kind"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
ID uint32 `json:"id"`
Kind string `json:"kind"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Role *payload.RoleRef `json:"role"`
RoleAssignedBy *payload.RoleAssigner `json:"role_assigned_by"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
func NewInviteHandler(createInvite *useCases.CreateInviteUseCase, cancelInvite *useCases.CancelInviteUseCase, renewInvite *useCases.RenewInviteUseCase, listUsersAndInvites *useCases.ListUsersAndInvitesUseCase) *InviteHandler {
@@ -83,8 +89,10 @@ func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
}
created, err := h.CreateInviteUseCase.CreateInvite(r.Context(), useCases.CreateInviteCommand{
Caller: access.CallerOf(r),
Email: request.Email,
InvitedBy: userId,
RoleID: request.RoleID,
})
if err != nil {
writeCreateInviteError(w, err)
@@ -94,12 +102,13 @@ func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
writeInviteResponse(w, http.StatusCreated, created)
}
func writeInviteResponse(w http.ResponseWriter, status int, entry invite.Invite) {
func writeInviteResponse(w http.ResponseWriter, status int, entry useCases.InviteView) {
body, err := json.Marshal(inviteResponse{
ID: entry.ID,
Kind: useCases.EntryKindInvite,
Email: entry.Email,
Status: string(entry.StatusAt(time.Now())),
Role: payload.RoleRefOf(entry.Role),
CreatedAt: entry.CreatedAt,
ExpiresAt: entry.ExpiresAt,
})
@@ -144,7 +153,7 @@ func (h *InviteHandler) RenewInvite(w http.ResponseWriter, r *http.Request) {
return
}
renewed, err := h.RenewInviteUseCase.RenewInvite(r.Context(), useCases.RenewInviteCommand{ID: id, RenewedBy: userId})
renewed, err := h.RenewInviteUseCase.RenewInvite(r.Context(), useCases.RenewInviteCommand{Caller: access.CallerOf(r), ID: id, RenewedBy: userId})
if err != nil {
writeManageInviteError(w, err)
return
@@ -153,6 +162,9 @@ func (h *InviteHandler) RenewInvite(w http.ResponseWriter, r *http.Request) {
}
func writeManageInviteError(w http.ResponseWriter, err error) {
if writeRoleRefusal(w, err) {
return
}
switch {
case errors.Is(err, invite.ErrInviteNotFound):
http.Error(w, apperr.InviteNotFoundErrorMessage, http.StatusNotFound)
@@ -168,10 +180,15 @@ func writeManageInviteError(w http.ResponseWriter, err error) {
}
func writeCreateInviteError(w http.ResponseWriter, err error) {
if writeRoleRefusal(w, err) {
return
}
switch {
case errors.Is(err, invite.ErrEmailHasAccount):
w.Header().Set(apperr.ErrorCodeHeader, apperr.EmailHasAccountErrorCode)
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrPendingInvite):
w.Header().Set(apperr.ErrorCodeHeader, apperr.InvitePendingErrorCode)
http.Error(w, apperr.PendingInviteErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrInviteEmailNotSent):
http.Error(w, apperr.InviteNotSentErrorMessage, http.StatusInternalServerError)
@@ -183,22 +200,27 @@ func writeCreateInviteError(w http.ResponseWriter, err error) {
}
func (h *InviteHandler) ListUsers(w http.ResponseWriter, _ *http.Request) {
entries := h.ListUsersAndInvitesUseCase.ListUsersAndInvites()
items := make([]any, 0, len(entries))
for _, entry := range entries {
if entry.Kind == useCases.EntryKindInvite {
items = append(items, inviteListItem{entry.ID, entry.Kind, entry.Email, entry.Status, entry.CreatedAt})
continue
}
items = append(items, userListItem{entry.ID, entry.Kind, entry.Username, entry.DisplayName, entry.Email, entry.Role, entry.Status, entry.CreatedAt})
}
body, err := json.Marshal(items)
entries, err := h.ListUsersAndInvitesUseCase.ListUsersAndInvites()
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
items := make([]any, 0, len(entries))
for _, entry := range entries {
if entry.Kind == useCases.EntryKindInvite {
items = append(items, inviteListItem{
ID: entry.ID,
Kind: entry.Kind,
Email: entry.Email,
Role: payload.RoleRefPtrOf(entry.Role),
Status: entry.Status,
CreatedAt: entry.CreatedAt,
})
continue
}
items = append(items, userListItemOf(entry))
}
writeNoStoreJSON(w, http.StatusOK, items)
}
+66
View File
@@ -0,0 +1,66 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/response"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"strconv"
"strings"
)
type LookupHandler struct {
LookupUseCase *useCases.LookupUserNamesUseCase
}
type userNameResponse struct {
ID uint32 `json:"id"`
DisplayName string `json:"display_name"`
}
func NewLookupHandler(lookup *useCases.LookupUserNamesUseCase) *LookupHandler {
return &LookupHandler{LookupUseCase: lookup}
}
func parseLookupIDs(raw string) ([]uint32, bool) {
if raw == "" {
return nil, false
}
if strings.Count(raw, ",")+1 > domainUser.MaxLookupIDs {
return nil, false
}
parts := strings.Split(raw, ",")
ids := make([]uint32, 0, len(parts))
for _, part := range parts {
id, err := strconv.ParseUint(part, 10, 32)
if err != nil {
return nil, false
}
ids = append(ids, uint32(id))
}
return ids, true
}
func (h *LookupHandler) Lookup(w http.ResponseWriter, r *http.Request) {
ids, ok := parseLookupIDs(r.URL.Query().Get("ids"))
if !ok {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
names, err := h.LookupUseCase.Lookup(ids)
if errors.Is(err, domainUser.ErrInvalidLookup) {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
items := make([]userNameResponse, 0, len(names))
for _, name := range names {
items = append(items, userNameResponse{ID: name.ID, DisplayName: name.DisplayName})
}
response.WriteJSON(w, http.StatusOK, items)
}
+62
View File
@@ -0,0 +1,62 @@
package user
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/role"
domain "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
type RemoveHandler struct {
RemoveUserUseCase *useCases.RemoveUserUseCase
}
func NewRemoveHandler(removeUser *useCases.RemoveUserUseCase) *RemoveHandler {
return &RemoveHandler{RemoveUserUseCase: removeUser}
}
func (h *RemoveHandler) Remove(w http.ResponseWriter, r *http.Request) {
targetID, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
expectedRoleID, err := strconv.ParseUint(r.URL.Query().Get("expected_role_id"), 10, 32)
if err != nil || expectedRoleID == 0 {
http.Error(w, apperr.InvalidRoleErrorMessage, http.StatusBadRequest)
return
}
err = h.RemoveUserUseCase.Remove(r.Context(), useCases.RemoveUserCommand{
Caller: access.CallerOf(r),
TargetID: uint32(targetID),
ExpectedRoleID: uint32(expectedRoleID),
})
if err != nil {
writeRemoveError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func writeRemoveError(w http.ResponseWriter, err error) {
if writeRoleRefusal(w, err) {
return
}
switch {
case errors.Is(err, role.ErrOwnRole):
http.Error(w, apperr.OwnRoleErrorMessage, http.StatusForbidden)
case errors.Is(err, domain.ErrUserNotFound):
http.Error(w, apperr.UserNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrRoleChanged):
http.Error(w, apperr.UserRoleChangedErrorMessage, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+73
View File
@@ -0,0 +1,73 @@
package user
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/role"
domain "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
type RoleHandler struct {
ChangeUserRoleUseCase *useCases.ChangeUserRoleUseCase
}
type ChangeRoleRequest struct {
RoleID uint32 `json:"role_id" validate:"required"`
}
func NewRoleHandler(changeRole *useCases.ChangeUserRoleUseCase) *RoleHandler {
return &RoleHandler{ChangeUserRoleUseCase: changeRole}
}
func (h *RoleHandler) Change(w http.ResponseWriter, r *http.Request) {
targetID, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
var request ChangeRoleRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
changed, err := h.ChangeUserRoleUseCase.ChangeRole(r.Context(), useCases.ChangeUserRoleCommand{
Caller: access.CallerOf(r),
TargetID: uint32(targetID),
RoleID: request.RoleID,
})
if err != nil {
writeChangeRoleError(w, err)
return
}
writeNoStoreJSON(w, http.StatusOK, userListItemOf(changed))
}
func writeChangeRoleError(w http.ResponseWriter, err error) {
if writeRoleRefusal(w, err) {
return
}
switch {
case errors.Is(err, role.ErrOwnRole):
http.Error(w, apperr.OwnRoleErrorMessage, http.StatusForbidden)
case errors.Is(err, domain.ErrUserNotFound):
http.Error(w, apperr.UserNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrRoleChanged):
http.Error(w, apperr.UserRoleChangedErrorMessage, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+52
View File
@@ -0,0 +1,52 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/payload"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
)
func userListItemOf(entry useCases.DirectoryEntry) userListItem {
return userListItem{
ID: entry.ID,
Kind: entry.Kind,
Username: entry.Username,
DisplayName: entry.DisplayName,
Email: entry.Email,
Role: payload.RoleRefPtrOf(entry.Role),
RoleAssignedBy: payload.RoleAssignerPtrOf(entry.RoleAssignedBy),
Status: entry.Status,
CreatedAt: entry.CreatedAt,
}
}
func writeNoStoreJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_, _ = w.Write(body)
}
func writeRoleRefusal(w http.ResponseWriter, err error) bool {
var escalation role.EscalationError
switch {
case errors.As(err, &escalation):
w.Header().Set(apperr.EscalationHeader, escalation.Key)
http.Error(w, apperr.EscalationErrorMessage, http.StatusForbidden)
case errors.Is(err, role.ErrRoleLocked):
http.Error(w, apperr.RoleLockedErrorMessage, http.StatusForbidden)
case errors.Is(err, role.ErrUnknownRole):
http.Error(w, apperr.InvalidRoleErrorMessage, http.StatusBadRequest)
default:
return false
}
return true
}
+6 -18
View File
@@ -2,6 +2,7 @@ package middleware
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/infrastructure/useCases"
"context"
@@ -10,9 +11,6 @@ import (
"strconv"
"strings"
"time"
"github.com/go-chi/jwtauth/v5"
"github.com/lestrrat-go/jwx/v2/jwt"
)
const bearerScheme = "bearer "
@@ -79,13 +77,7 @@ func APIKeyAuth(authenticate *useCases.AuthenticateAPIKeyUseCase, maxRequests in
return
}
token, err := sessionTokenFor(authenticated)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
ctx := jwtauth.NewContext(r.Context(), token, nil)
ctx := authtoken.NewContext(r.Context(), sessionClaimsFor(authenticated), nil)
ctx = context.WithValue(ctx, apiKeyIdContextKey{}, authenticated.Key.ID)
ctx = context.WithValue(ctx, apiKeyNameContextKey{}, authenticated.Key.Name)
limited.ServeHTTP(w, r.WithContext(ctx))
@@ -101,15 +93,11 @@ func writeAPIKeyError(w http.ResponseWriter, err error) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
func sessionTokenFor(authenticated useCases.AuthenticatedAPIKey) (jwt.Token, error) {
token := jwt.New()
if err := token.Set("user_id", float64(authenticated.User.ID)); err != nil {
return nil, err
func sessionClaimsFor(authenticated useCases.AuthenticatedAPIKey) authtoken.Claims {
return authtoken.Claims{
"user_id": float64(authenticated.User.ID),
"token_version": float64(authenticated.User.TokenVersion),
}
if err := token.Set("token_version", float64(authenticated.User.TokenVersion)); err != nil {
return nil, err
}
return token, nil
}
func apiKeyRateLimitKey(r *http.Request) string {
+3 -1
View File
@@ -1,6 +1,7 @@
package middleware
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/claims"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/useCases"
@@ -10,6 +11,7 @@ import (
func AuditContext(getUser *useCases.GetUserUseCase, trustedProxies []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = access.WithCurrentUser(r)
origin := audit.Origin{
Actor: auditActor(getUser, r),
Source: auditSource(r),
@@ -29,7 +31,7 @@ func auditActor(getUser *useCases.GetUserUseCase, r *http.Request) audit.Actor {
if !ok {
return audit.AnonymousActor("")
}
current, err := getUser.GetUser(userId)
current, err := access.LoadUser(getUser, r, userId)
if err != nil {
return audit.AnonymousActor("")
}
+4 -37
View File
@@ -1,6 +1,7 @@
package middleware
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/internal/domain/user"
@@ -23,7 +24,7 @@ func resolveCurrentUser(getUser *useCases.GetUserUseCase, w http.ResponseWriter,
return user.User{}, false
}
currentUser, err := getUser.GetUser(userId)
currentUser, err := access.LoadUser(getUser, r, userId)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
@@ -39,6 +40,7 @@ func resolveCurrentUser(getUser *useCases.GetUserUseCase, w http.ResponseWriter,
func RequireCurrentToken(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = access.WithCurrentUser(r)
currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok {
return
@@ -63,6 +65,7 @@ func RequireCurrentToken(getUser *useCases.GetUserUseCase) func(http.Handler) ht
func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = access.WithCurrentUser(r)
currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok {
return
@@ -77,39 +80,3 @@ func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) ht
})
}
}
func RequireOwner(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok {
return
}
if currentUser.Role != user.RoleOwner {
http.Error(w, apperr.OwnerRequiredErrorMessage, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
func RequireOwnerOrAdmin(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok {
return
}
if currentUser.Role != user.RoleOwner && currentUser.Role != user.RoleAdmin {
http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
+138
View File
@@ -0,0 +1,138 @@
package middleware
import (
"net/http"
"strconv"
"strings"
)
const (
corsAllowAnyOrigin = "*"
corsWildcardMarker = "*"
corsPreflightMaxAge = 300
corsPreflightSuccess = http.StatusNoContent
)
var (
corsAllowedMethods = []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodOptions}
corsAllowedHeaders = []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}
corsExposedHeaders = []string{"Link", "X-Total-Count"}
)
type originWildcard struct {
prefix string
suffix string
}
func (w originWildcard) matches(origin string) bool {
return len(origin) >= len(w.prefix)+len(w.suffix) && strings.HasPrefix(origin, w.prefix) && strings.HasSuffix(origin, w.suffix)
}
type originPolicy struct {
anyOrigin bool
exact map[string]struct{}
wildcards []originWildcard
}
func newOriginPolicy(origins []string) originPolicy {
policy := originPolicy{exact: map[string]struct{}{}}
for _, origin := range origins {
origin = strings.ToLower(strings.TrimSpace(origin))
prefix, suffix, hasWildcard := strings.Cut(origin, corsWildcardMarker)
switch {
case origin == "":
case origin == corsAllowAnyOrigin:
policy.anyOrigin = true
case hasWildcard:
policy.wildcards = append(policy.wildcards, originWildcard{prefix: prefix, suffix: suffix})
default:
policy.exact[origin] = struct{}{}
}
}
return policy
}
func (p originPolicy) allows(origin string) bool {
if p.anyOrigin {
return true
}
origin = strings.ToLower(origin)
if _, ok := p.exact[origin]; ok {
return true
}
for _, wildcard := range p.wildcards {
if wildcard.matches(origin) {
return true
}
}
return false
}
func isPreflight(r *http.Request) bool {
return r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != ""
}
func containsFold(allowed []string, value string) bool {
for _, candidate := range allowed {
if strings.EqualFold(candidate, value) {
return true
}
}
return false
}
func requestedHeadersAllowed(r *http.Request) bool {
requested := r.Header.Get("Access-Control-Request-Headers")
if requested == "" {
return true
}
for _, header := range strings.Split(requested, ",") {
if header = strings.TrimSpace(header); header != "" && !containsFold(corsAllowedHeaders, header) {
return false
}
}
return true
}
func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
policy := newOriginPolicy(allowedOrigins)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "" {
next.ServeHTTP(w, r)
return
}
w.Header().Add("Vary", "Origin")
if isPreflight(r) {
answerPreflight(w, r, origin, policy)
return
}
if policy.allows(origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Expose-Headers", strings.Join(corsExposedHeaders, ", "))
}
next.ServeHTTP(w, r)
})
}
}
func answerPreflight(w http.ResponseWriter, r *http.Request, origin string, policy originPolicy) {
w.Header().Add("Vary", "Access-Control-Request-Method")
w.Header().Add("Vary", "Access-Control-Request-Headers")
method := r.Header.Get("Access-Control-Request-Method")
if !policy.allows(origin) || !containsFold(corsAllowedMethods, method) || !requestedHeadersAllowed(r) {
w.WriteHeader(http.StatusOK)
return
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", strings.ToUpper(method))
w.Header().Set("Access-Control-Allow-Headers", strings.Join(corsAllowedHeaders, ", "))
w.Header().Set("Access-Control-Max-Age", strconv.Itoa(corsPreflightMaxAge))
w.WriteHeader(corsPreflightSuccess)
}
+36
View File
@@ -0,0 +1,36 @@
package middleware
import (
"RenewCMS/api/pkg/apperr"
"net/http"
"net/url"
)
func CrossOriginProtection(publicURL string) func(http.Handler) http.Handler {
protection := http.NewCrossOriginProtection()
if origin, ok := originOf(publicURL); ok {
_ = protection.AddTrustedOrigin(origin)
}
protection.SetDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, apperr.CrossOriginErrorMessage, http.StatusForbidden)
}))
return func(next http.Handler) http.Handler {
protected := protection.Handler(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "" {
next.ServeHTTP(w, r)
return
}
protected.ServeHTTP(w, r)
})
}
}
func originOf(rawURL string) (string, bool) {
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", false
}
return parsed.Scheme + "://" + parsed.Host, true
}
+53
View File
@@ -0,0 +1,53 @@
package middleware
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/claims"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
)
func OptionalAccess(getUser *useCases.GetUserUseCase, check *useCases.CheckPermissionUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = access.WithCurrentUser(r)
account, signedIn, err := verifiedAccount(getUser, r)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if !signedIn {
next.ServeHTTP(w, r)
return
}
grants, err := check.Grants(account)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
caller := access.Caller{UserID: account.ID, Grants: grants}
next.ServeHTTP(w, r.WithContext(access.WithCaller(r.Context(), caller)))
})
}
}
func verifiedAccount(getUser *useCases.GetUserUseCase, r *http.Request) (user.User, bool, error) {
userID, ok := claims.UserId(r)
if !ok {
return user.User{}, false, nil
}
account, err := access.LoadUser(getUser, r, userID)
if errors.Is(err, user.ErrUserNotFound) {
return user.User{}, false, nil
}
if err != nil {
return user.User{}, false, err
}
tokenVersion, ok := claims.TokenVersion(r)
if !ok || tokenVersion != account.TokenVersion {
return user.User{}, false, nil
}
return account, account.IsVerified, nil
}
+62
View File
@@ -0,0 +1,62 @@
package middleware
import (
"RenewCMS/api/pkg/access"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"github.com/go-chi/chi/v5"
"net/http"
)
type grantCheck func(account user.User, r *http.Request) (role.GrantSet, error)
func ArticleTypeKey(*http.Request) string {
return builtintype.KeyArticle
}
func PathTypeKey(r *http.Request) string {
return chi.URLParam(r, "key")
}
func RequirePermission(getUser *useCases.GetUserUseCase, check *useCases.CheckPermissionUseCase, key string) func(http.Handler) http.Handler {
return enforce(getUser, func(account user.User, _ *http.Request) (role.GrantSet, error) {
return check.Require(account, key)
})
}
func RequireFullScope(getUser *useCases.GetUserUseCase, check *useCases.CheckPermissionUseCase, key string) func(http.Handler) http.Handler {
return enforce(getUser, func(account user.User, _ *http.Request) (role.GrantSet, error) {
return check.RequireAll(account, key)
})
}
func RequireTypedPermission(getUser *useCases.GetUserUseCase, check *useCases.CheckPermissionUseCase, key string, typeKey func(r *http.Request) string) func(http.Handler) http.Handler {
return enforce(getUser, func(account user.User, r *http.Request) (role.GrantSet, error) {
return check.RequireFor(account, key, typeKey(r))
})
}
func enforce(getUser *useCases.GetUserUseCase, verify grantCheck) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = access.WithCurrentUser(r)
currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok {
return
}
grants, err := verify(currentUser, r)
if err != nil {
if !access.WriteIfMissingPermission(w, err) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
caller := access.Caller{UserID: currentUser.ID, Grants: grants}
next.ServeHTTP(w, r.WithContext(access.WithCaller(r.Context(), caller)))
})
}
}
+34 -12
View File
@@ -14,8 +14,19 @@ type rateLimitWindow struct {
count int
}
type rateLimitCounters struct {
mu sync.Mutex
window time.Duration
windows map[string]*rateLimitWindow
lastSweep time.Time
}
func NewRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler {
return newKeyedRateLimiter(maxAttempts, window, func(r *http.Request) string { return r.RemoteAddr })
return newKeyedRateLimiter(maxAttempts, window, connectionHostKey)
}
func connectionHostKey(r *http.Request) string {
return hostOf(r.RemoteAddr)
}
func NewUserRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler {
@@ -28,12 +39,11 @@ func userRateLimitKey(r *http.Request) string {
}
func newKeyedRateLimiter(maxAttempts int, window time.Duration, keyOf func(*http.Request) string) func(http.Handler) http.Handler {
var mu sync.Mutex
windows := make(map[string]*rateLimitWindow)
counters := &rateLimitCounters{window: window, windows: make(map[string]*rateLimitWindow)}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
retryAfter, allowed := recordAttempt(&mu, windows, keyOf(r), maxAttempts, window)
retryAfter, allowed := counters.recordAttempt(keyOf(r), maxAttempts, time.Now())
if !allowed {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
http.Error(w, apperr.TooManyRequestsErrorMessage, http.StatusTooManyRequests)
@@ -44,24 +54,36 @@ func newKeyedRateLimiter(maxAttempts int, window time.Duration, keyOf func(*http
}
}
func recordAttempt(mu *sync.Mutex, windows map[string]*rateLimitWindow, key string, maxAttempts int, window time.Duration) (int, bool) {
mu.Lock()
defer mu.Unlock()
func (c *rateLimitCounters) recordAttempt(key string, maxAttempts int, now time.Time) (int, bool) {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
current, exists := windows[key]
if !exists || now.Sub(current.start) >= window {
c.sweepExpired(now)
current, exists := c.windows[key]
if !exists || now.Sub(current.start) >= c.window {
current = &rateLimitWindow{start: now}
windows[key] = current
c.windows[key] = current
}
current.count++
if current.count > maxAttempts {
return retryAfterSeconds(window - now.Sub(current.start)), false
return retryAfterSeconds(c.window - now.Sub(current.start)), false
}
return 0, true
}
func (c *rateLimitCounters) sweepExpired(now time.Time) {
if now.Sub(c.lastSweep) < c.window {
return
}
c.lastSweep = now
for key, current := range c.windows {
if now.Sub(current.start) >= c.window {
delete(c.windows, key)
}
}
}
func retryAfterSeconds(remaining time.Duration) int {
seconds := int(remaining / time.Second)
if remaining%time.Second > 0 {
+2 -5
View File
@@ -2,16 +2,13 @@ package middleware
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
"net/http"
"github.com/go-chi/jwtauth/v5"
"github.com/lestrrat-go/jwx/v2/jwt"
)
func RequireSignedIn(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, _, err := jwtauth.FromContext(r.Context())
if err != nil || token == nil || jwt.Validate(token) != nil {
if _, err := authtoken.FromContext(r.Context()); err != nil {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
+2 -2
View File
@@ -4,8 +4,8 @@ import "net/http"
const contentSecurityPolicy = "default-src 'self'; " +
"script-src 'self' 'wasm-unsafe-eval'; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com; " +
"style-src 'self' 'unsafe-inline'; " +
"font-src 'self'; " +
"img-src 'self' data: blob: https:; " +
"connect-src 'self'; " +
"object-src 'none'; " +
+90
View File
@@ -0,0 +1,90 @@
package middleware
import (
"RenewCMS/api/pkg/apperr"
"net/http"
"strconv"
"sync"
"time"
)
type successSlots struct {
mu sync.Mutex
window time.Duration
now func() time.Time
taken map[string]time.Time
}
func NewUserSuccessLimiter(window time.Duration) func(http.Handler) http.Handler {
return NewUserSuccessLimiterAt(window, time.Now)
}
func NewUserSuccessLimiterAt(window time.Duration, now func() time.Time) func(http.Handler) http.Handler {
slots := &successSlots{window: window, now: now, taken: make(map[string]time.Time)}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := userRateLimitKey(r)
claimed, retryAfter, granted := slots.claim(key)
if !granted {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
http.Error(w, apperr.TooManyRequestsErrorMessage, http.StatusTooManyRequests)
return
}
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
succeeded := false
defer func() {
if !succeeded {
slots.release(key, claimed)
}
}()
next.ServeHTTP(recorder, r)
succeeded = recorder.status < http.StatusBadRequest
})
}
}
func (s *successSlots) claim(key string) (time.Time, int, bool) {
s.mu.Lock()
defer s.mu.Unlock()
now := s.now()
s.sweepExpired(now)
if claimedAt, held := s.taken[key]; held {
return time.Time{}, retryAfterSeconds(s.window - now.Sub(claimedAt)), false
}
s.taken[key] = now
return now, 0, true
}
func (s *successSlots) release(key string, claimedAt time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
if current, held := s.taken[key]; held && current.Equal(claimedAt) {
delete(s.taken, key)
}
}
func (s *successSlots) sweepExpired(now time.Time) {
for key, claimedAt := range s.taken {
if now.Sub(claimedAt) >= s.window {
delete(s.taken, key)
}
}
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(status int) {
s.status = status
s.ResponseWriter.WriteHeader(status)
}
func (s *statusRecorder) Unwrap() http.ResponseWriter {
return s.ResponseWriter
}
+41
View File
@@ -0,0 +1,41 @@
package access
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"net/http"
)
type Caller = useCases.ContentCaller
type callerContextKey struct{}
func WithCaller(ctx context.Context, caller Caller) context.Context {
return context.WithValue(ctx, callerContextKey{}, caller)
}
func CallerOf(r *http.Request) Caller {
caller, _ := r.Context().Value(callerContextKey{}).(Caller)
return caller
}
func Grants(r *http.Request) role.GrantSet {
return CallerOf(r).Grants
}
func WriteMissingPermission(w http.ResponseWriter, key string) {
w.Header().Set(apperr.MissingPermissionHeader, key)
http.Error(w, apperr.MissingPermissionErrorMessage, http.StatusForbidden)
}
func WriteIfMissingPermission(w http.ResponseWriter, err error) bool {
var missing role.MissingPermissionError
if !errors.As(err, &missing) {
return false
}
WriteMissingPermission(w, missing.Key)
return true
}
+37
View File
@@ -0,0 +1,37 @@
package access
import (
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"context"
"net/http"
)
type currentUserContextKey struct{}
type currentUserHolder struct {
loaded bool
userID uint32
account user.User
err error
}
func WithCurrentUser(r *http.Request) *http.Request {
if _, present := r.Context().Value(currentUserContextKey{}).(*currentUserHolder); present {
return r
}
return r.WithContext(context.WithValue(r.Context(), currentUserContextKey{}, &currentUserHolder{}))
}
func LoadUser(getUser *useCases.GetUserUseCase, r *http.Request, userID uint32) (user.User, error) {
holder, present := r.Context().Value(currentUserContextKey{}).(*currentUserHolder)
if !present {
return getUser.GetUser(userID)
}
if !holder.loaded || holder.userID != userID {
holder.account, holder.err = getUser.GetUser(userID)
holder.userID = userID
holder.loaded = true
}
return holder.account, holder.err
}
+81 -2
View File
@@ -16,15 +16,19 @@ const VerificationRequiredErrorMessage = "You must verify your account to access
const PasswordResetRequestedMessage = "If an account exists for that email, a password reset link has been sent."
const PasswordResetFailedErrorMessage = "The password reset code is invalid or has expired."
const TooManyRequestsErrorMessage = "Too many requests. Please try again later."
const CrossOriginErrorMessage = "Cross-origin requests are not allowed."
const EmailHasAccountErrorMessage = "This address already has an account."
const PendingInviteErrorMessage = "There's already a pending invite for this address."
const ErrorCodeHeader = "X-Error-Code"
const EmailHasAccountErrorCode = "email_has_account"
const InvitePendingErrorCode = "invite_pending"
const InviteNotSentErrorMessage = "Couldn't send the invite. Try again in a moment."
const InviteNotFoundErrorMessage = "Invite not found"
const InviteAlreadyAcceptedErrorMessage = "This invite has already been accepted."
const InviteInvalidErrorMessage = "This invite link doesn't work."
const InviteExpiredErrorMessage = "This invite has expired."
const OwnerRequiredErrorMessage = "Only the owner can do this."
const UsernameTakenErrorMessage = "That username is taken. Try another."
const PasswordTooLongErrorMessage = "The password must be 72 bytes or fewer."
const MediaQueryErrorMessage = "The media query is invalid."
const MediaFilenameErrorMessage = "Enter a filename."
const MediaMergeConflictErrorMessage = "Some of the images to merge changed. Refresh and try again."
@@ -37,8 +41,9 @@ const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the
const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html, json."
const APIKeyNotFoundErrorMessage = "API key not found"
const APIKeyExpiryErrorMessage = "The expiry must be a date in the future."
const MissingPermissionErrorMessage = "missing_permission"
const MissingPermissionHeader = "X-Missing-Permission"
const SessionRequiredErrorMessage = "This action needs a real session."
const OwnerOrAdminRequiredErrorMessage = "Only owners and admins can do this."
const PurgeConflictErrorMessage = "The library changed, so nothing was purged. Check the new summary and try again."
const PurgeBusyErrorMessage = "A purge is already running."
const PurgeIntervalErrorMessage = "Enter a whole number of days between 1 and 365."
@@ -57,3 +62,77 @@ const CachePurgeScopeErrorMessage = "Scope must be all, unused, variant or image
const CachePurgeTargetErrorMessage = "A variant name or an image id is required for this scope."
const VariantUnknownMessage = "Unknown variant"
const VariantUnsupportedParameterMessage = "Unsupported parameter"
const SiteNotFoundErrorMessage = "site_not_found"
const SiteKeyTakenErrorMessage = "site_key_taken"
const SiteHasArticlesErrorMessage = "site_has_articles"
const DefaultSiteErrorMessage = "default_site"
const SiteKeyImmutableErrorMessage = "key_immutable"
const InvalidSiteURLErrorMessage = "invalid_site_url"
const InvalidLocaleErrorMessage = "invalid_locale"
const SiteImageNotFoundErrorMessage = "image_not_found"
const UseSetDefaultErrorMessage = "use_set_default"
const InvalidReassignErrorMessage = "invalid_reassign"
const ReassignTargetNotFoundErrorMessage = "target_role_not_found"
const InvalidSocialTypeErrorMessage = "invalid_social_type"
const AuthorNotFoundErrorMessage = "author_not_found"
const AuthorSlugTakenErrorMessage = "author_slug_taken"
const AuthorHasArticlesErrorMessage = "author_has_articles"
const UserAlreadyLinkedErrorMessage = "user_already_linked"
const AuthorAlreadyLinkedErrorMessage = "author_already_linked"
const UserNotFoundErrorMessage = "user_not_found"
const AuthorRightsErrorMessage = "author_rights"
const BioTooLongErrorMessage = "bio_too_long"
const InvalidURLErrorMessage = "invalid_url"
const TooManyLinksErrorMessage = "too_many_links"
const InvalidSlugErrorMessage = "invalid_slug"
const AuthorImageNotFoundErrorMessage = "image_not_found"
const ArticleSlugTakenErrorMessage = "slug_taken"
const ArticleImageNotFoundErrorMessage = "image_not_found"
const InvalidTitleTemplateErrorMessage = "invalid_title_template"
const SlugSuggestionHeader = "X-Slug-Suggestion"
const ContentTypeNotFoundErrorMessage = "content_type_not_found"
const ContentTypeKeyTakenErrorMessage = "content_type_key_taken"
const ContentTypeHasEntriesErrorMessage = "content_type_has_entries"
const ContentTypeReferencedErrorMessage = "content_type_referenced"
const InvalidContentTypeKeyErrorMessage = "invalid_content_type_key"
const InvalidIconErrorMessage = "invalid_icon"
const BuiltinTypeNotFoundErrorMessage = "builtin_type_not_found"
const InvalidFieldErrorMessage = "invalid_field"
const SchemaIncompatibleErrorMessage = "schema_incompatible"
const EntryNotFoundErrorMessage = "entry_not_found"
const InvalidEntryErrorMessage = "invalid_entry"
const RoleNotFoundErrorMessage = "role_not_found"
const RoleLockedErrorMessage = "role_locked"
const RoleBuiltInErrorMessage = "role_built_in"
const BuiltInNameErrorMessage = "built_in_name"
const NotBuiltInErrorMessage = "not_built_in"
const OwnRoleErrorMessage = "own_role"
const EscalationErrorMessage = "escalation"
const EscalationHeader = "X-Escalation-Key"
const RoleNameTakenErrorMessage = "role_name_taken"
const RoleConflictErrorMessage = "role_conflict"
const RoleHasMembersErrorMessage = "role_has_members"
const InvalidRoleNameErrorMessage = "invalid_role_name"
const UnknownPermissionErrorMessage = "unknown_permission"
const InvalidScopeErrorMessage = "invalid_scope"
const OwnerOnlyPermissionErrorMessage = "owner_only_permission"
const DuplicatePermissionErrorMessage = "duplicate_permission"
const UnknownContentTypeErrorMessage = "unknown_content_type"
const InvalidRoleErrorMessage = "invalid_role"
const UserRoleChangedErrorMessage = "user_role_changed"
const InvalidTransferErrorMessage = "invalid_transfer"
const ConfirmationMismatchErrorMessage = "confirmation_mismatch"
const WrongPasswordErrorCode = "wrong_password"
const SameEmailErrorCode = "same_email"
const NoPendingEmailChangeErrorCode = "no_pending_email_change"
const InvalidEmailChangeCodeErrorCode = "invalid_email_change_code"
const EmailUnavailableErrorCode = "email_unavailable"
const LastAdminErrorCode = "last_admin"
const AccountNotDeletableErrorCode = "account_not_deletable"
const LastRoleHeader = "X-Last-Role"
+225
View File
@@ -0,0 +1,225 @@
package authtoken
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"maps"
"strings"
"time"
)
const (
SessionLifetime = 24 * time.Hour
algorithmHS256 = "HS256"
tokenType = "JWT"
maxTokenBytes = 4096
segmentCount = 3
segmentSep = "."
)
var (
ErrNoToken = errors.New("no token found")
ErrInvalid = errors.New("token is invalid")
ErrExpired = errors.New("token is expired")
ErrNotYetValid = errors.New("token is not yet valid")
ErrRevoked = errors.New("token is revoked")
)
type RevocationStore interface {
Revoke(id string, expiresAt time.Time) error
IsRevoked(id string) (bool, error)
}
type Claims map[string]any
type header struct {
Algorithm string `json:"alg"`
Type string `json:"typ,omitempty"`
}
type Auth struct {
secret []byte
clock func() time.Time
revocations RevocationStore
}
type Option func(*Auth)
func WithClock(clock func() time.Time) Option {
return func(a *Auth) { a.clock = clock }
}
func WithRevocationStore(store RevocationStore) Option {
return func(a *Auth) { a.revocations = store }
}
func New(secret []byte, options ...Option) *Auth {
auth := &Auth{secret: secret, clock: time.Now}
for _, option := range options {
option(auth)
}
return auth
}
func (a *Auth) Encode(claims map[string]any) (string, error) {
payload := maps.Clone(claims)
if payload == nil {
payload = map[string]any{}
}
now := a.clock()
if _, ok := payload["iat"]; !ok {
payload["iat"] = now.Unix()
}
if _, ok := payload["exp"]; !ok {
payload["exp"] = now.Add(SessionLifetime).Unix()
}
if _, ok := payload["jti"]; !ok {
payload["jti"] = rand.Text()
}
headerSegment, err := encodeSegment(header{Algorithm: algorithmHS256, Type: tokenType})
if err != nil {
return "", err
}
payloadSegment, err := encodeSegment(payload)
if err != nil {
return "", err
}
signingInput := headerSegment + segmentSep + payloadSegment
return signingInput + segmentSep + a.sign(signingInput), nil
}
func (a *Auth) Decode(token string) (Claims, error) {
if token == "" {
return nil, ErrNoToken
}
if len(token) > maxTokenBytes {
return nil, ErrInvalid
}
segments := strings.Split(token, segmentSep)
if len(segments) != segmentCount {
return nil, ErrInvalid
}
var tokenHeader header
if err := decodeSegment(segments[0], &tokenHeader); err != nil || tokenHeader.Algorithm != algorithmHS256 {
return nil, ErrInvalid
}
signingInput := segments[0] + segmentSep + segments[1]
if !hmac.Equal([]byte(a.sign(signingInput)), []byte(segments[2])) {
return nil, ErrInvalid
}
var claims Claims
if err := decodeSegment(segments[1], &claims); err != nil || claims == nil {
return nil, ErrInvalid
}
if err := a.validateTimes(claims); err != nil {
return nil, err
}
if err := a.checkRevocation(claims); err != nil {
return nil, err
}
return claims, nil
}
func (a *Auth) Revoke(claims Claims) error {
id, ok := claims["jti"].(string)
if !ok || a.revocations == nil {
return nil
}
expiry, present, err := numericDate(claims, "exp")
if err != nil || !present {
return ErrInvalid
}
return a.revocations.Revoke(id, expiry)
}
func (a *Auth) checkRevocation(claims Claims) error {
raw, present := claims["jti"]
if !present {
return nil
}
id, ok := raw.(string)
if !ok {
return ErrInvalid
}
if a.revocations == nil {
return nil
}
revoked, err := a.revocations.IsRevoked(id)
if err != nil {
return errors.Join(ErrInvalid, err)
}
if revoked {
return ErrRevoked
}
return nil
}
func (a *Auth) validateTimes(claims Claims) error {
now := a.clock()
expiry, present, err := numericDate(claims, "exp")
if err != nil {
return err
}
if !present {
return ErrInvalid
}
if !now.Before(expiry) {
return ErrExpired
}
notBefore, present, err := numericDate(claims, "nbf")
if err != nil {
return err
}
if present && now.Before(notBefore) {
return ErrNotYetValid
}
return nil
}
func numericDate(claims Claims, name string) (time.Time, bool, error) {
raw, present := claims[name]
if !present {
return time.Time{}, false, nil
}
seconds, ok := raw.(float64)
if !ok {
return time.Time{}, false, ErrInvalid
}
return time.Unix(int64(seconds), 0), true, nil
}
func (a *Auth) sign(signingInput string) string {
mac := hmac.New(sha256.New, a.secret)
mac.Write([]byte(signingInput))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func encodeSegment(value any) (string, error) {
encoded, err := json.Marshal(value)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(encoded), nil
}
func decodeSegment(segment string, into any) error {
decoded, err := base64.RawURLEncoding.DecodeString(segment)
if err != nil {
return err
}
return json.Unmarshal(decoded, into)
}
+19
View File
@@ -0,0 +1,19 @@
package authtoken
import (
"net/http"
"time"
)
func ExpiredCookie(secure bool) *http.Cookie {
return &http.Cookie{
Name: CookieName,
Value: "",
Expires: time.Now(),
MaxAge: -1,
Secure: secure,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
}
}
+79
View File
@@ -0,0 +1,79 @@
package authtoken
import (
"RenewCMS/api/pkg/apperr"
"context"
"net/http"
"strings"
)
const (
CookieName = "jwt"
bearerPrefix = "bearer "
)
type contextKey struct{}
type verdict struct {
claims Claims
err error
}
func Verifier(auth *Auth) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, err := auth.Decode(tokenFromRequest(r))
next.ServeHTTP(w, r.WithContext(NewContext(r.Context(), claims, err)))
})
}
}
func Authenticator(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, err := FromContext(r.Context()); err != nil {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func NewContext(ctx context.Context, claims Claims, err error) context.Context {
return context.WithValue(ctx, contextKey{}, verdict{claims: claims, err: err})
}
func FromContext(ctx context.Context) (Claims, error) {
stored, ok := ctx.Value(contextKey{}).(verdict)
switch {
case !ok:
return nil, ErrNoToken
case stored.err != nil:
return nil, stored.err
case stored.claims == nil:
return nil, ErrNoToken
}
return stored.claims, nil
}
func tokenFromRequest(r *http.Request) string {
if token := tokenFromHeader(r); token != "" {
return token
}
return tokenFromCookie(r)
}
func tokenFromHeader(r *http.Request) string {
header := r.Header.Get("Authorization")
if len(header) <= len(bearerPrefix) || !strings.EqualFold(header[:len(bearerPrefix)], bearerPrefix) {
return ""
}
return header[len(bearerPrefix):]
}
func tokenFromCookie(r *http.Request) string {
cookie, err := r.Cookie(CookieName)
if err != nil {
return ""
}
return cookie.Value
}
+3 -4
View File
@@ -1,14 +1,13 @@
package claims
import (
"RenewCMS/api/pkg/authtoken"
"math"
"net/http"
"github.com/go-chi/jwtauth/v5"
)
func UserId(r *http.Request) (uint32, bool) {
_, claims, err := jwtauth.FromContext(r.Context())
claims, err := authtoken.FromContext(r.Context())
if err != nil {
return 0, false
}
@@ -31,7 +30,7 @@ func UserId(r *http.Request) (uint32, bool) {
}
func TokenVersion(r *http.Request) (uint32, bool) {
_, claims, err := jwtauth.FromContext(r.Context())
claims, err := authtoken.FromContext(r.Context())
if err != nil {
return 0, false
}
+91
View File
@@ -0,0 +1,91 @@
package payload
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"time"
)
type RoleRef struct {
ID uint32 `json:"id"`
Name string `json:"name"`
BuiltIn bool `json:"built_in"`
Locked bool `json:"locked"`
}
type RoleAssigner struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
type RoleEditor struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
type RoleView struct {
role.Role
Members int `json:"members"`
UpdatedAt *time.Time `json:"updated_at"`
UpdatedBy *RoleEditor `json:"updated_by"`
}
type OwnershipSuccessor struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
type OwnershipPredecessor struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Role RoleRef `json:"role"`
}
type OwnershipTransfer struct {
Owner OwnershipSuccessor `json:"owner"`
FormerOwner OwnershipPredecessor `json:"former_owner"`
}
func RoleRefOf(ref useCases.RoleRef) RoleRef {
return RoleRef(ref)
}
func RoleRefPtrOf(ref *useCases.RoleRef) *RoleRef {
if ref == nil {
return nil
}
converted := RoleRefOf(*ref)
return &converted
}
func RoleAssignerPtrOf(assigner *useCases.RoleAssigner) *RoleAssigner {
if assigner == nil {
return nil
}
converted := RoleAssigner(*assigner)
return &converted
}
func RoleViewOf(view useCases.RoleView) RoleView {
converted := RoleView{Role: view.Role, Members: view.Members, UpdatedAt: view.UpdatedAt}
if view.UpdatedBy != nil {
editor := RoleEditor(*view.UpdatedBy)
converted.UpdatedBy = &editor
}
return converted
}
func RoleViewsOf(views []useCases.RoleView) []RoleView {
converted := make([]RoleView, 0, len(views))
for _, view := range views {
converted = append(converted, RoleViewOf(view))
}
return converted
}
func OwnershipTransferOf(transfer useCases.OwnershipTransfer) OwnershipTransfer {
return OwnershipTransfer{
Owner: OwnershipSuccessor(transfer.Owner),
FormerOwner: OwnershipPredecessor{ID: transfer.FormerOwner.ID, Name: transfer.FormerOwner.Name, Role: RoleRefOf(transfer.FormerOwner.Role)},
}
}
-2
View File
@@ -13,7 +13,6 @@ type PublicUser struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DisplayName string `json:"display_name"`
Role user.Role `json:"role"`
}
func New(u user.User) PublicUser {
@@ -25,6 +24,5 @@ func New(u user.User) PublicUser {
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
DisplayName: u.DisplayName,
Role: u.Role,
}
}
+21
View File
@@ -0,0 +1,21 @@
package response
import (
"encoding/json"
"log"
"net/http"
)
func WriteJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if status != http.StatusOK {
w.WriteHeader(status)
}
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
+48
View File
@@ -1,7 +1,11 @@
package validator
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"reflect"
"regexp"
"github.com/go-playground/validator/v10"
@@ -9,6 +13,12 @@ import (
const inviteEmailTag = "inviteemail"
const variantNameTag = "variantname"
const siteKeyTag = "sitekey"
const siteNameTag = "sitename"
const AuthorNameTag = "authorname"
const AuthorSlugTag = "authorslug"
const SocialTypeTag = "socialtype"
const ArticleSlugTag = "articleslug"
var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`)
@@ -18,6 +28,44 @@ func InitValidator() {
Validate = validator.New()
_ = Validate.RegisterValidation(inviteEmailTag, isInviteEmail)
_ = Validate.RegisterValidation(variantNameTag, isVariantName)
_ = Validate.RegisterValidation(siteKeyTag, isSiteKey)
_ = Validate.RegisterValidation(siteNameTag, isSiteName)
_ = Validate.RegisterValidation(AuthorNameTag, isAuthorName, true)
_ = Validate.RegisterValidation(AuthorSlugTag, isAuthorSlug, true)
_ = Validate.RegisterValidation(SocialTypeTag, isSocialType, true)
_ = Validate.RegisterValidation(ArticleSlugTag, isArticleSlug, true)
}
func isArticleSlug(field validator.FieldLevel) bool {
if isAbsent(field) {
return true
}
value := field.Field().String()
return value == "" || article.IsValidSlug(value)
}
func isAbsent(field validator.FieldLevel) bool {
return field.Field().Kind() == reflect.Ptr && field.Field().IsNil()
}
func isAuthorName(field validator.FieldLevel) bool {
return isAbsent(field) || author.IsValidName(field.Field().String())
}
func isSocialType(field validator.FieldLevel) bool {
return author.IsValidSocialType(field.Field().String())
}
func isAuthorSlug(field validator.FieldLevel) bool {
return isAbsent(field) || author.IsValidSlug(field.Field().String())
}
func isSiteKey(field validator.FieldLevel) bool {
return site.IsValidKey(field.Field().String())
}
func isSiteName(field validator.FieldLevel) bool {
return site.IsValidName(field.Field().String())
}
func isVariantName(field validator.FieldLevel) bool {
+364 -98
View File
@@ -5,29 +5,42 @@ import (
"RenewCMS/api/handlers/apikey"
"RenewCMS/api/handlers/article"
"RenewCMS/api/handlers/auth"
"RenewCMS/api/handlers/author"
"RenewCMS/api/handlers/builtintype"
"RenewCMS/api/handlers/contenttype"
"RenewCMS/api/handlers/entry"
"RenewCMS/api/handlers/frontend"
"RenewCMS/api/handlers/imagevariant"
"RenewCMS/api/handlers/llms"
"RenewCMS/api/handlers/media"
"RenewCMS/api/handlers/ownership"
"RenewCMS/api/handlers/permission"
rolehandler "RenewCMS/api/handlers/role"
"RenewCMS/api/handlers/settings"
"RenewCMS/api/handlers/site"
"RenewCMS/api/handlers/staticimages"
"RenewCMS/api/handlers/update"
"RenewCMS/api/handlers/user"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/authtoken"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/llmdocs"
"encoding/json"
"log"
"net/http"
"os"
"time"
"github.com/MadAppGang/httplog"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/jwtauth/v5"
)
func enableLoggingIfDev(r *chi.Mux, routerName string, environment string) {
if environment == "development" {
r.Use(httplog.LoggerWithName(routerName))
r.Use(chimiddleware.RequestLogger(&chimiddleware.DefaultLogFormatter{
Logger: log.New(os.Stdout, "["+routerName+"] ", log.LstdFlags),
}))
}
}
@@ -39,6 +52,14 @@ func GetHelloWorld(w http.ResponseWriter, _ *http.Request) {
const (
apiKeyMaxRequests = 120
apiKeyWindow = time.Minute
emailChangeMaxRequests = 5
emailChangeWindow = 15 * time.Minute
dataExportWindow = 15 * time.Minute
accountDeleteMaxRequests = 5
accountDeleteWindow = 15 * time.Minute
)
func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
@@ -48,39 +69,65 @@ func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
return c.apiKeyAuthMiddleware
}
func (c *UseCases) require(key string) func(http.Handler) http.Handler {
return middleware.RequirePermission(c.GetUserUseCase, c.CheckPermissionUseCase, key)
}
func (c *UseCases) requireTyped(key string, typeKey func(*http.Request) string) func(http.Handler) http.Handler {
return middleware.RequireTypedPermission(c.GetUserUseCase, c.CheckPermissionUseCase, key, typeKey)
}
func (c *UseCases) requireArticle(key string) func(http.Handler) http.Handler {
return c.requireTyped(key, middleware.ArticleTypeKey)
}
func (c *UseCases) requireEntry(key string) func(http.Handler) http.Handler {
return c.requireTyped(key, middleware.PathTypeKey)
}
func (c *UseCases) requireAll(key string) func(http.Handler) http.Handler {
return middleware.RequireFullScope(c.GetUserUseCase, c.CheckPermissionUseCase, key)
}
func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase)
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase)
seoHandler := article.NewSEOHandler(container.ResolveArticleSEOUseCase, container.CheckArticleSEOUseCase)
r.Group(func(r chi.Router) {
r.Use(middleware.OptionalAccess(container.GetUserUseCase, container.CheckPermissionUseCase))
r.Get("/", articleHandler.ListArticles)
r.Get("/{id}", articleHandler.GetArticle)
r.Get("/{id}/seo", seoHandler.Resolve)
})
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(container.Token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Post("/", articleHandler.PostArticle)
r.Delete("/{id}", articleHandler.DeleteArticle)
r.Patch("/{id}", articleHandler.UpdateArticleBody)
r.Patch("/{id}/publish", articleHandler.PublishArticle)
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
r.With(container.requireArticle(role.KeyContentCreate)).Post("/", articleHandler.PostArticle)
r.With(container.requireArticle(role.KeyContentDelete)).Delete("/{id}", articleHandler.DeleteArticle)
r.With(container.requireArticle(role.KeyContentUpdate)).Patch("/{id}", articleHandler.UpdateArticleBody)
r.With(container.requireArticle(role.KeyContentPublish)).Patch("/{id}/publish", articleHandler.PublishArticle)
r.With(container.requireArticle(role.KeyContentPublish)).Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
r.With(container.requireArticle(role.KeyContentRead)).Post("/{id}/seo/check", seoHandler.Check)
})
return r
}
func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
r := chi.NewRouter()
r.Use(jwtauth.Verifier(container.Token))
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction)
verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction)
meHandler := auth.NewMeHandler(container.GetUserUseCase)
meHandler := auth.NewMeHandler(container.GetUserUseCase, container.GetMyAuthorUseCase, container.GetMyAccessUseCase)
passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase)
emailChangeHandler := auth.NewEmailChangeHandler(container.RequestEmailChangeUseCase, container.ResendEmailChangeUseCase, container.CancelEmailChangeUseCase, container.ConfirmEmailChangeUseCase)
acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction)
r.Post("/register", loginHandler.Register)
@@ -105,43 +152,110 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(authtoken.Verifier(container.Token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Post("/verify", verificationHandler.Verify)
r.Delete("/register", verificationHandler.CancelRegistration)
})
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token))
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Delete("/email/change", emailChangeHandler.Cancel)
r.Post("/email/confirm", emailChangeHandler.Confirm)
r.Group(func(r chi.Router) {
r.Use(middleware.NewUserRateLimiter(emailChangeMaxRequests, emailChangeWindow))
r.Post("/email/change", emailChangeHandler.Request)
r.Post("/email/resend", emailChangeHandler.Resend)
})
})
r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(jwtauth.Authenticator(container.Token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Get("/me", meHandler.Me)
})
return r
}
func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
func NewUserRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
r := chi.NewRouter()
userHandler := user.NewHandler(container.UpdateUserProfileUseCase)
lookupHandler := user.NewLookupHandler(container.LookupUserNamesUseCase)
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
roleHandler := user.NewRoleHandler(container.ChangeUserRoleUseCase)
removeHandler := user.NewRemoveHandler(container.RemoveUserUseCase)
exportHandler := user.NewExportHandler(container.ExportUserDataUseCase)
deleteAccountHandler := user.NewDeleteAccountHandler(container.DeleteOwnAccountUseCase, isProduction)
r.Use(jwtauth.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Patch("/me", userHandler.UpdateMe)
r.Get("/", inviteHandler.ListUsers)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase))
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
r.Post("/invite", inviteHandler.CreateInvite)
r.Delete("/invite/{id}", inviteHandler.CancelInvite)
r.Post("/invite/{id}/renew", inviteHandler.RenewInvite)
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Patch("/me", userHandler.UpdateMe)
r.With(container.require(role.KeyUsersView)).Get("/", inviteHandler.ListUsers)
r.Get("/lookup", lookupHandler.Lookup)
r.Group(func(r chi.Router) {
r.Use(container.require(role.KeyUsersInvite))
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
r.Post("/invite", inviteHandler.CreateInvite)
r.Delete("/invite/{id}", inviteHandler.CancelInvite)
r.Post("/invite/{id}/renew", inviteHandler.RenewInvite)
})
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.With(middleware.NewUserSuccessLimiter(dataExportWindow)).Get("/me/export", exportHandler.Export)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.NewUserRateLimiter(accountDeleteMaxRequests, accountDeleteWindow))
r.Delete("/me", deleteAccountHandler.Delete)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeyUsersChangeRole))
r.Put("/{id}/role", roleHandler.Change)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeyUsersRemove))
r.Delete("/{id}", removeHandler.Remove)
})
return r
}
@@ -152,45 +266,186 @@ func NewSettingsRouter(container *UseCases, trustedProxies []string) http.Handle
settingsHandler := settings.NewHandler(container.GetSettingsUseCase, container.GetActivityRetentionUseCase, container.UpdateActivityRetentionUseCase)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token))
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(jwtauth.Authenticator(container.Token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeySettingsView))
r.Get("/", settingsHandler.GetSettings)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.RequireOwner(container.GetUserUseCase))
r.Use(container.require(role.KeySystemEditRetention))
r.Patch("/", settingsHandler.UpdateSettings)
})
return r
}
func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
siteHandler := site.NewHandler(container.ListSitesUseCase, container.CreateSiteUseCase, container.UpdateSiteUseCase, container.SetDefaultSiteUseCase, container.DeleteSiteUseCase)
sitemapHandler := site.NewSitemapHandler(container.ListSitemapUseCase)
ownershipHandler := ownership.NewHandler(container.TransferOwnershipUseCase)
r.Get("/{key}/sitemap", sitemapHandler.Sitemap)
r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", siteHandler.List)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.With(container.require(role.KeySettingsManageSites)).Post("/", siteHandler.Create)
r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}", siteHandler.Update)
r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}/default", siteHandler.SetDefault)
r.With(container.require(role.KeySettingsDeleteSite)).Delete("/{id}", siteHandler.Delete)
r.With(container.require(role.KeySystemTransferOwnership)).Post("/transfer-ownership", ownershipHandler.Transfer)
})
return r
}
func NewRoleRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
roleHandler := rolehandler.NewHandler(container.ListRolesUseCase, container.GetRoleUseCase, container.CreateRoleUseCase, container.UpdateRoleUseCase, container.DeleteRoleUseCase, container.ReassignRoleMembersUseCase)
r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeyRolesView))
r.Get("/", roleHandler.List)
r.Get("/{id}", roleHandler.Get)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeyRolesManage))
r.Post("/", roleHandler.Create)
r.Put("/{id}", roleHandler.Update)
r.Delete("/{id}", roleHandler.Delete)
r.Post("/{id}/reassign", roleHandler.Reassign)
r.Post("/{id}/reset", roleHandler.Reset)
})
return r
}
func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
authorHandler := author.NewHandler(container.ListAuthorsUseCase, container.CreateAuthorUseCase, container.CreateMyAuthorUseCase, container.UpdateAuthorUseCase, container.DeleteAuthorUseCase)
r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeyAuthorsView))
r.Get("/", authorHandler.List)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Post("/me", authorHandler.CreateMine)
r.With(container.require(role.KeyAuthorsEdit)).Patch("/{id}", authorHandler.Update)
r.With(container.require(role.KeyAuthorsCreate)).Post("/", authorHandler.Create)
r.With(container.require(role.KeyAuthorsDelete)).Delete("/{id}", authorHandler.Delete)
})
return r
}
func NewContentTypeRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
contentTypeHandler := contenttype.NewHandler(container.CreateContentTypeUseCase, container.ListContentTypesUseCase, container.GetContentTypeUseCase, container.UpdateContentTypeUseCase, container.DeleteContentTypeUseCase)
entryHandler := entry.NewHandler(container.CreateEntryUseCase, container.GetEntryUseCase, container.ListEntriesUseCase, container.UpdateEntryUseCase, container.DeleteEntryUseCase)
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.With(container.require(role.KeySchemaView)).Get("/", contentTypeHandler.List)
r.With(container.require(role.KeySchemaView)).Get("/{key}", contentTypeHandler.Get)
r.With(container.require(role.KeySchemaEdit)).Post("/", contentTypeHandler.Create)
r.With(container.require(role.KeySchemaEdit)).Put("/{key}", contentTypeHandler.Update)
r.With(container.require(role.KeySchemaDelete)).Delete("/{key}", contentTypeHandler.Delete)
r.With(container.requireEntry(role.KeyContentRead)).Get("/{key}/entry", entryHandler.List)
r.With(container.requireEntry(role.KeyContentCreate)).Post("/{key}/entry", entryHandler.Create)
r.With(container.requireEntry(role.KeyContentRead)).Get("/{key}/entry/{id}", entryHandler.Get)
r.With(container.requireEntry(role.KeyContentUpdate)).Put("/{key}/entry/{id}", entryHandler.Update)
r.With(container.requireEntry(role.KeyContentDelete)).Delete("/{key}/entry/{id}", entryHandler.Delete)
return r
}
func NewBuiltinTypeRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
builtinTypeHandler := builtintype.NewHandler(container.ListBuiltinTypesUseCase, container.GetBuiltinTypeUseCase)
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeySchemaView))
r.Get("/", builtinTypeHandler.List)
r.Get("/{key}", builtinTypeHandler.Get)
return r
}
func NewActivityRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
activityHandler := activity.NewHandler(container.ListActivityUseCase, container.GetActivitySummaryUseCase, container.ExportActivityUseCase)
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", activityHandler.List)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
r.Get("/summary", activityHandler.Summary)
r.Get("/export", activityHandler.Export)
})
r.With(container.require(role.KeyActivityView)).Get("/", activityHandler.List)
r.With(container.requireAll(role.KeyActivityView)).Get("/summary", activityHandler.Summary)
r.With(container.require(role.KeyActivityExport), container.require(role.KeyActivityView)).Get("/export", activityHandler.Export)
return r
}
@@ -204,27 +459,27 @@ const (
func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase, container.GetUserUseCase)
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase)
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/update", updateHandler.GetStatus)
r.With(container.require(role.KeySettingsView)).Get("/update", updateHandler.GetStatus)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase))
r.Use(container.require(role.KeySystemApplyUpdates))
r.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow))
r.Post("/update/check", updateHandler.Check)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase))
r.Use(container.require(role.KeySystemApplyUpdates))
r.Use(middleware.NewUserRateLimiter(updateDownloadMaxAttempts, updateDownloadWindow))
r.Post("/update/download", updateHandler.Download)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase))
r.Use(container.require(role.KeySystemApplyUpdates))
r.Post("/update/restart", updateHandler.Restart)
})
return r
@@ -236,14 +491,28 @@ func NewAPIKeyRouter(container *UseCases, trustedProxies []string) http.Handler
apiKeyHandler := apikey.NewHandler(container.CreateAPIKeyUseCase, container.ListAPIKeysUseCase, container.RevokeAPIKeyUseCase)
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Post("/", apiKeyHandler.Create)
r.Get("/", apiKeyHandler.List)
r.Delete("/{id}", apiKeyHandler.Revoke)
r.With(container.require(role.KeyAPIKeysCreate)).Post("/", apiKeyHandler.Create)
r.With(container.require(role.KeyAPIKeysView)).Get("/", apiKeyHandler.List)
r.With(container.require(role.KeyAPIKeysRevoke)).Delete("/{id}", apiKeyHandler.Revoke)
return r
}
func NewPermissionRouter(container *UseCases) http.Handler {
r := chi.NewRouter()
permissionHandler := permission.NewHandler(container.ListPermissionsUseCase)
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", permissionHandler.List)
return r
}
@@ -252,34 +521,31 @@ func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase, container.GetPurgeSettingsUseCase, container.UpdatePurgeSettingsUseCase, container.PreviewPurgeUseCase, container.RunPurgeUseCase, container.ListImageVariantsUseCase, container.GenerateImageVariantUseCase, container.GenerateAllImageVariantsUseCase, container.PurgeImageVariantUseCase, container.GenerateVariantsOnUploadUseCase)
r.Use(jwtauth.Verifier(container.Token))
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
r.Get("/purge-settings", mediaHandler.GetPurgeSettings)
r.Patch("/purge-settings", mediaHandler.UpdatePurgeSettings)
r.Get("/purge-preview", mediaHandler.PreviewPurge)
r.Post("/purge", mediaHandler.RunPurge)
})
r.With(container.require(role.KeySettingsView)).Get("/purge-settings", mediaHandler.GetPurgeSettings)
r.With(container.require(role.KeySettingsEditGeneral)).Patch("/purge-settings", mediaHandler.UpdatePurgeSettings)
r.With(container.requireAll(role.KeyMediaDelete)).Get("/purge-preview", mediaHandler.PreviewPurge)
r.With(container.requireAll(role.KeyMediaDelete)).Post("/purge", mediaHandler.RunPurge)
r.Group(func(r chi.Router) {
r.Use(middleware.RejectAPIKey)
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
r.Get("/{id}/variants", mediaHandler.ListVariants)
r.Post("/{id}/variants", mediaHandler.GenerateAllVariants)
r.Post("/{id}/variants/{name}", mediaHandler.GenerateVariant)
r.Delete("/{id}/variants/{name}", mediaHandler.PurgeVariant)
r.With(container.require(role.KeyMediaView)).Get("/{id}/variants", mediaHandler.ListVariants)
r.With(container.require(role.KeySettingsEditGeneral)).Post("/{id}/variants", mediaHandler.GenerateAllVariants)
r.With(container.require(role.KeySettingsEditGeneral)).Post("/{id}/variants/{name}", mediaHandler.GenerateVariant)
r.With(container.require(role.KeySettingsEditGeneral)).Delete("/{id}/variants/{name}", mediaHandler.PurgeVariant)
})
r.Get("/", mediaHandler.List)
r.Post("/", mediaHandler.Upload)
r.Post("/merge", mediaHandler.Merge)
r.Patch("/{id}", mediaHandler.Rename)
r.Delete("/{id}", mediaHandler.Delete)
r.Get("/{id}/usage", mediaHandler.Usage)
r.With(container.require(role.KeyMediaView)).Get("/", mediaHandler.List)
r.With(container.require(role.KeyMediaUpload)).Post("/", mediaHandler.Upload)
r.With(container.requireAll(role.KeyMediaDelete)).Post("/merge", mediaHandler.Merge)
r.With(container.require(role.KeyMediaUpload)).Patch("/{id}", mediaHandler.Rename)
r.With(container.requireAll(role.KeyMediaDelete)).Delete("/{id}", mediaHandler.Delete)
r.With(container.require(role.KeyMediaView)).Get("/{id}", mediaHandler.Get)
r.With(container.require(role.KeyMediaView)).Get("/{id}/usage", mediaHandler.Usage)
return r
}
@@ -288,21 +554,20 @@ func NewImageVariantRouter(container *UseCases, trustedProxies []string) http.Ha
variantHandler := imagevariant.NewHandler(container.ListVariantsUseCase, container.CreateVariantUseCase, container.UpdateVariantUseCase, container.SetVariantEnabledUseCase, container.DeleteVariantUseCase, container.PurgeVariantUseCase, container.GetVariantCacheStatsUseCase, container.PurgeVariantCacheUseCase)
r.Use(jwtauth.Verifier(container.Token))
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
r.Get("/", variantHandler.List)
r.Post("/", variantHandler.Create)
r.Get("/cache", variantHandler.CacheStats)
r.Post("/cache/purge", variantHandler.PurgeCache)
r.Put("/{name}", variantHandler.Update)
r.Patch("/{name}", variantHandler.SetEnabled)
r.Delete("/{name}", variantHandler.Delete)
r.Post("/{name}/purge", variantHandler.Purge)
r.With(container.require(role.KeyMediaView)).Get("/", variantHandler.List)
r.With(container.require(role.KeySettingsEditGeneral)).Post("/", variantHandler.Create)
r.With(container.require(role.KeySettingsView)).Get("/cache", variantHandler.CacheStats)
r.With(container.require(role.KeySettingsEditGeneral)).Post("/cache/purge", variantHandler.PurgeCache)
r.With(container.require(role.KeySettingsEditGeneral)).Put("/{name}", variantHandler.Update)
r.With(container.require(role.KeySettingsEditGeneral)).Patch("/{name}", variantHandler.SetEnabled)
r.With(container.require(role.KeySettingsEditGeneral)).Delete("/{name}", variantHandler.Delete)
r.With(container.require(role.KeySettingsEditGeneral)).Post("/{name}/purge", variantHandler.Purge)
return r
}
@@ -315,15 +580,21 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Use(middleware.JsonContentTypeMiddleware)
r.Get("/", GetHelloWorld)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token))
r.Use(authtoken.Verifier(container.Token))
r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies))
})
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
r.Mount("/user", NewUserRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies))
r.Mount("/role", NewRoleRouter(container, settings.TrustedProxies))
r.Mount("/author", NewAuthorRouter(container, settings.TrustedProxies))
r.Mount("/content-type", NewContentTypeRouter(container, settings.TrustedProxies))
r.Mount("/builtin-type", NewBuiltinTypeRouter(container, settings.TrustedProxies))
r.Mount("/activity", NewActivityRouter(container, settings.TrustedProxies))
r.Mount("/media", NewMediaRouter(container, settings.TrustedProxies))
r.Mount("/image-variants", NewImageVariantRouter(container, settings.TrustedProxies))
r.Mount("/api-key", NewAPIKeyRouter(container, settings.TrustedProxies))
r.Mount("/permission", NewPermissionRouter(container))
r.Mount("/system", NewSystemRouter(container, settings.TrustedProxies))
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
@@ -349,14 +620,7 @@ func enableCorsIfConfigured(r *chi.Mux, origins []string) {
if len(origins) == 0 {
return
}
r.Use(cors.Handler(cors.Options{
AllowedOrigins: origins,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: false,
MaxAge: 300,
}))
r.Use(middleware.CORS(origins))
}
func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
@@ -366,8 +630,10 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
apiRouter := chi.NewRouter()
apiRouter.Use(middleware.SecurityHeaders(settings.IsProduction()))
enableCorsIfConfigured(apiRouter, settings.CorsAllowedOrigins)
apiRouter.Use(middleware.CrossOriginProtection(settings.PublicURL))
apiRouter.Use(chimiddleware.GetHead)
apiRouter.Get("/healthz", GetHealth)
llms.NewHandler(llmdocs.Docs, settings.Version, settings.PublicURL).Register(apiRouter)
apiRouter.Mount("/v1", backend)
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase, container.ServeVariantUseCase))
apiRouter.Mount("/", frontend)
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"RenewCMS/internal/infrastructure/mcpserver"
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
var version = "dev"
func main() {
allowDestructive := flag.Bool("allow-destructive", false, "also expose delete_article and delete_media")
showVersion := flag.Bool("version", false, "print the version and exit")
flag.Parse()
if *showVersion {
fmt.Println(version)
return
}
baseURL := os.Getenv("RENEWCMS_URL")
apiKey := os.Getenv("RENEWCMS_API_KEY")
if baseURL == "" || apiKey == "" {
log.Fatal("RENEWCMS_URL and RENEWCMS_API_KEY must be set")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
server := mcpserver.New(mcpserver.NewClient(baseURL, apiKey, nil), mcpserver.Options{
Version: version,
AllowDestructive: *allowDestructive,
})
if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil {
log.Fatal(err)
}
}
+28 -24
View File
@@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"io/fs"
"log"
"net"
"net/http"
@@ -15,8 +16,6 @@ import (
"slices"
"syscall"
"time"
"github.com/joho/godotenv"
)
var version = "dev"
@@ -42,27 +41,28 @@ var requiredEnvVars = []string{
}
var envDefaults = map[string]string{
"ENVIRONMENT": "production",
"PORT": "8080",
"DB_FILE": "./data/renewcms.db",
config.EnvDatabaseDriver: config.DefaultDatabaseDriver,
"DB_TLS": config.DefaultDatabaseTLS,
"DB_SSLMODE": config.DefaultDatabaseSSLMode,
"DB_MAX_OPEN_CONNS": config.DefaultMaxOpenConns,
"DB_MAX_IDLE_CONNS": config.DefaultMaxIdleConns,
"DB_CONN_MAX_LIFETIME_SECONDS": config.DefaultConnMaxLifetimeSeconds,
"UPLOAD_DIR": "./data/uploads",
config.EnvStorageDriver: config.DefaultStorageDriver,
"S3_REGION": config.DefaultS3Region,
"S3_PATH_STYLE": config.DefaultS3PathStyle,
"S3_TLS": config.DefaultS3TLS,
"AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5",
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
"TRUSTED_PROXIES": "",
"UPDATE_FEED_URL": "https://git.floriansylvain.fr",
"RENEWCMS_CONTAINER": "false",
config.EnvImageVariantMaxSourceBytes: config.DefaultImageVariantMaxSourceBytes,
config.EnvImageVariantMaxSourcePixels: config.DefaultImageVariantMaxSourcePixels,
"ENVIRONMENT": "production",
"PORT": "8080",
"DB_FILE": "./data/renewcms.db",
config.EnvDatabaseDriver: config.DefaultDatabaseDriver,
"DB_TLS": config.DefaultDatabaseTLS,
"DB_SSLMODE": config.DefaultDatabaseSSLMode,
"DB_MAX_OPEN_CONNS": config.DefaultMaxOpenConns,
"DB_MAX_IDLE_CONNS": config.DefaultMaxIdleConns,
"DB_CONN_MAX_LIFETIME_SECONDS": config.DefaultConnMaxLifetimeSeconds,
"UPLOAD_DIR": "./data/uploads",
config.EnvStorageDriver: config.DefaultStorageDriver,
"S3_REGION": config.DefaultS3Region,
"S3_PATH_STYLE": config.DefaultS3PathStyle,
"S3_TLS": config.DefaultS3TLS,
"AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5",
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
"TRUSTED_PROXIES": "",
config.EnvSmtpAllowInsecure: config.DefaultSmtpAllowInsecure,
"UPDATE_FEED_URL": "https://git.floriansylvain.fr",
"RENEWCMS_CONTAINER": "false",
config.EnvImageVariantMaxSourceBytes: config.DefaultImageVariantMaxSourceBytes,
config.EnvImageVariantMaxSourcePixels: config.DefaultImageVariantMaxSourcePixels,
config.EnvImageVariantMaxConcurrentRenders: config.DefaultImageVariantMaxConcurrentRenders,
}
@@ -106,9 +106,13 @@ func initEnvVariables() {
func loadEnvFile() {
for _, envLocation := range possibleEnvFileLocations {
if godotenv.Load(envLocation) == nil {
err := config.LoadDotenv(envLocation)
if err == nil {
return
}
if !errors.Is(err, fs.ErrNotExist) {
log.Println("WARNING: Could not parse env file:", err)
}
}
log.Println("WARNING: Could not load any .env file")
}
+436 -16
View File
@@ -10,23 +10,33 @@ The origin that serves the app, for example `https://cms.example`. Every path st
for example `https://cms.example/v1/article/`.
## Authentication
- Public routes need nothing: `GET /v1/article/` and `GET /v1/article/{id}`. They return online
articles only.
- Public routes need nothing: `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo`
and `GET /v1/site/{key}/sitemap`. They return online articles, plus the drafts the caller's role may read
(see [Content permissions](#content-permissions)); any other draft answers 404 `Article not found`.
- Everything else needs a signed-in browser session (the `jwt` cookie) or an API key:
`Authorization: Bearer rcms_...`. Create keys at `/integration/api-keys`. A key acts as its
creator, with that user's role, and shows in full once.
- A key cannot call `POST /v1/auth/verify`, `DELETE /v1/auth/register`, `/v1/api-key/*` or
`/v1/system/*`. Those answer 403 "This action needs a real session." An invalid, unknown, expired
or revoked key answers 401, even on public routes.
or revoked key answers 401, even on public routes. The site writes (`POST`, `PATCH` and `DELETE`
under `/v1/site`) and the author writes (`POST`, `PATCH` and `DELETE` under `/v1/author`, `POST /v1/author/me` included) are session only too.
- Each key can make 120 requests per minute. Beyond that: 429 with a `Retry-After` header.
- A session lasts 24 hours. `POST /v1/auth/logout` revokes it on the server, so a copied cookie
stops working at once; other sessions of the same user stay valid. A password reset ends every
session and revokes every key of that user.
- A browser `POST`, `PUT`, `PATCH` or `DELETE` sent from another origin without an
`Authorization` header answers 403 "Cross-origin requests are not allowed." This blocks
cross-site request forgery with the cookie; scripts on other origins must send their key.
## Routes a key can call (24)
## Routes a key can call (30)
| Group | Routes |
| --- | --- |
| Articles | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
| Media | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) |
| Users and invites | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me |
| Articles | GET /v1/article/, GET /v1/article/{id}, GET /v1/article/{id}/seo, POST /v1/article/{id}/seo/check, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
| Media | GET /v1/media/, GET /v1/media/{id}, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) |
| Users and invites | GET /v1/user/ (`users.view`), PATCH /v1/user/me, GET /v1/user/me/export, DELETE /v1/user/me, POST /v1/user/invite (`users.invite`), DELETE /v1/user/invite/{id} (`users.invite`), POST /v1/user/invite/{id}/renew (`users.invite`), GET /v1/auth/me (with the linked `author`) |
| Settings | GET /v1/settings |
| Sites | GET /v1/site/, GET /v1/site/{key}/sitemap |
| Authors | GET /v1/author/ |
## Activity log
The activity routes are session only: a key answers 403 "This action needs a real session." and a
@@ -35,12 +45,12 @@ key never reads the audit trail. Events are append-only; there is no `POST`, `PU
| Route | Who | Purpose |
| --- | --- | --- |
| `GET /v1/activity` | Any verified user | Filtered, paged events. Owner and admin see every event; any other role is scoped to its own events, and `actor` set to someone else answers 403 "Only owners and admins can do this." |
| `GET /v1/activity` | Any verified user | Filtered, paged events. A role with `activity.view` at all sees every event; a role at own is scoped to its own events, and `actor` set to someone else answers 403 `missing_permission`. |
| `GET /v1/activity/summary` | Owner, admin | Counts for the range, the previous period, active people and seven-bucket series. |
| `GET /v1/activity/export` | Owner, admin | The same filters as the list without `page` or `size`, as a `text/csv; charset=utf-8` attachment (`when,actor,action,target,outcome,source,ip`). Cells starting with `=`, `+`, `-` or `@` get a leading quote. |
Query: `q`, `actor` (`user:ID`, `key:ID`, `system`), `category` (`articles`, `media`, `users`,
`api_keys`, `auth`, `system`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range`
`api_keys`, `auth`, `system`, `sites`, `authors`, `roles`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range`
(`today`, `7d`, `30d`, `all`, `custom`; default `7d`), `from` and `to` (dates, with `custom`), `page` (from
1) and `size` (25, 50 or 100). Any invalid value answers 400 with the body-mismatch message. Dates
are UTC. The list answers `{ items, total, page, size }`, newest first (`created_at`, then `id`).
@@ -49,7 +59,7 @@ are UTC. The list answers `{ items, total, page, size }`, newest first (`created
90), `activity_retention_max_gb` (0 for no size limit), `activity_log_size_bytes`,
`activity_last_cleanup_at` and `activity_last_cleanup_removed`. `PATCH /v1/settings` with
`{"activity_retention_days": 30}`, `{"activity_retention_max_gb": 5}` or both is session only and
owner only (403 "Only the owner can do this."); any other value answers 400. The daily cleanup
needs `system.edit_retention` (403 `missing_permission`); any other value answers 400. The daily cleanup
removes events past the age limit, then the oldest events until the log fits the size limit.
Whichever limit is reached first wins.
@@ -64,13 +74,15 @@ never changes the response or the change itself.
| --- | --- |
| `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` |
| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge`, `media.variant_generate_all`, `media.variant_purge_image`, `media.variant_purge_all`, `media.variant_purge_unused` |
| `users` | `user.register`, `user.verify`, `user.update_profile`, `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
| `users` | `user.register`, `user.verify`, `user.update_profile`, `user.request_email_change`, `user.resend_email_change`, `user.cancel_email_change`, `user.confirm_email_change`, `user.export_data`, `user.delete_account` (flagged `security`), `user.change_role`, `user.remove`, `user.transfer_ownership` (flagged `security`), `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
| `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete` |
| `authors` | `author.create`, `author.edit`, `author.delete` |
| `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` |
Target types: `article`, `image`, `image_variant` (id and name are the variant name), `user`, `invite`,
Target types: `article`, `site`, `author`, `image`, `image_variant` (id and name are the variant name), `user`, `invite`,
`api_key` and `release` (id and name are the tag).
Only the fields `title`, `online`, `body`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes`, with the
kinds `text` and `body`; a body edit carries a signed character count delta and never the text. A
Only the fields `title`, `online`, `body`, `site`, `name`, `base_url`, `default_robots`, `locale`, `default_social_image`, `slug`, `canonical_url`, `noindex`, `cover_image`, `social_image`, `excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_alt`, `site_name`, `title_template`, `default_description`, `bio`, `website`, `social_links`, `avatar`, `user`, `authors`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes` (a site key is never a change; moving an article records `site` with the site names; changing the credited authors records `authors` with the names in order; the `user` change of an author holds usernames, never an email), with the
kinds `text` and `body`; a body edit carries a signed character count delta and never the text; the free-text SEO fields (`excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_alt`) do the same under their own field name, while `slug`, `canonical_url`, `noindex`, `cover_image`, `social_image` (image file names) and the site fields `site_name`, `title_template`, `default_description` carry `before` and `after`. `seo_dismissed` is not an edit and records nothing. A
change that leaves a value as it was (same body, same publish state, same file name, same display
name) records nothing. Params: `media.merge` and `media.purge` and `system.media_purge` carry
`merged` and `articles_updated`, plus `failed_groups` when a group failed (still outcome `success`);
@@ -109,13 +121,170 @@ with `items: []` and the real `total`. Any invalid `page`, `size`, `filter`, `so
`hash` is 400 "The media query is invalid.". Every item carries `variant_count` and `variant_size`
(the index rows of the image and their bytes, 0 when none).
`GET /v1/media/{id}` reads one image with the same auth as the list (a verified session or an API key; 403 for an
unverified account, 401 without credentials) and writes nothing, so it records no activity event. It answers 200 with
the object of one `items` element of the list, a missing image is 404 "Image not found" and a malformed id is 400 with
the id-format message.
Breaking change: `next_cursor` is gone and no `X-Total-Count` header is sent. `cursor` and `limit`
are no longer parameters; they are ignored, never an error, and have no effect on paging.
## Sites
Every article belongs to exactly one site, and exactly one site is the default. A site is `{ id,
name, key, base_url, default_social_image_id, default_robots, locale, is_default, article_count }`:
`name` is 1 to 100 characters, `key` is 2 to 32 characters of lowercase letters, digits and single
dashes (`^[a-z0-9]+(-[a-z0-9]+)*$`) and never changes, `base_url` is `https` (or `http` for
`localhost`, `127.0.0.1` and `[::1]`) with no path, query or user info (a trailing slash is
stripped, uniqueness is not enforced), `default_robots` is `index` (default) or `noindex`, `locale`
is `null` or a tag such as `en` or `fr-CA` (an empty string means none) and `default_social_image_id`
is `null` or the id of an image. Read access needs a verified session or an API key; every write is
session only and need `settings.manage_sites` (`settings.delete_site` for DELETE): 403 `missing_permission`, or "This action needs a real
session." with a key. The role is checked before the body.
| Route | Purpose |
| --- | --- |
| `GET /v1/site/` | The sites, default first and then by name, each with `article_count`. Query `q` (up to 200 characters, matched in `name`, `key` or `base_url`, ignoring case and accents, `%` and `_` literal), `page` (a whole number from 1) and `size` (5, 25, 50 or 100). Without `page` and `size` every matching site is returned; with only `page`, `size` is 25; with only `size`, `page` is 1. The body stays a JSON array (`[]` when nothing matches, never `null`) and `X-Total-Count` holds the number of matching sites before paging. A page past the end is `200 []` with the total. An invalid `page`, `size` or `q` is 400 with the body-mismatch message and no `X-Total-Count`. |
| `POST /v1/site/` | Create. 201 with the site. `is_default: true` makes it the default. `site_name`, `title_template` and `default_description` are optional and validated like on `PATCH`. |
| `PATCH /v1/site/{id}` | Edit `name`, `base_url`, `default_robots`, `locale`, `default_social_image_id`, `site_name`, `title_template`, `default_description`. Absent fields are untouched. `key` may only be repeated unchanged; `is_default` is refused. |
| `PATCH /v1/site/{id}/default` | Make the site the default, atomically. Setting the current default is a no-op. |
| `DELETE /v1/site/{id}?reassign_to={id}` | 204. A site with articles needs `reassign_to`; the articles move and the site is deleted in one transaction. `reassign_to` is ignored when the site has no article. |
Errors are plain text whose body is exactly the token: 409 `site_key_taken`, 409 `site_has_articles`,
403 `default_site` (checked before the articles), 400 `key_immutable`, 400 `invalid_site_url`, 400
`invalid_locale`, 400 `invalid_title_template`, 400 `image_not_found`, 400 `use_set_default`, 400 `invalid_reassign` (missing
number, the site itself or not a whole number) and 404 `site_not_found`. Refused writes except `site_not_found` and `image_not_found` are recorded as failed events. A body that cannot be
decoded is 400 with the body-mismatch message, a malformed id is 400 with the uint32 message, and a
field that fails validation (`name`, `key` format, `default_robots`, `site_name` over 100 characters, `title_template` over 200, `default_description` over 320) is 400 with the validator text.
A site also carries `site_name` (at most 100 characters, `""` by default), `title_template` (at most 200 characters, `%title% | %site%` by default; it must contain `%title%`, case sensitive, otherwise 400 `invalid_title_template`, an empty template included) and `default_description` (at most 320 characters, `""` by default). They appear on every site response, `POST` accepts them and `PATCH` edits them like the other site fields. Existing sites get the defaults when the server starts after the upgrade.
Articles carry `site_id`. A new article starts on the default site. `GET /v1/article/?site=<key>`
keeps only the articles of that site and composes with `status`, `q`, `sort`, `size` and `page`;
`counts` stay global, an unknown key answers 200 with no item and an empty `site=` is 400 with the
body-mismatch message. `PATCH /v1/article/{id}` takes `body`, `site_id`, `author_ids` and the SEO
fields below, and nothing else, such as `title`: absent fields are untouched, an empty object, an object without
those fields, a `null` value (except on `cover_image_id` and `social_image_id`) or a malformed value is 400 with the body-mismatch message, `site_id: 0` is
400 with the validator text and an unknown site is 404 `site_not_found`. Moving an article never
changes `is_online`. `{"body": ""}` still sets an empty body, but `{}` and `{"body": null}` no longer do: they are 400.
### Resolved SEO, check, list status and sitemap
RenewCMS renders no page and injects no meta tag: it returns the data a frontend needs.
| Route | Who | Purpose |
| --- | --- | --- |
| `GET /v1/article/{id}/seo` | public for online articles; a verified session or API key also reads the drafts its role may read | The resolved object `{ title, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks, json_ld }`. It never carries the body, the id or `seo_dismissed`, ignores `format`, and writes nothing. An offline article for any other caller, and a missing one, is 404 `Article not found`. |
| `POST /v1/article/{id}/seo/check` | verified session or API key (401 without credentials, 403 unverified) | Evaluates the article without saving. The body is `{}` or any of the twelve SEO fields of `PATCH` plus `body`; each replaces the saved value for this check only. `site_id`, `author_ids`, unknown fields and `null` (except on `cover_image_id` and `social_image_id`) are 400 with the body-mismatch message. The other validation is the one of `PATCH` (`invalid_slug`, `invalid_url` as 422, `image_not_found`, validator text). Answers `{ status, suggestions, slug }` where `suggestions` is `[{ code, field, params }]` and `slug` is `{ available, suggestion }`. A taken slug is a 200 with `available: false` and the first free `<slug>-N` in `suggestion`, never a 409. |
| `GET /v1/site/{key}/sitemap` | public | A JSON array `[{ url, modified_at, noindex }]` of the online articles of the site with that key, by id ascending. `url` is `base_url/slug` (never the canonical override). `page` (default 1) and `size` (25, 50, 100 or 500, default 100); an invalid value is 400 with the body-mismatch message. The total is in the `X-Total-Count` header. An unknown key, an id included, is 404 `site_not_found`. |
`GET /v1/article/` also takes `seo=ready|suggestions|hidden` (any other value, an empty one included, is 400 with the body-mismatch message, after the `format` check). It is applied before paging and composes with `q`, `status`, `site`, `sort`, `page` and `size`; `total` follows it and `counts` ignore it. Every item carries `seo_status` (`ready`, `suggestions` or `hidden`) and `seo_suggestions` (the number of suggestions that are not dismissed). The filter is evaluated in memory over every matching article, so its cost grows with the number of articles on the site; without it only the returned page is assessed.
`X-Total-Count` is added to `Access-Control-Expose-Headers` next to `Link`, so a browser on an allowed origin can read it.
### Article SEO fields
An article carries twelve search and sharing fields, all edited through `PATCH /v1/article/{id}` and returned by
every single-article read (`GET /v1/article/{id}`, `POST`, `PATCH`, `publish`, `unpublish`):
`slug`, `excerpt`, `seo_title`, `seo_description`, `cover_image_id`, `cover_image_alt`, `social_title`,
`social_description`, `social_image_id`, `canonical_url`, `noindex` and `seo_dismissed`. Text fields are `""`
when empty, the two image ids are `null` when unset, `noindex` is `false` and `seo_dismissed` is `[]` by
default. Each item of `GET /v1/article/` carries `excerpt` and `slug` and none of the other ten.
The limits are counted in characters: `excerpt` 300, `seo_title` 120, `seo_description` 320,
`cover_image_alt` 200, `social_title` 120, `social_description` 320, `canonical_url` 2048; going over is
400 with the validator text. `cover_image_id` and `social_image_id` accept `null` (and only those two do);
`0` is 400 with the validator text and an unknown image is 400 `image_not_found`. `canonical_url` is `""` or an absolute
`https` URL with a host, otherwise 422 `invalid_url`. `seo_dismissed` is a list of the codes `title_long`,
`title_empty`, `desc_short`, `desc_long`, `cover_alt`, `cover_none`, `slug_long`, `no_au`, `no_h`, `body_alt`
and `short`: an unknown code is 400 with the validator text and duplicates are collapsed with the order
kept. `noindex` is only a flag, it does not take the article offline.
The `slug` matches `^[a-z0-9]+(-[a-z0-9]+)*$` with 1 to 100 characters (400 `invalid_slug`) and is unique per
site, never globally. It is generated from the title when the article is created: lowercase, accents folded,
every run of other characters becomes one dash, dashes trimmed, cut to 100 characters, and `article` when no
letter or digit is left; a slug already used on the site gets `-2`, `-3`... silently. The same rule
backfills the existing articles when the server starts after the upgrade (in id order within each site, so
the oldest article keeps the plain slug). Sending `slug: ""` regenerates the slug from the title. A slug
used by another article on the target site (the new site when `site_id` is sent too, and a move re-checks
the current slug) is 409 with the plain-text body `slug_taken`; the response then carries the header
`X-Slug-Suggestion` holding the first free `<slug>-N` from 2 on that site, fitting 100 characters. The header
is set on this 409 only, never on another response. A regeneration that collides is refused the same way, never suffixed
silently. Saving an article with its own slug is not a collision. Publishing never changes a slug, a deleted article
frees its slug.
New error tokens are plain text from the handler: 409 `slug_taken`, 400 `invalid_slug`, 422 `invalid_url`, 400
`image_not_found` and 400 `invalid_title_template` (sites). Edits that change a SEO field are recorded as
`article.edit` events (`slug`, `canonical_url`, `noindex`, `cover_image`, `social_image` with before and after;
the free-text fields as a size delta only); a refused slug is recorded as failed.
A site's `default_social_image_id` counts as a use of that image: it raises `used_in` and keeps the
image out of the `unused` filter. Deleting the image sets the field to `null` and succeeds. `POST /v1/media/merge` and `POST
/v1/media/purge` repoint the sites of the merged copies to the kept image and report it as
`sites_updated` next to `articles_updated`.
An article's `cover_image_id` and `social_image_id` count the same way. `used_in` counts every article that uses the
image in its body, as cover or as social image once, plus the authors using it as an avatar and the sites using it as default
social image. Deleting an image never fails because of its uses: the covers and social images pointing at it are set to `null`
(the `cover_image_alt` and the bodies are untouched), and a failed delete leaves every reference in place. A merge or a purge repoints
the covers and social images of the copies to the kept image, and an article rewritten in its body and its cover counts once in
`articles_updated`.
`GET /v1/media/{id}/usage` stays an array. Every item keeps the keys it had and gains `type` (`article`,
`author` or `site`) and `fields`. An article item is `{ "type": "article", "id", "title", "published", "fields" }` with
`fields` from `body`, `cover` and `social` (one item per article, in that order); an author item is
`{ "type": "author", "id", "title": <name>, "fields": ["avatar"] }` and a site item is `{ "type": "site", "id", "title": <name>,
"fields": ["default_social_image"] }`; author and site items have no `published` key. The array is `[]` for an unused image.
## Authors
An author is a public byline, with or without an account. It is `{ id, name, slug, bio, avatar_id,
website, social_links, user_id, is_linked, article_count }`, plus `user_email` for owners and admins
only (absent for every other caller). `name` is 1 to 100 characters after trimming, `slug` matches
`^[a-z0-9]+(-[a-z0-9]+)*$` with 1 to 100 characters and is unique (generated from the name when
omitted: accents folded, punctuation runs become one dash, `author` when nothing is left), `bio` has
at most 280 characters (counted in runes), `avatar_id` is `null` or an image id, `website` is `null`
or an `https` URL with a host (an empty string means none, at most 2048 characters),
`social_links` holds at most 5 `{ "type", "url" }` with `type` one of `x`, `linkedin`, `github`,
`mastodon`, `custom` and an `https` URL. `user_id` is the unique link to a user. Zero authors on an
article is allowed and never blocks publishing.
| Route | Who | Purpose |
| --- | --- | --- |
| `GET /v1/author/` | verified session or API key | The authors newest first. Query `q` (name or slug, accent and case insensitive), `page` (default 1), `size` (5, 25, 50 or 100, default 25). Body `{ items, total, page, size }`, with `total` repeated in the `X-Total-Count` header on every 200; a bad query is 400 with the body-mismatch message and no header. |
| `POST /v1/author/` | owner or admin, session only | Create. 201 with the author. `user_id` links an account that has no author. |
| `POST /v1/author/me` | any verified session | Create the caller's own author from the display name (or the username). A taken slug gets `-2`, `-3`... silently. 201 with the author plus `slug_adjusted`: `true` when the stored slug differs from the plain slug of the name (a collision suffix was added), `false` otherwise. No other author response carries this field. |
| `PATCH /v1/author/{id}` | owner or admin on any author; any verified session on the author linked to it; session only | Edit `name`, `slug`, `bio`, `avatar_id`, `website`, `social_links`, `user_id`. Absent fields are untouched, `avatar_id`, `website` and `user_id` accept `null`, `social_links` is replaced as a whole. `user_id` links or unlinks the account and is owner or admin only. |
| `DELETE /v1/author/{id}?reassign_to={id\|none}` | owner or admin, session only | 204. An author credited on articles needs `reassign_to`: an author id (that author takes the deleted one's position and is never duplicated on an article) or `none` (the credit is removed). The move and the delete are one transaction. `reassign_to` is ignored when the author has no article. The linked account is untouched. |
The role checks use the existing bodies: 403 `missing_permission` for `POST /` and
`DELETE`, 403 `author_rights` for a `PATCH` by a role that holds `authors.edit` at own on an author
that is not its own (or that sends `user_id`), and 403 "This action needs a real session." for a key.
Errors are plain text whose body is exactly the token: 404 `author_not_found`, 404 `user_not_found`,
409 `author_slug_taken`, 409 `author_has_articles`, 409 `user_already_linked`, 409
`author_already_linked`, 400 `invalid_slug`, 400 `invalid_reassign` (own id, not a whole number,
negative or empty), 400 `image_not_found`, 422 `bio_too_long`, 422 `invalid_url` and 422
`too_many_links`. A body that cannot be decoded, an empty `PATCH` or one that nulls a required field
is 400 with the body-mismatch message, a malformed id is 400 with the uint32 message and other
validation failures (`name`, link `type`) are 400 with the validator text. Writes refused for a
reason other than a missing target or an invalid value are recorded as failed events.
Articles carry `authors`, the ordered credits (the first is the primary byline), each with only `id`,
`name`, `slug`, `bio`, `avatar_id`, `website` and `social_links`; it is `[]` when there is none and it
is visible on online articles to anonymous readers. `PATCH /v1/article/{id}` also takes
`author_ids`, the whole ordered list: it replaces the credits, `[]` empties them, an unknown id is 404
`author_not_found`, a duplicate, `null` or a malformed list is 400 with the body-mismatch message and
`0` is 400 with the validator text. Absent means unchanged. A new article is credited to the
creator's linked author when there is one. `GET /v1/auth/me` returns the existing user fields plus
`author`: the caller's linked author (`id`, `name`, `slug`, `bio`, `avatar_id`, `website`,
`social_links`, `article_count`) or `null`.
An author's `avatar_id` counts as a use of that image like a site's social image: it raises `used_in`
and keeps the image out of the `unused` filter, and `GET /v1/media/{id}/usage` lists the author with the field `avatar`.
Deleting the image sets `avatar_id` to `null`. `POST /v1/media/merge` and `POST /v1/media/purge`
repoint the avatars of the merged copies and report it as `authors_updated`.
## Image variants
Owners and admins manage named variants of the stored images. These routes are session only (no API
key) and sit behind the verified-user check; any other role gets 403 "Only owners and admins can do
this." Behaviour, caching and the public `?v=<name>` contract are in [image-variants.md](image-variants.md).
key) and sit behind the verified-user check; a role without the needed permission gets 403 `missing_permission`. Behaviour, caching and the public `?v=<name>` contract are in [image-variants.md](image-variants.md).
| Route | Purpose |
| --- | --- |
@@ -184,10 +353,261 @@ disabled variant on `POST`), 413 "The source image is too large to generate this
source image can't be read, so this variant can't be generated.", 422 "Variants aren't generated for
this format." and 500 "Internal server error".
## Roles
Routes under `/v1/role`, behind the verified chain. Reads accept a key; every write is session only (a key answers
403 "This action needs a real session."). The permission is checked before the id, the body and any lookup.
| Route | Permission | Purpose |
| --- | --- | --- |
| `GET /v1/role` | `roles.view` | Every role by id, built-ins first. |
| `GET /v1/role/{id}` | `roles.view` | One role. |
| `POST /v1/role` | `roles.manage` | Create a role (201). `built_in` and `locked` in the body are ignored. |
| `PUT /v1/role/{id}` | `roles.manage` | Replace name, description and grants. `version` is required and must match. |
| `DELETE /v1/role/{id}` | `roles.manage` | Delete a role with no members (204). |
| `POST /v1/role/{id}/reassign` | `roles.manage` | Move every member to `{"target_role_id": N}`; answers `{"moved": n}`. |
| `POST /v1/role/{id}/reset` | `roles.manage` and `roles.edit_owner_only` | Restore a built-in role to its seeded grants and description; body `{"version": N}`. |
A role is `{ id, name, description, built_in, locked, version, grants, members, updated_at, updated_by }`.
`grants` is the normalised effective list: catalogue order, unknown keys and owner-only keys on an unlocked role
dropped, duplicates resolved to the most restrictive scope, and a per-type entry (`content.update:events`, scope
`none` included) right after its wildcard. A response is always valid as the `grants` of a `PUT`. `members` counts
the users holding the role plus the open invites (pending or expired, never accepted ones) for it, counted in two
statements whatever the number of roles. A user whose `role_id` is 0 or names no role counts for nobody.
`updated_at` (RFC 3339, UTC) and `updated_by` (`{ id, name }`, the display name else the username) are `null` for a role
never edited since seeding, and `updated_by` is also `null` once the saving user is deleted.
Refusals are plain text, one snake_case code per body:
| Status | Body | When |
| --- | --- | --- |
| 400 | `unknown_permission`, `invalid_scope`, `owner_only_permission`, `duplicate_permission`, `unknown_content_type` | A grant is not valid (`unknown_content_type` for a `content.*:{type}` key that is neither `article` nor a content type). |
| 409 | `target_role_not_found` | `POST /v1/role/{id}/reassign` when the target role does not exist (any longer). |
| 400 | `invalid_role_name`, `built_in_name`, `not_built_in`, `invalid_reassign` | Blank name after trim; renaming a built-in role; resetting a custom role; a bad reassign target (self, 0, a locked role, a role the caller cannot cover). |
| 403 | `role_locked` | Editing, deleting or moving from the Owner role. |
| 403 | `missing_permission` | The usual gate, plus `X-Missing-Permission: roles.edit_owner_only` when a built-in role is edited or reset without that permission. |
| 403 | `own_role` | Editing, deleting or moving members out of the caller's own role. |
| 403 | `escalation` | The caller would grant, or touch a role holding, more than they hold. `X-Escalation-Key` names the first offending key, such as `content.publish` or `content.publish:events`. |
| 404 | `role_not_found` | Unknown role id. |
| 409 | `role_name_taken`, `role_conflict`, `role_has_members`, `role_built_in` | Name already used (case-insensitive); stale `version`; the role still has members (users or open invites); deleting or moving members out of a built-in role. |
Names are trimmed and unique case-insensitively. The order of checks on a write is: permission, id, body, role lookup,
locked, built-in rules, own role, existing role covered by the caller, version, grant validation, new grants covered
by the caller, name taken, write. Only the Owner holds `roles.edit_owner_only`, so only the Owner edits the Admin and
Writer roles; owner-only keys are refused on every role a client can write. A save with nothing changed answers 200
at the same version and records nothing. Members see an edit on their next request, and so do the keys they created.
Activity: `role.create`, `role.edit`, `role.delete` and `role.reassign` in the `roles` category, target
`{ type: "role", id, name }`. `role.edit` carries `name` and `description` changes and the params `added`, `removed`,
`changed` (`{ key, from, to }`) and `members`; `role.reassign` carries `from`, `to`, `to_name` (the destination role's name at move time; absent on older events) and `moved` (users and open invites together) and is recorded only when
`moved` is above 0. A refusal records nothing.
`GET /v1/auth/me` lists per-type entries in `access.permissions` (scope `none` included), right after their wildcard.
Deleting a content type removes its per-type rows from every role (the affected roles' `version` goes up by one), and
creating one with the key of a deleted type clears any stale row for that key.
## Changing the email address
Four session-only routes under `/v1/auth` let a verified user change their own email address. A missing or invalid
cookie is 401, an API key is 403 `This action needs a real session.` and an unverified user is 403
`You must verify your account to access this resource.`
| Route | Body | Success |
| --- | --- | --- |
| `POST /v1/auth/email/change` | `{ "new_email": "...", "current_password": "..." }` | 200 `{ "pending_email": "<new>" }` |
| `POST /v1/auth/email/resend` | none | 200 `{ "pending_email": "<new>" }` |
| `DELETE /v1/auth/email/change` | none | 204, no body |
| `POST /v1/auth/email/confirm` | `{ "code": "<raw code>" }` | 200 `{ "email": "<new>" }` |
`users.email` does not change until the link is confirmed, so the old address keeps receiving password-reset links and
the password and username keep working. A request stores `pending_email`, a bcrypt hash of a random code and an
expiration two hours ahead, then mails the raw code as a link to the new address (`verify-new-email`) and a notice to
the old one (`email-change-notice`). A new request or a resend replaces the previous code; cancelling clears it. The
link works once.
Errors are plain text and the code is the whole body: 400 `same_email` (the new address equals the current one,
whatever its case), 403 `wrong_password`, 404 `no_pending_email_change` (resend only), 403
`invalid_email_change_code` (nothing pending, wrong, expired, cancelled or issued to another user) and 409
`email_unavailable` (the address belongs to another user at confirmation time; the pending change is cleared). A body
that is not JSON is 400 with the body-mismatch message, and a failed validation is 400 with the validator text
(`new_email` must be a full address, `current_password` and `code` are required).
A request for an address that another user already holds answers exactly like a success, stores the pending change and
still sends the notice to the old address, but sends no verification mail, so the response never reveals that an account
exists. A failed send is logged without the code and does not change the response. `POST /v1/auth/email/change` and
`POST /v1/auth/email/resend` share one limit of 5 attempts per user per 15 minutes; the sixth is 429 with a
`Retry-After` header. Cancel and confirm are not limited.
Audit actions (category `users`, source `web`): `user.request_email_change`, `user.resend_email_change` and
`user.confirm_email_change` are flagged `security`; `user.cancel_email_change` is not, and cancelling with nothing
pending records nothing. A refused request, resend or confirm records a failed event of the same action, flagged `security`, with a specific reason. No email address is written into an event, and a wrong password records a failed
`user.request_email_change` with the reason `Wrong password.`
## Exporting your own data
`GET /v1/user/me/export` answers a ZIP of the caller's personal data. It needs a real, verified session: no cookie is 401,
an API key is 403 "This action needs a real session." and an unverified account is 403. There is no body and no permission
gate, since it only reads the caller's own data. The success answer is 200 with `Content-Type: application/zip`,
`Cache-Control: no-store` and `Content-Disposition: attachment; filename="renewcms-export-<username>-<YYYY-MM-DD>.zip"`;
the username is lowercased and reduced to `a-z`, `0-9` and `-`, and the date is the UTC day of the export. The archive is
built in memory first, so a failure is a plain text 500 and never a half file.
| File | Content |
| --- | --- |
| `profile.json` | `id`, `username`, `display_name`, `email`, `pending_email`, `role {id, name}`, `is_verified`, `created_at`, `updated_at`, `author` (the linked author profile or `null`), `api_keys [{id, name, prefix, created_at, last_used_at, revoked_at}]` |
| `authored-content.json` | `articles [{id, title, body, slug, site_id, is_online, created_at, updated_at, authors}]` created by the caller (drafts included) or published and crediting their author profile, with `authors [{id, name, slug}]`, and `entries [{id, content_type, data, created_at, updated_at}]` created by the caller; soft-deleted articles are left out |
| `uploaded-media.json` | `images [{id, filename, mime, size, hash, uploaded_at}]`: references only, no file bytes; soft-deleted images are left out |
| `account-activity.json` | `events [{id, created_at, action, category, target, outcome, security, source, ip, user_agent, params}]` where the caller is the actor, without events whose target is another user, an invite or another person's author profile |
The archive never holds the password hash, a verification, reset or email-change code, the token version or an API-key
hash. One export succeeds per user every 15 minutes; a second attempt is 429 "Too many requests. Please try again later."
with a `Retry-After` header in seconds. An export that fails with a 500 does not use the slot. A successful export records
one `user.export_data` audit event (category `users`, not flagged `security`).
The archive is built in memory. At most 2 exports are built at the same time across all users (the constant
`maxConcurrentDataExports` in `api/dependecyInjection.go`); another request waits for a free slot. If the request is
cancelled or times out while waiting, the answer is 503 "Service unavailable" and the user's 15-minute slot is not used.
## Deleting your own account
`DELETE /v1/user/me` deletes the caller's account and personal data. It needs a real, verified session: no cookie is 401,
an API key is 403 "This action needs a real session." and an unverified account is 403. It is not `DELETE /v1/user/{id}`,
which stays the admin action and is unchanged.
Body: `{"current_password": "...", "confirmation": "DELETE"}`. The password is required, and `confirmation` must be exactly
`DELETE` (case-sensitive, no spaces). The checks run in this order:
| Answer | Body | When |
| --- | --- | --- |
| 400 | the validator message, or "The request cannot be processed due to a mismatch in the format of the body." | the body is not JSON, the password is empty or `confirmation` is not `DELETE` |
| 403 | `wrong_password` | the password is not the account's |
| 409 | `last_admin`, with the header `X-Last-Role: Owner` or `X-Last-Role: Admin` | the caller holds the built-in Owner role and no other verified user does, or the built-in Admin role and no other verified user holds Owner or Admin. Other roles, custom ones included, are never blocked. |
| 409 | `account_not_deletable` | the holders of the role changed between the check and the removal; nothing was removed |
| 429 | "Too many requests. Please try again later." with `Retry-After` | more than 5 attempts per user in 15 minutes, counted before any check |
| 500 | "Internal server error" | the removal failed; everything is rolled back |
| 204 | none, and a `Set-Cookie` that expires `jwt` | the account is deleted |
The removal is one atomic operation: the user row, all the user's API keys, the linked author (deleted when no article
credits it, otherwise kept as "Deleted user" with the slug `deleted-user-<author id>`, no bio, avatar, website or links and
no user; the avatar image is deleted too, with its stored file after the commit, unless something else uses it), and the user's past activity events (actor and target names become "Deleted user", the IP address, user agent, `params` and `changes` are emptied, and author events on the user's own profile lose the profile name; the events stay). Articles, entries and images keep the old user id. The current session is revoked, and
one `account-deleted` mail (the site address only, no personal data) is sent to the address the account had; a failed send
is logged and does not change the 204. A successful deletion records one `user.delete_account` event (category `users`,
flagged `security`, actor and target named "Deleted user", no email in the params); a refused attempt records a failed
`user.delete_account` event with the reason `Wrong password.`, `Last Owner.` or `Last administrator.`.
## Users, invites and ownership
A user has exactly one stored role, `role_id`, and nothing else: there is no `role` string any more (the
`users.role` column of older databases stays in place, unused). `RoleRef` is the one role summary shape,
`{ id, name, built_in, locked }`. It appears as `access.role` in `GET /v1/auth/me`, as `role` on user and invite items
(`null` for a user whose role cannot be resolved) and in the responses below. `PublicUser` is exactly `id`, `username`,
`email`, `is_verified`, `created_at`, `updated_at` and `display_name` in every response that includes a user (login,
verify, register, invite accept, `GET /v1/auth/me`); `GET /v1/auth/me` adds `pending_email` (string or `null`), `author` and `access`.
`role_assigned_by` is `{ id, name }` or `null`: who last set the user's role. It is stored as the nullable column
`role_assigned_by_id` on the user (no foreign key, no backfill, existing rows are `NULL`) and the name is never stored: it
is read from the current record of that user at read time (`display_name` when set, else `username`), so a rename shows
at once. When that record is gone the whole object is `null`. It is set by `PUT /v1/user/{id}/role` to the caller (a
request with the user's current role writes nothing), by invite accept to the inviter (`NULL` when the inviter record is
missing), and by the ownership transfer: the new Owner is assigned by the former Owner, whose own value becomes `NULL`.
First-user registration and `POST /v1/role/{id}/reassign` leave it alone. The key is always present on accepted-user
items of `GET /v1/user` (gate `users.view`, unchanged; assigners are resolved from the users already loaded) and on
`access` of `GET /v1/auth/me` (own data, one extra lookup at most). Invite items never carry it, and `PublicUser` and the
login, verify, register and invite-accept bodies do not change.
| Route | Permission | Session only | Purpose |
| --- | --- | --- | --- |
| `GET /v1/user` | `users.view` | no | Users and open invites, newest first. A user is `{ id, kind: "user", username, display_name, email, role, role_assigned_by, status: "accepted", created_at }`; an invite is `{ id, kind: "invite", email, role, status, created_at }`. The roles are read with one `List` call. |
| `PUT /v1/user/{id}/role` | `users.change_role` | yes | Body `{"role_id": N}` (required). Answers 200 with the user item, whose `role_assigned_by` is the caller. |
| `DELETE /v1/user/{id}?expected_role_id=N` | `users.remove` | yes | Removes an accepted user (204, no body). `expected_role_id` is required (a whole number above 0, else 400 `invalid_role`) and must be the id of the role the target still holds: a compare-and-swap (`DeleteIfRole`), 409 `user_role_changed` otherwise. Order of checks: session, permission, id, `expected_role_id`, target (404 `user_not_found`), target is the caller (403 `own_role`), stale role (409), target role locked (403 `role_locked`), target role covered (403 `escalation`), delete. The removed user's cookie sessions and API keys answer 401 afterwards; articles, entries, media, invites and audit events they created stay. Records `user.remove` (category `users`, target the user); a refusal records nothing and a storage failure records it with outcome `failed`. Invites are cancelled with `DELETE /v1/user/invite/{id}`. |
| `POST /v1/user/invite` | `users.invite` | no | Body `{"email", "role_id"}`; `role_id` is optional and 0 or absent means the Writer role. Answers 201 `{ id, kind: "invite", email, status, role, created_at, expires_at }`. |
| `POST /v1/user/invite/{id}/renew` | `users.invite` | no | Renews an expired or pending invite; it keeps its role and the response carries it. |
| `DELETE /v1/user/invite/{id}` | `users.invite` | no | Cancels an invite (204). No coverage is needed: cancelling removes power. |
| `POST /v1/site/transfer-ownership` | `system.transfer_ownership` | yes | Body `{"user_id", "confirm_email"}` (both required). Answers 200 `{ "owner": { id, name }, "former_owner": { id, name, role } }`. |
The no-escalation rule applies to people as it does to roles: the caller must cover (`GrantSet.Covers`) the target's
current role and the new role on a role change, the role of an invite on create and renew. An API key may create and
renew invites, and the check then uses the live role of the key's creator. Nobody can give, take away or invite to the
Owner role: `PUT /v1/user/{id}/role` to or from it, and an invite for it, are 403 `role_locked`; only the transfer moves
the Owner role. Changing the role of your own account is 403 `own_role`. Giving the role a user already holds answers
200 and writes and records nothing. A role change keeps `token_version`, sessions and keys: the next request is decided
by the new grants. An unverified user may be given a role.
The order of checks on `PUT /v1/user/{id}/role` is: session, permission, id, body, target user, target holds a locked
role, `role_id` lookup, new role locked, own account, current role covered, new role covered, same role (no-op), write.
On `POST /v1/user/invite`: permission, rate limiter, body, inviter, role (default Writer), role locked, role covered,
email already has an account, pending invite, create, mail. On `POST /v1/user/invite/{id}/renew`: invite lookup (404),
already accepted (409), invite role covered, renew, mail. On `POST /v1/site/transfer-ownership`: session, permission,
body, target, `confirm_email`, write. Every refusal writes nothing, not even the deletion of an expired invite.
| Status | Body | When |
| --- | --- | --- |
| 409 | the human message | `POST /v1/user/invite` only. "This address already has an account." carries `X-Error-Code: email_has_account`; "There's already a pending invite for this address." carries `X-Error-Code: invite_pending`. |
| 400 | `invalid_role` | `role_id` names no role (user change or invite), or the invite's role no longer exists on renew. |
| 400 | `invalid_transfer` | The transfer target is unknown, yourself, unverified, or not a user (an invite id is never valid). |
| 400 | `confirmation_mismatch` | `confirm_email` differs from the target's email (exact, case-sensitive match). |
| 403 | `role_locked` | The target holds the Owner role, or the role to give is the Owner role. |
| 403 | `own_role` | The target of a role change is the caller's own account. |
| 403 | `escalation` | A role to cover is above the caller's grants; `X-Escalation-Key` names the first offending key. |
| 403 | `missing_permission` | The gate, with `X-Missing-Permission`. A key on a session-only route answers 403 "This action needs a real session." first. |
| 404 | `user_not_found` | Unknown user id on `PUT /v1/user/{id}/role`. |
| 409 | `user_role_changed` | `DELETE /v1/user/{id}` as well: the target's role changed between read and write (a compare-and-swap on the role that was read), or the caller no longer holds the Owner role on a transfer. |
Transferring ownership moves the Owner role to the target and gives the former owner the built-in Admin role in one
transaction; both keep `token_version`, cookies and API keys, so the former owner's next request is decided by the
Admin grants and the new owner's by the Owner grants. Exactly one verified user holds the Owner role afterwards, also
under two simultaneous transfers (one answers 200, the other 409).
Accepting an invite (`POST /v1/auth/invite/accept`) creates the user with the role the invite names, read at accept
time; an invite whose `role_id` is 0 or names a deleted role answers the usual invalid-invite 404 and creates nothing.
Registration creates the first user with the built-in Owner role; when the Owner role is not seeded it answers 500.
A user whose `role_id` is 0 or names no role is denied on every gated route (403 `missing_permission`) and gets 500 on
`GET /v1/auth/me`; it is never promoted.
Activity: `user.change_role` (category `users`, target the user, `changes` holds `{ field: "role", kind: "text", before,
after }` with the role names, params `from` and `to` hold the role ids) and `user.transfer_ownership` (category `users`,
`security: true`, target the new owner, params `from` and `to` hold the two user ids). `invite.create` and
`invite.accept` carry the params `role_id` and `role` (the role name); `invite.renew` is unchanged. A refusal and a
no-op record nothing; a storage failure at the write records the same event with outcome `failed`.
## Content permissions
Articles and the entries of custom content types are checked against the `content.*` permissions of the
caller's role, which is read on every request (a key follows its creator's role).
| Route | Permission | Scope check after the lookup |
| --- | --- | --- |
| `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo` | public, `content.read` widens what drafts show | online articles, plus drafts when `content.read` is `all`, or the caller's own drafts when it is `own` |
| `POST /v1/article/{id}/seo/check` | `content.read` | the article must be visible, else 404 |
| `POST /v1/article/` | `content.create` | none; `created_by` is the caller and the article is always a draft |
| `PATCH /v1/article/{id}` | `content.update` | `own` needs `created_by` to be the caller |
| `DELETE /v1/article/{id}` | `content.delete` | same |
| `PATCH /v1/article/{id}/publish`, `/unpublish` | `content.publish` | same; `PATCH` never changes `is_online` |
| `GET /v1/content-type/{key}/entry`, `GET .../entry/{id}` | `content.read` for `{key}` | `own` lists only the caller's entries (total, page, size and `q` follow) and answers 404 `entry_not_found` for another's entry |
| `POST /v1/content-type/{key}/entry` | `content.create` for `{key}` | `created_by` is the caller |
| `PUT`, `DELETE .../entry/{id}` | `content.update`, `content.delete` for `{key}` | `own` needs `created_by` to be the caller |
Each action reads its own permission: publish does not imply update, update does not imply read. `own`
means `created_by` equals the caller; an unattributed item (`created_by` 0) is owned by nobody. A permission
can also be set for one content type (`content.update:blog`, `content.update:article` for articles); that
row replaces the wildcard for the type, whether it lowers or raises it, and a `none` row denies. Only the
wildcard applies to a type with no row. An id that does not exist is 404 even for a caller at `own`. Article responses expose `created_by` to signed-in callers and API keys only (never to anonymous callers), so a client can tell which rows are its own; see `docs/api/articles.md`.
A refusal is 403 `missing_permission` with `X-Missing-Permission` set to the base key (`content.update`,
never `content.update:blog`). The permission is checked before the id and the body are read, then the scope
after the lookup, and a refused call writes nothing and records no activity event. The verification and
token checks still come first. Creating a content type whose key is `article`, `author`, `site`, `media`
or `user` is 409 `content_type_key_taken`. Owner-only permissions only count on the locked Owner role: the
same key placed by hand on another role is ignored and `GET /v1/auth/me` does not list it.
## Errors
Every error is a plain-text body with one sentence; the status is in the HTTP status line. The
statuses are 400, 401, 403, 404, 409, 429 and 500. Messages are listed on the Integration page.
A caller whose role lacks the permission a route needs gets 403 with the body `missing_permission` and the
header `X-Missing-Permission` set to the permission key (for example `users.invite`). The role is read on
every request, so an API key follows the current role of its creator.
## Notes
- Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL.
- Browsers can call the API only from origins in the server's CORS setting.
+96 -3
View File
@@ -2,6 +2,8 @@
Every route that returns an article accepts the optional `format` query parameter and returns the article body in that format. Articles are stored as HTML (Tiptap output). Storage and the request bodies of `POST /v1/article/` and `PATCH /v1/article/{id}` stay HTML.
`PATCH /v1/article/{id}` takes `body`, `site_id`, `author_ids` (the whole ordered list of author ids, see `docs/api.md`) and the SEO fields listed below, and nothing else. The request must contain at least one of them, and `null` answers `400` except for `cover_image_id` and `social_image_id`. Behaviour change: `{}` and `{"body": null}` used to set an empty body and now answer `400`; send `{"body": ""}` to empty a body.
## Routes that honour `format`
- `GET /v1/article/`
@@ -11,7 +13,7 @@ Every route that returns an article accepts the optional `format` query paramete
- `PATCH /v1/article/{id}/publish`
- `PATCH /v1/article/{id}/unpublish`
Visibility is unchanged: anonymous and unverified callers only see online articles, and an offline article answers `404 Article not found`.
Anonymous and unverified callers only see online articles, and an offline article answers `404 Article not found`. A verified caller also sees the drafts that `content.read` covers: every draft at `all`, only their own at `own`, and none without the permission. The `all`, `online` and `draft` counts follow the same visible set.
## Pagination, search and counts
@@ -23,7 +25,7 @@ Visibility is unchanged: anonymous and unverified callers only see online articl
"total": 30,
"page": 1,
"size": 25,
"counts": { "all": 30, "online": 30, "draft": 0 }
"counts": { "all": 30, "online": 30, "draft": 0, "suggestions": 4 }
}
```
@@ -33,12 +35,15 @@ Visibility is unchanged: anonymous and unverified callers only see online articl
| `size` | `5`, `25`, `50` or `100` | `25` |
| `q` | up to 200 characters, matched anywhere in the title, ignoring case and accents | none |
| `status` | `online` or `draft` | none |
| `site` | the `key` of a site; an unknown key gives an empty page, an empty value is `400` | none |
| `seo` | `ready`, `suggestions` or `hidden`; any other value, an empty one included, is `400` | none |
| `sort` | `title`, `status`, `created_at` or `updated_at`, then `:asc` or `:desc` (lowercase, for example `title:asc`) | `updated_at:desc` |
- `items` is `[]` when empty, never `null`. Each item is the article object below.
- Items follow `sort` (default `updated_at:desc`) across the whole list before paging. `title` ignores case, `status` orders on online state (`status:asc` puts drafts first), and every ordering ends with `id` descending so pages never overlap.
- `total` is the number of visible articles matching `q` and `status`, before paging. `page` and `size` echo the values applied.
- `counts` ignores `q` and `status`, so tab counts do not move while searching. For anonymous and unverified callers `all` equals `online` and `draft` is `0`, and `status=draft` answers `items: []` and `total: 0`.
- `counts` ignores `q`, `status`, `site`, `seo`, `sort`, `page` and `size`, so tab counts do not move while searching. `suggestions` is the number of visible articles whose `seo_status` is `suggestions` (hidden and ready articles and dismissed codes are not counted); it equals the `total` of `?seo=suggestions` sent with no other filter by the same caller. For anonymous and unverified callers `all` equals `online` and `draft` is `0`, and `status=draft` answers `items: []` and `total: 0`.
- Each item also carries `seo_status` and `seo_suggestions` (see [SEO status and suggestions](#seo-status-and-suggestions)). `seo` filters on the status before paging, so `total` follows it while `counts` do not.
- A page beyond the last answers `200` with `items: []` and the real `total`.
- An invalid `page`, `size`, `status`, `sort` or an over-long `q` answers `400` with the plain-text message `The request cannot be processed due to a mismatch in the format of the body.` An invalid `format` is reported first.
@@ -66,11 +71,96 @@ Breaking change: the default used to be HTML. Clients that need HTML must send `
"body": "## Plan\n\nReplace the **old** deck.",
"body_format": "markdown",
"is_online": true,
"site_id": 1,
"created_by": 1,
"authors": [],
"slug": "backyard-renovation-update",
"excerpt": "",
"seo_title": "",
"seo_description": "",
"cover_image_id": null,
"cover_image_alt": "",
"social_title": "",
"social_description": "",
"social_image_id": null,
"canonical_url": "",
"noindex": false,
"seo_dismissed": [],
"created_at": "2026-03-12T09:14:00Z",
"updated_at": "2026-09-10T07:00:00Z"
}
```
`created_by` is the id of the user who created the article (`0` when it is attributed to nobody). It is present on every article object (list, read, create and update responses) only when the caller is signed in with a verified session or authenticates with an API key; the key acts as its creator. Anonymous callers, and unverified or stale sessions, get the same object without the field. The SPA reads it to decide which rows a role limited to `own` can edit, delete or publish.
Every single-article read (`GET /v1/article/{id}`, `POST`, `PATCH`, `publish`, `unpublish`) carries the twelve SEO fields. Each item of `GET /v1/article/` carries `slug` and `excerpt` only, and none of the other ten.
## SEO fields
| Field | Type | Limit and rule |
| --- | --- | --- |
| `slug` | string | `^[a-z0-9]+(-[a-z0-9]+)*$`, 1 to 100 characters, unique per site. `""` regenerates it from the title. |
| `excerpt` | string | 300 characters |
| `seo_title` | string | 120 characters |
| `seo_description` | string | 320 characters |
| `cover_image_id` | number or `null` | an existing image id (`0` is refused) |
| `cover_image_alt` | string | 200 characters, independent of the cover |
| `social_title` | string | 120 characters |
| `social_description` | string | 320 characters |
| `social_image_id` | number or `null` | an existing image id (`0` is refused) |
| `canonical_url` | string | `""` or an absolute `https` URL with a host, at most 2048 characters |
| `noindex` | boolean | only a flag: the article stays online |
| `seo_dismissed` | array of strings | any of `title_long`, `title_empty`, `desc_short`, `desc_long`, `cover_alt`, `cover_none`, `slug_long`, `no_au`, `no_h`, `body_alt`, `short`; duplicates are collapsed, order kept |
Limits are counted in characters (runes), not bytes. Absent fields are untouched; text fields and `noindex` are cleared with `""`, `false` and `[]`, the two image ids with `null`.
### The slug
A slug is unique per site (`site_id`, `slug`), never globally. At creation it is generated from the title: lowercase, accents folded, every run of other characters becomes one dash, dashes trimmed, cut to 100 characters without a trailing dash, and `article` when no letter or digit is left. A slug already used on the site gets `-2`, `-3`... silently, the base being truncated so the result fits 100 characters. The same rule backfills existing articles on the first start after the upgrade, in id order within each site.
Editing a slug that another article of the target site already uses (the new site when `site_id` is sent in the same request; moving an article also re-checks its current slug there) answers `409` with the plain-text body `slug_taken`, and the response header `X-Slug-Suggestion` holds the first free `<slug>-N` from 2 on that site, fitting 100 characters. `X-Slug-Suggestion` is set on this `409` only and on no other response. A request with `slug: ""` that regenerates onto another article's slug is refused the same way and never suffixed silently. Re-sending an article's own slug is not a conflict. A refused request changes nothing.
Publishing, unpublishing and `noindex` never change a slug. A deleted article frees its slug.
## Resolved SEO object
`GET /v1/article/{id}/seo` answers the data a frontend renders in its `<head>`. Online articles are public; a draft needs a verified session or API key whose role may read it (see [Content permissions](../api.md#content-permissions)) and is otherwise `404 Article not found`. The query is ignored (no `format`). Keys:
`title`, `description`, `canonical_url`, `robots`, `og` (`type`, `title`, `description`, `image`, `image_alt`, `url`, `site_name`, `locale`), `twitter` (`card`, `title`, `description`, `image`, `image_alt`), `image` (`url`, `alt`, `source`), `authors` (`name`, `url`, `same_as`), `published_at`, `modified_at`, `fallbacks` and `json_ld` (a ready schema.org `Article`).
The fallback chain, first non-empty wins:
| Output | Chain | `fallbacks` value |
| --- | --- | --- |
| `title` | `seo_title`, then the article title, then the site `title_template` (`%title%`, `%site%` replaced by `site_name`, result trimmed) | `seo_title`, `title` |
| `description` | `seo_description`, `excerpt`, the first sentence of the body (tags removed), the site `default_description` | `seo_description`, `excerpt`, `body`, `site`, `none` |
| `image` | `social_image_id`, `cover_image_id` (with `cover_image_alt`), the site `default_social_image_id`; `url` is the site `base_url` plus the image path, `""` and `none` when there is none | `social`, `cover`, `site`, `none` |
| `canonical_url` | `canonical_url`, then site `base_url` + `/` + `slug` | `canonical_url`, `site` |
| `robots` | `noindex, nofollow` when `noindex` is true, otherwise from the site `default_robots` (`index, follow` or `noindex, nofollow`) | none |
| `og` and `twitter` title and description | `social_title` and `social_description`, then the resolved values | none |
`og.type` is `article`, `og.locale` is the site locale (`""` when none), `twitter.card` is `summary_large_image` with an image and `summary` without. `published_at` is the creation time of an online article and `null` for a draft; `modified_at` is `updated_at`. `authors[].url` is the author website (`""` when none) and `same_as` lists the social link URLs only. `json_ld.headline` is the meta title without the site template, and `author`, `image` and `datePublished` are left out when empty.
## SEO status and suggestions
`POST /v1/article/{id}/seo/check` and the list use one rule set. Soft ranges never block saving or publishing; lengths are counted in characters.
| Code | Field | Fires when | `params` |
| --- | --- | --- | --- |
| `title_long` | `seo_title` | the meta title (`seo_title`, else the title) is over 60 characters | `length`, `max` |
| `title_empty` | `seo_title` | the meta title is empty after trimming | none |
| `desc_short` | `seo_description` | the resolved description is under 70 characters | `length`, `min` |
| `desc_long` | `seo_description` | the resolved description is over 160 characters | `length`, `max` |
| `cover_alt` | `cover_image_alt` | there is a cover and its alt text is empty | none |
| `cover_none` | `cover_image_id` | there is no cover | none |
| `slug_long` | `slug` | the slug is over 75 characters | `length`, `max` |
| `no_au` | `author_ids` | no author is credited | none |
| `no_h` | `body` | the text has at least 1000 characters and no `h2` to `h6` | none |
| `body_alt` | `body` | an image of the body has no alt text | `count` |
| `short` | `body` | the text has fewer than 300 characters | `length`, `min` |
Suggestions come in that order. Codes in `seo_dismissed` are never reported or counted. The status is `hidden` when `noindex` is true, else `suggestions` when at least one suggestion remains, else `ready`. The list items carry `seo_status` and `seo_suggestions` (the count, also for a hidden article).
## Markdown output
Headings, bold, italic, links, bullet and ordered lists, blockquotes, fenced code blocks, rules, images and inline code are converted. Anything else is reduced to its text. `script` and `style` content is removed, and the source HTML is never passed through; the only HTML in the output is the alignment wrapper described below. List indentation attributes are dropped. Links with a scheme other than http, https, mailto or tel are reduced to their text. Characters that markdown would read as formatting (`*`, `_`, `` ` ``, `[`, `]`, `<`, `\`) and line starts that would read as a heading, list, quote or rule (`#`, `-`, `+`, `>`, `1.`) are backslash-escaped, so plain text stays plain text. A line break becomes a backslash followed by a newline.
@@ -124,5 +214,8 @@ Example for `<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>`:
| --- | --- |
| `400` | `The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).` for an invalid id |
| `400` | `The format must be one of: markdown, html, json.` for an unsupported format |
| `400` | `invalid_slug`, `image_not_found` or the validator text for an over-long SEO field, `cover_image_id: 0` or an unknown `seo_dismissed` code |
| `404` | `Article not found` |
| `409` | `slug_taken`, with the header `X-Slug-Suggestion: <first free slug>` (this response only) |
| `422` | `invalid_url` for a `canonical_url` that is not an absolute `https` URL with a host |
| `500` | `Internal server error` when the body cannot be converted |
+44 -3
View File
@@ -15,7 +15,7 @@ api/ ──▶ internal/infrastructure/useCases ──▶ internal/domain
| Domain | `internal/domain/<name>/` | Entities, `Err…` sentinel errors, `Repository` interfaces. Imports only the stdlib and other domain packages, with no GORM tags. |
| Use cases | `internal/infrastructure/useCases/` | Application logic. Depends only on domain interfaces, never on persistence, mailer, GORM or `net/http`. |
| Adapters | `internal/infrastructure/persistence/{models,mappers,repositories}`, `internal/infrastructure/mailer` | Implement a domain `Repository` (assert with `var _ domain.Repository = &X{}`). GORM structs stay in `models` and are converted by `mappers`. |
| Database drivers | `internal/infrastructure/persistence/database` | `database.Connect(Config{Driver, Source})` looks the driver up in a registry and returns a `Connection` (GORM handle, `NewBackup`, `Close`). A `Driver` implements `Open` and `NewBackup`, so a new database is one new file plus one registry entry. `database.Migrate` owns the `AutoMigrate` model list and the user-role backfill. Only `sqlite` exists today. |
| Database drivers | `internal/infrastructure/persistence/database` | `database.Connect(Config{Driver, Source})` looks the driver up in a registry and returns a `Connection` (GORM handle, `NewBackup`, `Close`). A `Driver` implements `Open` and `NewBackup`, so a new database is one new file plus one registry entry. `database.Migrate` owns the `AutoMigrate` model list and the role backfills (legacy `users.role` into `role_id` while that column exists, invites without a role to Admin, then `created_by`). Only `sqlite` exists today. |
| Blob storage | `domain/image.Storage`, `internal/infrastructure/storage` | `Storage` (`Put`, `Open`, `Delete`) is the port for uploaded image bytes. `storage.Open(Config{Driver, Location})` picks the adapter; `local` is the only one. Repositories never touch files: use cases coordinate `Storage` and `image.Repository`. |
| Delivery | `api/handlers/<name>/`, `api/router.go` | HTTP handlers receive use cases through `NewHandler(...)`. They never import persistence, mailer or GORM. |
| Composition root | `api/dependecyInjection.go` | The only place that picks the database and storage drivers, builds repositories and use cases, and runs `database.Migrate` and the image metadata backfill at boot. |
@@ -27,7 +27,8 @@ Nothing under `internal/` imports `api/`.
| Package | Role |
|---|---|
| `api/handlers/user` | Authenticated user endpoints: profile update (`UpdateMe`) and the users list with invitations (`CreateInvite`, `ListUsers`). |
| `api/handlers/user` | Authenticated user endpoints: profile update (`UpdateMe`), the users list with invitations (`CreateInvite`, `ListUsers`) and the role change (`RoleHandler.Change`). |
| `api/handlers/ownership` | `POST /v1/site/transfer-ownership`, built by `NewHandler(TransferOwnershipUseCase)` and mounted by `NewSiteRouter` in the session group. |
| `api/handlers/media` | The media library under `/v1/media` (list, upload, rename, delete, merge, usage). Images belong to the library, not to an article: deleting an article never deletes an image, and an image's "used in" is read from article bodies each time. |
| `api/pkg/upload` | Multipart `file` reading and the mapping of upload errors (415, 413, 400) shared by both upload routes. |
| `api/pkg/publicuser` | `PublicUser`, the JSON shape of a user with no password, codes or token version. `publicuser.New` converts a domain `user.User`, so handlers never serialise the domain struct directly. |
@@ -55,6 +56,14 @@ The use cases `GetPurgeSettings`, `UpdatePurgeSettings`, `PreviewPurge` and `Run
| `GET /v1/media/purge-preview` | Groups, copies, distinct articles, the first 5 groups, `more` and the fingerprint. |
| `POST /v1/media/purge` | Runs the purge. Optional `fingerprint`; 409 when stale or when a run is in progress. |
### Sessions and personal data retention
A session is an HS256 token from `api/pkg/authtoken`. `Encode` adds a random `jti` and an `exp` 24 hours ahead; `Decode` refuses a token without `exp`. `authtoken.WithRevocationStore` plugs in `useCases.SessionRevocationUseCase`, which stores a `session.Revocation` (`jti`, `exp`) through the `internal/domain/session` `Repository` (`persistence.RevokedSessionRepository`, table `revoked_sessions`). `LoginHandler.Logout` revokes the presented token; from then on `Decode` answers `ErrRevoked` for it, and a store that cannot be read makes every token invalid rather than valid. Revoking every session of a user stays the job of `token_version`, bumped by a password reset.
`useCases.EnforceRetentionUseCase.Tick(now)` deletes revocations past their expiry, unverified accounts 7 days after their code expired (`user.UnverifiedRetention`) and invites 30 days after they expired or were accepted (`invite.Retention`), and returns a `retention.Result`. `scheduler.NewSystemRetentionRunner` calls it one minute after startup and then hourly, logs one line only when something was removed, and stops with the other runners on shutdown.
Uploads go through the `image.MetadataStripper` port before they are measured for size, hashed and stored. `imaging.MetadataStripper` walks the JPEG segments, PNG chunks and WebP RIFF chunks without re-encoding pixels, drops metadata, keeps colour profiles and re-adds an orientation-only EXIF block when the photo was rotated. A container it cannot walk fails the upload with `image.ErrImageUnreadable`.
### Activity log
`internal/domain/audit` holds the append-only `Event` (actor, target and failure-reason snapshots, outcome, security flag, source, ip, user agent, `params` and `changes`), the closed vocabularies (actor type, category, outcome, source, change kind), the `Filter`, `Summary` and `Settings` types, the `Repository` interface (`Append`, `List`, `Summary`, `DeleteOlderThan`: no way to edit or delete one event), the `SettingsRepository` (`Get`, `SaveRetention`, `SaveLastCleanup`; no row means 90 days) and the sentinels `ErrInvalidFilter`, `ErrInvalidRetention`, `ErrInvalidEvent` and `ErrActorNotAllowed`. The `Recorder` port is `Record(ctx, event)`; it returns nothing, so a caller can never fail on it.
@@ -69,11 +78,33 @@ The use cases `ListActivity`, `GetActivitySummary` and `ExportActivity` turn the
Producers: `ArticleChanges`, `TextChange` and `BodyChange` in `internal/domain/audit` build the change lists (the body change holds only a signed rune count delta), and `NewTarget` and `ReleaseTarget` build the target snapshots (types `article`, `image`, `user`, `invite`, `api_key`, `release`). The use cases create, edit, publish, unpublish and delete article, upload, rename, delete, merge and purge media, register, verify, update profile, create, cancel, renew and accept invite, check, download and restart take the request context first and an `audit.Recorder` last, and record exactly one event after the change or the refusal (the action catalogue is in `docs/api.md`). Events are never written by handlers, middleware or GORM hooks, and nothing is recorded for reads or for a target that does not exist. `useCases/auditFailures.go` holds the fixed table of failure sentences keyed by domain `Err...` sentinels (matched with `errors.Is`, each at most 255 characters; the restart action overrides `ErrJobInProgress`) with the generic fallback "Something went wrong on our side. Please try again."; raw error text is never stored. The manual purge runs the groups through the non-recording `MergeMediaUseCase.merge`, so only one `media.purge` event is written; `RunPurgeUseCase.Execute` is the non-recording run that `PurgeSchedulerUseCase.Tick(now)` uses to record one `system.media_purge` event with the system actor and `context.Background()`. `StartUpdateDownloadUseCase` hands `context.WithoutCancel(ctx)` to its job goroutine so the `system.update_downloaded` event, written when the job becomes `ready` or `failed`, keeps the origin of the request. `middleware.AuditContext` is mounted on the article, user, media and system routers (after the API key middleware where keys are accepted), so API key calls are attributed to the key.
### Sites
`internal/domain/site` holds the `Site` entity (name, immutable key, normalised base URL, optional default social image, `index` or `noindex` robots, optional locale, the default flag and the computed `article_count`), `NormalizeBaseURL`, `SeedBaseURL`, `NormalizeLocale`, `IsValidKey`, the `Patch` type (with `Nullable` for the two nullable fields), the `Err...` sentinels (`ErrSiteNotFound`, `ErrSiteKeyTaken`, `ErrSiteHasArticles`, `ErrDefaultSite`, `ErrKeyImmutable`, `ErrInvalidSiteURL`, `ErrInvalidLocale`, `ErrUseSetDefault`, `ErrInvalidReassign`) and the `Repository` interface. `persistence.SiteRepository` implements it over `models.Site` (`sites`, unique `site_key`); the single default is kept by the transactional operations `Create` (when flagged) and `SetDefault`, not by an index, and `DeleteMovingArticles` moves the articles and deletes the site in one transaction. Articles carry a `site_id` (indexed, no foreign key, so the SQL stays portable).
The use cases `ListSites`, `CreateSite`, `UpdateSite`, `SetDefaultSite` and `DeleteSite` live in `internal/infrastructure/useCases`; the article, media and purge use cases take the site repository as a required constructor argument, wired by the composition root (new articles take the default site, `UpdateArticle` moves an article and records `site` with site names, the media use cases count, clear and repoint default social images). `api/handlers/site` serves `/v1/site`; reads accept a session or an API key, writes are session only behind `RequireOwner`. `database.Migrate(db, publicURL)` registers the model, seeds one default site from `PUBLIC_URL` when the table is empty and backfills `articles.site_id`.
### Authors
`internal/domain/author` holds the `Author` entity (name, unique slug, bio, optional avatar, optional website, social links, the unique optional `user_id`, `is_linked`, the computed `article_count` and a `user_email` that only owner and admin responses carry), the public `Byline` shape embedded in articles, the `Patch` type, the slug generation (a hand-written Latin diacritic folding table, no external dependency), the value rules (`ValidateBio`, `ValidateURL`, `ValidateLinks`, `IsValidSlug`, `IsValidName`), the `Err...` sentinels (`ErrAuthorNotFound`, `ErrSlugTaken`, `ErrAuthorHasArticles`, `ErrUserAlreadyLinked`, `ErrAuthorAlreadyLinked`, `ErrAuthorRights`, `ErrBioTooLong`, `ErrInvalidURL`, `ErrTooManyLinks`, `ErrInvalidSlug`, `ErrInvalidReassign`) and the `Repository` interface. `persistence.AuthorRepository` implements it over `models.Author` (`authors`, unique `slug` and unique nullable `user_id`) and the ordered join `models.ArticleAuthor` (`article_authors`, unique `(article_id, author_id)` and a `position`); uniqueness is checked inside the transaction and re-explained after a failed write, so a race still answers a domain sentinel, and `DeleteMovingCredits` moves the credits (keeping the position, never duplicating) and deletes the author in one transaction. `ArticleRepository` attaches the ordered `Byline`s to the articles it returns, writes `author_ids` in the article update transaction and removes the credits when an article is deleted.
The use cases `ListAuthors`, `CreateAuthor`, `CreateMyAuthor`, `UpdateAuthor`, `DeleteAuthor` and `GetMyAuthor` live in `internal/infrastructure/useCases`; `CreateArticle` credits the creator's author, `UpdateArticle` validates and records `author_ids`, and the media, purge and activity use cases take the author repository as a required constructor argument (avatars count as media usage, are cleared on delete and repointed on merge and purge). `api/handlers/author` serves `/v1/author`: the list accepts a session or an API key, every write is session only, `POST /` and `DELETE` sit behind `RequireOwnerOrAdmin`, and the edit rights (owner or admin, or the caller's own linked author) are decided by `UpdateAuthor` from the caller the handler passes in. `GET /v1/auth/me` adds the linked author through `GetMyAuthor`.
### Article SEO data
`internal/domain/slug` holds the one slug rule (`Generate`, `WithSuffix`, `IsValid`: lowercase, accents folded, runs of other characters become one dash, 100 characters, a caller-chosen fallback). `internal/domain/article` uses it with the fallback `article` and holds the twelve SEO fields of `Article`, the `Update` fields for them (`Nullable` for the two image ids), `ValidateCanonicalURL`, the eleven dismiss codes and `NormalizeDismissed`, and the sentinels `ErrInvalidSlug`, `ErrSlugTaken` and `ErrInvalidCanonicalURL`. `*SlugTakenError` wraps `ErrSlugTaken` and carries the suggested free slug; the handler turns it into the `X-Slug-Suggestion` header of the 409. `author.GenerateSlug` delegates to the same package.
Slugs are unique per site, enforced by the unique index `idx_articles_site_slug` on `(site_id, slug)`. The use cases decide first: `CreateArticle` generates the slug from the title and takes the first free `-N` on the default site (retrying when the repository reports a race), `UpdateArticle` re-checks the slug on the target site for an edited slug, an empty slug (regenerated from the title) and a move, and computes the suggestion as the first free `-N` from 2, fitting 100 characters. `ArticleRepository` locks the site row, checks the slug inside the transaction and re-explains a failed write as `ErrSlugTaken`. Deleting an article replaces its slug with the tombstone `#deleted-<id>` (it can never match a valid slug) before the soft delete, so the slug is free again while the unique index keeps covering soft-deleted rows.
`database.Migrate` adds the columns with `AutoMigrate`, then runs `BackfillSiteTitleTemplates` and `BackfillArticleSlugs` (only rows with an empty slug, in id order, per site, applying the same rule and the same suffixes, with the tombstone for soft-deleted rows) and only then creates the unique index, so it is idempotent and completes a partly backfilled table. The default `title_template` is written by the backfill and the seed, not by a column default, because the SQLite migrator rebuilds the table on every start when the default contains `%` and `|`.
Media usage has more sources than article bodies: `Article.CoverImageID` and `SocialImageID`, `Author.AvatarID` and `Site.DefaultSocialImageID`. `ListMedia`, the purge plan and `GetMediaUsage` count an article once however many places use the image. `DeleteMedia` clears the cover and social references (`article.Repository.ClearImage`, restored with `RestoreImage` when the delete fails), `MergeMedia` repoints them (`RepointImage`) and counts an article rewritten in its body and its cover once, and `GetMediaUsage` (now built with the site and author repositories) returns articles, authors and sites with `type` and `fields`.
### Image variants
`internal/domain/image` holds the `Variant` entity and its rules (`VariantSettings.Normalized`, the settings `Fingerprint`, `VariantETag`, `ETagMatches`, `IsAnimatedGIF`), the sentinels (`ErrVariantNotFound`, `ErrVariantExists`, `ErrVariantInvalid`, `ErrVariantFormatUnavailable`, `ErrUnknownVariant`, `ErrUnsupportedParameter`, `ErrSourceTooLarge`) and three ports: `VariantRepository` (definitions), `VariantFileRepository` (the index of generated files: variant name, source stored name, cached stored name, content type, size and the settings fingerprint, needed because `Storage.Put` chooses the stored name itself) and `VariantRenderer` (decode a source, encode a variant, report the formats it can encode). `internal/infrastructure/imaging` is the renderer adapter, free of GORM and HTTP; the persistence adapters are `VariantRepository` and `VariantFileRepository` over `models.ImageVariant` and `models.ImageVariantFile`.
Generated bytes are stored only through `image.Storage`, so they follow `STORAGE_DRIVER`, and are never rows of `images`. `ServeVariantUseCase` checks, in order: unsupported parameters, the variant (it must exist and be enabled), the source blob, SVG and animated GIF (served as they are), the size cap, the `If-None-Match` ETag, then the cached file, and generates under a `singleflight` keyed by variant, settings and source. A row whose fingerprint no longer matches, or whose blob vanished, is replaced. `UpdateVariantUseCase` purges a variant's files when a rendering setting changes; `DeleteVariantUseCase` and `PurgeVariantUseCase` purge a blob and then its row, so a failed blob delete keeps the row and a retry can finish; `mediaRemover` purges the files of a source before it deletes the source (delete and merge).
Generated bytes are stored only through `image.Storage`, so they follow `STORAGE_DRIVER`, and are never rows of `images`. `ServeVariantUseCase` checks, in order: unsupported parameters, the variant (it must exist and be enabled), the source blob, SVG and animated GIF (served as they are), the size cap, the `If-None-Match` ETag, then the cached file, and generates under an in-process single-flight group keyed by variant, settings and source. A row whose fingerprint no longer matches, or whose blob vanished, is replaced. `UpdateVariantUseCase` purges a variant's files when a rendering setting changes; `DeleteVariantUseCase` and `PurgeVariantUseCase` purge a blob and then its row, so a failed blob delete keeps the row and a retry can finish; `mediaRemover` purges the files of a source before it deletes the source (delete and merge).
The cache screen adds three ports in `internal/domain/image`: `VariantCacheRepository` (unused totals, top sources by bytes and cursor-paged row listing, implemented by the same persistence type as `VariantFileRepository`), `VariantUsageRepository` (one batched write of request times and per-day rejection counts, and the rejection total of a window) and `VariantUsageTracker` (in-memory hits and rejections). `internal/infrastructure/usage.Tracker` implements the tracker; `GetVariantCacheStatsUseCase` and `PurgeVariantCacheUseCase` use the ports only. See [image-variants.md](image-variants.md).
@@ -81,6 +112,16 @@ The cache screen adds three ports in `internal/domain/image`: `VariantCacheRepos
At boot, `BackfillImageMetadataUseCase` (through `image.Repository.ListMissingMetadata` and `UpdateMetadata`) fills `filename`, `size`, `content_type`, `width`, `height` and `sha256` for image rows that lack them, reading the bytes through `image.Storage` (the files in `UPLOAD_DIR` with the local adapter, the bucket objects with the S3 adapter). It is idempotent, and a row whose file is missing or unreadable is left as it is without stopping the boot. The old `article_id` column and the `article_images` table are kept and never written.
### Roles and permissions
`internal/domain/role` holds the permission catalogue, the built-in roles, one exported constant per catalogue key (`roleKeys.go`, the only place a key is spelled in `api/`) and the pure rules over grants: `ValidateGrants`, `ParseTypedKey` and `Role.GrantSet()`. `Role.GrantSet()` is the single constructor of a `GrantSet` and the only place a stored grant becomes an effective one: it drops unknown keys, typed keys on non-content permissions or with an invalid type key, and owner-only permissions on a role that is not locked; it treats a toggle stored at `own` as `none`; and it resolves duplicate rows to the most restrictive scope. `ValidateGrants` rejects the same rows at write time (`ErrDuplicateGrant`, `ErrOwnerOnlyGrant`, `ErrInvalidScope`, `ErrUnknownPermission`), so a bad row is refused when written and ignored when read. A toggle permission only counts at scope `all`, a scoped one at `own` or `all`. A content permission can be narrowed or widened per content type with a key such as `content.update:blog`; the typed row replaces the wildcard for that type and a `none` row denies. A `GrantSet` is opaque: `ScopeFor(key, typeKey)` resolves wildcard and typed rows, `Permits(key, typeKey, callerID, createdBy)` is the one implementation of own and all (`created_by` 0 and caller 0 own nothing), and `Holds`, `HoldsFor` and `HoldsAll` answer the gate questions. Use cases, handlers and middleware never compare a caller id with `CreatedBy` and never read grant rows. `role.Repository` holds `List` (with every role's grants, in one pass), `Get`, `GetByName`, `Create`, `Save`, `Delete`, `ReassignMembers`, `RemoveTypedGrants` and `MemberCounts` (users grouped by `role_id` plus invites with `accepted_at` NULL grouped by `role_id`, two statements for any number of roles); no role use case scans `user.Repository.GetAll`. `user.Repository` gains `UpdateRole(id, from, to)`, a compare-and-swap on the role the use case read (`ErrRoleChanged`, `ErrUserNotFound`, `role.ErrRoleNotFound`), and `TransferOwnership(from, to, ownerRoleID, formerRoleID)`, which updates both rows in one transaction and fails whole when `from` no longer holds the Owner role (`ErrRoleChanged`) or `to` is missing or unverified (`ErrInvalidTransfer`). `invite.Invite` carries `RoleID`. `ChangeUserRoleUseCase`, `TransferOwnershipUseCase` and the amended invite use cases find the built-in roles with one helper, `builtInRole(roles, name)` (`GetByName`, then `built_in` must be true), and a user's role with `resolveRole`, which reads `role_id` only. `Delete` re-counts users and open invites inside its transaction and `ReassignMembers` moves users and open invites in one transaction and returns their sum. `Save` is version checked: it updates the row only when the caller's version matches, bumps the version, writes `updated_at` and `updated_by`, replaces the grants in the same transaction and returns `ErrRoleConflict` otherwise. It never changes `built_in` or `locked`. `Delete` removes the role and its grants in one transaction and returns `ErrRoleHasMembers` when a user still has the role id; `ReassignMembers(from, to)` changes only `role_id` and returns the count; `RemoveTypedGrants(typeKey)` removes the five `content.*:{typeKey}` rows from every role and bumps the version of each affected role once. The role domain also owns `GrantSet.Covers(other, typeKeys)`, the single implementation of "the caller holds at least these grants" behind the no-escalation rule (it returns an `EscalationError` naming the first offending key), `Role.EffectiveGrants()` (the normalised list used by role responses, the audit diff and `/v1/auth/me`) and `DiffGrants`. The roles API (`/v1/role`) is served by `ListRolesUseCase`, `GetRoleUseCase`, `CreateRoleUseCase`, `UpdateRoleUseCase` (which also resets a built-in role), `DeleteRoleUseCase` and `ReassignRoleMembersUseCase`; they depend on `role.Repository`, `user.Repository`, `contenttype.Repository` and `audit.Recorder` only, and `api/handlers/role` maps their domain errors to the status and code. `DeleteContentTypeUseCase` and `CreateContentTypeUseCase` also take the role repository to clear per-type grants. `DeleteContentTypeUseCase` deletes the type and then clears its typed rows; if that cleanup fails the rows stay, which is safe because the role views (`GET /v1/role`, `GET /v1/role/{id}` and every write response) drop typed rows for content types that no longer exist, so a GET body stays a valid PUT body, and `CreateContentTypeUseCase` clears the rows of a key right after a successful create, before any role can match the new type. A failed create never touches the roles. The repository re-checks the name case-insensitively inside the `Create` and `Save` transactions (`ErrRoleNameTaken`), locks the role row in `Delete` before counting members (a no-op on SQLite) and guards the `ReassignMembers` update with an `EXISTS` on the target role, answering `ErrInvalidReassign` when the target vanished.
Enforcement path. The first middleware that needs the signed-in user (`AuditContext`, `RequireCurrentToken`, `RequireVerifiedUser` or a permission gate) stores a request-scoped holder in the context (`access.WithCurrentUser`), so the user is loaded once per request; an API key adds the one load of its own authentication. `CheckPermissionUseCase` resolves the caller's role with one `role.Repository.Get` (a user whose `role_id` is 0 resolves to `ErrRoleNotFound`, an empty grant set), builds the `GrantSet` from the grants the role already carries and never calls `List`. `RequireTypedPermission` gates a content route on the typed scope before any lookup (the type key is `article` for articles and the `{key}` of the URL for entries) and puts the caller (`access.Caller`: user id and grants) in the context. The optional `OptionalAccess` middleware does the same for the public article routes without ever refusing: no cookie, an unverified user, a stale token or an unresolvable role leave the caller anonymous, and only a role repository failure answers 500. The handler passes the caller to the use case as `useCases.ContentCaller`; the use case loads the article or entry, decides the scope with `Permits` right after the load and before any other lookup or validation, and returns a `role.MissingPermissionError`, which `access.WriteIfMissingPermission` writes as 403 with the base key in `X-Missing-Permission`. Reading follows the same helper: an article is visible when it is online or `content.read` permits its `created_by`, and an entry the caller may not read is `entry.ErrEntryNotFound`. A list under scope `own` is filtered before totals, search and paging. `CreateContentTypeUseCase` refuses the built-in type keys with `contenttype.ErrKeyTaken`.
Built-in reconciliation: `database.SeedBuiltInRoles` creates the three built-in roles when they are missing and never overwrites an edited one. On every start it adds the catalogue keys a built-in role lacks: always for Owner, at scope `all`, and for Admin and Writer only while their version is still 1. It never removes or downgrades a grant and never bumps the version.
Created-by backfill: `database.BackfillCreatedBy` assigns articles (soft-deleted ones included) and entries whose `created_by` is 0 to the first owner by id. A database without an owner is left alone. Entries have no soft delete, so no unscoped query is needed for them.
## Frontend ↔ backend
```
+5 -4
View File
@@ -1,6 +1,6 @@
# Configuration
RenewCMS reads its configuration from environment variables. On startup it also loads a `.env` file from the working directory (or its parent) if one exists. Real environment variables take precedence over `.env`.
RenewCMS reads its configuration from environment variables. On startup it also loads a `.env` file from the working directory (or its parent) if one exists. Real environment variables take precedence over `.env`. The parser reads `NAME=value` lines, an optional `export ` prefix, `#` comments (inline ones need a space before the `#`), and single- or double-quoted values (`\n`, `\t`, `\"` and `\\` are unescaped in double quotes). It does not expand `$VARIABLES` and does not support multi-line values. A malformed line makes the whole file ignored, and the server logs the line number.
Copy [`.env.example`](../.env.example) to `.env` to get started. When a required variable is missing, the server refuses to start and names the variable.
@@ -10,8 +10,8 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
| Name | Description | Example |
|-----------------|---------------------------------------------------------------------------------------------------------------------|----------------------------|
| `PUBLIC_URL` | Public base URL of the site, including scheme and no trailing slash. Used for links in emails and in the startup log. | `https://cms.example.com` |
| `JWT_SECRET` | Secret used to sign the auth JWT. Use a long random value; changing it logs everyone out. | `openssl rand -hex 32` |
| `PUBLIC_URL` | Public base URL of the site, including scheme and no trailing slash. Used for links in emails and in the startup log. It is also the CMS's own address: at the first migration, when the sites table is empty, it seeds the default site (name "Default", key "default") with its scheme and host (path dropped, non-loopback `http` upgraded to `https`); an unparseable value stops the migration. It is never read for sites afterwards. | `https://cms.example.com` |
| `JWT_SECRET` | Secret used to sign the auth JWT. It must be at least 32 bytes, and the example values from `.env.example` and `docker.env.example` are refused: the server stops at startup with `JWT_SECRET is too weak` and how to generate one. Changing it logs everyone out. | `openssl rand -hex 32` |
| `SMTP_LOGIN` | SMTP username used to authenticate with the server. Some providers (e.g. Brevo) issue a login distinct from the sender address. | `noreply@example.com` |
| `SMTP_SENDER` | Sender address used in the `From` header. Must be an address/domain the SMTP provider allows that login to send as. | `noreply@example.com` |
| `SMTP_PASSWORD` | SMTP password. | |
@@ -29,9 +29,10 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
| `DATA_DIR` | directory of `DB_FILE` | Directory for update state, staged updates and database backups (`backups/`). Set it with `DB_DRIVER=mysql` or `postgres`, where there is no database file to sit beside (the default still follows `DB_FILE`). With SQLite, leave it unset to keep everything beside the database. |
| `STORAGE_DRIVER` | `local` | `local` (files in `UPLOAD_DIR`) or `s3` (an S3-compatible bucket). Any other value stops the server with `unsupported storage driver "<value>"`. See [Image storage](#image-storage). |
| `UPLOAD_DIR` | `./data/uploads` | Directory where uploaded images are stored. Ignored, without error, when `STORAGE_DRIVER=s3`. |
| `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). |
| `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. Matching is case-insensitive. An entry may contain one `*` (`https://*.preview.example.com`), and a lone `*` allows every origin. Credentials are never allowed cross-origin. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). |
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login`, `POST /v1/auth/password-reset` or the invite-accept pair (`GET /v1/auth/invite` and `POST /v1/auth/invite/accept`, sharing one counter) within the window, tracked independently per route. |
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. |
| `SMTP_ALLOW_INSECURE` | `false` | `true` lets RenewCMS deliver mail without encryption to a server on another host that does not offer STARTTLS (a relay on a private network, a local Mailpit container). By default such delivery fails with an error naming this variable; port `465` always uses implicit TLS and `localhost`, `127.0.0.1` and `::1` are always allowed in clear text. Credentials (`SMTP_LOGIN`/`SMTP_PASSWORD`) are never sent unencrypted to another host, whatever this says. Any value other than `true` or `false` (case-insensitive) stops the server at boot. |
| `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). |
| `RENEWCMS_CONTAINER` | `false` | Set to `true` (case-insensitive; any other value means `false`) when the app runs in a container. Settings then tells the owner to update by pulling a new image instead of offering browser updates, and `can_apply` is always `false`. The Dockerfile and `docker-compose.yml` set it to `true`. Update checks still work. |
| `IMAGE_VARIANT_MAX_SOURCE_BYTES` | `10485760` | Largest stored image, in bytes, that an [image variant](image-variants.md) may be generated from. A request for a bigger source answers 413 with a plain-text body. The original is never affected, and SVG and animated GIF are always served as they are. A value that is not a whole number greater than zero stops the server at boot with an error naming the variable. |
+20
View File
@@ -0,0 +1,20 @@
# Custom content types
A content type is a named schema (a key, a name and 1 to 50 fields) stored as one row with the fields as a JSON text column. Entries of every type share one generic table whose `data` column holds a JSON object. No DDL runs at runtime, so creating or changing a type never alters the database schema.
## Icon, built-in types and user lookup
- A content type has an optional `icon`: `null` or a string matching `^[a-z][a-z0-9-]{0,39}$`, validated in the domain (`ErrInvalidIcon`, `400 invalid_icon`) and never checked against a list of known icons. `""` is stored as `null`. `PUT` replaces the icon like the name and the fields, so an absent icon clears it. The column is nullable, so rows stored before it existed read as `null`.
- `GET /v1/builtin-type` and `GET /v1/builtin-type/{key}` expose a static, read-only catalogue (`internal/domain/builtintype`) of the built-in types `article`, `author`, `site`, `media` and `user` as `key`, `name` and `fields` shaped like content type fields. Owners and admins only. It uses no repository; an unknown key is `404 builtin_type_not_found`. A test checks that every catalogue field key exists in the JSON of the matching entity.
- `GET /v1/user/lookup?ids=1,2,3` returns `[{id, display_name}]` for any verified user, ordered by id, with the username when the display name is empty. 1 to 100 distinct uint32 ids; anything else is `400`. Unknown ids are omitted.
## Layering
The domain packages `internal/domain/contenttype` and `internal/domain/entry` own the rules: key, name and field validation, entry validation against a schema, the sort whitelist, the page limits and the value equality used by uniqueness. The use cases orchestrate them through the two repositories. The repositories only store and load rows. Entry counting for the "type still has entries" rule lives on the entry repository.
## Known limitations of phase 1
- Uniqueness and schema re-validation are O(n). Checking a unique field loads every entry of the type and compares values in Go, and updating a type with stored entries loads all of them to re-validate. This keeps the SQL portable across SQLite, MySQL/MariaDB and PostgreSQL. The path to fix it is the filterable-fields phase: project the values of unique and filterable fields into an indexed table, so uniqueness becomes an indexed lookup and re-validation can be restricted to the changed fields.
- Unique values are checked without a transaction, so two concurrent writes of the same unique value can both succeed. A content type key is protected by a unique index, and a violation is reported as `content_type_key_taken` (409).
- `entry.Repository.DeleteByContentType` is intentionally unused for now. A content type that still has entries cannot be deleted; the method is kept as part of the repository contract for a later cascade.
- Listing is limited to page 1,000,000 and to page sizes of 100 so that the SQL offset cannot overflow.
+1 -1
View File
@@ -274,7 +274,7 @@ Either way, set `TRUSTED_PROXIES` (see [configuration.md](configuration.md)) to
RenewCMS sends `Content-Security-Policy` (including `frame-ancestors 'none'`), `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy` and `Cross-Origin-Opener-Policy` itself, so the proxy doesn't need to add them. With `ENVIRONMENT=production` it also sends `Strict-Transport-Security: max-age=31536000` (browsers ignore it over plain HTTP, so serve the site over HTTPS only). Don't add the same headers in the proxy, or they will be duplicated.
The CSP allows Google Fonts (`fonts.googleapis.com`, `fonts.gstatic.com`) and `https:` images, because the SPA loads its fonts from Google and articles may embed remote images. `style-src` keeps `'unsafe-inline'` for the article editor; `script-src` is `'self'` only.
The CSP serves fonts from `'self'` only (the SPA bundles its fonts, so no visitor IP address is sent to a font CDN) and allows `https:` images, because articles may embed remote images. `style-src` keeps `'unsafe-inline'` for the article editor; `script-src` is `'self'` only.
## Using MySQL or MariaDB
+41 -6
View File
@@ -3,13 +3,31 @@
Nuxt UI v4 back-office. Tokens only, no raw hex (brand mark excepted). Light and dark. 1440×900 and 390×844. Radius 0.
## Boards
- `RenewCMS Screens Back-office.dc.html`: every back-office screen, Settings → Media (variants, cache), and the group "States matrix: controls". Frames: `AppFrame` (shell), `ActivityFrame`, `MediaFrame`, `VariantsFrame`, `IntegrationPage`, `IntegrationImages`, `CodeSnippet`, `StatesMatrix`.
- `RenewCMS Screens Back-office.dc.html`: every back-office screen, Settings → Media (variants, cache), and the group "States matrix: controls". Frames: `AppFrame` (shell), `DirectoryFrame` (Sites and Authors), `ActivityFrame`, `MediaFrame`, `VariantsFrame`, `IntegrationPage`, `IntegrationSearch`, `IntegrationImages`, `CodeSnippet`, `StatesMatrix`.
- `RenewCMS Screens Auth.dc.html` (7 groups, 50 states), `RenewCMS Screens API keys.dc.html` (12 states).
- `RenewCMS Prototype.dc.html`: updated to match (title-only New article, no body limit or counter, neutral count, pagination, link button, AppFrame's mobile drawer).
- Unchanged: Foundations, Components, Handoff, Directions and Emails boards.
## Pass 3: sites and authors (revised)
Roles, decisions and the slice order are in `sites-authors-slices.md` (Sites and Authors) and `seo-slices.md` (SEO).
Back-office board groups: Sites, Sites: form, Sites: delete, Authors, Authors: form, Authors: delete, Editor: site and authors, Profile: author profile, Articles: sites and authors. Plus tables: decisions to confirm (13 rows, 5 marked Decided), deviations, API and errors (with the 403 rows), components, accessibility, copy per state.
- Roles: only creating, editing, deleting and setting the default site are owner only (no sidebar entry or Publishing card for others, 403 page on a direct link). Anyone who can edit an article can set its site and read the site list. Authors: owner and admin manage; other roles read-only, and any role can create and edit their own author (user link locked, no delete).
- The site key is immutable: read-only with a lock in the edit form; the warning state and dialog are gone.
- The "Author profile" card is drawn in every existing Profile frame. The "Site and authors" section shows collapsed in every existing Editor frame that has the details sidebar (39 already did; the Loading frame now shows the header with a skeleton summary). The Not found frame has no editor, and on mobile the section is inside the Details sheet. The dedicated groups keep only the feature-specific states.
- Shared pieces (list, slide-over, delete-with-reassign dialog, slug field, media field) are defined once in `DirectoryFrame`, used by Sites and Authors. The Profile card is defined once in `AppFrame`.
- States trimmed from 87 to 68 (Sites 9, Sites: form 7, Sites: delete 3, Authors 8, Authors: form 11, Authors: delete 6, Editor: site and authors 16, Profile: author profile 5, Articles: sites and authors 3). Dropped: in-progress and done variants (covered by the button loading pattern and the confirm state's trigger), per-error URL variants, the site picker (same picker as the avatar), manual slug override, long names (merged into Many authors), per-field duplicates. The blocked-delete dialog is one frame, "Server fallback (409)".
- Only individual states that render a relative time or a countdown carry the "Clock-sensitive" tag (13 states, listed in states.md). No Home or Articles frame is tagged as a whole.
- Role-restricted variants drawn: read-only Authors list and detail, hidden Sites entry, Sites 403, own-author edit.
## Pass 4: custom content types
Board `RenewCMS Screens Schema.dc.html` (frames: `SchemaFrame`, mounted in `AppFrame`): 11 groups, 99 states. 1 Schema list 7, 2 Schema builder 19, 3 Review and delete 9, 4 Field types 7, 5 Content and entry list 12, 6 Entry editor 23, 7 Relations 5, 8 Permissions 2, 9 Export and import 3, 10 Media picker for a field 8, 11 Media library usage 4. The Media field type (pass 5) is specified in `media-field-changes.md`; the exact copy and measurements are there, and the boards are the visual reference. Plus tables: scope, routes, new components with their Nuxt UI mapping, errors to copy, needs backend (4 rows), decisions to confirm, accessibility. Specs: screens/schema.md, content.md, entry.md, schema-later.md, media.md, media-picker.md, and media-field-changes.md for the Media field type.
- AppFrame gains a Schema item (owner and admin) and a Content group (sidebar and mobile drawer). Nothing else in AppFrame changed.
- Backend gaps still open: built-in types and fields, display names for created_by and updated_by, a type icon, per-change entry counts.
- Not done: Back-office and Prototype boards were not compared visually after the sidebar change (see Checks below).
## Specs
routes.md · components.md · states.md · foundations.md · change-list.md · activity-spec.js · screens/*.md (22 files)
routes.md · components.md · states.md · foundations.md · change-list.md · activity-spec.js · screens/*.md (24 files)
## Status
| Section | Status |
@@ -30,7 +48,7 @@ routes.md · components.md · states.md · foundations.md · change-list.md · a
| B9 Media and picker | Badge added; Media pagination drawn (9 states); filter, sort and size not folded in |
| B10 "proposed" labels | Only article pagination and JSON export |
## State counts (Back-office board: 341)
## State counts (Back-office board: 341 before pass 3, plus 68 for sites and authors = 413)
| Group | States |
|---|---|
| Boot | 1 |
@@ -39,7 +57,7 @@ routes.md · components.md · states.md · foundations.md · change-list.md · a
| Articles: pagination | 11 |
| New article | 5 |
| Editor | 41 |
| Media | 52 |
| Media | 56 |
| Media picker | 13 |
| Integration | 8 |
| Activity | 9 |
@@ -65,7 +83,7 @@ routes.md · components.md · states.md · foundations.md · change-list.md · a
| Cache panel | 10 |
| Editor picker | 5 |
Back-office total 341. Auth board 50 (Log in 9, Register 6, Pending 6, Verify 6, Accept invite 10, Forgot password 6, Reset password 7). API keys board 12. Total 403 states; every one has a desktop and a mobile frame, and the theme switch gives light and dark. The matrix has 24 control rows, 5 states on desktop and 4 on mobile (no hover). screens/*.md list exactly these 403.
Back-office total 341 (pass 2 groups, table above) + 68 (sites and authors) + 4 (Media: used elsewhere) = 413. Auth board 50 (Log in 9, Register 6, Pending 6, Verify 6, Accept invite 10, Forgot password 6, Reset password 7). API keys board 12. Total 481 states; every one has a desktop and a mobile frame, and the theme switch gives light and dark. The matrix has 24 control rows, 5 states on desktop and 4 on mobile (no hover). screens/*.md list the states of the pass 2 groups and the 68 pass 3 states.
## Fixes applied (pass 2)
1. Activity export dialog: removed the 42% progress bar, the "large export" state, the server-job copy and the "ready later" toast; added "Preparing download…" (indeterminate), "Download started", failed, nothing to export, 403; CSV only, with one proposed JSON state.
@@ -89,7 +107,7 @@ Back-office total 341. Auth board 50 (Log in 9, Register 6, Pending 6, Verify 6,
## Defaults chosen
- Slide-over rule: only forms with a live preview and record details; on mobile always a 92% bottom sheet.
- Hover, active, disabled and focus tokens for the matrix (components not specified before): primary hover `--ui-text`, active `--ui-text-toned`; destructive hover opacity 0.85 and active 0.75; disabled 40% (rows 55%).
- Article pagination: API shape (array body, total in `X-Total-Count`) is a design assumption to confirm with the specifier.
- Article pagination: Shipped shapes: the article list body is {items, total, page, size, counts}; the media list body is an array with the total in `X-Total-Count`. Users and authors follow their own routes (see routes.md).
- The Remove action in the link popover has no confirmation; undo restores the link.
- The link button is disabled when an image is selected.
- The tooltip text is "Insert link (Ctrl+K)" and "Insert link (⌘K)" on macOS.
@@ -118,4 +136,21 @@ Back-office total 341. Auth board 50 (Log in 9, Register 6, Pending 6, Verify 6,
- Contrast was not computed for every hover and active background, only the pairs listed in foundations.md.
- The glossary pass was a search for known patterns, not a line-by-line read of every string. Titles in Title Case were not searched.
- The Components, Handoff and Foundations boards were not updated to mention the new tokens, CodeSnippet or the matrix.
- Integration still lists "max 10000" for article bodies, as the backend does today (change-list item 11).
- The sandbox cannot export; no files were bundled.
## Pass 5: roles and permissions
Board `RenewCMS Screens Roles.dc.html` (frame `RolesFrame`, data `roles-spec.js`): 16 groups, 96 states, all Available now (pass 6 added groups 12 to 15 and changed groups 5, 8 and 9; see change-log-roles-sync.md). Spec `screens/roles.md`; additions in components.md, states.md, routes.md, change-list.md. The Permissions grid is folded into the role editor and `/settings/permissions` is retired. Writer frames use AppFrame's existing restricted `user` navigation. Not done: AppFrame itself is unchanged, so the Back-office board still shows owner/admin/user nav; Schema board group 8 (Permissions) should be marked superseded.
The Schema board group 7 (Relations) now has 4 states; the Integration page and the Activity category filter follow the roles sync (see `change-log-roles-sync.md`).
## Sync notes (app sync)
- Back-office board: +4 Media states (Used elsewhere: avatar, site, both, none). Roles board: +6 states (group 12b Remove user).
- Pagination: articles return {items, total, page, size, counts}; media returns an array with X-Total-Count.
- Event names follow the backend: role.create, role.edit, role.delete, role.reassign, user.change_role, user.transfer_ownership, user.remove.
- Counts above include the Media (+4) and Roles (+6) states only. States added this round to the Editor, Articles, Sites, Authors, Profile and Schema groups are on the boards and in the screens files, and are not yet recounted here.
## Pass 7: account self-service (email, export, delete)
Board `source/RenewCMS Screens Account.dc.html` (33 states: change email 14, export 9, delete 10), Auth board groups "Verify new email" (7) and "Account deleted" (1), four email templates in `source/emails/` (previews on the Emails board). Specs: `screens/account.md`, `emails.md`, `routes.md`, `components.md`, `states.md`. Frames: `source/AccountCards.dc.html` (new; copy in `source/account-copy.js`), `AppFrame.dc.html` (Profile hooks, modal list, warning, link and disabled confirm). Every Profile frame on the other boards shows the new cards in their default state. English only.
Decided: authored content is anonymized to "Deleted user"; email-change link 2 hours; export link 24 hours, one export per 15 minutes; direct download is the primary export flow, the emailed link is the documented alternative; the export-ready link needs a logged-in session; "last administrator" counts Owner; a change request for an address already in use answers like success; the account-deleted email goes to the address the account had.
Not done: Foundations and Components boards and the Prototype are not updated. The 15-minute rate-limit state is clock-sensitive (frozen clock 09:41) and is skipped by `task visual:check`.
+96 -1
View File
@@ -1,4 +1,64 @@
# Change list for implementation (status after pass 2)
# Change list for implementation (status after pass 2; SEO round drawn; cleanup round drawn)
## Cleanup round
1. Editor: site and authors, "Create author" states (4 states, desktop and mobile): the mobile dialog sat under the Details sheet (z-index 50 against 55) and was hidden. It now stacks above the sheet (z-index 60), as the SEO panel does. Full-width bottom sheet with grabber, same fields, Cancel left and "Create author" right (Cancel below on mobile, as the other mobile dialogs). Frame: `DirectoryFrame.dc.html`. The four states share the fix: dialog, "for me", slug taken, failed.
2. Integration, "6. Errors": the 403 row gets the muted note "Site SEO defaults are owner only. Article SEO fields need only the right to edit the article." The row below shifts down by the wrapped note height (two 18 px lines desktop, three mobile, plus the 2 px row gap). The old inline parenthetical with the same sentence in the 403 cause text was removed, so it isn't said twice. Frame: `IntegrationPage.dc.html`.
3. Integration, sitemap errors: 404 now shows the body `site_not_found` in code style (was "Site not found"); 400 now shows the generic body-mismatch text used by the other routes (was "Invalid page or size.").
4. New groups on the Back-office board: "Sites: pagination" and "Authors: pagination", 10 states each (20 states, every one desktop and mobile; light and dark by the theme switch). Back-office total 409 → 429. Same pager as Articles and Media, sizes 5, 25, 50, 100, default 25 (no difference to report). One deviation: on a single page the page-size select is hidden and only "N sites" or "N authors" remains.
5. Specs: screens/sites.md, screens/authors.md (pager states, URL rules, out-of-range fallback), screens/integration.md, states.md (pagination states, three HTTP rows), components.md (single-page variant, no new component), routes.md (Sites pagination in the URL).
**Backend assumptions to confirm.** GET /v1/site/ takes `q`, `page`, `size` and returns `X-Total-Count` (it returns every site today). GET /v1/author/ gets `page`, `size` and `X-Total-Count` (it has `q`).
**Open points.**
- Sites has no search field. The two states that need one (empty after search, search resets to page 1) draw the Authors field on Sites with "Search by name or key". Drop those states if you don't want search on Sites.
- Stale copy found, not changed: GET /v1/article/ on Integration still shows the 400 caption "Invalid page or size." while every other route, and now the sitemap, uses the body-mismatch text. The 404 row of the Errors table still lists only the four resource messages and doesn't mention `site_not_found`. The existing Sites and Authors "Default" frames don't draw the count line. README.md state counts and the "Pagination" notes were not updated (outside this round).
## SEO round: decisions (frames drawn)
**Placement.** A "Search and sharing" section sits under "Site and authors" in the Editor sidebar and in the mobile Details sheet. It is collapsed by default, shows a one-line summary ("Ready", "2 suggestions" or "Hidden from search") and is remembered per browser like Site and authors. It holds only the slug, the full URL and three actions: "Edit search and sharing", "What the frontend gets" and the suggestion list. The fields and live previews open in a slide-over (desktop) or a 92% bottom sheet stacked over the Details sheet (mobile), because the 280 px sidebar is too narrow for previews and the slide-over rule already covers forms with a live preview. The API preview is a 720 px dialog (desktop) or a bottom sheet (mobile).
**Principles.** RenewCMS does not render pages or inject meta tags. Every field is optional and the UI shows its fallback. No scores. Suggestions never block saving or publishing; publishing is allowed regardless. Suggestions are dismissible per article.
**Fallback chain (resolved server-side, shown in the UI).**
| Output | Order |
|---|---|
| title | seo_title, then article title; site title_template applied last |
| description | seo_description, then excerpt, then first sentence of the body, then site default_description |
| image | og_image_id, then cover_image_id, then site social image |
| canonical | canonical_url, then site base_url + "/" + slug |
| robots | noindex true gives "noindex, nofollow"; otherwise site default_robots |
| og/twitter title and description | social_title, social_description, then the resolved values above |
**Soft ranges (counters, never blocks).** Meta title 30–60 characters; description 70–160; slug "very long" above 75 characters. Counter turns neutral-warning (`--ui-warning`) outside the range, never red.
**New article fields (PATCH /v1/article/{id}, all optional, snake_case).** `slug`, `excerpt` (max 300), `seo_title` (max 120), `seo_description` (max 320), `cover_image_id`, `cover_image_alt` (max 200; the cover is "ready" only with alt text), `social_title`, `social_description`, `social_image_id`, `canonical_url` (absolute https URL), `noindex` (boolean; only sets a flag in the API, it doesn't change visibility), `seo_dismissed` (string[] of suggestion codes). Slug: lowercase letters, numbers, hyphens; unique per site; 409 `slug_taken` with `suggestion` ("<slug>-2"); auto-generated from the title until edited.
**New site fields (PATCH /v1/site/{id}, owner only).** `site_name`, `title_template` (default "%title% | %site%", must contain %title%), `default_description` (max 320), `social_image_id` (existing), `default_robots` ("index" | "noindex"; existing as robots).
**New endpoints.**
- `GET /v1/article/{id}/seo`: resolved object, public for online articles, 404 for drafts to anonymous callers. Shape: `{ title, description, canonical_url, robots, og: { type, title, description, image, image_alt, url, site_name, locale }, twitter: { card, title, description, image, image_alt }, image: { url, alt, source }, authors: [{ name, url, same_as: [] }], published_at, modified_at, fallbacks: { title, description, image, canonical }, json_ld }`. `json_ld` is a ready Article object (headline, description, image, author[] with name, url and sameAs from author profile URL and social links, datePublished, dateModified, mainEntityOfPage).
- `GET /v1/site/{key}/sitemap?page=&size=`: published URLs only, total in `X-Total-Count`. Items: `{ url, modified_at, noindex }`. Decision: yes, shown on Integration under "Search and sharing" with a copy button; noindex articles are included with the flag so the frontend can skip them.
- `GET /v1/article/?seo=ready|suggestions|hidden`: filter behind "Needs attention" (suggestions) with `seo_status` and `seo_suggestions` (count) added to list items.
**Articles list.** Quiet indicator inside the Status cell (and under the title on mobile cards): nothing when ready, a lightbulb chip "N" for suggestions, an eye-off chip "Hidden" for noindex. Toolbar gets a "Needs attention" toggle next to search; tabs and counts are unchanged. It behaves the same on a single-site install (independent of the Site filter, which stays hidden).
**Authors.** No change to the Authors screen. Profile URL (website) and social links feed `authors[].url` and `same_as`, and JSON-LD `author[].sameAs`; the API preview shows them.
**Permissions.** Anyone who can edit an article edits its SEO fields. Only the owner edits site SEO defaults. 401 and 403 rows join the existing HTTP table: 403 `owner_only` on Site SEO fields, 401 on a missing session.
**Left out on purpose.** Keyword scoring and focus keywords, readability scores, redirects management, schema types beyond Article, hreflang, robots.txt and sitemap rendering (RenewCMS only provides data), automatic image generation for social cards.
**Open questions.**
1. Is `slug` immutable after first publish, or can it change (and break URLs with no redirects)?
2. Does the API return the SEO object for drafts to authenticated callers (for frontend previews)?
3. Should noindex articles be excluded from the sitemap endpoint instead of flagged?
4. Is title_template applied by the API (resolved title) or only returned for the frontend to apply?
5. Backfill: generate slugs for existing articles from their titles at migration, with `-2` suffixes on conflicts?
6. Should excerpt be exposed in the list payload as the article teaser?
**Status.** Frames drawn on the Back-office board (groups: Articles: search status, Editor: search and sharing, Editor: what the frontend gets, Sites: SEO defaults), Integration page group added, specs updated. Placement rationale: the sidebar holds only the summary, address and suggestions because it is 280 px wide; previews and fields need a slide-over.
Ordered by implementation dependency: tokens and shell first, then shared components, then screens. "Frontend only" means no backend work is needed.
@@ -25,3 +85,38 @@ Ordered by implementation dependency: tokens and shell first, then shared compon
| 18 | All | States matrix for controls (hover, focus-visible, active, disabled) | Yes (board "States matrix: controls") | No | None | Yes |
| 19 | All | Contrast fixes: paginator current page border, no muted text on sand | Yes | No | None | Yes |
| 20 | All | Spacing, type and icon sizes made consistent (foundations.md, "One value per role") | Yes in the frames listed there | No | None | Yes |
| 21 | Settings, sidebar | Sites screen under Settings → Sites ("Publishing" group); "Publishing" card on Settings. Owner only: hidden for other roles, 403 page on a direct link. Key immutable after creation | Yes | **Yes:** `site` table, GET/POST/PATCH/DELETE /v1/site/ (writes owner only; PATCH rejects a changed key), `reassign_to`, seed from PUBLIC_URL | SitesTable, SiteForm, SlugField (locked variant), ReassignDialog | No |
| 22 | Sidebar | Authors entry between Media and Users, all roles. Owner and admin manage; other roles read-only list and detail | Yes | **Yes:** `author` table, GET /v1/author/ (any role), POST/PATCH/DELETE (owner and admin; a user may PATCH their own linked author), POST /v1/author/me (any role) | AuthorsTable, AuthorForm, SocialLinksRepeater, MediaField | No |
| 23 | Editor | "Site and authors" section in every Editor frame (collapsed by default): Site select (any role that can edit the article), reorderable authors, inline create (owner and admin), hint | Yes | **Yes:** `site_id`, `author_ids[]` on PATCH /v1/article/{id} | MetaSection, AuthorList, AuthorSelect, SiteSelect, CreateAuthorHint | No |
| 24 | Articles | Authors column; Site column and filter when more than one site | Yes | **Yes:** `site` filter, `site` and `authors` in the list payload | AuthorStack | No |
| 25 | Profile | "Author profile" card in every Profile frame, one-click create, edit own author (user link locked) | Yes | **Yes:** POST /v1/author/me | AuthorProfileCard | No |
| 26 | Backfill | Existing articles get the default site; authors start empty | n/a | **Yes:** migration | None | No |
## Roles and permissions: screens that change
| Screen | Change |
|---|---|
| Sidebar and mobile drawer (AppFrame) | Items are listed per permission, not per fixed role. Writer sees Home, Articles, Media, Authors, Activity and Content. A Roles entry appears under Settings for people who can view roles. |
| Settings navigation (AppFrame, done) | New "Access" group with a "Roles" item (shield icon) under Settings in the sidebar and the mobile drawer, shown to everyone except the restricted `user` role. "Permissions" is removed. |
| /settings/permissions | Retired. 301 to /settings/roles. The grid lives in the role editor. |
| Users (list, invite, row menu) | Role badges, Change role dialog, role picker in invite (default Writer), pending invites show the role, Transfer ownership, protected owner row. |
| Articles list and Editor | Publish hidden for roles without it; status shows Draft with "Ask an editor to publish." Delete and Edit disabled with a reason when scope is Own and the item is someone else's. |
| Content entry lists and editor | Same Own rules. View only mark. |
| Settings pages | Read-only mode with banner for people who can view but not edit. Owner-only actions disabled with an Owner only tag. |
| Schema | Hidden for roles without Schema view. Read-only for view-only roles. |
| API keys | Info alert "Keys follow their creator's role", a Follows role column. |
| Profile | Your role card and See what I can do. |
| Activity and filters | New event types, role diff in the detail panel. |
| Error and 403 copy | Two codes and the page-level state added. |
Decisions to confirm:
| # | Decision made | Alternative |
|---|---|---|
| 1 | Content permissions come before General permissions in the editor, because the common case (Contributor) only touches the grid. | General first, as in the brief. |
| 2 | Writer gets Authors: view and edit own, and Activity: own. | No Authors or Activity access. |
| 3 | A new role starts with nothing granted; presets fill the grid. | Start from a copy of Writer. |
| 4 | Own-scope rows others created show "View only" with a lock icon, not a hidden row. | Hide other people's entries. |
| 5 | Reset to default exists for built-in roles edited by the owner. | No reset. |
| 6 | Activity own/all scope uses the same None / Own / All control. | Toggle plus a separate scope select. |
| 7 | Transfer ownership takes effect at once, no email to the new owner. | The new owner must accept. |
| 8 | A user with Roles: manage cannot edit their own role, even in a custom role. | Allow narrowing only. |
+119
View File
@@ -0,0 +1,119 @@
# Change log: roles sync (pass 6)
Diff this against the code. "Old" is the previous design copy, "New" is the copy now in the design. EN is the design language; FR is in the board table "Final copy: toasts and errors, EN and FR" (screens/roles.md, same title). Every state is drawn light and dark, 1440×900 and 390×844, on `RenewCMS Screens Roles.dc.html` (data `roles-spec.js`, frame `RolesFrame.dc.html`) unless stated. Roles board: 61 → 90 states, 11 → 15 groups.
## 3. Differences adopted
### 3.1 Neutral wording ("they")
| File · state | Old | New |
|---|---|---|
| roles-spec.js, screens/roles.md, RolesFrame · Users: Change role dialog and Change role: options blocked | Her API keys follow the new role. | Their API keys follow the new role. |
| same · Users: Transfer ownership: confirm | You become an Admin. You can't take ownership back unless he transfers it. | You become an Admin. You can't take ownership back unless they transfer it. |
Searched all .md, .js, .dc.html and email files for he, she, his, her, him, hers. No other occurrence.
### 3.2 Event names
| File | Old | New |
|---|---|---|
| routes.md, screens/roles.md (Needs backend), roles-spec.js | role.created, role.updated, role.deleted, user.role_changed, site.ownership_transferred | role.create, role.update, role.delete, role.reassign (new), user.role_change, site.ownership_transfer |
| states.md (new section "Roles sync"), screens/activity.md (new section) | event names and sentences were not written down | table of name, sentence, failed sentence |
Sentences themselves are unchanged ("Role Contributor created", and so on).
### 3.3 Invite endpoint
| File | Old | New |
|---|---|---|
| screens/roles.md group 5, roles-spec.js group 5 | POST /v1/invite | POST /v1/user/invite |
routes.md, components.md and the other screen files never cited `/v1/invite`. IntegrationPage already said `/v1/user/invite`.
## 1. Copy
All EN and FR strings: roles board, table "Final copy: toasts and errors, EN and FR", and group 12 (toasts) and 13 (inline errors).
| State | EN |
|---|---|
| Toast: role changed (group 12) | Role changed · Ines Duarte is now Content lead, and their API keys follow the new role. |
| Toast: role edited elsewhere, 409 | Role changed elsewhere · Jonas Weber edited Contributor, so nothing was saved. Reload and try again. · Reload |
| Toast: role deleted elsewhere | That role no longer exists · It was deleted somewhere else, so we removed it from the list. |
| Toast: member's role changed before a change | Role not changed · Ines Duarte's role changed in the meantime, so nothing was saved. Reload and try again. · Reload |
| Toast: target's role changed before a transfer | Ownership not transferred · Jonas Weber's role changed in the meantime, so nothing was transferred. Check it and try again. · Reload |
| Toast: member's role changed before a removal | User not removed · Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again. · Reload |
| Role editor: save failed (new) | Couldn't save the role · Your changes are still here. Try again in a moment. · Try again |
| Change role: failed (new) | Couldn't change the role · Nothing was changed. Try again in a moment. |
| Delete role: target deleted (new) | Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to. |
| Delete role: role gained members (new) | Role still has members · Intern now has 1 member. Move them to another role first. |
| Delete role: move failed (new) | Couldn't move the members · Nothing was changed. Try again in a moment. |
| Invite: invalid email (new) | Enter a valid email address. |
| Invite: account exists, 409 (new) | This address already has an account. |
| Invite: role above yours (new) | Your role can't assign a role with more power than yours. |
| Invite: send failed (new) | Couldn't send the invite · Try again in a moment. |
| Activity, role.reassign | Moved 3 members from Intern to Writer |
| Activity, failed sentences | Failed to {verb} {object}; one per event in the table "Activity events: failed sentences, EN and FR" |
| Activity filter, category names | Articles · Media · Users and invites (old: Users & invites) · Roles (new) · API keys · Authentication · System |
Unchanged and still correct: Name required, Name already taken, Escalation blocked, Conflict banner, Permission lowered.
## 2. Profile role card (group 9)
Decision: the summary line is removed. "Assigned by Marta Alves" is removed with it, because `access.role` has no such field either. "See what I can do" stays; its summary is computed from the permission list in GET /v1/me.
| State | Old | New |
|---|---|---|
| Role card: custom role | Your role · Contributor · Assigned by Marta Alves · Creates content and edits only their own. · See what I can do · Your API keys follow this role… | Your role · [Contributor badge] [Custom tag] · See what I can do · Your API keys follow this role. If your role changes, they change too. |
| Role card: Owner (new) | none | Your role · [Owner badge] [Locked] [Built-in] · See what I can do · same key line |
Layout, top to bottom, inside the existing card: title; one row of badges (role badge with icon, then Locked if `locked`, then Built-in or Custom from `built_in`); link row with the list-checks icon; muted key line. Gap 12, as every card.
## 4. Behaviour
### Read = Own (group 14, new, 5 states; also group 2 Role editor and group 3 preview via the same editor)
| State | Content |
|---|---|
| Role editor: Read set to Own | New info alert under the grid: Read: Own · Articles show online articles plus the person's own drafts. Custom content types show only entries the person created. Status counts follow the same rule. The preview sentence for Read = Own is now type-specific: "Can read online articles and their own drafts" (Articles) and "Can read only entries they created" (custom types). Old preview text was "read only their own". |
| Articles list: Read = Own | Junior writer: 3 online articles by others and 2 own drafts. Tabs All 5, Online 3, Draft 2. Banner: Drafts by others are hidden. |
| Articles list: no drafts of their own | Draft tab, counts All 3, Online 3, Draft 0. You have no drafts · Drafts you create appear here. Your role can't see drafts by other people. |
| Entry list: Read = Own | Only own entries, "3 entries", banner Entries by others aren't shown. |
| Entry list: no entries of their own | You haven't created an event yet · Your role shows only entries you created, so other people's events aren't listed here. Use New entry to add one. |
Decisions table row 4 now says Update and Delete set to Own keep the View only rows, Read set to Own hides them.
### 409 conflicts (group 15, new, 5 states)
| State | Where | Edits kept | Recovery |
|---|---|---|---|
| Stale save: banner (existing Conflict state, copy unchanged) | Banner above the form | Yes, Save off | Reload role, or Review differences |
| Stale save: review differences (new) | Dialog over the editor | Yes | Keep my edits (reload and re-apply edits on top, Save on) or Use the saved role (drop edits) |
| Stale save: edits kept after reload (new) | Info toast, form shows saved role plus edits | Yes | Review, then Save |
| Change role: member's role changed (new) | Alert inside the dialog; toast if closed | Selection kept; Current mark moves | Change role again |
| Transfer ownership: target's role changed (new) | Alert inside the dialog; toast if closed | Typed email kept; primary off | Reload member, then Transfer ownership |
### Integration page (IntegrationPage.dc.html, Back-office board group Integration)
| Item | Old | New |
|---|---|---|
| Authentication, API key card | …with that user's role (owner or admin). A key never has more rights than its creator. | …with that user's role as it is now. A key never has more rights than its creator. A route that needs a permission answers 403 "missing_permission" to a key whose creator's role lacks it. |
| Errors table, 403 | "This action needs a real session.", "Only the owner can do this.", "Only owners and admins can do this." or "You must verify your account to access this resource." | "This action needs a real session.", `missing_permission` (code style, literal body), or "You must verify your account to access this resource." Note: `missing_permission` is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article. |
| Access chip on 9 routes (content types create, update, delete; built-in types list, get; media purge settings get, patch, preview, purge) | Owner or admin | Needs permission. The route description ends with Needs the permission "…". Proposed permissions: Create and edit content types; Delete content types; View content types; Delete files (purge routes). |
| Per-route 403 line on those routes | Only owners and admins can do this. | missing_permission (code style) |
| Content types section intro | An owner or admin creates a type… | A user whose role can create and edit content types defines a type… |
"Owner only" chips are kept for the owner-only routes.
### Relation picker (SchemaFrame, Schema board group 7, Later)
Sample data: 25 venues per page (the design had 3 sample results, never 10), footer "Showing 25 of 62" with Load more. New state "Relation picker: loading more": skeleton rows, Loading… on a disabled button. Group 7: 3 → 4 states.
## Files touched
roles-spec.js, RolesFrame.dc.html, screens/roles.md (regenerated from the spec), routes.md, states.md, screens/activity.md, screens/integration.md, screens/schema-later.md, schema-spec.js, SchemaFrame.dc.html, IntegrationPage.dc.html, README.md.
## Open items for you
See the end of this file's companion list in the chat reply; repeated here.
1. Relation picker: the design never had a "Load more" frame or a 10-result sample. Both were added, not updated.
2. Activity event names: states.md and screens/activity.md did not contain event names; they only existed in roles.md, roles-spec.js and routes.md. I added the table to the first two.
3. `user.role_changed` and `site.ownership_transferred` became `user.role_change` and `site.ownership_transfer`. Your brief said "the same present-tense pattern for the other role events". Confirm those two are included.
4. `/v1/invite` appeared only in screens/roles.md and roles-spec.js. routes.md, components.md and the other screens already had `/v1/user/invite` or no citation.
5. The 403 row in the design did not list "missing_permission". It listed "Only the owner can do this." and "Only owners and admins can do this.", which I removed. Check that owner-only routes now also answer `missing_permission`.
6. Status naming: you say All / Online / Draft; the design already used Online and Draft, so no change.
7. "Role deleted by someone else (409)": a deleted role normally answers 404. I drew 404 for the roles list and an inline 409 only where the deleted role was a reassign target. Confirm the codes.
8. "Changing a role that changed meanwhile": I read it as the member's role having changed, not the role definition. Confirm.
9. Profile: `access.role` has no assigned_by, so "Assigned by" was removed along with the summary. Confirm that the permission summary can be built from /v1/me.
10. Activity categories: the filter had six categories. I added Roles and renamed "Users & invites" to "Users and invites" to match the permission group. Authors, Sites and Content types have activity in the product but no category in the filter; I did not add them.
11. Purge routes: the catalogue has no permission for media purge. I used Delete files. Pick the real one.
12. Not changed, but now out of date: IntegrationPage Entries intro still says "Any verified user can write entries"; screens/schema.md and the Schema frames say "Owner or admin" for Schema; states.md "Sites and authors: roles" and the 403 owner_only rows still describe fixed roles; screens/profile.md Display name copy. Say if you want these aligned.
13. Decision 4 in "Decisions to confirm" was narrowed (Read = Own hides rows). Direct link to a hidden item answers the existing 404 page; this is proposed.
14. French role names: built-in names (Owner, Admin, Writer) stay English in FR copy until you decide.
15. Toast bodies use two short sentences in places; the glossary says one. Kept for clarity.
## Corrections made while importing into the repo
These were checked against the backend and applied to the repo copy of the design (the Claude Design copy still has the old values).
1. Event names: the backend emits `role.edit` (not `role.update`), `user.change_role` (not `user.role_change`) and `user.transfer_ownership` (not `site.ownership_transfer`). The other three, `role.create`, `role.delete` and `role.reassign`, match.
2. Media purge permissions: `GET /v1/media/purge-settings` needs "View settings", `PATCH /v1/media/purge-settings` needs "Edit general settings", and `GET /v1/media/purge-preview` and `POST /v1/media/purge` need "Delete files".
3. Open item 7 (deleted role): the backend answers 404 for a deleted role and 409 for a stale role save, as drawn.
4. Open item 8: "changing a role that changed meanwhile" means the member's role, as read.
5. Event names now match the backend: role.create, role.edit, role.delete, role.reassign, user.change_role, user.transfer_ownership, user.remove. The living specs (screens/roles.md, roles-spec.js, routes.md, states.md, screens/activity.md) use these names; the history rows above keep the names as they were at the time.
+118 -2
View File
@@ -27,13 +27,15 @@ One component for every code or copyable value. Props: `tabs` (`{ k, label, code
## Editor: link
- **Toolbar button** (icon `link`, after the list buttons): default `--ui-text-toned`; hover `--ui-bg-elevated` with tooltip "Insert link (Ctrl+K)" (macOS: "Insert link (⌘K)"); focus-visible 2 px `--ui-primary` outline; active when the cursor is inside a link (`aria-pressed`, `--rc-sand` and `--rc-on-sand`); disabled at 40% opacity when an image is selected. 28 px desktop, 40 px mobile.
- **LinkModal**: dialog on desktop, bottom sheet on mobile. Fields "Link text" (prefilled from the selection), "URL" (http, https, mailto, relative path, #anchor), toggle "Open in new tab". Footer: Cancel left, "Insert link" or "Save link" right; "Remove link" as a text button in edit mode. Errors: "Enter the link text." and "Enter a valid URL. Use http(s), mailto, a path or #anchor."
- **LinkModal**: dialog on desktop, bottom sheet on mobile. Fields "Link text" (prefilled from the selection), "URL" (http, https, mailto, relative path, #anchor), toggle "Open in new tab". Footer: Cancel left, "Insert link" or "Save link" right; "Remove link" as a text button in edit mode. Errors: "Enter the link text." and "Enter a valid URL. Use http(s), mailto, a path or #anchor." Helper text under the toggle: "Links open in the same tab unless you turn on a new tab."
- **Link popover** (desktop): under the link, 288 px, URL in mono on top, then "Edit", "Open", "Remove" (error colour). Opens on click or Enter on a focused link; Esc closes. **Touch**: bottom sheet with the link text, the URL and three 48 px rows "Edit link", "Open link", "Remove link". Remove needs no confirmation (undo restores).
- **Rendered link**: `--ui-text-highlighted`, 1 px underline, 3 px offset. Hover 2 px underline. Focus 2 px `--ui-primary` outline, offset 2. Selected: `--rc-sand` fill, `--rc-on-sand` text.
- **Toolbar at 390 px**: the full toolbar (22 buttons) does not fit. It scrolls sideways in one row, never wraps; a 24 px fade (`--ui-bg` to transparent) marks hidden content on each side. Buttons stay 40 px.
## Paginator
First, previous, page numbers, next, last; "Showing 26–50 of 312"; page-size select "5 per page", "25 per page", "50 per page", "100 per page" (default 25). Mobile: previous, "Page 2 of 13", next. Hidden when there is one page. Buttons 32 px (44 px mobile), current page `--rc-sand` with `--ui-primary` border, disabled controls 40% opacity, focus ring 2 px `--ui-primary`. Page size and page live in the URL as `size` and `page`.
First, previous, page numbers, next, last; "Showing 26–50 of 312"; page-size select "5 per page", "25 per page", "50 per page", "100 per page" (default 25). Mobile: previous, "Page 2 of 13", next. Hidden when there is one page. Desktop layout, one row: the summary on the left (it takes the free space), the page-size select next to it, the page controls right-aligned; Sites, Authors, Articles and Media draw it the same way. Buttons 32 px (44 px mobile), current page `--rc-sand` with `--ui-primary` border, disabled controls 40% opacity, focus ring 2 px `--ui-primary`. Page size and page live in the URL as `size` and `page`.
Variant for Sites and Authors (no new component): when everything fits on one page the page-size select is hidden together with the controls and the footer shows only the count line "N sites" or "N authors" in `--ui-text-muted`. Loading dims the whole pager to 40% and disables it; the summary reads "Loading page N…". Sizes are unchanged: 5, 25, 50, 100.
## Activity export
Menu: "Export current view" (CSV, active filters, all pages) and "Export all activity…" (CSV, ignores filters; owner and admin only). Dialog: Format (read-only "CSV"; a CSV and JSON control appears when JSON ships, proposed), Date range (default "All time"). States: configure, "Preparing download…" (indeterminate bar, no percentage, Cancel left, "Preparing…" disabled right), "Download started" (Close), "Couldn't prepare the download" (Cancel, Retry), "Nothing to export" (Close), 403 "You can't export all activity" (Close). Synchronous: no job, no progress, no later notification.
@@ -52,3 +54,117 @@ Row 32 px (44 px in the mobile drawer). Parent selected: `--rc-sand`, `--rc-on-s
- **MediaCleanupCard**: square `USwitch`, `UInputNumber` with "days" suffix, saves on change.
- **MediaLinksCard**: rows "Manage image variants" and "Cache usage", 44 px min height.
- **StatesMatrix**: the board "States matrix: controls".
## Sites and authors (pass 3)
Frames: `DirectoryFrame.dc.html` (Sites, Authors, editor section content; the list, slide-over, delete-with-reassign dialog, slug field and media field are defined once there and used by both Sites and Authors); `AppFrame.dc.html` changed in place (sidebar items, Articles columns and filter, editor section and mobile details sheet, Settings card, Profile card, role-based sidebar). Existing components are reused unchanged: dialogs, toasts, empty states, skeletons, paginator, media picker.
| Component | Status | Nuxt UI | Notes |
|---|---|---|---|
| SitesTable, AuthorsTable | New | UTable; below 640 px a list of UCard | Same row heights as Users. Defined once as DirectoryList in DirectoryFrame and used by both. Read-only variant has no actions, chevron instead of the menu |
| SiteForm, AuthorForm | New | USlideover + UForm, UFormField, UInput, UTextarea, URadioGroup, USwitch | Sheet on mobile. Close with changes opens the discard UModal |
| SlugField | New | UInput + UButton (icon) + UBadge | Defined once in DirectoryFrame. Auto badge, pencil, "Reset to match the name". Locked read-only variant (lock icon, "Used in API paths, so it can't change.") for the site key after creation |
| SocialLinksRepeater | New | USelect + UInput + UButton, in UFormField | Max 5 rows. Placeholder follows the type |
| MediaField | New | UButton + UModal (existing picker) | Defined once in DirectoryFrame. Used for the social image and the avatar |
| AuthorViewPanel | New | USlideover, read-only | Detail for roles without author rights. Close is the only action |
| AuthorAvatar | New | UAvatar | Initials fallback, square, radius 0 |
| AuthorStack | New | UAvatarGroup max=3 | Shows "+N" |
| AuthorList | New | List of UButton, UAvatar, UBadge; sorting with useSortable | No Nuxt UI match for sortable rows. Keyboard reordering is custom |
| AuthorSelect | New | USelectMenu multiple with create-item | Footer item "Create author…" opens the dialog |
| SiteSelect | New | USelectMenu | Item slot shows name and host |
| UserSelect | New | USelectMenu | Only users without an author |
| ReassignDialog | New | UModal + USelectMenu + UAlert | Defined once in DirectoryFrame. Used for site and author deletion |
| MetaSection | New | UCollapsible | Desktop sidebar and mobile sheet. Site select for anyone who can edit the article. No "Create author…" row without author rights |
| AuthorProfileCard | New | UCard + UButton + UAlert | Profile screen. Defined once in AppFrame; every Profile frame shows it |
| CreateAuthorHint | New | UAlert closable | Dismissal stored per user |
| Default badge, Linked badge, Guest badge | New | UBadge | Sand fill, info soft fill, outline |
| Row menu with a disabled item | Existing + change | UDropdownMenu + UTooltip | The item stays focusable |
| SearchSharingSection | New | UCollapsible + UButton + USeparator | Editor sidebar and Details sheet, after Site and authors. Summary: Ready, N suggestions, Hidden from search, Checking… |
| SeoPanel | New | USlideover (desktop 960 px) / bottom sheet (mobile) + UFormField, UInput, UTextarea, USwitch, UCollapsible | Defined once in DirectoryFrame. Reuses SlugField, MediaField and the picker. Fallback chip: dashed outline, "Using …" |
| SoftCounter | New | text | Mono, "n / max". Amber (--ui-warning) outside the recommended range, never red, never blocks |
| SearchPreview | New | UCard | Desktop and Mobile toggle (UTabs, 2 items). Title cut at 60 (desktop) or 70 (mobile) characters including the template, description at 160 or 120 |
| SocialCardPreview | New | UCard | 1.91:1 image, host, title, 2-line description; fallback chips |
| SuggestionList | New | UAlert-style rows + UButton (link, ghost icon) | Calm elevated block. States: list, all good, checking. Per-article dismiss; "Show dismissed" |
| ApiPreviewDialog | New | UModal / bottom sheet + UTabs + UButton + UAlert | Formatted and Raw; Copy JSON; loading, error, draft note |
| SeoStatusChip | New | UBadge | Lightbulb + count (warning soft) or eye-off "Hidden" (outline). Articles rows and cards |
| NeedsAttentionToggle | New | UButton (pressed state) | Articles toolbar, sand when on |
| TitleTemplateField | New | UInput + live example | Sites form |
## Custom content types (pass 4)
Frames: `SchemaFrame.dc.html` (all schema, content and entry bodies; relation, permissions and import frames are Later), mounted in `AppFrame.dc.html`. Existing pieces are reused unchanged: dialogs and sheets, toasts, alerts, paginator, skeletons, empty states, SlugField, CodeSnippet, the single switch.
**AppFrame changes.** Sidebar: a "Schema" item (icon shapes) after Users, owner and admin only. A "Content" group under the main items, one row per custom type (default icon files, name truncated with the full name in the tooltip, selected row sand). Over 5 types it shows the first 5 and the selected one, then "Show all N". Mobile drawer: the group is one collapsed row "All content types" with the count; expanded it lists the types at 44 px. New props on the shell state: `content` (types), `contentOn`, `contentOpen`. The group is hidden when there are no custom types. No existing item changed.
| Component | Status | Nuxt UI | Notes |
|---|---|---|---|
| SchemaTable | New | UTable; below 640 px a list of UCard | Same pattern as SitesTable. Built-in rows add the lock and a Built-in badge |
| BuiltInBadge | New | UBadge outline + lock icon | Existing badges have no lock; same size as the Linked badge |
| FieldList | New | List of UButton rows, useSortable, UBadge | No Nuxt UI sortable list. Keyboard: the grip is a button; Space picks up, arrows move, Space drops, Esc cancels. Move up and Move down buttons on the selected row. Same approach as AuthorList |
| FieldInspector | New | UForm, UFormField, UInput, UInputNumber, UInputDate, USelect, UTextarea, USwitch | Composition only. Controls follow the field type |
| FieldTypePicker | New | URadioGroup variant="card" in a grid, icon in the label | Existing radio cards fit; only the icon and the lock state are added |
| KeyField | Existing | SlugField (UInput + UButton + UBadge) | Reused for the type key and field keys. Locked variant already exists |
| ChoicesEditor | New | UInput + UButton rows, useSortable | UInputTags can't reorder or flag a blank or duplicate per row |
| ApiPreview | New | UCollapsible + CodeSnippet + UButton | CodeSnippet is reused. Sheet on mobile |
| ChangeReview | New | UModal (sheet on mobile), UBadge, list rows | Dialog pattern unchanged. Rows reuse the UAlert-style row of SuggestionList |
| SchemaRefusal, DeleteTypeDialog | New | UModal + UAlert | Dialog pattern unchanged. Delete uses color="error" |
| ContentNavGroup | New | UNavigationMenu group "Content" (NavGroup) | Adds the 5 + selected cap, "Show all N" and the collapsed mobile row |
| EntryTable | New | UTable with column visibility | Cells per field type (FieldCell). Cards on mobile |
| ColumnPicker | New | UPopover + UCheckbox list; bottom sheet on mobile | UDropdownMenu closes on each pick and has no cap message |
| SortSelect | New | USelect + UButton icon | ID, Created and Updated only |
| FieldControl | New | UInput, UInputNumber, UInputDate, USelect, USwitch (the single toggle), MarkdownField | One switch per field type, generated from the schema |
| MarkdownField | New | UTextarea + UTabs (Write, Preview) + toolbar of UButton | The article editor is a full document editor. This is a compact one |
| FieldCell | New | text, UBadge outline (choice), icon + text (yes or no) | Renders one value per type, with the empty dash |
| EntryForm | New | UForm generated from the schema | Provenance footer, schema-changed alert |
| RelationPicker (later) | New | URadioGroup card + USelect | Sentence preview is text |
| RelationField (later) | New | UInputMenu multiple | Chips and search |
| PermissionsGrid (later) | New | UTable + UCheckbox; UCard list on mobile | |
| SchemaImportDialog (later) | New | UModal + UFileUpload | |
## Media field (pass 5)
Frames: `SchemaFrame.dc.html` (`MCtl`) and `MediaFrame.dc.html` (picker in field mode, usage). Full spec: `media-field-changes.md`.
| Component | Status | Nuxt UI | Notes |
|---|---|---|---|
| MediaFieldControl | New | UFormField, UButton outline and ghost, UModal (desktop) or bottom UDrawer (mobile) for the picker, UTooltip, UIcon (image, image-off, unlink, image-plus). The thumbnail is a plain button | Props label, required, modelValue (image id or null), image (filename, width, height, size, or missing), disabled, error. Emits update:modelValue. States: empty, filled, hover, focus, error, missing, saving, read-only |
| FieldCell (media) | Extended | Thumbnail 40 px square, dash when empty, dashed square with unlink when the image is gone | Row height 48 px, mobile card thumbnail 32 px |
| MediaPicker (field mode) | Extended | Existing picker | Title "Choose an image", "Use this image", no alt text, no variant select, "Current" badge, optional "No image" row |
## Roles and permissions (new)
| Component | Used for | Nuxt UI mapping |
|---|---|---|
| ScopeControl (tri-state) | None / Own / All in grid cells, mobile cards and general rows | URadioGroup variant="table" orientation="horizontal" size="xs", three items, in a grid cell. Selected item: sand fill, weight 600 and an underline |
| OnOffCell | Create column and boolean rows | USwitch with visible On/Off label |
| PermissionGrid | Content permissions | Custom grid (role="grid") of ScopeControl cells; header buttons use UDropdownMenu for bulk actions |
| PresetPills | Read only, Write own, Full content | UButton variant="outline" size="xs", pressed state via aria-pressed |
| PermissionGroup | Collapsible group with "x of y enabled" | UCollapsible; header UButton variant="ghost" |
| OwnerOnlyTag | Locked owner-only items | UBadge variant="subtle" with lucide lock icon |
| RoleBadge | Users, roles, API keys | UBadge variant="outline" with role icon (crown, shield, pencil, user-round) |
| RolePreview | What this role can do | Card on desktop (UCard), USlideover side="bottom" on mobile |
| RolePicker | Change role, invite, reassign on delete | URadioGroup variant="card" with description; USelectMenu for the reassign field; items disabled with a reason in description |
| SaveBar | Unsaved changes | Sticky container with UButton color="neutral" variant="outline" and UButton color="primary" |
| ReadOnlyBanner | Read-only pages | UAlert color="info" icon="i-lucide-lock" variant="soft" |
| ViewOnlyMark | Own scope rows | Text with lucide lock icon and UTooltip |
Accessibility of the tri-state cell:
| Item | Pattern |
|---|---|
| Structure | The grid is role="grid". Each cell contains a role="radiogroup" named "{Type}: {Action}" (for example "Events: Update") with three role="radio" items None, Own, All. Create is a role="switch" named "Events: Create". |
| Keyboard | Tab moves between cells (one stop per cell). Inside a group, Left and Right (or Up and Down) move and select, Home and End jump to None and All. Arrow keys also move the roving tabindex. Space selects. Header bulk buttons open a menu with the same three values. |
| Announcement | Selected item is aria-checked="true". A polite live region announces changes: "Events, Update, set to Own." Disabled items are aria-disabled and described by the reason ("You can only grant what your role has."). |
| Not by color alone | Selected segment: sand fill plus weight 600 plus a 2 px underline in --ui-primary. Locked: lock icon. Changed cell: square marker. Mixed: text badge. |
| Targets | 26 px segments on desktop, 44 px on mobile. Focus: 2 px --ui-primary outline, offset 2 px. |
## Account (change email, export, delete)
| Component | Status | Nuxt UI mapping | Where |
|---|---|---|---|
| EmailChangeForm | New | UFormField + UInput (email, password) + UButton, inside the Account UCard | Profile |
| PendingEmailNotice | New | UAlert (info) + two UButton (outline) | Profile |
| DataExportCard | New | UCard + UButton; notices as UAlert | Profile |
| DangerZoneCard | New | UCard with border var(--ui-error), UButton color error | Profile |
| DeleteAccountDialog | New | UModal (existing destructive ConfirmDialog shell) + UInput x2 + list + UAlert info + ULink | Profile |
| BlockedDialog | New | UModal with dismissible false, no close button; UButton primary for the next step | Profile |
Specs: field heights 40 (44 mobile), buttons 36 (44 mobile), card padding 16, section gap 16. Colors from tokens only. Destructive confirm is disabled at 40% opacity until valid. Implemented in AccountCards.dc.html and the AppFrame modal.
+12
View File
@@ -14,3 +14,15 @@ Previews: `source/RenewCMS Emails.dc.html`.
Three Go html/template files: emails/verification.html, emails/password-reset.html and emails/invitation.html . They use a table layout with inline CSS and a 600 px maximum width. The button is bulletproof: a colored table cell plus a padded link, so it survives Outlook. Each has a plain-URL fallback, a hidden preheader, and prefers-color-scheme overrides for clients that support them. The mark is live text in a black cell rather than an image, so it shows even when images are blocked. Previews below use example values.
Suggested subjects are "Verify your RenewCMS account", "Reset your RenewCMS password" and "<InvitedBy> invited you to RenewCMS", where .InvitedBy is the inviter's display name or username. html/template escapes the .Email value for the query string in the reset link (the @ becomes %40), and the confirm route reads it back decoded.
## Account emails (change email, deletion, export)
Go `html/template` files built from `source/emails/verification.html` (same table layout, tokens, dark overrides, bulletproof button, URL fallback). English only.
| File | To | Subject | Variables |
|---|---|---|---|
| `source/emails/verify-new-email.html` | the new address | Confirm your new RenewCMS email address | `.Host`, `.Username`, `.VerificationCode`, linking to `{{.Host}}/account/email/confirm?c={{.VerificationCode}}` (expires in 2 hours, works once) |
| `source/emails/email-change-notice.html` | the old address, at request time | Your RenewCMS email address change was requested | `.Host`, `.Username`, `.NewEmail`, linking to `{{.Host}}/profile` |
| `source/emails/account-deleted.html` | the address the account had, sent once | Your RenewCMS account has been deleted | `.Host`, `.Username`, `.DeletedAt`; no button, generic text, no personal data |
| `source/emails/export-ready.html` | the account address, async flow only | Your RenewCMS data export is ready | `.Host`, `.Username`, `.VerificationCode`, linking to `{{.Host}}/account/export/{{.VerificationCode}}` (expires in 24 hours, requires a logged-in session) |
`export-ready.html` is only needed if the async export alternative is built. Previews are rows on `source/RenewCMS Emails.dc.html`.
+121
View File
@@ -0,0 +1,121 @@
# Media field type: change list for implementation
Board: `RenewCMS Screens Schema.dc.html`. Frames changed: `SchemaFrame.dc.html`, `MediaFrame.dc.html`. Spec data: `schema-spec.js` (states) and `media-field-tables.js` (tables, appended to the board). No new tokens, no new colors, radius 0.
## 1. Behaviour
- New field type `media`, name "Media", icon `lucide:image`, description "One image from your media library".
- Stored value: id of a library image, or null. The file is never copied. The field always shows the current library image.
- Options: label, key, required. No default, unique, min, max or choices. The inspector hides them.
- Required: the entry can't be saved while the field is empty (422 invalid_entry, rule required).
- A saved field's type never changes. Key and type are locked after the first save (existing rule).
- Adding a required media field to a type with entries is refused, because a media field can't have a default. The review step shows this before the server is asked.
- Image deleted while entries point at it: the entry keeps the id. The control shows the missing state. Saving is refused only if the field is required.
- Entries count as uses of an image in Media. Deleting a used image is allowed after a warning.
- Variants: the editor preview uses the original. No variant select in the field. Nothing about variants in the builder.
- Galleries (several images) are out of scope and not drawn.
## 2. New component: MediaFieldControl
Props: label, required, modelValue (image id or null), image (filename, width, height, size, or `missing: true`), disabled, error. Emits `update:modelValue`. Opens the picker.
Nuxt UI v4: `UFormField` (label, help, error, required), `UButton` outline ("Pick an image", "Change image", "Choose another image"), `UButton` ghost ("Remove", "Clear"), `UModal` on desktop and bottom `UDrawer` on mobile for the picker, `UTooltip` for the list placeholder and the badge, `UIcon` lucide:image, image-off, unlink, image-plus. The thumbnail is a plain `button`.
States and measurements (SchemaFrame `MCtl`):
| State | Drawing |
|---|---|
| Empty | Dashed box (1px dashed `--ui-border-accented`), padding 12, 40 px icon square (`--ui-bg-elevated`), "No image selected.", button "Pick an image" (icon image-plus). Mobile: stacked, 44 px button |
| Filled | Solid 1px `--ui-border-accented` box, padding 12. Thumbnail 160 × 120 (mobile: full width, 16:9). Filename weight 500, then "1600 × 900 · 240 KB" 12/18 muted. Buttons "Change image" (outline, icon image) and "Remove" (ghost, icon x). Help line "One image from your library." |
| Hover (desktop) | Border `--ui-text-muted`. Bottom bar 28 px, `--ui-bg-inverted` with `--ui-text-inverted`, icon image 14 and "Change image". No hover on mobile |
| Focus | 2 px `--ui-primary` outline, offset 2, on the thumbnail button |
| Error | Box border `--ui-error` plus inset 1px `--ui-error`, error line with circle-alert 14: "Pick an image for Thumbnail." Focus ring on the first button (Pick an image, or Choose another image) |
| Missing | Dashed 160 × 120 (mobile 16:9) placeholder with image-off 24. Text "This image is no longer in the library" (weight 500). Help: optional "You can still save. The entry keeps the link until you change it." / required "Thumbnail is required. Choose another image to save." Buttons "Choose another image" (outline) and "Clear" (ghost) |
| Saving | Thumbnail at 60%, buttons at 40%, all form fields disabled, Save reads "Saving…" |
| Read-only (fields changed) | Same as saving, plus warning alert "The fields of Events changed" and button "Reload form" |
Label is the field label, plus " *" when required (existing form rule; "(optional)" is not used).
## 3. Per screen
### 3.1 Schema list (group 1), state "Custom type: read-only view"
Fields table of a custom type (Label, Key, Type, Rules) in the built-in table style. Thumbnail row: image icon, key `thumbnail`, type "Media", rules "One image from the library". Header "Events" with badge "Read only" (lock). Alert: "You can read these fields here" / "Only an owner or admin can change a content type." The built-in Media type in Schema is unchanged. Decision needed: no route shows a custom type read-only today.
### 3.2 Schema builder (group 2), 3 states
- "Media field selected": saved Thumbnail row selected (index 7). Inspector: Label, Key (locked, "Used in the API, so it can't change."), Type tiles (seven, Media selected, others dimmed with "Can't change after saving"), Required switch ("Required" / "An entry can't be saved with this field empty."). Hidden for media: Unique values, Default value, limits, choices. Hint (info-soft block, info icon): "Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version."
- "New media field, seven types": new row with "New" badge, key editable ("The name used in the API. Starts with a letter. Lowercase letters, digits and underscores."), type tiles all enabled. State chip "Unsaved changes".
- "Media field, required": row shows the existing Required marker.
- Field row: grip, 28 px icon square (image), label, key (mono), badges. Mobile: inspector in the existing 92% sheet (title "Field: Thumbnail", footer "Done").
- Type tile grid now has seven tiles (3 columns desktop inspector, 2 in sheets).
### 3.3 Review (group 3), 2 states
Dialog "Review changes to Events" / "Apply them to save the content type." Summary "Add 1 field" / "Events has 12 entries. Each one is checked against the new fields when you apply." The info bar in the builder behind reads "12 entries use this content type…".
- Refused: row "Add Thumbnail", warning flag "Can't be applied", text "Media, required. A media field can't have a default, so the 12 existing entries would have no image. Make Thumbnail optional to add it." Note "Back to editing keeps your other changes." Buttons "Back to editing", "Apply changes" (disabled). This departs from the existing review (Apply is never disabled): decision.
- Clean: row "Add Thumbnail", "Media, optional. 12 entries are unaffected." Apply enabled.
### 3.4 Field types (group 4), state "Media"
Per-type frame like the others: header icon image, "Media", "One image from your media library", `type: "media"`. Columns: inspector options, entry form control (filled with hover, then empty), list cell (Filled thumbnail, Empty "–", Image missing), errors by rule, filter (later).
Errors by rule: required 422 "Pick an image for Thumbnail."; type 422 "Thumbnail must be an image from the library."; min, max, options, unique: "Not used for media." Filter widget: "Thumbnail: has an image" with Any / Has an image / No image, tagged Later.
### 3.5 Entry list (group 5), 3 states
Columns shown: Title, Thumbnail, Starts on, Category (user choice). Header "Thumbnail", no sort icon, column 80 px. Cell: 40 px square thumbnail; "–" when empty; dashed 40 px square with unlink icon, accessible name "Image no longer in the library", when the image is gone. Row height stays 48 px (thumbnail fits with 4 px above and below). Mobile card: 32 px thumbnail leads the card (empty: "–", missing: dashed square), card min height 68 px. Columns menu lists Thumbnail with the image icon ("4 of 6", "Choose up to 6 fields to show. 4 selected." on mobile). Tooltip on the missing placeholder (desktop): "Image no longer in the library".
### 3.6 Entry editor (group 6), 10 states
Control sits after Description, at the end of the form (schema order; see conflicts). States: empty optional, empty required, filled, hover, focus, validation error (status chip "Not saved"), image no longer in the library, missing + required (error), saving, read-only. The board scrolls the form so the control is visible in the frame; the real form is not scrolled.
### 3.7 Picker (group 10), 8 states, MediaFrame field mode
Existing picker (Upload and Library tabs, single selection, search "Search by filename", "Newest first · 48 images", Load more, 4 columns desktop and 3 on mobile, square tiles). Differences:
- Title "Choose an image".
- Primary "Use this image" (disabled until a tile is chosen). When "No image" is chosen it reads "Remove image".
- No alt text field, no variant select. Selected strip shows thumbnail, filename, "dimensions · size".
- Current image tile badge "Current" (replaces "In this article").
- Optional fields only: row "No image" / "Leave this field empty." at the top of the Library tab. Selected: sand fill, primary border, check.
- Empty: "Your library is empty" / "Images you upload appear here, ready to use in any article or entry." / "Upload an image".
- Failed: "Couldn't load your library" / "Check your connection and try again." / "Try again".
- Loading: skeleton tiles after 150 ms.
- Upload tab: unchanged flow (crop, resize); the new image lands in the library and is selected.
- Desktop dialog 640 wide, mobile bottom sheet.
### 3.8 Media library (group 11), 4 states
- Badge "Used in N" = articles + entries. The tooltip (hover or focus) reads "Used in entries: 3" and, if articles also use it, "Used in articles: 2" on the first line. Example: bathroom-tiles.png, 3 entries, "Used in 3".
- List view: same badge in the list row and mobile card.
- Detail panel (lime-plaster.jpg, 2 articles, 5 entries): preview is shorter when the image has entries; existing "Used in 2 articles" list; new section "Used in entries" with up to 3 rows (type name muted, entry title as link, arrow-up-right) and "and 2 more". When there are entries the empty-articles line reads "No article shows it." (not "safe to delete").
- Delete: title "Delete an image that's in use?" (existing in-use dialog), body `"lime-plaster.jpg" is used in 5 entries and 2 articles. Those entry fields will show "This image is no longer in the library", and the articles will show a broken image. This can't be undone.` with the entries and articles lists; "Cancel" and destructive "Delete image".
## 4. Copy added (exact)
Media · One image from your media library · Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version. · No image selected. · Pick an image · Change image · Remove · Choose another image · Clear · One image from your library. · This image is no longer in the library · You can still save. The entry keeps the link until you change it. · Thumbnail is required. Choose another image to save. · Pick an image for Thumbnail. · Thumbnail must be an image from the library. · Not used for media. · Image no longer in the library · Choose an image · Use this image · Remove image · Current · No image · Leave this field empty. · Used in entries · and 2 more · Used in entries: 3 · Can't be applied · Media, required. A media field can't have a default, so the 12 existing entries would have no image. Make Thumbnail optional to add it. · Media, optional. 12 entries are unaffected. · The Thumbnail field was changed after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields.
Sentence case, "image" never "asset", buttons are verbs.
## 5. API and backend
- Schema API accepts type `media` with label, key, required. Value: image id or null.
- 422 invalid_entry: required → "Pick an image for Thumbnail."; type (id not in the library on a changed value) → "Thumbnail must be an image from the library."
- Entry and list responses include, per media field, filename, width, height, size and a thumbnail URL, or `missing: true`.
- GET /v1/media/{id}/usage returns articles and entries (content type name, key, entry id, title), a total and the first few items. GET /v1/media/ list items carry the combined use count.
- DELETE /v1/media/{id} stays allowed for used images. Entries keep the id.
- A new required media field on a type with entries is refused (schema_incompatible). The client shows the refusal first.
HTTP to copy (picker and field), from states.md: 400 "That request wasn't valid. Check the fields and try again."; 401/403 global; 404 "That item no longer exists."; 409 "This changed somewhere else. Reload and try again."; 413 "That file is too large."; 429 "Too many requests. Try again in a few minutes."; 500/network "Something went wrong on our side. Nothing was changed. Try again in a moment."
## 6. Accessibility
- Control is `role="group"` labelled by the field label. Required: `aria-required` on the main button.
- Names: "Pick an image for Thumbnail"; thumbnail "Change image, lime-plaster-workshop.jpg, 1600 by 900, 240 KB"; "Remove image from Thumbnail"; "Clear Thumbnail"; missing placeholder `role="img"` "Image no longer in the library".
- Error: error text has an id. The main button gets `aria-invalid="true"` and `aria-describedby` (error id, then help id). Focus moves to the first invalid control on a refused save.
- Order: label, thumbnail, Change image, Remove, help or error. The thumbnail duplicates Change image; use `tabindex="-1"` on it if one stop is preferred.
- One polite live region: "Image selected: lime-plaster-workshop.jpg", "Image removed from Thumbnail.", "Thumbnail cleared."
- Focus: Use this image → Change image. Remove or Clear → Pick an image. Cancel or Esc → the opener.
- Picker: tiles are buttons with `aria-pressed`, names "stone-steps.jpg, 310 KB, Current". "No image" has `aria-pressed`. "Use this image" uses `aria-disabled`.
- Contrast: Current badge and selected rows use `--rc-on-sand`; hover bar 18.9 (inverted pair). 44 px targets on mobile. No hover on mobile.
## 7. Conflicts to decide
1. Row height: design is 48 px (56 px media rows, 68 px cards), not 32 px. Drawn at 48 and 68.
2. Thumbnail position: existing form has Description last. Drawn after Description (end of form).
3. "Used in N" counted articles. Now articles + entries.
4. Delete title: existing in-use title kept; "Delete this image?" stays for unused images.
5. "Clear" vs glossary "Remove".
6. "1600 × 900 · 240 KB" (design separator) instead of "1600 x 900 - 240 KB".
7. Review step disables Apply for the certain refusal; existing review never does.
8. Picker button "Use this image" vs article picker "Insert image"; "Remove image" when No image is chosen is my addition.
9. "Pick an image" also on optional empty fields.
10. No read-only route for custom types.
11. Two tab stops for the same picker (thumbnail and Change image).
12. Disabled opacity: form 60%, foundations 40%.
13. The picker frame shows a stand-in entry form, not the real editor.
## 8. Not done
README.md, states.md and the screens/*.md files are not updated with the new counts (group 2 +3, 3 +2, 4 +1, 5 +3, 6 +10, 1 +1, new groups 10 (8) and 11 (4): 32 states). Light/dark and mobile frames were checked by reading the DOM, not state by state on screen.
+51
View File
@@ -10,7 +10,19 @@
| /articles | Articles list | page, size, tab, q | GET /v1/article/?page=&size=&q=&status= (page, size; total in the `X-Total-Count` header; array body unchanged) |
| /activity | Activity | q, actor, category, outcome, security, range, from, to, page, size | GET /v1/activity (page, size), GET /v1/activity/export (shipped, CSV) |
| /integration | Integration | none | none |
| /settings/sites | Sites (owner only; other roles get a 403 page and no sidebar entry): list, add and edit slide-over, delete with reassignment. The key is read-only after creation | none | GET/POST /v1/site/, PATCH/DELETE /v1/site/{id} (DELETE takes `reassign_to`) |
| /authors | Authors, all roles. Owner and admin: list, search, add and edit slide-over, delete with reassignment. Other roles: read-only list and detail | q, page, size | GET/POST /v1/author/, PATCH/DELETE /v1/author/{id}, POST /v1/author/me |
| /articles | Articles list gains Authors column; Site column and filter when more than one site | site | GET /v1/article/?site={key} |
| /article/:id/edit | Editor gains the "Site and authors" section (Site select and authors, any role that can edit the article) | none | PATCH /v1/article/{id} {site_id, author_ids[]} |
| /profile | Profile gains the "Author profile" card, all roles | none | POST /v1/author/me |
| /integration/api-keys | API keys | none | GET/POST /v1/api-key, DELETE /v1/api-key/{id} |
| /schema | Schema list, custom and built-in types (owner or admin; others get a 403 page and no sidebar entry). Available now | none | GET /v1/content-type |
| /schema/new | Schema builder, new type (owner or admin). Available now | none | POST /v1/content-type |
| /schema/:key | Schema builder for a custom type, read-only view for a built-in (owner or admin). Review step on save when the type has entries. Available now | none | GET/PUT/DELETE /v1/content-type/{key} |
| /content/:key | Entry list (any verified user). Available now | page, size, sort | GET /v1/content-type/{key}, GET /v1/content-type/{key}/entry?page=&size=&sort= |
| /content/:key/new | New entry (any verified user). Available now | none | POST /v1/content-type/{key}/entry |
| /content/:key/:id | Entry editor (any verified user). Available now | none | GET/PUT/DELETE /v1/content-type/{key}/entry/{id} |
| /settings/permissions | Retired. 301 to /settings/roles (see Roles and permissions below) | none | not built |
## Pagination in the URL (Articles, Media)
`/articles?page=2&size=50` and `/media?page=7&size=50`. Parameter names follow the repo (activity already uses `page` and `size`). Allowed sizes 5, 25, 50, 100. Defaults `page=1`, `size=25`, omitted from the URL when default. Changing search, tab, filter or size resets `page` to 1. `page` above the last page redirects (replace) to the last page and shows "That page doesn't exist. Showing the last page." An invalid `size` falls back to 25.
@@ -30,3 +42,42 @@
## Public image request
`/media/photo.png?v=<variant-name>`. Unknown or disabled variant: 400 `{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "<name>" }`. Any other query parameter: 400 `{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "<parameter>" }`. Missing source image: 404.
## Sites and authors in the URL
`/articles?site=journal` filters by site key; it combines with `page`, `size`, `tab` and `q`, and resets `page` to 1 when it changes. An unknown key is dropped from the URL with no error. `/authors?q=camille&page=2&size=25` follows the same pagination rules as Articles. Deep links: `/authors?edit={slug}` and `/settings/sites?edit={key}` open the slide-over for owner and admin (authors) and the owner (sites); an unknown value shows the info toast "That author no longer exists" or "That site no longer exists". For other roles `/authors?edit={slug}` opens the read-only detail, and `/settings/sites` and its `?edit=` link show the 403 page "You don't have access to Sites".
Sites pagination: `/settings/sites?q=&page=&size=` with the same rules as Articles (sizes 5, 25, 50, 100; default 25; defaults omitted; search and size changes reset to page 1; a page past the end falls back to the last page). API: GET /v1/site/?q=&page=&size=, total in `X-Total-Count` (design assumption). Authors: `/authors?q=&page=&size=` as above.
## SEO
No new page routes. The panel and dialog are overlays on `/article/:id/edit`: `?panel=seo` (optionally `&field=seo_title|seo_description|slug|cover|authors|body` for the suggestion jump) and `?dialog=seo-api`. `/articles?seo=suggestions` is the Needs attention filter. API: GET /v1/article/{id}/seo, GET /v1/site/{key}/sitemap?page=&size=, PATCH /v1/article/{id} (new fields), PATCH /v1/site/{id} {site_name, title_template, default_description, social_image_id, default_robots}, GET /v1/article/?seo=ready|suggestions|hidden. 401 without a session; 403 owner_only on site SEO fields; 409 slug_taken {suggestion} on PATCH slug; 400 on invalid canonical_url or title_template.
## Custom content types in the URL
`/content/events?page=2&size=50&sort=created_at:desc`. `page` and `size` follow Articles (sizes 5, 25, 50, 100; default 25). `sort` is one parameter, `<id|created_at|updated_at>:<asc|desc>`, default `updated_at:desc`. Defaults are omitted from the URL; a size or sort change resets `page` to 1; invalid values are replaced silently by the default; a `page` past the end falls back to the last page. The response carries `total`, `page`, `size` and `sort`: use them for the summary ("Showing 1–5 of 37"), the pager and the "37 entries use this content type" line of the review step. No filter or search on custom fields yet. Auth: `/schema*` owner or admin (403 page otherwise), `/content*` any verified user (unverified accounts are redirected to /register/pending). Unknown type: 404 content_type_not_found page. Unknown entry: 404 entry_not_found page.
## Roles and permissions
| Route | Purpose | Access |
|---|---|---|
| /settings/roles | Roles list | Roles: view |
| /settings/roles/new | New role | Roles: manage |
| /settings/roles/:id | Role editor (read-only without manage) | Roles: view |
| /settings/permissions | Retired. 301 to /settings/roles | n/a |
API: GET/POST /v1/role · GET/PUT/DELETE /v1/role/{id} · POST /v1/role/{id}/reassign · PUT /v1/user/{id}/role · POST /v1/site/transfer-ownership · GET /v1/me · GET /v1/permission. Errors: 403 missing_permission, 403 role_locked, 409 role_has_members, 409 role_name_taken, 409 role_conflict.
Needs backend:
| Item | Detail |
|---|---|
| Role storage | Table role (id, name, description, built_in, locked, version) and role_permission (role_id, key, scope none|own|all). One role_id on user. Built-ins seeded and non-deletable. |
| Endpoints | GET/POST /v1/role · GET/PUT/DELETE /v1/role/{id} · POST /v1/role/{id}/reassign · PUT /v1/user/{id}/role · POST /v1/site/transfer-ownership · GET /v1/me (role and permissions) · GET /v1/permission (catalogue) |
| created_by | Add created_by (user id) to articles and entries so Own scope can be checked on read, update, delete and publish. Backfill from existing author or activity data. |
| Enforcement | Server checks every call against role permissions and the no-escalation rules. API keys resolve the creator's current role on each request. |
| Version field | role.version for the conflict state (409 role_conflict). |
| Activity events | role.create, role.edit (with a diff of added and removed permissions), role.delete, role.reassign, user.change_role, user.transfer_ownership. Present tense, as the backend emits them. |
## Account: change email, export, delete
| Route | Screen | Auth | API (assumed) |
|---|---|---|---|
| /account/email/confirm?c=CODE | Verify new email (AuthCard). States in screens/account.md | Session required; without one, inline log in (as /register/validate) | POST /v1/auth/email/confirm |
| /profile | Gains change email, Your data, Danger zone | Any verified user | POST /v1/auth/email/change, POST /v1/auth/email/resend, DELETE /v1/auth/email/change, GET /v1/user/me/export, DELETE /v1/user/me |
| /login?reason=account-deleted | One-time message after deletion | Public | none |
+72
View File
@@ -0,0 +1,72 @@
# Account: change email, export my data, delete account
Route: `/profile` (extends the Account card, adds "Your data" and "Danger zone" cards). Verification landing: `/account/email/confirm?c=CODE`. API calls are assumed, not specified.
Placement: Profile already holds the read-only Email in the Account card and the Password card, so the email change extends that card; the two other features are cards at the bottom. No new navigation. Order: Display name, Account, Your role, Author profile, Password, Your data, Danger zone.
Boards: `RenewCMS Screens Account.dc.html` (Profile states), `RenewCMS Screens Auth.dc.html` (groups "Verify new email" and "Account deleted"). Frames: `AppFrame`, `AccountCards`, `AuthCard`. Copy source: `account-copy.js`. English only.
## Change email
| State | Copy |
|---|---|
| Default | "Email" field, link "Change email" |
| Form | "New email address" · "Current password" · "We'll send a verification link to the new address. Your email stays the same until you open it." · "Cancel" · "Send verification link" |
| Invalid format | "Enter a full email address, like name@example.com." |
| Same as current | "That's already your email address." |
| Password missing | "Enter your current password." |
| Wrong password (403) | "That password isn't correct. Try again." |
| Sending | "Sending…" |
| Pending | "Waiting for verification" · "We sent a link to marta.alves@atelier-renew.example. Until you open it, you keep logging in with marta@renew.example." · "Resend link" · "Cancel change"; toast "Verification link sent" / "Check marta.alves@atelier-renew.example. The link works once, within 2 hours." |
| Resending | "Sending…" |
| Resent | toast "Link sent again" / "The previous link no longer works." |
| Resend rate limited (429) | "Resend in 4:30" (disabled) · "Too many requests. Try again in a few minutes." |
| Cancelled | toast "Email change cancelled" / "You keep logging in with marta@renew.example." |
| Rate limited (429) | "Too many requests. Try again in a few minutes.", Send disabled |
| Server or network | "Something went wrong on our side. Nothing was changed. Try again in a moment." |
Rules: the old email stays active for login until the link is opened. The old address gets the notice email at request time. A request for an address already in use answers like success, so the UI and the response never reveal that an account exists.
## Verification landing (AuthCard)
| State | Copy |
|---|---|
| Confirming | "Confirming your new email…" |
| Success | "Your email address has changed" · "Your account now uses marta.alves@atelier-renew.example. Use it the next time you log in." · "Go to profile"; toast "Email address changed" / "Your account now uses marta.alves@atelier-renew.example." |
| Invalid or expired (403) | "This link doesn't work" · "It may have expired (links last 2 hours), been cancelled or already been used." · "Request a new link from your profile." · "Go to profile" |
| Address unavailable (409) | "We couldn't change your email address" · "This address can't be used for your account. Go back to your profile and try a different one." · "Go to profile" (same copy whether the address is taken or rejected) |
| No session (401) | "Log in to confirm your new email" · "You opened this link somewhere you're not logged in. Log in here and we'll confirm the change right after." · "Log in and confirm" |
| Missing code | "This link is incomplete" · "The confirmation code is missing. Open the link from your email again, or copy the full address into your browser." · "Go to log in" |
| Server error | "Couldn't confirm the change" · "Something went wrong on our side. Nothing was changed. Try again in a moment." · "Try again" |
## Your data
Contents listed in the card: Profile (Username, display name, email, role and author profile.); Authored content (Your articles and entries.); Uploaded media (References to the files you uploaded.); Account activity (Your log-ins and the actions recorded under your name.). Card copy: "Your data" · "Download a copy of the personal data RenewCMS holds about you, as JSON files in a ZIP.".
| State | Copy |
|---|---|
| Direct (recommended): default | "Download my data" · "Ready in a few seconds." |
| Preparing | "Preparing download…" |
| Started | toast "Download started" / "renewcms-export-marta-2026-10-29.zip is in your downloads." |
| Rate limited (429, one per 15 minutes, clock-sensitive) | "You can export once every 15 minutes" · "Your last export was at 09:41. Try again in 12 minutes." · "Available in 12:00" |
| Error | "Couldn't prepare your data" · "Nothing was changed. Try again in a moment." |
| Alternative, emailed link: default | "Request my data" · "Larger exports are prepared in the background. We'll email a download link to marta@renew.example when it's ready." |
| Requested | "We're preparing your export" · "This can take a few minutes. We'll email a link to marta@renew.example when it's ready." · "Export requested"; toast "Export requested" / "Check marta@renew.example in a few minutes." |
| Already in progress | "An export is already being prepared" · "We'll email a link to marta@renew.example when it's ready." |
Recommendation: direct download; async is the documented alternative and uses the export-ready email.
## Delete account
Danger zone: "Danger zone" · "Delete your account and your personal data. This can't be undone." · "Delete my account".
| State | Copy |
|---|---|
| Dialog | "Delete your account?" · "This permanently deletes your account. It can't be undone." · list: "Your username, display name and email address", "Your author profile and API keys", "Your sessions: you'll be logged out everywhere" · "Your articles and media stay" / "They stay on the site, credited to "Deleted user". Your name is removed from them." · "Export my data first" · "Current password" · "Type DELETE to confirm" · "Keep my account" · "Delete my account" |
| Empty, typing | Confirm disabled until password is filled and the word equals "DELETE" (exact, case-sensitive) |
| Wrong password (403) | "That password isn't correct. Try again." |
| Deleting | "Deleting…"; fields and Cancel disabled; then sign out and redirect to /login?reason=account-deleted |
| Rate limited (429) | "Too many requests. Try again in a few minutes." |
| Server error | "Couldn't delete your account. Nothing was deleted. Try again in a moment." |
| Blocked: last Owner or administrator | "You can't delete your account yet" · Owner: "You are the last Owner. RenewCMS needs at least one. Transfer ownership or promote another user first, then come back to delete your account." · Administrator: "You are the last administrator. RenewCMS needs at least one. Promote another user first, then come back to delete your account." · "Back to profile" · "Go to Users" |
| Blocked: cannot be deleted | "This account can't be deleted" · "RenewCMS can't delete this account right now. Ask an administrator to help, then try again." · "Back to profile" |
| Login after deletion | "Your account has been deleted" · "Your articles and media were kept without your name." |
Blocked dialogs have no close button, do not close on Escape or backdrop click, and always give a next step.
Authored content: anonymized to "Deleted user", stated in the dialog. Export-ready email: the link needs a logged-in session, stated in the email and the card hint. Account-deleted email: generic, no personal data, sent to the address the account had at deletion.
+30
View File
@@ -93,3 +93,33 @@ Route: `/activity`. API: GET /v1/activity (server scoped to the user).
| 1 | Default | No summary strip, no actor filter, no Export. Title becomes My activity. | |
| 2 | Empty | The user has not done anything yet. | |
| 3 | Details | Same slide-over, own event. | |
## Roles sync (pass 6): event names, failed sentences, categories
Event names are present tense, as the backend emits them: role.create, role.edit, role.delete, role.reassign, user.change_role, user.transfer_ownership. The outcome is a field; a failed event keeps its name and uses the failed sentence.
| Event | Sentence | Failed sentence |
|---|---|---|
| role.create | Role Contributor created | Failed to create the role Contributor |
| role.edit | Role Contributor edited | Failed to edit the role Contributor |
| role.delete | Role Intern deleted | Failed to delete the role Intern |
| role.reassign | Moved 3 members from Intern to Writer (one member: Moved 1 member from Intern to Writer) | Failed to move 3 members from Intern to Writer |
| user.change_role | Ines Duarte's role changed from Writer to Content lead | Failed to change the role of Ines Duarte |
| user.remove | Removed Ines Duarte | Failed to remove Ines Duarte |
| user.transfer_ownership | Ownership transferred from Marta Alves to Jonas Weber | Failed to transfer ownership to Jonas Weber |
Failed template: "Failed to {verb} {object}". A failed row shows the Failed badge after the sentence; the details panel shows "Why it failed" with the reason from the API.
Category filter values: Articles, Media, Users and invites (was "Users & invites"), Roles (new), API keys, Authentication, System. French and the failed sentences for every event are in screens/roles.md, group 8 and the table "Activity events: failed sentences, EN and FR". Frames: Roles board, group "8 Activity".
### Fallback wordings (app sync)
| Event | Condition | Sentence |
|---|---|---|
| role.reassign | target role unknown | Moved 3 members from Intern to another role |
| role.reassign | count unknown | Moved the members of the role Intern |
| role.reassign | failed, count unknown | Failed to move the members of the role Intern |
| role.reassign | failed, one member | Failed to move 1 member |
| user.remove | name unknown | Removed a user (proposal, not in the app) |
Event names match the backend: role.create, role.edit, role.delete, role.reassign, user.change_role, user.transfer_ownership, user.remove.
+31 -1
View File
@@ -42,7 +42,7 @@ Rows update when the server responds, not optimistically. Search, tab and sort a
| 5 | No results | Search matched nothing. The tab counts still show the full list. | "No articles match "tiles"" / "Check the spelling, or search for a shorter part of the title." / "Clear search" |
| 6 | No drafts | Drafts tab with nothing in it. The "Online" tab has an equivalent "Nothing is online yet" state. | "Everything you've written is online." / "New article" |
| 7 | Action in progress | Publish chosen from the row menu. That row is disabled. | "Publishing…" |
| 8 | Published | 200: the row updates in place and a toast confirms. | "Just now" / "Article published" / ""Winter checklist for timber windows" is now online." |
| 8 | Published | 200: the row updates in place and a toast confirms. Clock-sensitive: shows a relative time or a countdown. | "Just now" / "Article published" / ""Winter checklist for timber windows" is now online." |
| 9 | Action failed | A 500 or network error. The row stays as it was, and the toast stays until retried or dismissed. | "Couldn't unpublish" / "The server didn't respond. Nothing changed." / "Retry" |
| 10 | Row vanished (404) | Any row action gets a 404. | "That article no longer exists" / "It was deleted somewhere else, so we removed it from the list." |
| 11 | Delete: confirm | Delete… from the row menu. | |
@@ -69,3 +69,33 @@ Page and page size live in the URL as page and size. Defaults (page 1, size 25)
| 9 | Empty after search | Zero results. Paginator and summary are hidden. | "No articles match "tiles"" / "Check the spelling, or search for a shorter part of the title." / "Clear search" |
| 10 | Page size menu | The select opens upward. 5, 25, 50 or 100; the chosen size is saved as size and the page resets to 1. | |
| 11 | Focus-visible | Keyboard focus on Next: 2 px --ui-primary outline, 2 px offset. Same ring on every paginator control. | |
## Changed in pass 3: authors, site column and site filter
### Articles: sites and authors (group "Articles: sites and authors", 3 states)
Route: `/articles?site=journal`. API: GET /v1/article/?site={key}&page=&size=&q=&status=.
Every Articles frame has the Authors column (avatars, at most 3 plus "+N"; "—" when none; on mobile avatars with the first name). When more than one site exists, a Site column and a Site filter appear, read-only text for every role; with one site they are not rendered.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Several sites | More than one site exists. | Authors · Site · All sites · Renew · Journal · Projects |
| 2 | Site filter open | The Site select in the toolbar. Choosing a site stores ?site=key and resets the page to 1. | All sites · Renew · Journal · Projects · Archive |
| 3 | No articles on a site | The chosen site has no articles. | No articles on "Archive" · Move articles here from the editor, or choose another site. · Show all sites |
All existing Articles frames show the Authors column (avatars, at most 3 plus "+N", "—" when none).
## Articles: search status (SEO round)
Route: `/articles?seo=suggestions`. API: GET /v1/article/?seo=suggestions&page=&size= (items carry seo_status and seo_suggestions).
A quiet indicator in the Status cell on desktop and under the title on mobile cards: a lightbulb chip with a count when there are suggestions, an eye-off chip "Hidden" when noindex is on, nothing when the article is ready. The toolbar gets a "Needs attention" toggle with a count next to the search field. Tabs and their counts are unchanged. On a single-site install nothing changes: the Site filter and column stay hidden and the toggle works the same, over all articles.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Status chips | Row 2 has 2 suggestions, row 3 is hidden, row 4 has 3, row 5 has 1. | Needs attention · 3 · Hidden · 2 suggestions (tooltip) · Hidden from search engines (tooltip) |
| 2 | Needs attention on | The toggle is pressed. The list shows only articles with suggestions. Hidden-only articles are not included. | Needs attention · 3 |
| 3 | Needs attention: none | Nothing to show with the toggle on. | Nothing needs attention · Every article has a clean check. Suggestions are optional, so this is just a bonus. · Show all articles |
Filter: "Needs attention" sets `?seo=suggestions` and resets `page` to 1. Tab counts do not change. Single-site installs behave the same; the Site filter stays hidden.
+82
View File
@@ -0,0 +1,82 @@
# Authors
Route: `/authors`. API: GET /v1/author/ · POST /v1/author/ · PATCH /v1/author/{id} · DELETE /v1/author/{id}?reassign_to={id|none} · POST /v1/author/me.
An author is a public byline, not an account. It may be linked to one CMS user; guest writers have no link. The slug is globally unique, generated from the name and editable. An article has zero or more authors in a set order; the first is the primary byline. Zero authors is allowed.
**Roles.** Owner and admin can create, edit and delete any author. Other roles see the Authors screen read-only: the list and a read-only detail, with no Add author button, no row menu, no slide-over form and no delete. They can pick authors in the article editor. Any role can use "Create my author profile" and edit their own linked author: the form opens with the linked user locked and has no delete action.
**Shared pieces.** The list, slide-over, delete-with-reassign dialog, slug field and media field are defined once in `DirectoryFrame.dc.html` and referenced by both Authors and Sites. The media picker is drawn once, in "Authors: form". The server-fallback 409 dialog is drawn once, in "Authors: delete".
Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Authors", "Authors: form", "Authors: delete". Every state is drawn in light and dark (theme switch) at 1440×900 and 390×844. Copy is exact. Strings in a Copy cell are separated by " · " and listed in reading order.
### Authors (group "Authors", 8 states)
Sidebar entry between Media and Users, visible to every role. Owner and admin create, edit and delete any author. Other roles see the list and the detail read-only. Columns: avatar and name, slug, account (Linked or Guest), article count. Search by name or slug. Long names truncate in the row and show in full on hover. Above 25 authors the list uses the same paginator as Articles; its states are drawn in "Authors: pagination". List, panel and dialogs come from DirectoryFrame and are shared with Sites.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Default | Owner or admin. The list loaded, newest first. | Authors · The names readers see on your articles. An author doesn't need an account. · Add author · Search by name or slug · Author · Slug · Account · Articles · Linked · Guest · 9 articles · 1 article · No articles |
| 2 | Loading | The first load, or a retry. | |
| 3 | Error | 500 or network failure. | Couldn't load authors · Check your connection and try again. · Try again |
| 4 | Empty, first run | Zero authors. Toolbar hidden, one call to action. Other roles see the same text without the button. | No authors yet · Authors are the names readers see on articles. They don't need an account. · Add author |
| 5 | No results | Search matched nothing. | No authors match "zoe" · Check the spelling, or search for a shorter part of the name. · Clear search |
| 6 | Row menu | Owner or admin. The ellipsis button. Mobile: bottom sheet. | Edit · Delete… |
| 7 | Read-only list (other roles) | Any role except owner and admin. No Add author button, no row menu and no bottom button on mobile. Search still works. A row opens the read-only detail. | Authors · The names readers see on your articles. Only an owner or admin can change authors. · Search by name or slug · Author · Slug · Account · Articles |
| 8 | Read-only detail | A row was opened by a role without author rights. Same panel width as the form, no fields, Close as the only action. The account email is not shown. | Inès Duarte · Linked · 9 articles · Bio · Website · Only an owner or admin can change this author. · Close |
### Authors: add and edit (group "Authors: form", 11 states)
Owner and admin. A slide-over, because the form has a live preview (the byline). Fields: avatar (media picker), name, slug (auto, editable), bio with counter, website, social links (X, LinkedIn, GitHub, Mastodon, Custom; up to 5), linked user (only users without an author). Slug field, media field and picker are the shared ones from DirectoryFrame. Saving shows "Saving…" in the button, as in the other forms.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Add: empty | "Add author". Owner or admin. | Add author · Avatar (optional) · Choose image · Name · e.g. Camille Roche · Slug · Generated from the name · Bio (optional) · A sentence or two about the author. · 0 / 280 · Website (optional) · https://example.com · Social links (optional) · Add link · 0 of 5 · Linked user (optional) · No linked user · Link a CMS user if this author has an account. Guest writers need no link. · Byline preview · Author name · The bio appears here. · Cancel · Add author |
| 2 | Slug from the name | Typing a name fills the slug. Auto badge and "Edit slug" pencil. | camille-roche · Auto · Generated from the name. Used in public author URLs and API paths. Unique across all authors. |
| 3 | Slug collision | 409 on submit, or on blur after a check. | The slug "camille-roche" is already used by another author. · Use camille-roche-2 · Reset to match the name |
| 4 | Validation | On submit or blur. Nothing is sent. | Enter a name. · Shorten the bio to 280 characters. · 291 / 280 · Enter a full URL starting with https://. |
| 5 | Social links | Add link adds a row with a type select and a URL. One link is invalid. | X · LinkedIn · GitHub · Mastodon · Custom · https://x.com/username · https://www.linkedin.com/in/username · https://github.com/username · https://mastodon.social/@username · https://example.com · Enter a full URL starting with https://. · You can add up to 5 links. |
| 6 | Linked user: choose | The select is open. Only users without an author. When none are left it reads "Every user already has an author." | Choose a user · Users without an author · Marta Alves · marta@renew.example · Rui Santos · rui@renew.example |
| 7 | Edit: linked author | Edit on an author linked to a user. | Edit Inès Duarte · Linked user (optional) · ines.duarte@renew.example · Unlink · New articles by this user start with this author. · Save changes |
| 8 | Avatar: picker | "Choose image". The existing media picker; shown as a square. The site social image uses this same picker. | Choose an image · The avatar for Camille Roche. · Shown as a square. Choose an image with the face or logo near the middle. · Use image |
| 9 | Save failed | 500 or network failure. | Couldn't save the author. Your changes are still here. Try again in a moment. |
| 10 | Unlink: confirm | "Unlink" next to the linked user, owner or admin. On 200 the field returns to "No linked user" and a toast reads "User unlinked" · "Inès Duarte is now a guest author." | Unlink Inès Duarte? · Inès Duarte will stay on their articles. New articles by ines.duarte@renew.example will no longer start with this author. · Cancel · Unlink user |
| 11 | Re-link | "Link user" on a guest author in the row menu or the form. Same list as above. | Link Camille Roche to a user? · Only users without an author are listed. · Cancel · Link user |
### Authors: delete (group "Authors: delete", 6 states)
Owner and admin. Reassign: the dialog asks who gets the articles, with "Nobody" as the last option, and shows the count in the button. Zero authors are allowed, so Nobody is always valid. The 409 dialog is the server fallback and is drawn once.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Reassign (recommended) | "Delete…" on an author with articles, owner or admin. The button reads "Reassigning…" while the request runs. On 204 a toast reads "Author deleted" · "3 articles now credit Inès Duarte." A linked author also shows "Linked to ines.duarte@renew.example. The user account isn't affected." | Delete this author? · "Camille Roche" will be deleted and is credited on 3 articles. This can't be undone. · Credit these articles to · Inès Duarte · Where an article has several authors, Camille Roche is removed and the others stay. · Cancel · Delete and reassign 3 articles |
| 2 | Choose who | The select is open. The deleted author is not listed. | Nobody · Remove the byline |
| 3 | Nobody | "Nobody" chosen. | The articles keep any other authors. Articles with only this author will have no byline. |
| 4 | Failed (shared by site and author delete) | 500 or network failure. Both delete dialogs use this notice. | Couldn't delete the author · Nothing was changed. Try again in a moment. |
| 5 | Server fallback (409) | Fallback only. The dialog above always sends reassign_to, so this appears only when the server refuses a stale request because articles still credit the author. Drawn once, not as a flow. | Can't delete this author · "Camille Roche" is credited on 3 articles. Choose another author to credit instead, or remove Camille Roche from those articles first. · Close · Reassign articles |
| 6 | No articles | An author with zero articles: plain confirm. | Delete this author? · "Léa Fontaine" will be deleted. No articles credit this author. This can't be undone. · Delete author |
### Authors: pagination (group "Authors: pagination", 10 states)
Route: `/authors?page=3&size=50`. API: GET /v1/author/?q=&page=&size= (total in `X-Total-Count`, array body unchanged; `q` already exists). Owner, admin and read-only roles see the same pager. Drawn with 128 authors (6 pages) and generated rows.
**URL.** `/authors?page=2&size=50`. `page` and `size` are the parameter names (as Articles and Activity). `size` is one of 5, 25, 50, 100; default 25. Defaults are omitted from the URL (`page=1`, `size=25`). Combined with search: `/authors?q=mar&page=2`. A search change or a size change resets `page` to 1 and drops it from the URL. An invalid `size` or `page` (not a positive integer) is replaced silently by the default with `history.replace`. A `page` past the last page falls back to the last page: the URL is replaced by `?page=<last>` and the info notice "That page doesn't exist. Showing the last page." is shown, as on Articles. Browser Back returns to the previous page of results.
**Pager.** The same component as Articles and Media, in the same footer position (below the list, summary on the left, page-size select next to it, right-aligned controls; mobile: summary and select on one row, then Previous, "Page X of Y", Next). Nothing new is drawn. Sizes are the sizes of the existing pattern: 5, 25, 50, 100 (default 25). API: total in `X-Total-Count`, array body unchanged. Keyboard and aria as Articles. The only difference from Articles: on a single page the page-size select is hidden too and the footer holds only the count line "N authors" (Articles keeps its summary and select). Existing frames of this screen that show a short list (the "Default" frames above) don't draw the count line yet; it appears whenever the list has loaded.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | First page | /authors. Previous and First are disabled (40% opacity, aria-disabled). Mobile: Previous disabled. | Showing 1–25 of 128 · 25 per page · Page 1 of 6 |
| 2 | Middle page | /authors?page=3. Current page uses --rc-sand and --rc-on-sand with a --ui-primary border; ellipses mark skipped ranges once there are more than 7 pages. | Showing 51–75 of 128 · Page 3 of 6 |
| 3 | Last page | /authors?page=6. Next and Last are disabled. | Showing 126–128 of 128 · Page 6 of 6 |
| 4 | Single page | Everything fits on one page (6 authors). The pager and the page-size select are hidden; the count line stays in the footer position. Zero results use state 8 instead. | 6 authors |
| 5 | Page size menu | The select opens upward. Sizes are the Articles sizes: 5, 25, 50, 100; default 25. The chosen size is saved as size and the page resets to 1. | 5 per page · 25 per page · 50 per page · 100 per page |
| 6 | Loading a page | Next, Previous, a page number or a size change was used. The rows are replaced by the existing skeleton rows (8 desktop, 6 mobile). The pager stays and is disabled (40% opacity, not focusable); the summary reads "Loading page 4…" and the page number shown is still 3. | Loading page 4… |
| 7 | Error loading a page | 500 or network failure on a page change. The rows of page 3 stay, the pager keeps "Page 3", and the inline error with Retry sits above the pager. Retry repeats the request for page 4. | Couldn't load page 4. Showing page 3. · Check your connection and try again. · Retry |
| 8 | Empty after search | A search matched nothing. The existing empty state replaces the list; no pager, no count. Clear search returns to /authors and page 1. | No authors match "zoe" · Check the spelling, or search for a shorter part of the name. · Clear search |
| 9 | Search resets to page 1 | Searching "mar" from page 5 goes to /authors?q=mar (page omitted). Any search change, and any size change, resets the page to 1. | Showing 1–25 of 31 |
| 10 | Long labels on mobile | A large total on a 390 px screen. The summary and the page-size select share one row and wrap onto two if needed; Previous, the "Page 12 of 52" label and Next stay on one row (the two buttons flex, the label never wraps). Totals over 999 use thousands separators in both labels. | Showing 276–300 of 1,284 · Page 12 of 52 |
## SEO round
No change to the Authors screen. Name, profile URL (website) and social links feed `authors[].name`, `authors[].url`, `authors[].same_as` and the JSON-LD author (`sameAs`) in GET /v1/article/{id}/seo. They are visible in the editor's "What the frontend gets" dialog.
+26
View File
@@ -0,0 +1,26 @@
# Content
Route: `/content/:key`. API: GET /v1/content-type/{key} · GET /v1/content-type/{key}/entry?page=&size=&sort=. Auth: any verified user.
Visual reference: `RenewCMS Screens Schema.dc.html`, groups "Content and entry list". Frames: `SchemaFrame.dc.html` mounted in `AppFrame.dc.html` (shell, sidebar, topbar, drawer). Every state is drawn in light and dark (theme switch) at 1440×900 (shown at 50%) and 390×844 (shown at 75%). Copy is exact. Strings in a Copy cell are separated by " · " and listed in reading order. Nothing is clock-sensitive.
### 5 Content navigation and entry list (group "5 Content and entry list", 9 states)
Route: `/content/:key`. API: GET /v1/content-type/{key}, GET /v1/content-type/{key}/entry. Auth: Any verified user.
The sidebar gets a "Content" group, one item per custom type (a default icon: types have no icon in the API). With more than 5 types it shows the first 5 and the selected one, then "Show all N"; long names truncate with the full name in a tooltip. On mobile the group is one collapsed row in the drawer. List: ID, up to 6 chosen field columns (default: first 4), Created, Updated, Updated by. Sort: ID, Created, Updated, either direction (default Updated, newest first). Page size 5, 25, 50, 100 (default 25). No filter or search. URL: /content/events?page=2&size=50&sort=created_at:desc (defaults omitted). The response carries total, page, size and sort; the summary, the pager and the review dialog use them.
| # | State | Trigger | Copy | Scope |
|---|---|---|---|---|
| 1 | Populated | /content/events. 7 entries, one page: no pager, only the count. | Content · Events · events · 7 fields · New entry · Sort by · Last updated · Columns · Edit content type · ID · Title · Starts on · Ends on · Online · Created · Updated · Updated by · 7 entries | Available now |
| 2 | Paginated, 5 per page | /content/events?size=5. 37 entries. | Showing 1–5 of 37 · 5 per page · Page 1 of 8 (mobile) | Available now |
| 3 | Columns menu | The Columns button. Popover on desktop, sheet on mobile. Created and updated always show. | Columns · 4 of 6 · Choose up to 6 fields to show. 4 selected. · Created and updated dates always show · Reset · Done | Available now |
| 4 | Many types in the sidebar | 14 custom types. Long names truncate. Mobile shows the drawer with the group expanded. | Content · Frequently asked questions and answers (truncated) · Show all 14 · All content types 14 (mobile, collapsed) | Available now |
| 5 | Loading | First load or retry. | (skeleton) | Available now |
| 6 | Empty for this type | Total is 0. The hint points to New entry. | No entries yet · Use New entry to add the first event. Each entry follows the fields of this content type. · New entry | Available now |
| 7 | Error | 500 or network failure. | Couldn't load entries · GET /v1/content-type/events/entry failed. Check your connection and try again. · Try again | Available now |
| 8 | Forbidden (403) | The API answers 403. Unverified accounts are redirected to /register/pending before this. | You don't have access to Events · Your account can't open this content right now. If you think this is a mistake, ask an owner or an admin. · Go to Home | Available now |
| 9 | Type not found | 404 content_type_not_found on /content/unknown. | Content type not found · It may have been deleted, or the address may be mistyped. · Go to Home | Available now |
## Media field (pass 5)
Spec, states, exact copy, measurements, backend needs and accessibility: `media-field-changes.md`, section 3.5. Board groups: 5 Content and entry list.
+79
View File
@@ -96,3 +96,82 @@ On mobile, Save and Publish sit in a sticky bottom bar and the details panel ope
| 39 | Leave with unsaved changes | Back, a nav link or closing the tab. | "Leave without saving?" / "Your changes to "How we price a bathroom renovation" haven't been saved." / "Discard changes" / "Keep editing" |
| 40 | Offline | The connection dropped. Writing continues, and Save and Publish wait. | |
| 41 | Not found (404) | Unknown id, or the article was deleted elsewhere. | |
## Changed in pass 3: site and authors
Every existing Editor frame that has the details sidebar shows the "Site and authors" section collapsed, as the SPA renders it: one summary line ("Inès Duarte") because the install has one site and the article has an author, so there is no Site select. In the Loading frame the header is shown with a skeleton summary. The Not found frame has no editor, so it has no section. On mobile the section lives in the Details sheet, closed by default, so the mobile editor frames do not show it until the sheet is opened. The group below holds only the states specific to the feature.
### Editor: site and authors (group "Editor: site and authors", 16 states)
Route: `/article/:id/edit`. API: PATCH /v1/article/{id} {site_id, author_ids[]} · GET /v1/site/ · GET /v1/author/ · POST /v1/author/.
States specific to this feature. The collapsed default (one site, the article has an author) and the Loading and Not found states are drawn in the existing Editor frames, which show the section in the same place; they are not repeated here. The section is open when the install has more than one site or the article has no authors, and collapsed to one summary line otherwise; the choice is remembered per browser. On mobile it is closed by default. With one site the Site select is not rendered. Anyone who can edit the article can set its site and pick authors; "Create author…", the Create author dialog and its "This is me" checkbox are for owner and admin only. For any other role, the "Create my author profile" hint (state 4) creates the profile directly with POST /v1/author/me, with no dialog, then shows the author in the list with a toast. Authors are a reorderable list; the first is the primary byline.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Open: one site, one author | The section was opened. No Site select. | Authors · Add an author |
| 2 | Open: several sites | Site select above the authors, with the canonical host as helper text. | Site · Journal · journal.renew.example · Canonical host: journal.renew.example |
| 3 | Site select open | The select is open. Options show name and host; the default site is first. | Renew · renew.example · Journal · journal.renew.example · Projects · Archive |
| 4 | No authors, with hint | The article has no authors and the user has no author profile. The hint is dismissible and does not return once dismissed. "Create my author profile" creates the profile directly for every role (POST /v1/author/me, no dialog), then the author appears in the list and a toast confirms. | You don't have an author profile yet · Create one to be credited on your articles. · Create my author profile · No authors. This article will have no byline. |
| 5 | Many authors | Four authors. The first shows "Primary". Each row has a drag handle and a remove button. Long names truncate with an ellipsis and carry the full name in title and aria-label. | Primary · Drag to reorder. The first author is the primary byline. · Dr. Alexandra Konstantinopoulou-Whitfield |
| 6 | Add author: open | The Add an author combobox is open. Authors already on the article are checked and dimmed. "Create author…" is shown only to owner and admin. | Search authors · Create author… |
| 7 | Add author: no match | Typing a name that matches nobody. "Create author…" is shown only to owner and admin. | No author matches "Zoe Lambert". · Create author "Zoe Lambert"… |
| 8 | Add author: open (no create) | A role without author rights. The combobox lists the authors with no "Create author…" row, because that row is shown only to owner and admin. | Search authors |
| 9 | Add author: no match (no create) | A role without author rights types a name that matches nobody. Only the message shows; there is no create link, because "Create author…" is shown only to owner and admin. | No author matches "Zoe Lambert". |
| 10 | Add author: error | GET /v1/author/ failed. The article keeps its authors. | Couldn't load authors · Check your connection and try again. · Try again |
| 11 | Reorder: keyboard | Space or Enter on the handle grabs the row. Arrow keys move it. | Jonas Pereira grabbed. Position 2 of 3. Use the up and down arrow keys to move, Space to drop, Escape to cancel. |
| 12 | Reorder: pointer | Dragging a row. The row lifts and a line shows the drop position. | |
| 13 | Create author: dialog | "Create author…" in the dropdown, owner and admin only. The writer stays in the editor. | Create author · Adds a byline for this article. You can add a bio and avatar later in Authors. · Name · Slug · Zoe Lambert · zoe-lambert · Cancel · Create author |
| 14 | Create author: for me | Owner and admin only. When the user has no author profile, a checkbox links the new author to them. | This is me · Links the author to marta@renew.example. |
| 15 | Create author: slug taken | The slug exists. The dialog suggests a free one. | The slug "zoe-lambert" is already used by another author. · Use zoe-lambert-2 |
| 16 | Create author: failed | 500 or network failure. | Couldn't create the author · The article is unchanged. Try again in a moment. |
## Editor: search and sharing (SEO round)
Route: `/article/:id/edit`. API: PATCH /v1/article/{id} {slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex, seo_dismissed[]}.
RenewCMS is headless: it never renders pages or adds meta tags. This section helps the writer fill in SEO data and shows what the frontend will receive. Everything is optional and each empty field shows its fallback. Placement: a "Search and sharing" section under "Site and authors" in the sidebar and in the mobile Details sheet. Collapsed by default with a one-line summary (Ready, N suggestions, Hidden from search), remembered per browser. The fields and live previews open in a slide-over (desktop, 960 px) or a 92% sheet over the Details sheet (mobile). Anyone who can edit the article can edit these fields. Suggestions never block saving or publishing: publishing is allowed regardless. Counters are soft guides and turn amber, never red, outside the recommended range. Light and dark follow the theme switch above.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Collapsed: ready | The default. Nothing to suggest. | Search and sharing · Ready |
| 2 | Collapsed: suggestions | Summary in the warning colour. No count badge elsewhere. | Search and sharing · 2 suggestions |
| 3 | Collapsed: hidden | noindex is on. | Search and sharing · Hidden from search |
| 4 | Open: ready | Address, the all-good message and two actions. | Address · renew.example/how-we-price-a-bathroom-renovation · All good · Nothing to suggest for this article. · Edit search and sharing · What the frontend gets |
| 5 | Open: suggestions | At most three suggestions here, then a count. Each links to its field. | The meta title is 74 characters. Search engines may cut it off after about 60. · Go to meta title · The cover image has no alt text. Add a short description of it. · Go to cover image · The body has no headings. H2 headings help readers scan the article. · Go to body · +1 more in Search and sharing |
| 6 | Open: checking | While the checks run after the editor loads. The rest of the section stays usable. | Checking this article… |
| 7 | Open: hidden | A line explains the flag. | Hidden from search engines. This only sets a flag in the API. |
| 8 | Open: other site | The address uses the article's site. | journal.renew.example/how-we-price-a-bathroom-renovation |
| 9 | Panel: defaults | A writer who changed nothing. Every value is a fallback and says so. | Search and sharing · URL slug · Generated from the title until you edit it. · Meta title · Using article title · Recommended 30 to 60 characters. This is a guide, not a limit. · Meta description · Using first sentence of the body · Recommended 70 to 160 characters. · Excerpt · A short summary. It is also sent as the article teaser. · Cover image · Using the site social image (north-wall.jpg). · Social sharing · Optional · Uses the meta values · Advanced · Canonical URL: automatic · Hide from search engines · Sets noindex in the API. It doesn't change who can read the article. · Search result · Social card · Suggestions · What the frontend gets · Cancel · Save changes |
| 10 | Panel: filled in | Own values everywhere. The fallback chips are gone and the suggestion list shows All good. | All good · Nothing to suggest for this article. |
| 11 | Panel: outside the soft ranges | Counters turn amber. The preview shows the cut-off. Nothing is blocked. | The meta title is 74 characters. Search engines may cut it off after about 60. · The description is 41 characters. About 70 to 160 reads best in results. · Cut off here, the way a search engine would. |
| 12 | Panel: slug edited | The Auto badge is gone. "Reset to match the title" brings it back. | Reset to match the title |
| 13 | Panel: slug taken | Checked on blur and on save. Nothing is saved until a free slug is chosen. | The slug "bathroom-costs" is already used on Renew. · Use bathroom-costs-2 |
| 14 | Panel: cover without alt | Alt text is required before the cover counts as ready. It is a suggestion, not an error. | Alt text · Needed before the cover counts as ready. Sent as image alt in the API. · Describe the image for people who can't see it · The cover image has no alt text. Add a short description of it. |
| 15 | Panel: choose cover | The shared Media picker. | Choose a cover image · The cover for this article. · Use image |
| 16 | Panel: social sharing open | Optional overrides. Each falls back to the meta values. | Social sharing · Optional · Social title · Using meta title · Social description · Using meta description · Social image · Using the cover image. |
| 17 | Panel: social customised | The card shows the overrides, so the Using chips disappear. | Customised |
| 18 | Panel: advanced, invalid canonical | Collapsed by default. The address must be absolute. | Advanced · Canonical URL · Only change this if the same content lives at another address. · Enter a full address starting with https:// |
| 19 | Panel: hidden from search | The switch only sets a flag in the API. | Hide from search engines · Sets noindex in the API. It doesn't change who can read the article. |
| 20 | Panel: mobile search preview | The Desktop and Mobile toggle changes the width and where the title is cut. | Search result · Desktop · Mobile |
| 21 | Panel: checking | Checks run on open and after each pause in typing. | Checking this article… |
| 22 | Panel: saving | Fields are disabled while the request runs. | Saving… |
| 23 | Panel: save failed | 500 or network failure. The values stay. | Couldn't save search and sharing. Your changes are still here. Try again in a moment. |
| 24 | Saved | The panel closes. The summary updates. | Search and sharing saved · Your search and sharing settings are up to date. |
Mobile: the section sits under "Site and authors" in the Details sheet. "Edit search and sharing" opens a 92% sheet on top of the Details sheet, with suggestions first, then fields, then the two previews. Publishing is allowed regardless of suggestions; Save and Publish are never disabled by them.
## Editor: what the frontend gets (SEO round)
Route: `/article/:id/edit`. API: GET /v1/article/{id}/seo.
A read-only dialog (desktop, 720 px) or bottom sheet (mobile) with the resolved SEO object exactly as the API returns it. Opened from the sidebar section or the panel. Each value that came from a fallback carries a chip. Formatted shows a compact key and value list, Raw shows the JSON. Copy JSON copies the raw object. Author name, profile URL and social links come from the Authors screen and surface in authors[] and in the JSON-LD author, so Authors itself needs no change.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Formatted | Default view. | What the frontend gets · The SEO data returned by GET /v1/article/12/seo, read only. RenewCMS doesn't render pages or add meta tags: your frontend does. · Formatted · Raw JSON · Copy JSON · Close |
| 2 | Raw JSON | The JSON as the API returns it. | Raw JSON |
| 3 | Copied | The button reads Copied for 2 s and this is announced. | Copied |
| 4 | Draft article | Drafts answer 404 to anonymous callers. | This article is a draft. The API returns this data only to signed-in callers until it is published. |
| 5 | Loading | Copy is disabled. | – |
| 6 | Error | GET failed. Nothing in the article changes. | Couldn't load the SEO data · GET /v1/article/12/seo failed. Your article is unchanged. Try again in a moment. · Try again |
+30
View File
@@ -0,0 +1,30 @@
# Entry editor
Route: `/content/:key/new`, `/content/:key/:id`. API: POST /v1/content-type/{key}/entry · GET/PUT/DELETE /v1/content-type/{key}/entry/{id}. Auth: any verified user.
Visual reference: `RenewCMS Screens Schema.dc.html`, groups "Entry editor". Frames: `SchemaFrame.dc.html` mounted in `AppFrame.dc.html` (shell, sidebar, topbar, drawer). Every state is drawn in light and dark (theme switch) at 1440×900 (shown at 50%) and 390×844 (shown at 75%). Copy is exact. Strings in a Copy cell are separated by " · " and listed in reading order. Nothing is clock-sensitive.
### 6 Entry editor (group "6 Entry editor", 13 states)
Route: `/content/:key/new, /content/:key/:id`. API: POST /v1/content-type/{key}/entry, GET/PUT/DELETE /v1/content-type/{key}/entry/{id}. Auth: Any verified user.
The form is generated from the schema, one control per field in schema order. Required fields carry * and the line "Fields marked * are required." Defaults are pre-filled on create and tagged Default. Saving replaces the whole entry.
| # | State | Trigger | Copy | Scope |
|---|---|---|---|---|
| 1 | New entry | /content/events/new. Online is pre-filled from its default. | New entry · Events · Fields marked * are required. · Default · Each entry needs a different title. · Between 1 and 5000. · Choose an option · Pick a date · Save · Cancel · New entry · Not saved yet | Available now |
| 2 | Editing | /content/events/42. Save is disabled until something changes. | Lime plaster workshop · Events · Entry 42 · Saved · Delete · Cancel · Save · Created by · Updated by · Inès Duarte · 12 Oct 2026, 09:14 · Marta Alves · 15 Oct 2026, 11:02 | Available now |
| 3 | Unsaved changes | Any edit. | Unsaved changes · 32 / 80 | Available now |
| 4 | Markdown preview | The Preview tab of a rich text field. | Write · Preview | Available now |
| 5 | Saving | PUT or POST in flight. Form dims. | Saving… | Available now |
| 6 | Saved | 200. | Saved at 11:02 · Entry saved · "Lime plaster workshop" is up to date. | Available now |
| 7 | Save failed | 500 or network failure. Values stay. | Save failed · Couldn't save the entry · Something went wrong on our side. Your changes are still here. Try again in a moment. · Try again | Available now |
| 8 | Required fields empty | 422 invalid_entry: title: required, starts_on: required. The first field with an error takes focus. | Fill in Title. · Pick a date for Starts on. · Not saved | Available now |
| 9 | Rule errors | 422 invalid_entry for max, type, max and options. One rule per field is shown at a time. | Title can be up to 80 characters. · 91 / 80 · Ends on must be a date, like 2026-11-21. · Capacity must be at most 5000. · Choose one of: Talk, Workshop or Meetup. · Not saved | Available now |
| 10 | Duplicate unique value (409) | 409 invalid_entry: title: unique. | Not saved · Another entry already uses this title. Change it and save again. · Another entry already uses this Title. Enter a different one. | Available now |
| 11 | Entry not found | 404 entry_not_found, for example after a delete elsewhere. | Entry not found · It may have been deleted by someone else. · Back to Events | Available now |
| 12 | Schema changed since load | 422 invalid_entry: room: required, or unknown_field. The form is read-only until reloaded. | The fields of Events changed · A field was removed and a new required field, Room, was added after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields. · Reload form | Available now |
| 13 | Delete entry: confirm | Delete. | Delete this entry? · "Lime plaster workshop" will be deleted from Events. This can't be undone. · Cancel · Delete entry | Available now |
## Media field (pass 5)
Spec, states, exact copy, measurements, backend needs and accessibility: `media-field-changes.md`, section 3.6. Board groups: 6 Entry editor.
+105 -6
View File
@@ -12,18 +12,18 @@ Sticky section index on the right (desktop), "Jump to" select (mobile). A refere
- Intro: "RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office."
- Base URL: label "Base URL", a CodeSnippet with one tab ("Base URL") and the button "Copy" / "Copied" (2 s, announced).
- Authentication, card "Public": "No key needed. Two read-only routes, and only online articles are returned." `GET /v1/article/`, `GET /v1/article/{id}`.
- Card "API key": "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator." CodeSnippet tabs curl, fetch (GET /v1/auth/me). "Keys start with `rcms_` and are shown in full once, when you create them." Link "Manage API keys".
- Card "API key": "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role as it is now. A key never has more rights than its creator. A route that needs a permission answers 403 "missing_permission" to a key whose creator's role lacks it." CodeSnippet tabs curl, fetch (GET /v1/auth/me). "Keys start with `rcms_` and are shown in full once, when you create them." Link "Manage API keys".
- Note 1: "A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified."
- Note 2: "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header."
- Groups: Articles (7), Media (6), Users and invites (6), Settings (1), Session only (12 routes, 3 sub-groups; every `/v1/system` route is in this group). Session-only banner: "These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session.""
- Errors: "Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain." Table 400, 401, 403, 404, 429, 500 with the real messages below. Sample: a status line plus the text body (`HTTP/1.1 403 Forbidden`, `Content-Type: text/plain`, blank line, `This action needs a real session.`).
- Groups, in index order: Authentication, Articles (7), Search and sharing (2), Content types (5), Entries (5), Built-in types (2), Media (10), Sites and authors (2), Images and variants, Users and invites (6), Settings (1), Session only (35 routes in 6 sub-groups: System, Sign in and account, API key management, Sites and authors writes, Image variants, Activity; every `/v1/system` route is in this group), Errors, Notes. Session-only banner: "These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session.""
- Errors: "Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain." Table 400, 401, 403, 404, 429, 500 with the real messages below. The 403 row has a note line under it: "Site SEO defaults are owner only. Article SEO fields need only the right to edit the article." (12/18, `--ui-text-muted`, in the cause column, 2 px under the cause text). It is the only row with a note; rows below it move down by the wrapped note height (two 18 px lines on desktop, three on mobile, plus 2 px). Sample: a status line plus the text body (`HTTP/1.1 403 Forbidden`, `Content-Type: text/plain`, blank line, `This action needs a real session.`).
- Notes: image URL prefix, CORS (existing copy), and "Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default."
## EndpointCard states
- Collapsed (44 px row): method badge, mono path, one-line description, chips, chevron. Desktop is one line; mobile is three (method and path, description, chips).
- Expanded: "Request body" table (Name, Type, Required, Rule; "No request body." when empty), tabs fetch / curl / Response in one CodeSnippet with Copy, then "Errors": status chip plus one-line caption (also its `title` tooltip).
- Session only: dashed border, muted fill, lock icon, "Session only" chip, no method colour, no expand.
- Chips: access "Public", "Verified user", "Owner only"; key "API key OK" (success soft) or "Session only" (dashed, muted).
- Chips: access "Public", "Verified user", "Needs permission" (routes gated by a role permission; the permission name is in the route description), "Owner only"; key "API key OK" (success soft) or "Session only" (dashed, muted).
- Method badges: GET `--ui-bg-inverted`, POST success soft, PATCH warning soft, DELETE error soft. Existing tokens only.
## States
@@ -34,8 +34,8 @@ Desktop 1440×900, mobile 390×844, light and dark.
Plain text, one sentence. Used in the table and in the per-endpoint error captions.
- 400: "The request cannot be processed due to a mismatch in the format of the body." / "The expiry must be a date in the future." Image requests (`/media/photo.png?v=`) answer 400 with a JSON body (`status`, `error`, `message`, `detail`) in two cases: `{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "unknown" }` for an unknown or disabled variant, and `{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" }` for any other query parameter (`width`, `height`, `format`, `quality`). Both are rows of the "Responses" table in "Images and variants".
- 401: "You must be logged in to access this resource."
- 403: "This action needs a real session." (session-only routes called with a key) / "Only the owner can do this." / "You must verify your account to access this resource."
- 404: "Article not found" / "Image not found" / "Invite not found" / "API key not found"
- 403: "This action needs a real session." (session-only routes called with a key) / `missing_permission` (the role of the user, or of the key's creator, lacks the permission the route needs) / "You must verify your account to access this resource." Note under the 403 row: "`missing_permission` is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article." The inline parenthetical that said the same thing in the row's cause text was removed to avoid saying it twice.
- 404: "Article not found" / "Image not found" / "Invite not found" / "API key not found" / `site_not_found` (GET /v1/site/{key}/sitemap only; body is the code, not a sentence)
- 429: "Too many requests. Please try again later." A per-key limit also sends `Retry-After`.
- 500: "Internal server error"
- 409, only as examples under their endpoints: `POST /v1/user/invite` "This address already has an account."; `POST /v1/media/merge` "Some of the images to merge changed. Refresh and try again." There is no 409 or 422 row in the table.
@@ -108,3 +108,102 @@ New section "Images and variants" on the Integration page, after Media and befor
## Pass 2 notes
- The base URL field and the image URL builder output are CodeSnippets with a single tab.
- Page padding 24 px (16 px on mobile), page title 20/28, section headings 18/26, card titles 15/24, sub headings 14/22 (foundations.md).
## Search and sharing (SEO round)
New group "Search and sharing" after Articles, in the sidebar index and the mobile Jump to select. Two cards in the existing endpoint style (collapsed, method badge, auth badge "Public", example, 401 and 404 rows, copy buttons):
- GET /v1/article/{id}/seo: the resolved SEO object. Fields: title, description, canonical_url, robots, og{type,title,description,url,site_name,image,image_alt,locale}, twitter{card,title,description,image,image_alt}, image{url,alt,source}, authors[{name,url,same_as}], published_at, modified_at, fallbacks, json_ld. Drafts answer 404 to anonymous callers.
- GET /v1/site/{key}/sitemap?page=&size=: yes, shown per site by key. Items: { url, modified_at, noindex }. Total in X-Total-Count. Sizes 25, 50, 100, 500, default 100. Errors: 400 "The request cannot be processed due to a mismatch in the format of the body." (same text as the other routes; bad `page` or `size`), 401, 404 `site_not_found` (unknown site key; this is the body the API returns, shown as a code and not rewritten as prose).
PATCH /v1/article/{id} lists the new optional fields (slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex, seo_dismissed). The 403 row of the HTTP status table adds: site SEO defaults are owner only; article SEO fields need only the right to edit the article.
## Search (jump-to palette)
Board: group "Integration", states 8a to 8h, desktop 1440×900 and mobile 390×844, light and dark. Search never hides or filters the page. It only jumps, so find-in-page and deep links keep working.
### Behaviour
- Trigger, desktop: a field-style button in the page header, right of the title block, 320×36. Placeholder "Search the API reference…", "/" key hint. Mobile: 44×44 icon button right of "Jump to", inside the sticky bar. Opens with "/" (when focus is not in an input), Ctrl+K and Cmd+K. Both shortcuts are announced with aria-keyshortcuts.
- Palette: modal, 720 px wide, 96 px from the top, on the existing overlay. Mobile: full-screen sheet with a "Cancel" button (44 px) in the input row. Focus goes to the input; on close it returns to the trigger.
- Groups: "Endpoints" (max 6 rows, then "N more endpoints. Keep typing to narrow the list."), then "Sections". Empty query: one group "Suggested" with 5 rows.
- Match: every space-separated term must appear (case-insensitive substring) in method, path, description or access chip, or in a request field name, or in an error code or message. Rows matched only in a field or error show a second line ("Body field", "Query parameter" or "Error") with the match. Terms are highlighted with --rc-sand / --rc-on-sand. Rows matched in the path or description sort first.
- Selecting: Enter or click closes the palette, scrolls to the card (or section heading), expands the card, moves focus to the card button, and flashes the row (--rc-sand fill, 2 px --ui-primary ring, 1.5 s, then the open-card fill). With prefers-reduced-motion the flash is a static ring with no fade. The index active item updates through the existing scroll-spy. The URL hash becomes #ep-<id> so the result can be shared.
- Keys: Up and Down move (wrap), Enter opens, Esc closes, Home and End go to the first and last row. Typing resets the selection to the first row.
- Roles: input role="combobox" with aria-expanded, aria-controls, aria-autocomplete="list", aria-activedescendant; list role="listbox"; groups role="group" with aria-labelledby; rows role="option" with aria-selected and a full aria-label (method, path, description, access). A polite live region announces the count.
### Row anatomy
- Endpoint: method badge (existing colours), mono path 13/500, one-line description (ellipsis), access chip. Session-only: dashed muted badge, 400 weight path, dashed chip with lock icon, text --ui-text-muted.
- Section: icon in the badge column (hash, or lock for Session only), title 500, muted description.
- Selected: --ui-bg-elevated with a 2 px --ui-primary bar on the left (same as the section index active item). Hover: --ui-bg-elevated.
- Mobile rows: 56 px min height, badge and path on line 1, chip then description on line 2.
### Copy per state
| # | State | Copy |
|---|---|---|
| 8a | Trigger focused | Placeholder "Search the API reference…"; key hint "/"; aria-label "Search the API reference" |
| 8b | Open, empty | Input placeholder "Search the API reference…"; group "Suggested": Authentication ("Public access and API keys"), POST /v1/media/ ("Upload an image (multipart)." Verified user), Errors ("Status codes and messages"), GET /v1/article/ ("List articles, one page at a time." Public), PATCH /v1/user/me ("Update my profile." Verified user); footer "↑↓ Move", "↵ Open", "Esc Close", count "5 suggestions"; live "5 suggestions" |
| 8c | Typing "media" | Groups "Endpoints" (6 media routes) and "Sections" (Media, "10 routes"); count and live "17 results, showing 7" |
| 8d | "404", third row selected | Endpoints list the six first 404 routes with an "Error" line, for example "404 Article not found"; Sections: Errors ("Status codes and messages"); "N more endpoints. Keep typing to narrow the list."; count "22 results"; live "22 results, showing 7" |
| 8e | "keep_id" | POST /v1/media/merge, "Merge identical copies into one image.", Verified user, line "Body field keep_id"; "1 result" |
| 8f | "login" | POST /v1/auth/login, "Log in", chip "Session only" (lock); Section "Session only", "35 routes, not available to API keys"; "2 results" |
| 8g | No results | No match for "xyz". Try a path, a method or a status code like 404. Count "0 results"; live "No results" |
| 8h | After selecting | Page only: POST /v1/media/merge expanded and highlighted; index and Jump to read "Media" |
### Nuxt UI v4 mapping
- Trigger: UButton variant="outline" color="neutral" with a UIcon and UKbd (desktop); UButton icon-only (mobile). Shortcut: defineShortcuts({ '/': open, meta_k: open, ctrl_k: open }).
- Palette: UModal (fullscreen on mobile via the fullscreen prop) wrapping UCommandPalette with :groups, v-model:search-term, placeholder, :close (mobile "Cancel"). Fuse options off for the endpoint group (our own multi-field match), highlight via the #item-label and #item-description slots. UCommandPalette provides the combobox and listbox roles, aria-activedescendant and arrow-key handling.
- Rows: custom #item slot with UBadge (method, access chip), mono path, UIcon (lock, hash). Footer: #footer slot with UKbd for ↑ ↓ ↵ Esc. Empty: #empty slot. Live region: a visually hidden element with aria-live="polite".
- Flash: a CSS class on the EndpointCard row driven by a ref; uses var(--rc-sand) and var(--ui-primary) only.
### Assumptions to confirm
- Search is client-side over a static index built from the same data as the cards (method, path, first sentence of the description, access, request fields, error codes and messages). It does not search code samples, prose or response examples.
- Matching is substring per term, not fuzzy ("mdia" does not match "media").
- The palette shows the first sentence of each endpoint description, not the full text.
- Endpoints are capped at 6 rows with a count line; sections are never capped.
- Recent searches are left out. Open question: store the last 3 queries in localStorage?
- "/" is ignored while a text field has focus. Cmd+K on this route does not clash with any back-office shortcut (to confirm).
- The search trigger scrolls away with the header on desktop. Open question: keep it sticky next to the section index?
- Highlight uses --rc-sand on --rc-on-sand. On the selected row (--ui-bg-elevated) the two tones are close; the dark text keeps contrast. No new token is needed.
- The 1.5 s flash duration and the #ep-<id> hash are proposals.
## App sync notes
- Schema routes show the permission key as their access chip: schema.view (GET), schema.edit (POST, PUT), schema.delete (DELETE). Purge routes show "Needs permission" until the app names the key.
- 403 body for these routes: "Your role doesn't include this action. Ask an owner or admin." (app string, messages.ts).
- Entries intro: "Writing entries needs the entry permissions for that type." (proposal; the app has no string yet).
- Remaining "owners and admins" wording (activity export, content-types notice) stays as the app ships it.
- Article list body: {items, total, page, size, counts} (no X-Total-Count). counts keys: all, online, draft, suggestions.
- Users group has 7 routes: list, PATCH me, invite, cancel invite, renew invite, DELETE /v1/user/{id}?expected_role_id=N (permission, 409 target_role_not_found), GET /v1/auth/me. Profile routes use /v1/auth/me.
- 404 adds site_not_found (sitemap, unknown key). The 403 note sits under its row at full row width. Every 400 uses the generic body-mismatch text ("The request cannot be processed due to a mismatch in the format of the body."); "Invalid page or size." is gone.
## Custom content types round
Board: group "Integration", states 9a to 9j. Source: `IntegrationPage.dc.html`, `IntegrationSearch.dc.html`. Every route an API key can call is now documented.
### New groups
- Content types (`ctypes`): description "Define your own kind of content, like events or recipes. An owner or admin creates a type made of 1 to 50 ordered fields; the type then has its own entry routes." A "Field types" table (Type, Value in an entry, Rules) and a sample field object sit above the routes (anchor `ct-fields`).
- `GET /v1/content-type/` and `GET /v1/content-type/{key}`: Verified user. 404 `content_type_not_found`.
- `POST /v1/content-type/`: Owner or admin. Body `key` (2 to 40, lowercase letters, digits, hyphens), `name` (max 100), `icon` (optional), `fields` (1 to 50). 409 `content_type_key_taken`.
- `PUT /v1/content-type/{key}`: Owner or admin. Body `name`, `icon`, `fields`. 409 `schema_incompatible`.
- `DELETE /v1/content-type/{key}`: Owner or admin. 204. 409 `content_type_has_entries`, 409 `content_type_referenced`.
- Entries (`entries`): `GET` and `POST /v1/content-type/{key}/entry`, `GET`, `PUT` and `DELETE /v1/content-type/{key}/entry/{id}`. Verified user. List query `page`, `size` (5, 25, 50, 100), `sort` (`id`, `created_at`, `updated_at`, then `:asc` or `:desc`); the response is `{ items, total, page, size }`, not a bare array. Body `data` (object keyed by field key). `media` in a response is keyed by field key. 409 and 422 bodies are both `invalid_entry: <reason>`; 409 only when a unique field clashes. Id 400: "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)."
- Built-in types (`builtin`): `GET /v1/builtin-type/` and `/{key}`, Owner or admin. 404 `builtin_type_not_found`.
- Sites and authors (`sites`): `GET /v1/site/` and `GET /v1/author/` (paged, `{ items, total, page, size }`), Verified user.
- Media adds `GET` and `PATCH /v1/media/purge-settings` (`auto_purge_enabled`, `auto_purge_interval_days` 1 to 365; response also `last_purge_at`, `last_purge_merged`), `GET /v1/media/purge-preview` and `POST /v1/media/purge` (optional `fingerprint` from the preview). Owner or admin.
- PUT uses the PATCH badge colours.
### Access and errors
- 403 rows: "Only owners and admins can do this." (owner-or-admin routes) and "Only the owner can do this.", both listed in the 403 row; the existing note stays.
- 404 adds `content_type_not_found`, `entry_not_found`, `builtin_type_not_found`. 409 and 422 are real rows now (earlier text said there is no such row). Code-style text is the literal body.
- Authentication copy says a key can also manage content types and entries, and owner or admin routes answer 403 to a key whose creator is neither.
### Search
The index covers the new endpoints, body fields (`data`, and the events sample keys such as `capacity`) and error codes. "entry" also matches "entries". "N more endpoints" is singular for one.
### States 9a to 9j
9a create a content type, curl tab · 9b entries list, Response tab · 9c entry replace with the 409 and 422 errors · 9d Errors table · 9e Session only, 6 sub-groups · 9f search "entry" · 9g search "capacity" (Body field) · 9h search "schema_incompatible" (Error) · 9i search "content type" (Sections) · 9j navigation with scroll-spy on Content types. 8c now announces "17 results, showing 7" and 8d "22 results, showing 7" (the copy table above predates this; the board is right).
### Final round
- `POST /v1/media/purge` and the built-in type samples now match the API (no `icon`; keys `article`, `author`, `site`, `media`, `user`). Built-in types are read-only through the API.
- `GET /v1/media/` takes `page` (default 1) and `size` (5, 25, 50, 100; default 25), returns a JSON array and sends the total in `X-Total-Count`; 400 "Invalid page or size."
- Counts: Articles 7, Search and sharing 2, Content types 5, Entries 5, Built-in types 2, Media 10, Sites and authors 2, Users and invites 6, Settings 1, Session only 35. Search index: 75 endpoints (40 documented, 35 session-only).
- Announced: 8c "17 results, showing 7", 8d "22 results, showing 7", 8f "2 results", 9f "8 results, showing 7", 9g "2 results", 9h "1 result", 9i "12 results, showing 8".
- Illustrative only: built-in sample fields, purge-preview sample, the reason after `invalid_entry:`, session-only row labels, entry `created_by`/`updated_by`, `cover` media data.
- Not confirmed by the design tool: dark mode, the search palette on mobile and the Session only list on mobile. `task visual:check` is the gate.
- `POST /v1/content-type/` and `POST …/entry` answer 201; POST cards show only the JSON body.
+3
View File
@@ -45,3 +45,6 @@ The Library tab of the picker gets a Variant select. Options are Original plus t
| 3 | No variants defined | Only Original is offered. | "Articles" / "How we price a bathroom renovation" / "Edit article" |
| 4 | Variant deleted | The block keeps its URL. Pick another variant opens the select. | "Articles" / "How we price a bathroom renovation" / "Edit article" / "Variant 'hero' no longer exists." |
| 5 | Variant disabled | Same block, different cause. Proposed copy. | "Articles" / "How we price a bathroom renovation" / "Edit article" / "Variant 'hero' is disabled." |
## Media field (pass 5)
Spec, states, exact copy, measurements, backend needs and accessibility: `media-field-changes.md`, section 3.7. Board groups: 10 Media picker for a field.
+11 -2
View File
@@ -10,7 +10,7 @@ Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Media", "Media
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
### Media (group "Media", 52 states)
### Media (group "Media", 56 states)
Route: `/media`. API: GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage.
@@ -49,7 +49,7 @@ New route for verified users, in the sidebar between Articles and Users with the
| 29 | Upload: several files, mixed | Upload button or drop, several files. Each file has its own row and progress. Files upload two at a time. A row can end as added, "Already in your library", rejected in the browser, or failed. Rejected and failed rows stay until dismissed. | "2.2 MB" / "2.6 MB" / "1.1 of 2.6 MB" / "1.9 MB" |
| 30 | Upload: summary | All rows settled. The summary counts each outcome. "Use existing" selects the existing image and drops the row; "Upload anyway" stores a second copy, which then appears under Duplicates. "Try again" retries one file. Announced when it finishes. | "2.2 MB" / "2.6 MB" / "Upload finished" / "5 images" / "2 uploaded · 1 already in your library · 2 need attention" |
| 31 | Upload: rejected files (413, 400) | Type and size are checked in the browser first, so nothing is sent. A server 413 or 400 for a file that got through shows the same row with its own message. | "210 KB" / "notes.pdf isn't an image" / "Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image." / "3.0 MB" / "broken.png couldn't be read" / "The server couldn't read this image. Save it again as a PNG or JPEG, then try again." / "Nothing was uploaded" / "3 files were rejected" |
| 32 | Upload: too many requests (429) | Rate limited. Retry-After is counted down in the button. It re-enables at zero. Already-added files are kept. | |
| 32 | Upload: too many requests (429) | Rate limited. Retry-After is counted down in the button. It re-enables at zero. Already-added files are kept. Clock-sensitive: shows a relative time or a countdown. | |
| 33 | Duplicates: groups | The Duplicates tab. Groups of identical images side by side, each with its uploader, upload date and "Used in N articles". The suggested copy to keep is the most used, then the oldest. | |
| 34 | Purge duplicates: confirm | "Purge duplicates" in the toolbar, next to the sort menu (in the stacked toolbar on mobile). Opens the same dialog as Settings; every other state is on the Media cleanup board. | |
| 35 | Duplicates: no purge button (member) | Verified users who are not owner or admin. The "Purge duplicates" button is hidden. "Merge into this one" is unchanged. | |
@@ -113,3 +113,12 @@ A secondary "Purge duplicates" button (icon merge) in the toolbar next to the so
|---|---|---|---|
| 1 | Duplicates tab with the button | Owner or admin. | |
| 2 | Duplicates tab: offline | The button is disabled with the existing banner. | |
## Media field (pass 5)
Spec, states, exact copy, measurements, backend needs and accessibility: `media-field-changes.md`, section 3.8. Board groups: 11 Media library usage.
## App sync (batch A)
- Detail panel, "Used elsewhere" below the articles and entries lists: rows "Author avatar" (/authors) and "Site default social image" (/settings/sites). States: avatar only, site only, both, none (section absent). The "Used in N" badge includes these, N = articles + entries + elsewhere. "Not used in any article" appears only when N is 0; otherwise the line reads "No article shows it."
- Selected card (grid, mobile list): outline badges ("Used in N", "Draft") keep opaque bg-default over the sand tint, text colour unchanged. --ui-text-toned on --ui-bg: 9.7:1 light (#444 on #fff), 11.7:1 dark (#cfccc7 on #111).
- Media library board (variants): adds tabs (All, Unused, Duplicates), Select all, grid/list toggle, Duplicate badges, the variants filter row and the Details | Variants tab row. Variant URL: /static/uploadedImages/photo.png?v=thumb.
- Not yet done: mobile picker sheet height and tile count, desktop frame of the mobile-only state, stray miniatures.
+22
View File
@@ -46,3 +46,25 @@ Opened from the account menu at the bottom of the sidebar. Username, email and p
| 11 | Reset: too many requests (429) | Rate limited. The button is disabled for 5 minutes. | |
| 12 | Reset: failed | 500 or network failure. The button can be pressed again. | |
| 13 | Display names in the Users list | The Display name column shows the name when one is set and a dash when not. Your own row follows your saved name. | |
## Changed in pass 3: author profile
Every existing Profile frame has the "Author profile" card between Account and Password, in its default state ("Create my author profile"). The card is defined once, in `AppFrame.dc.html`.
### Profile: author profile (group "Profile: author profile", 5 states)
Route: `/profile`. API: POST /v1/author/me · GET /v1/auth/me (author field).
The "Author profile" card sits between Account and Password in every Profile frame, defined once in AppFrame. The existing Profile frames show its default state ("Create my author profile"). All roles can create their own profile and edit their own linked author. Only the states specific to the card are drawn here.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | No author profile | The user has no linked author. | Author profile · Your public byline on articles. It's separate from your account. · You don't have an author profile yet. Create one to be credited on your own articles. · Create my author profile · Uses your display name, Marta Alves. You can change it afterwards. |
| 2 | Error | 500 or network failure. | Couldn't create your author profile · Try again in a moment. |
| 3 | Created | 201: the card shows the author. The author is named from the display name and linked to the user. | Author profile created · Marta Alves is now your byline. · Edit author · Linked to your account. New articles start with this author. |
| 4 | Created, slug adjusted | The slug was taken, so the server added a number. No error is shown. | Your slug is marta-alves-2 · marta-alves was already used by another author. You can change it in Edit author. |
| 5 | Edit own author | Edit author on the card, by a role without author rights. The form opens with the linked user locked and no Unlink. There is no delete action in the form or on this screen. | Edit Marta Alves · Linked user · marta@renew.example · This is your author profile. Only an owner or admin can change the link. · Save changes |
| 6 | No author profile, no display name | The user has no linked author and has not set a display name. | Uses your username, marta. You can change it afterwards. |
| 7 | Edit own author: owner or admin | Edit author on the card, by an owner or admin. The form opens with the linked user and Unlink is allowed. | Edit Marta Alves · Linked user · marta@renew.example · Unlink · Save changes |
Order on the page: Display name, Account, Your role (role card), Author profile, Password.
+886
View File
@@ -0,0 +1,886 @@
# Roles and permissions
Routes: `/settings/roles`, `/settings/roles/new`, `/settings/roles/:id`. `/settings/permissions` is retired and redirects (301) to `/settings/roles`. Board: `RenewCMS Screens Roles.dc.html` (frame `RolesFrame`). Every state is "Available now". Copy below is exact; `·` separates strings.
## 1 Roles list
Route: /settings/roles · API: GET /v1/role · Auth: Roles: view
Settings > Roles. Visible to anyone who can view roles. Owner carries a Locked badge. The row menu has Edit, Duplicate and Delete. Mobile: one card per role.
### Default (Available now)
Trigger: Owner opens /settings/roles.
Copy: Roles · Who can do what on this site. Each user has one role. · New role · Role · Description · Members · Type · Owner · Locked · Admin · Writer · Contributor · Content lead · Built-in · Custom
### Loading (Available now)
Trigger: First load or retry. Skeleton rows after 150 ms. New role disabled.
Copy: (skeleton) · New role (disabled)
### Only built-in roles (Available now)
Trigger: GET /v1/role returns the three built-ins and no custom role.
Copy: No custom roles yet · The three built-in roles cover most sites. Create a role when someone needs something in between. · New role
### Row menu: custom role (Available now)
Trigger: Open the row menu on Contributor.
Copy: Edit · Duplicate · Delete
### Row menu: built-in role (Available now)
Trigger: Open the row menu on Writer as owner. Delete is disabled and its tooltip shows on focus or hover.
Copy: Edit · Duplicate · Delete (disabled) · Built-in roles can't be deleted. Duplicate it to make a custom role.
### No permission to edit (Available now)
Trigger: User has Roles: view but not Roles: manage. New role is disabled with a tooltip.
Copy: ReadOnlyBanner: You can see roles, but only people who manage roles can create or change them.
### Error (Available now)
Trigger: 500 or network failure.
Copy: Couldn't load roles · GET /v1/role failed. Check your connection and try again. · Try again
## 2 Role editor
Route: /settings/roles/new, /settings/roles/:id · API: POST /v1/role, GET/PUT /v1/role/{id} · Auth: Roles: manage (view for read-only)
Name and description, then Content permissions (the grid moved here from /settings/permissions) and General permissions. Desktop: the plain-language panel sits on the right. Mobile: the grid is one card per type, and a sticky bar holds "What this role can do" and Save. The old route /settings/permissions redirects here (301 to /settings/roles).
### New role (empty) (Available now)
Trigger: Owner or admin opens /settings/roles/new. Nothing is granted. Save is off until the role has a name.
Copy: New role · Name · For example, Contributor · Description (optional) · What this role is for · Name your role to save it · Discard · Save changes · Name Contributor · Description Creates content and edits only their own. · Content permissions · What this role can do with entries of each content type. Own means entries the person created. · Quick presets · Read only · Write own · Full content · Content type · Read · Create · Update · Delete · Publish · All content types · Sets every row · Articles · Built-in · Schema locked · None · Own · All · On · Off · General permissions · Everything outside entries. · Expand all
### Editing a custom role (Available now)
Trigger: Open Contributor. Two groups open. Case studies shows Custom with a Reset link.
Copy: Name Contributor · Description Creates content and edits only their own. · Content permissions · What this role can do with entries of each content type. Own means entries the person created. · Quick presets · Read only · Write own · Full content · Content type · Read · Create · Update · Delete · Publish · All content types · Sets every row · Articles · Built-in · Schema locked · None · Own · All · On · Off · General permissions · Everything outside entries. · Expand all · Custom · Reset · 4 members · Custom
### Built-in role, as owner (Available now)
Trigger: The owner opens Writer. A warning shows how many members are affected.
Copy: This role has 5 members · Changes apply to all of them when you save, and to the API keys they created. Built-in roles can be restored with Reset to default. · 2 unsaved changes
### Built-in role, as admin (read-only) (Available now)
Trigger: An admin opens Writer. All controls are disabled and the fields are locked.
Copy: Only the owner can edit built-in roles · You can read what Writer allows. To change it, duplicate it into a custom role. · Duplicate role · Built-in
### Owner role (locked) (Available now)
Trigger: Anyone with Roles: view opens Owner.
Copy: The Owner role is locked · It always has every permission. To hand it to someone else, transfer ownership from Users. · Go to Users · Locked
### General permissions (Available now)
Trigger: Scrolled to General permissions. Groups collapse; each shows "x of y enabled". Scoped rows use the same None / Own / All control.
Copy: Activity · See activity · Read the log of who did what. · Export activity · Download the log as a CSV file. · Users and invites · View users · Invite users · Change a user's role · Remove users · Settings · View settings · Edit general settings · Manage sites · See API keys · Create API keys · Revoke API keys · Delete a site · Owner only · 2 of 2 enabled
### Owner-only items (Available now)
Trigger: Roles and System groups, as an admin. Owner-only items are disabled with a lock and an Owner only tag. They stay off even for Admin.
Copy: Roles · View roles · See roles and what they allow. · Manage roles · Create, edit, duplicate and delete roles. · Edit roles with owner-only power · Change a role that carries owner-only abilities. · System · Apply software updates · Install a new version of RenewCMS. · Edit activity retention · How long the activity log is kept. · Transfer ownership · Make another member the owner. · Owner only
### Unsaved changes (Available now)
Trigger: Three edits. Changed cells carry a square marker and the preview tags the changed line.
Copy: 3 unsaved changes · Discard · Save changes · Changed
### Saving (Available now)
Trigger: PUT in flight. Fields dim; Save shows the loader.
Copy: Saving… · Discard (disabled)
### Saved (Available now)
Trigger: 200. The bar closes and a toast confirms.
Copy: Role saved · Contributor is updated for 4 members.
### Name required (Available now)
Trigger: Save with an empty name.
Copy: Give the role a name. · 1 problem to fix
### Name already taken (Available now)
Trigger: 409 role_name_taken on POST or PUT.
Copy: A role named "Writer" already exists. Choose a different name. · 1 problem to fix
### Escalation blocked (Available now)
Trigger: The editor holds Roles: manage but can publish only their own entries, so Publish: All is disabled with a lock.
Copy: You can only grant what your role has. Your role can publish only entries you created, so All isn't available. · All (locked)
### Conflict (Available now)
Trigger: 409 role_conflict on PUT (version mismatch).
Copy: This role changed while you were editing · Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits. · Reload role · Review differences · 2 unsaved changes. Save is off until you reload
### Permission lowered while open (403) (Available now)
Trigger: 403 missing_permission on PUT or on refresh. The page becomes read-only.
Copy: Your access changed · An owner changed your role while this page was open. You can still see roles, but you can't change them. Your edits weren't saved. · Reload page
## 3 Plain-language preview
Route: Panel inside the role editor; bottom sheet on mobile · API: None. Computed in the browser from the form · Auth: Roles: view
Title "What this role can do". Two parts: Content (one sentence per group of types that share the same permissions) and Everything else (one sentence per group). A last line lists areas with no access. Lines that change while editing carry a Changed tag. Sentences are built from fixed templates, never from keys.
### Live panel (Available now)
Trigger: Editing Contributor. Events: Update changed from Own to All. The first line is tagged.
Copy: What this role can do · Updates as you edit. · Content · All content types · Can read any entry, create entries and edit only their own. Can't delete and publish. · Everything else · Schema · Can see content types and fields. · Media · Can browse the media library and upload files. · Authors · Can see authors and edit authors, only their own. · No access to Activity, Users and invites, Settings, Roles. · Applies to 4 members, and to the API keys they created. · Changed
### Read-only summary (Available now)
Trigger: Opened from a built-in role in read-only mode. No "Updates as you edit".
Copy: What this role can do · A summary of what this role allows.
### Nothing granted (Available now)
Trigger: New role with every permission off.
Copy: Content · All content types · No access to entries. · No access to Schema, Media, Authors, Activity, Users and invites, Settings, Roles.
### Mobile sheet (Available now)
Trigger: Tap "What this role can do" in the sticky bar. 92% sheet with grabber and close.
Copy: What this role can do · Updates as you edit. · Close
## 4 Delete role
Route: Dialog over /settings/roles · API: POST /v1/role/{id}/reassign, DELETE /v1/role/{id} · Auth: Roles: manage
A role with members is blocked until they move. Choosing a target role enables a single action that moves them and deletes the role. Roles with more power than the user are disabled in the picker with a reason.
### Blocked: has members (Available now)
Trigger: Delete Contributor, 4 members.
Copy: Delete Contributor? · 4 members have this role. Move them to another role first. Then Contributor can be deleted. · Move members to · Choose a role · They keep their API keys. Keys follow the new role. · Cancel · Move 4 members and delete role (disabled until a role is chosen)
### Role picker open (Available now)
Trigger: The picker shows each role's summary. Admin is disabled for an admin.
Copy: Writer · Reads everything and writes content. Can't delete or publish. · Content lead · Edits and publishes all content. · Admin · Your role can't assign a role with more power than yours.
### Confirm: no members (Available now)
Trigger: Delete Intern, 0 members.
Copy: Delete Intern? · No one has this role. This can't be undone. · Deleting a role doesn't affect content. · Cancel · Delete role
### Built-in role (Available now)
Trigger: Delete is disabled in the menu. If the dialog is reached by link it shows this state.
Copy: Delete Writer? · Built-in roles can't be deleted. · Writer is built in · Duplicate it to make a custom role you can change or delete. · Duplicate role · Close
## 5 Users: roles and ownership
Route: /users · API: PUT /v1/user/{id}/role, POST /v1/user/invite, POST /v1/site/transfer-ownership · Auth: Users: view; change role, invite, remove; owner for transfer
The Role column shows a role badge with an icon: crown (Owner), shield (Admin), pencil (Writer), person (custom). The owner row is protected. Pending invites show their role.
### List with roles (Available now)
Trigger: Owner opens /users.
Copy: Users · 5 members and invites · Invite user · Name · Email · Role · Status · Owner · Admin · Writer · Contributor · Content lead · Active · Invite pending
### Row menu (Available now)
Trigger: Open the menu on Ines Duarte.
Copy: Change role · Send password reset · Remove user
### Owner row menu (Available now)
Trigger: Owner row: Change role and Remove are disabled; Transfer ownership appears.
Copy: Change role (disabled) · Transfer ownership · Remove user (disabled) · The owner can't be changed or removed. Transfer ownership first.
### Change role dialog (Available now)
Trigger: Each option shows the role's one-line summary. The current role is marked.
Copy: Change role for Ines Duarte · Their API keys follow the new role. · Admin · Writer · · Current · Contributor · Content lead · Cancel · Change role
### Change role: options blocked (Available now)
Trigger: An admin opens the dialog. Roles with more power are disabled with a reason.
Copy: Owner · There is one owner. Transfer ownership to change who it is. · Content lead · It can publish. Your role can't grant that.
### Invite dialog (Available now)
Trigger: Role picker defaults to Writer and shows its summary.
Copy: Invite user · They get an email with a link to set a password. · Email · Role · Writer · Writer is the default. You can change a role later. · Cancel · Send invite
### Transfer ownership: pick member (Available now)
Trigger: Owner menu > Transfer ownership. Only members can be chosen.
Copy: Transfer ownership · Choose who becomes the owner. Only members can be chosen. · Cancel · Continue
### Transfer ownership: confirm (Available now)
Trigger: Consequences listed. The action stays off until the email is retyped exactly.
Copy: Transfer ownership to Jonas Weber · This takes effect straight away. · Jonas Weber becomes the owner. · You become an Admin. You can't take ownership back unless they transfer it. · Only the owner can apply updates, delete a site and change activity retention. · Your API keys follow your new role. · Type jonas@renewbuild.example to confirm · The email must match exactly. · Back · Transfer ownership
### Transfer ownership: done (Available now)
Trigger: The former owner is now Admin. The list shows the new owner.
Copy: Ownership transferred · Jonas Weber is now the owner. You are an Admin.
## 6 Role-aware shell
Route: Every page · API: GET /v1/auth/me returns role and permission keys · Auth: Any
Rule: hide whole areas the person can never use; disable, with a reason, an action that is blocked on something they can see; show read-only pages with a banner. Writer in these frames is the built-in Writer; Contributor shows "own" scope.
### The rule (Available now)
Trigger: Reference card used across the app.
Copy: Hidden · The person can never use it. · Disabled, with a reason · The person sees the thing but can't act on it. · Read-only · The person may look but not change.
### Writer: Articles list (Available now)
Trigger: Create and edit all, no delete, no publish. No Schema, Users, Integration or Settings in the sidebar.
Copy: Articles · You can write and edit all articles. Publishing is done by an editor. · New article · Created by · Status · Draft · Published
### Writer: row menu (Available now)
Trigger: Delete is disabled with a reason.
Copy: Edit · Duplicate · Delete (disabled) · Your role can't delete articles.
### Writer: entry editor (Available now)
Trigger: Publish is hidden. Status reads Draft with a hint.
Copy: Save draft · Status · Draft · Saved at 11:02 · Ask an editor to publish.
### Own scope: entry list (Available now)
Trigger: Contributor can edit only entries they created. Others show a View only mark.
Copy: Events · You can create entries and edit your own. · Entries by others are view only · Your role lets you edit entries you created. · New entry · View only
### Own scope: row menu (Available now)
Trigger: On an entry by someone else, Edit and Delete are disabled.
Copy: Edit (disabled) · Duplicate · Delete (disabled) · You can only edit content you created.
### Admin: read-only settings (Available now)
Trigger: An admin without Edit general settings. Fields locked, Save off.
Copy: You can read these settings but not change them · Ask the owner to change them, or ask for a role that can edit settings. · Save changes (disabled)
### Admin: owner-only settings (Available now)
Trigger: Software and Retention show an Owner only tag. The action stays visible and disabled.
Copy: Owner only · Apply update (disabled) · Only the owner can apply updates. · Keep activity for · Admins can read the log. Only the owner can change how long it is kept.
## 7 No access: 403 copy
Route: Any page · API: 403 missing_permission, 403 role_locked · Auth: Any
A direct link to a page the person can't use shows the page-level state with a link to a page they can use. A blocked action shows a toast.
### Page: no access (Available now)
Trigger: Contributor opens /settings/roles by link.
Copy: You don't have access to this page · Your role, Contributor, doesn't include it. Ask an owner or admin if you need it. · Go to Articles · See what I can do
### Action: missing_permission (Available now)
Trigger: An action was refused with 403 missing_permission.
Copy: You don't have permission to do that · Your role doesn't include Delete entries. Ask an owner or admin.
### Action: role_locked (Available now)
Trigger: An attempt to edit or delete the Owner role.
Copy: This role is locked · The Owner role can't be changed or deleted. Transfer ownership from Users instead.
## 8 Activity: role events
Route: /activity · API: GET /v1/activity · Auth: Activity: own or all
New event types. The detail panel summarises a role diff as Added and Removed lines using the human permission names.
### List with role events (Available now)
Trigger: Six role events. Event names: role.create, role.edit, user.change_role, user.transfer_ownership, role.reassign, role.delete.
Copy: Role Contributor created · Role Contributor edited · Ines Duarte's role changed from Writer to Content lead · Ownership transferred from Marta Alves to Jonas Weber · Moved 3 members from Intern to Writer · Role Intern deleted
### Detail: role diff (Available now)
Trigger: Open "Role Contributor edited".
Copy: Activity details · What changed · Added · Delete entries (Own) · Removed · Publish · 4 members have this role.
### Failed role events (Available now)
Trigger: The same six events with outcome Failed. The sentence starts with "Failed to" and the Failed badge follows it.
Copy: Failed to create the role Contributor · Failed to edit the role Contributor · Failed to change the role of Ines Duarte · Failed to transfer ownership to Jonas Weber · Failed to move 3 members from Intern to Writer · Failed to delete the role Intern · Failed
### Failed event: details (Available now)
Trigger: Open "Failed to edit the role Contributor". The reason comes from the API.
Copy: Activity details · Why it failed · Jonas Weber saved changes to Contributor first, so this edit was refused. · The role was not changed.
### Category filter (Available now)
Trigger: The Category select open. Roles is a new category; Users & invites is renamed Users and invites.
Copy: All categories · Articles · Media · Users and invites · Roles · API keys · Authentication · System
## 9 Profile: your role
Route: /profile · API: GET /v1/auth/me · Auth: Any
The person sees their own role and a read-only summary.
### Role card: custom role (Available now)
Trigger: Any signed-in user with a custom role. The card shows only what access.role carries (id, name, built_in, locked): the name as a role badge and a Custom tag. The one-line summary and Assigned by were removed.
Copy: Your role · Contributor · Custom · See what I can do · Your API keys follow this role. If your role changes, they change too.
### Role card: Owner (Available now)
Trigger: The owner. Same card; the Owner badge is followed by Locked (locked is true) and Built-in.
Copy: Your role · Owner · Locked · Built-in · See what I can do · Your API keys follow this role. If your role changes, they change too.
### See what I can do (Available now)
Trigger: Opens the summary. Dialog on desktop, sheet on mobile.
Copy: What you can do · A summary of your role, Contributor. Read-only. · Close
## 10 API keys: inherited role
Route: /settings/api-keys · API: GET /v1/api-key · Auth: Settings: API keys
Keys follow their creator's role live. The fact is stated in an info alert and each row shows the creator's role.
### Keys follow the role (Available now)
Trigger: Always shown on the API keys screen.
Copy: Keys follow their creator's role · A key can do what its creator's role can do, today. If the person's role changes, their keys change with it. · Follows role
## 11 Flow: create "Contributor" in under a minute
Route: /settings/roles, /settings/roles/new · API: POST /v1/role · Auth: Roles: manage
Six steps, about 40 seconds: 1 New role. 2 Name and description. 3 Choose the preset Write own. 4 Check the preview and the Schema and Media defaults. 5 Save. 6 Done. A new role starts with nothing granted; presets fill the grid in one tap.
### 1 Roles list: New role (Available now)
Trigger: Click New role.
Copy: New role
### 2 Name it (Available now)
Trigger: Type a name and a one-line description.
Copy: Name Contributor · Description Writes posts and edits only their own
### 3 Tap the preset: Write own (Available now)
Trigger: The preset sets Read: All, Create: On, Update: Own, Delete and Publish: None for every type.
Copy: Write own · 7 unsaved changes
### 4 Check General permissions (Available now)
Trigger: Optional. Schema view and Media view and upload are on by default for the preset. Check the preview.
Copy: What this role can do · Can read any entry, create entries and edit only their own. Can't delete and publish.
### 5 Save (Available now)
Trigger: Save changes. The role appears in the list and in the invite picker.
Copy: Save changes
### 6 Done (Available now)
Trigger: Toast, and the bar closes.
Copy: Role created · Contributor is ready. Assign it from Users.
## 12 Toasts: role changed and stale state
Route: Any page · API: PUT /v1/user/{id}/role, PUT /v1/role/{id}, DELETE /v1/user/{id}, POST /v1/site/transfer-ownership · Auth: As the action
Warning toasts say that the thing the person acted on changed first, that nothing was changed, and offer Reload. They stay until dismissed or Reload is pressed. Info toasts (404) close after 6 s. Success toasts close after 6 s. Final EN and FR copy is in the table "Final copy: toasts and errors, EN and FR".
### Role changed (Available now)
Trigger: 200 on PUT /v1/user/{id}/role. The row updates in place and a success toast confirms.
Copy: Role changed · Ines Duarte is now Content lead, and their API keys follow the new role.
### Role edited elsewhere (409) (Available now)
Trigger: 409 on an action from the roles list (Duplicate, Delete, Move members) because the role version changed after the list loaded. Inside the role editor the Conflict banner is used instead. Reload refetches GET /v1/role.
Copy: Role changed elsewhere · Jonas Weber edited Contributor, so nothing was saved. Reload and try again. · Reload
### Role deleted elsewhere (Available now)
Trigger: Any action on a role deleted by someone else. The row is removed from the list. A target role deleted during Move members or Change role shows an inline error instead.
Copy: That role no longer exists · It was deleted somewhere else, so we removed it from the list.
### Role not changed: member's role changed first (409) (Available now)
Trigger: Change role was confirmed after someone else had changed the same member. The dialog is closed and the list is stale. Reload refetches GET /v1/user/.
Copy: Role not changed · Ines Duarte's role changed in the meantime, so nothing was saved. Reload and try again. · Reload
### Ownership not transferred: target's role changed first (409) (Available now)
Trigger: Transfer ownership was confirmed after the target's role changed, and the dialog was closed in the meantime. Nothing was transferred.
Copy: Ownership not transferred · Jonas Weber's role changed in the meantime, so nothing was transferred. Check it and try again. · Reload
### User not removed: member's role changed first (409) (Available now)
Trigger: Remove user was confirmed after the member's role changed. Nothing was removed.
Copy: User not removed · Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again. · Reload
## 13 Inline errors: editor and dialogs
Route: Role editor, Change role, Delete role, Invite · API: POST/PUT /v1/role, PUT /v1/user/{id}/role, POST /v1/role/{id}/reassign, POST /v1/user/invite · Auth: As the action
Errors stay in the dialog or form that caused them, above the fields or under the field they belong to. The dialog stays open and keeps what the person entered. The primary button stays on so the same action can be tried again, except when the person has to choose again.
### Role editor: save failed (500) (Available now)
Trigger: 500 or network failure on POST or PUT /v1/role. Every edit is kept and Save stays on.
Copy: Couldn't save the role · Your changes are still here. Try again in a moment. · Try again
### Change role: failed (500) (Available now)
Trigger: 500 or network failure. The choice is kept.
Copy: Couldn't change the role · Nothing was changed. Try again in a moment.
### Delete role: target role deleted (Available now)
Trigger: 409 because the chosen target role was deleted meanwhile. The picker is cleared and the action is off until a role is chosen.
Copy: Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to.
### Delete role: role gained members (Available now)
Trigger: 409 role_has_members when a role that had no members got one after the dialog opened. The dialog turns into the blocked state.
Copy: Role still has members · Intern now has 1 member. Move them to another role first. · Move 1 member and delete role (disabled until a role is chosen)
### Delete role: move failed (500) (Available now)
Trigger: 500 or network failure on reassign. The chosen role is kept.
Copy: Couldn't move the members · Nothing was changed. Try again in a moment.
### Invite: invalid email (Available now)
Trigger: 400 or client check. Shown under Email.
Copy: Enter a valid email address.
### Invite: address already has an account (409) (Available now)
Trigger: The address belongs to a user. Shown under Email. Same text as the API.
Copy: This address already has an account.
### Invite: role above yours (403) (Available now)
Trigger: An admin picked a role with more power than their own. Shown under Role.
Copy: Your role can't assign a role with more power than yours.
### Invite: send failed (500) (Available now)
Trigger: 500 or network failure. Email and role are kept.
Copy: Couldn't send the invite · Try again in a moment.
## 14 Read = Own: what the person sees
Route: /articles, /content/:key, /settings/roles/:id · API: GET /v1/article/, GET /v1/content-type/{key}/entry · Auth: As the role
A role with Read set to Own sees online articles plus its own drafts. For custom content types it sees only entries it created. Counts, totals and pagination follow the same rule, so nothing reveals how many hidden items exist. This is different from Update or Delete set to Own, where other people's rows are still listed with a View only mark.
### Role editor: Read set to Own (Available now)
Trigger: Junior writer has Read: Own on every type. A note under the grid explains what Own means for Read. The preview panel uses type-specific sentences.
Copy: Read: Own · Articles show online articles plus the person's own drafts. Custom content types show only entries the person created. Status counts follow the same rule. · Can read online articles and their own drafts, create entries and edit only their own. · Can read only entries they created, create entries and edit only their own.
### Articles list: Read = Own (Available now)
Trigger: Pedro Lima, Junior writer. Three online articles by others and his two own drafts. Counts: All 5, Online 3, Draft 2. Drafts by others are not listed and not counted.
Copy: Articles · Online articles and the drafts you created. · Drafts by others are hidden · Your role reads online articles and the drafts you created. The counts follow the same rule. · All 5 · Online 3 · Draft 2 · New article
### Articles list: no drafts of their own (Available now)
Trigger: The Draft tab when the person owns nothing. Counts: All 3, Online 3, Draft 0.
Copy: You have no drafts · Drafts you create appear here. Your role can't see drafts by other people. · New article · All 3 · Online 3 · Draft 0
### Entry list: Read = Own (Available now)
Trigger: Events with Read: Own. Only entries the person created, no pager, count line shows only their own entries.
Copy: Events · Your role shows only entries you created. · Entries by others aren't shown · Your role reads only entries you created. Ask an owner or admin if you need to see more. · 3 entries · New entry
### Entry list: no entries of their own (Available now)
Trigger: The person has created none. Other people's entries exist but are not hinted at.
Copy: You haven't created an event yet · Your role shows only entries you created, so other people's events aren't listed here. Use New entry to add one. · New entry
## 15 Role 409 conflicts: stale save, change and transfer
Route: /settings/roles/:id, /users · API: PUT /v1/role/{id}, PUT /v1/user/{id}/role, POST /v1/site/transfer-ownership · Auth: As the action
Roles carry a version. A stale request returns 409 and nothing is changed. The message appears where the person is working (banner in the editor, alert inside a dialog), never as a toast while that surface is open. The form always keeps what the person entered. Recovery: in the editor, review the differences and either keep the edits on top of the saved role or use the saved role; in a dialog, reload the member and try again.
### Stale save: banner (Available now)
Trigger: 409 role_conflict on PUT (version mismatch). Banner above the form. Edits are kept. Save is off until the person reloads or reviews.
Copy: This role changed while you were editing · Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits. · Reload role · Review differences · 2 unsaved changes. Save is off until you reload
### Stale save: review differences (Available now)
Trigger: Review differences. A dialog lists only the settings where the saved role and the edits disagree. Keep my edits reloads the role and puts the edits back on top (Save turns on). Use the saved role drops the edits. Reload role in the banner is the same as Use the saved role.
Copy: How the saved role differs from your edits · Jonas Weber saved changes to Contributor at 11:02. · Setting · Saved · Yours · Events · Update · Own · All · Events · Delete · None · Own · Use the saved role · Keep my edits
### Stale save: edits kept after reload (Available now)
Trigger: After Keep my edits. The form shows the saved role with the edits on top, changed cells marked, Save on.
Copy: Role reloaded · Your 2 changes are still here. Review them, then save. · 2 unsaved changes
### Change role: member's role changed first (409) (Available now)
Trigger: Change role confirmed after someone else changed the same member. The dialog stays open, the current role now shows the new value, and the choice is kept. Change role tries again against the new version.
Copy: Role changed in the meantime · Ines Duarte is now Admin. Your choice is kept. Check it and try again. · Change role
### Transfer ownership: target's role changed first (409) (Available now)
Trigger: Transfer confirmed after the target's role changed. The typed email is kept, the primary action is off until Reload member refreshes the target.
Copy: Role changed in the meantime · Jonas Weber is now Writer. What you typed is kept. Check the change, then try again. · Reload member · Transfer ownership (disabled until reloaded)
## Tables
The board lists, and this file repeats, the tables below.
### Permission catalogue (names shown to people)
| Group | Items (human name) | Scope |
|---|---|---|
| Content, per type | Read entries · Create entries · Update entries · Delete entries · Publish entries | None / Own / All, except Create (on or off) |
| Schema | View content types · Create and edit content types · Delete content types | On or off |
| Media | View the media library · Upload files · Delete files | Delete: None / Own / All |
| Authors | View authors · Create authors · Edit authors · Delete authors | Edit: None / Own / All |
| Activity | See activity · Export activity | See: None / Own / All |
| Users and invites | View users · Invite users · Change a user's role · Remove users | On or off |
| Settings | View settings · Edit general settings · Manage sites · See API keys · Create API keys · Revoke API keys · Delete a site (owner only) | API keys: None / Own / All |
| Roles | View roles · Manage roles · Edit roles with owner-only power (owner only) | On or off |
| System (owner only) | Apply software updates · Edit activity retention · Transfer ownership | Locked off for everyone but the owner |
### Built-in roles
| Role | What it has | Editable by |
|---|---|---|
| Owner | Everything. One per site. Ownership can be transferred; the former owner becomes Admin. | No one (locked) |
| Admin | Everything except the owner-only list. Can see all settings, read-only where they can't edit. | Owner |
| Writer | Reads schema and all content; creates and edits all content. No delete, no publish; no schema, settings, sites, users or integrations. Authors: view, edit own (assumed). | Owner (duplicate for variants) |
### No-escalation feedback
| Rule | Where it shows | Copy |
|---|---|---|
| Grant only what you hold | Disabled segment or switch with a lock; popover on focus or hover | You can only grant what your role has. Your role can publish only entries you created, so All isn't available. |
| No role above yours | Disabled option in the role picker (Change role, Delete reassign, Invite) | Your role can't assign a role with more power than yours. |
| No editing your own role | Editor opens read-only with a banner | You can't change the permissions of your own role. Ask another owner or admin. |
| No delete while it has members | Delete dialog, blocked state | 4 members have this role. Move them to another role first. Then Contributor can be deleted. |
| Owner-only list | Disabled item with an Owner only tag and a lock | Only the owner can apply updates. |
### Hidden or disabled
| Case | Treatment | Example |
|---|---|---|
| Whole area the role never has | Hidden (no sidebar item, no page card). Direct link shows the 403 page. | Schema for a Writer |
| Action on something visible, blocked by role | Disabled with a reason in a tooltip or row text | Delete on an article for a Writer |
| Action that only exists above their level | Hidden | Publish for a Writer; status shows Draft and "Ask an editor to publish." |
| Row the person can read but not edit (Own) | View only mark on the row; Edit and Delete disabled in the menu | You can only edit content you created. |
| Settings page they can read | Read-only: locked fields, banner, Save off | Admin on General without Edit general settings |
| Owner-only action visible to an admin | Disabled, Owner only tag | Apply update |
| Item the role may not read (Read = Own) | Not listed and not counted. No View only mark, no hint that it exists. | Another person's draft article, or an entry created by someone else |
### HTTP status to copy (additions)
| Code | Status | Title | Body |
|---|---|---|---|
| missing_permission | 403 | You don't have permission to do that | Your role doesn't include {permission name}. Ask an owner or admin. |
| role_locked | 403 | This role is locked | The Owner role can't be changed or deleted. Transfer ownership from Users instead. |
| role_has_members | 409 | Role still has members | {n} members have this role. Move them to another role first. |
| role_name_taken | 409 | Name already used | A role named "{name}" already exists. Choose a different name. |
| role_conflict | 409 | This role changed while you were editing | {name} saved changes to {role} at {time}. Reload to see them, or review how they differ from your edits. |
| role_conflict (change role) | 409 | Role not changed | Dialog alert: {user} is now {role}. Your choice is kept. Check it and try again. Toast if the dialog was closed. |
| role_conflict (remove, transfer) | 409 | User not removed / Ownership not transferred | {user}'s role changed in the meantime, so nothing was removed (transferred). Check it and try again. |
| role_not_found | 404 | That role no longer exists | It was deleted somewhere else, so we removed it from the list. |
| escalation | 403 | You can't grant that | You can only grant what your role has. |
| (page level) | 403 | You don't have access to this page | Your role, {role}, doesn't include it. Ask an owner or admin if you need it. |
### Components (new) and Nuxt UI v4 mapping
| Component | Used for | Nuxt UI mapping |
|---|---|---|
| ScopeControl (tri-state) | None / Own / All in grid cells, mobile cards and general rows | URadioGroup variant="table" orientation="horizontal" size="xs", three items, in a grid cell. Selected item: sand fill, weight 600 and an underline |
| OnOffCell | Create column and boolean rows | USwitch with visible On/Off label |
| PermissionGrid | Content permissions | Custom grid (role="grid") of ScopeControl cells; header buttons use UDropdownMenu for bulk actions |
| PresetPills | Read only, Write own, Full content | UButton variant="outline" size="xs", pressed state via aria-pressed |
| PermissionGroup | Collapsible group with "x of y enabled" | UCollapsible; header UButton variant="ghost" |
| OwnerOnlyTag | Locked owner-only items | UBadge variant="subtle" with lucide lock icon |
| RoleBadge | Users, roles, API keys | UBadge variant="outline" with role icon (crown, shield, pencil, user-round) |
| RolePreview | What this role can do | Card on desktop (UCard), USlideover side="bottom" on mobile |
| RolePicker | Change role, invite, reassign on delete | URadioGroup variant="card" with description; USelectMenu for the reassign field; items disabled with a reason in description |
| SaveBar | Unsaved changes | Sticky container with UButton color="neutral" variant="outline" and UButton color="primary" |
| ReadOnlyBanner | Read-only pages | UAlert color="info" icon="i-lucide-lock" variant="soft" |
| ViewOnlyMark | Own scope rows | Text with lucide lock icon and UTooltip |
### Accessibility: tri-state cell
| Item | Pattern |
|---|---|
| Structure | The grid is role="grid". Each cell contains a role="radiogroup" named "{Type}: {Action}" (for example "Events: Update") with three role="radio" items None, Own, All. Create is a role="switch" named "Events: Create". |
| Keyboard | Tab moves between cells (one stop per cell). Inside a group, Left and Right (or Up and Down) move and select, Home and End jump to None and All. Arrow keys also move the roving tabindex. Space selects. Header bulk buttons open a menu with the same three values. |
| Announcement | Selected item is aria-checked="true". A polite live region announces changes: "Events, Update, set to Own." Disabled items are aria-disabled and described by the reason ("You can only grant what your role has."). |
| Not by color alone | Selected segment: sand fill plus weight 600 plus a 2 px underline in --ui-primary. Locked: lock icon. Changed cell: square marker. Mixed: text badge. |
| Targets | 26 px segments on desktop, 44 px on mobile. Focus: 2 px --ui-primary outline, offset 2 px. |
### Needs backend
| Item | Detail |
|---|---|
| Role storage | Table role (id, name, description, built_in, locked, version) and role_permission (role_id, key, scope none|own|all). One role_id on user. Built-ins seeded and non-deletable. |
| Endpoints | GET/POST /v1/role · GET/PUT/DELETE /v1/role/{id} · POST /v1/role/{id}/reassign · PUT /v1/user/{id}/role · POST /v1/site/transfer-ownership · GET /v1/me (role and permissions) · GET /v1/permission (catalogue) |
| created_by | Add created_by (user id) to articles and entries so Own scope can be checked on read, update, delete and publish. Backfill from existing author or activity data. |
| Enforcement | Server checks every call against role permissions and the no-escalation rules. API keys resolve the creator's current role on each request. |
| Version field | role.version for the conflict state (409 role_conflict). The same 409 covers a change of role or a removal when the member's role changed first, and an ownership transfer when the target's role changed first. |
| Activity events | role.create, role.edit (with a diff of added and removed permissions), role.delete, role.reassign, user.change_role, user.transfer_ownership. Present tense, as the backend emits them. The Failed outcome is a field on the event, not a separate name. |
### Existing screens that change
| Screen | Change |
|---|---|
| Sidebar and mobile drawer (AppFrame) | Items are listed per permission, not per fixed role. Writer sees Home, Articles, Media, Authors, Activity and Content. A Roles entry appears under Settings for people who can view roles. |
| Settings navigation | New "Roles" item. "Permissions" is removed. |
| /settings/permissions | Retired. 301 to /settings/roles. The grid lives in the role editor. |
| Users (list, invite, row menu) | Role badges, Change role dialog, role picker in invite (default Writer), pending invites show the role, Transfer ownership, protected owner row. |
| Articles list and Editor | Publish hidden for roles without it; status shows Draft with "Ask an editor to publish." Delete and Edit disabled with a reason when scope is Own and the item is someone else's. |
| Content entry lists and editor | Same Own rules. View only mark. |
| Settings pages | Read-only mode with banner for people who can view but not edit. Owner-only actions disabled with an Owner only tag. |
| Schema | Hidden for roles without Schema view. Read-only for view-only roles. |
| API keys | Info alert "Keys follow their creator's role", a Follows role column. |
| Profile | Your role card and See what I can do. |
| Activity and filters | New event types, role diff in the detail panel. |
| Error and 403 copy | Two codes and the page-level state added. |
### Activity events: sentences, EN and FR
| Event name | EN | FR |
|---|---|---|
| role.create | Role Contributor created | Rôle Contributor créé |
| role.edit | Role Contributor edited | Rôle Contributor modifié |
| role.delete | Role Intern deleted | Rôle Intern supprimé |
| role.reassign | Moved 3 members from Intern to Writer (1 member: Moved 1 member from Intern to Writer) | 3 membres déplacés de Intern vers Writer (1 membre : 1 membre déplacé de Intern vers Writer) |
| user.change_role | Ines Duarte's role changed from Writer to Content lead | Le rôle d'Ines Duarte est passé de Writer à Content lead |
| user.transfer_ownership | Ownership transferred from Marta Alves to Jonas Weber | Propriété transférée de Marta Alves à Jonas Weber |
### Activity events: failed sentences, EN and FR
| Event name (outcome failed) | EN | FR |
|---|---|---|
| Template | Failed to {verb} {object} | Échec de {action} {objet} |
| role.create | Failed to create the role Contributor | Échec de la création du rôle Contributor |
| role.edit | Failed to edit the role Contributor | Échec de la modification du rôle Contributor |
| role.delete | Failed to delete the role Intern | Échec de la suppression du rôle Intern |
| role.reassign | Failed to move 3 members from Intern to Writer | Échec du déplacement de 3 membres de Intern vers Writer |
| user.change_role | Failed to change the role of Ines Duarte | Échec du changement de rôle d'Ines Duarte |
| user.transfer_ownership | Failed to transfer ownership to Jonas Weber | Échec du transfert de la propriété à Jonas Weber |
| user.invite | Failed to invite sofia@renewbuild.example | Échec de l'invitation de sofia@renewbuild.example |
| user.remove | Failed to remove Ines Duarte | Échec du retrait d'Ines Duarte |
| Reason: role_conflict | Jonas Weber saved changes to Contributor first, so this edit was refused. | Jonas Weber a enregistré des modifications de Contributor avant, donc cette modification a été refusée. |
| Reason: role_locked | The Owner role can't be changed or deleted. | Le rôle Owner ne peut être ni modifié ni supprimé. |
| Reason: missing_permission | The role of this person doesn't include that permission. | Le rôle de cette personne n'inclut pas cette autorisation. |
### Activity filter: category names, EN and FR
| Value | EN | FR |
|---|---|---|
| (none) | All categories | Toutes les catégories |
| articles | Articles | Articles |
| media | Media | Médias |
| users | Users and invites (was "Users & invites") | Utilisateurs et invitations |
| roles (new, to confirm) | Roles | Rôles |
| api_keys | API keys | Clés API |
| auth | Authentication | Authentification |
| system | System | Système |
### Final copy: toasts and errors, EN and FR
| State | EN | FR |
|---|---|---|
| Toast: role changed | Role changed · Ines Duarte is now Content lead, and their API keys follow the new role. | Rôle modifié · Ines Duarte a maintenant le rôle Content lead, et ses clés API suivent le nouveau rôle. |
| Toast: role edited elsewhere (409) | Role changed elsewhere · Jonas Weber edited Contributor, so nothing was saved. Reload and try again. · Reload | Rôle modifié ailleurs · Jonas Weber a modifié Contributor, donc rien n'a été enregistré. Rechargez et réessayez. · Recharger |
| Toast: role deleted elsewhere | That role no longer exists · It was deleted somewhere else, so we removed it from the list. | Ce rôle n'existe plus · Il a été supprimé ailleurs, nous l'avons donc retiré de la liste. |
| Toast: member's role changed before a change of role (409) | Role not changed · Ines Duarte's role changed in the meantime, so nothing was saved. Reload and try again. · Reload | Rôle non modifié · Le rôle d'Ines Duarte a changé entre-temps, donc rien n'a été enregistré. Rechargez et réessayez. · Recharger |
| Toast: target's role changed before a transfer (409) | Ownership not transferred · Jonas Weber's role changed in the meantime, so nothing was transferred. Check it and try again. · Reload | Propriété non transférée · Le rôle de Jonas Weber a changé entre-temps, donc rien n'a été transféré. Vérifiez-le et réessayez. · Recharger |
| Toast: member's role changed before a removal (409) | User not removed · Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again. · Reload | Utilisateur non retiré · Le rôle d'Ines Duarte a changé entre-temps, donc rien n'a été retiré. Vérifiez-le et réessayez. · Recharger |
| Editor: name required | Give the role a name. | Donnez un nom au rôle. |
| Editor: name taken (409) | A role named "Writer" already exists. Choose a different name. | Un rôle nommé « Writer » existe déjà. Choisissez un autre nom. |
| Editor: save failed (500) | Couldn't save the role · Your changes are still here. Try again in a moment. · Try again | Impossible d'enregistrer le rôle · Vos modifications sont toujours là. Réessayez dans un instant. · Réessayer |
| Editor: stale save banner (409) | This role changed while you were editing · Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits. · Reload role · Review differences | Ce rôle a changé pendant que vous le modifiiez · Jonas Weber a enregistré des modifications de Contributor à 11:02. Rechargez pour les voir, ou comparez-les à vos modifications. · Recharger le rôle · Voir les différences |
| Editor: review differences | How the saved role differs from your edits · Setting · Saved · Yours · Use the saved role · Keep my edits | Comment le rôle enregistré diffère de vos modifications · Réglage · Enregistré · Vous · Utiliser le rôle enregistré · Garder mes modifications |
| Editor: edits kept after reload | Role reloaded · Your 2 changes are still here. Review them, then save. | Rôle rechargé · Vos 2 modifications sont toujours là. Relisez-les, puis enregistrez. |
| Editor: Read = Own note | Read: Own · Articles show online articles plus the person's own drafts. Custom content types show only entries the person created. Status counts follow the same rule. | Lecture : Les siennes · Les articles affichent les articles en ligne et les brouillons de la personne. Les types de contenu personnalisés n'affichent que les entrées créées par la personne. Les compteurs de statut suivent la même règle. |
| Change role: member's role changed (409) | Role changed in the meantime · Ines Duarte is now Admin. Your choice is kept. Check it and try again. | Rôle modifié entre-temps · Ines Duarte a maintenant le rôle Admin. Votre choix est conservé. Vérifiez-le et réessayez. |
| Change role: failed (500) | Couldn't change the role · Nothing was changed. Try again in a moment. | Impossible de modifier le rôle · Rien n'a été modifié. Réessayez dans un instant. |
| Change role: role above yours | Your role can't assign a role with more power than yours. | Votre rôle ne peut pas attribuer un rôle plus puissant que le vôtre. |
| Delete role: target deleted | Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to. | Writer n'existe plus · Il a été supprimé ailleurs. Choisissez un autre rôle pour y déplacer les membres. |
| Delete role: role gained members (409) | Role still has members · Intern now has 1 member. Move them to another role first. | Le rôle a encore des membres · Intern a maintenant 1 membre. Déplacez-le d'abord vers un autre rôle. |
| Delete role: move failed (500) | Couldn't move the members · Nothing was changed. Try again in a moment. | Impossible de déplacer les membres · Rien n'a été modifié. Réessayez dans un instant. |
| Invite: invalid email | Enter a valid email address. | Saisissez une adresse e-mail valide. |
| Invite: account exists (409) | This address already has an account. | Cette adresse a déjà un compte. |
| Invite: role above yours | Your role can't assign a role with more power than yours. | Votre rôle ne peut pas attribuer un rôle plus puissant que le vôtre. |
| Invite: send failed (500) | Couldn't send the invite · Try again in a moment. | Impossible d'envoyer l'invitation · Réessayez dans un instant. |
| Transfer: target's role changed (409) | Role changed in the meantime · Jonas Weber is now Writer. What you typed is kept. Check the change, then try again. · Reload member | Rôle modifié entre-temps · Jonas Weber a maintenant le rôle Writer. Ce que vous avez saisi est conservé. Vérifiez le changement, puis réessayez. · Recharger le membre |
### Read = Own: what each surface returns
| Surface | Read = All | Read = Own |
|---|---|---|
| Articles list | Every article, including drafts by others | Online articles plus the person's own drafts |
| Status counts (All, Online, Draft) | Counted over every article | Counted over the visible articles only: Draft counts own drafts, Online counts every online article |
| Custom type entry list | Every entry | Only entries the person created. The count line and pager use that number. |
| API list calls and X-Total-Count | Every item | Only the visible items. The total does not reveal hidden ones. |
| Direct link to a hidden item (proposed) | Opens | The existing 404 page: Entry not found. No hint that the item exists. |
| Empty state | Existing empty states | You have no drafts · You haven't created an event yet. New article or New entry stays available when Create is on. |
### Decisions to confirm
| # | Decision made | Alternative |
|---|---|---|
| 1 | Content permissions come before General permissions in the editor, because the common case (Contributor) only touches the grid. | General first, as in the brief. |
| 2 | Writer gets Authors: view and edit own, and Activity: own. | No Authors or Activity access. |
| 3 | A new role starts with nothing granted; presets fill the grid. | Start from a copy of Writer. |
| 4 | Update or Delete set to Own: rows others created show "View only" with a lock icon, not a hidden row. Read set to Own: those rows are not returned, so they are hidden and not counted. | Hide other people's entries for Update and Delete too. |
| 5 | Reset to default exists for built-in roles edited by the owner. | No reset. |
| 6 | Activity own/all scope uses the same None / Own / All control. | Toggle plus a separate scope select. |
| 7 | Transfer ownership takes effect at once, no email to the new owner. | The new owner must accept. |
| 8 | A user with Roles: manage cannot edit their own role, even in a custom role. | Allow narrowing only. |
State count: 90.
## Read = Own: singular noun
The singular noun in Read = Own copy ("entries you created", "event") is derived from the type name. Irregular names ("People", "Series") need a per-type singular field. Drawn as a Later item.
## 2b Role editor: app sync
Presets: Read only sets Read: All and everything else None for every type. Write own sets Read: All, Create: On, Update: Own, Delete and Publish: None. Full content sets Read, Update, Delete and Publish: All and Create: On. A preset overwrites every type row, including custom rows, and every changed cell counts as an unsaved change.
"N unsaved changes" counts cells in the content grid plus general toggles that differ from the loaded role. Name and description count as one each. Setting a cell back to its loaded value removes it from the count.
The footer bar is pinned to the bottom. The content grid scrolls sideways inside its card below 720 px. There is no leave guard on the delete dialog: it holds no unsaved edits.
| State | Trigger | Copy |
|---|---|---|
| No Schema: view | The editor's role lacks schema.view, so custom types can't be listed. | Custom content types aren't listed · Your role doesn't include Schema: view, so only Articles is shown. The permissions of custom types stay as they are. |
| Reset to default: confirm | Built-in role, owner only. | Reset Writer to default? · Writer goes back to its built-in permissions. 5 members and the API keys they created are affected as soon as you save. This can't be undone. · Cancel · Reset to default (proposal) |
| Reset to default: done | | Role reset · Writer is back to its defaults. Save to apply it to 5 members. (proposal) |
| Review differences: name and description | Name and description appear as rows above the permission rows. | Name · Contributor · Event writer · Description · Writes posts · Writes events |
| Duplicate | /settings/roles/new?from=<id>. Name prefilled "<role> copy"; nothing saved until Save changes. | Copied from Contributor · The permissions are the same. Give the copy a name that fits, then save. (proposal) |
| role_not_found page | /settings/roles/:id for a deleted role. | That role no longer exists · It was deleted somewhere else, so we removed it from the list. · Back to Roles |
| invalid_role_name (400) | Server refuses a blank name. | Give the role a name. |
| Generic 400 | Any other 400 on save. | Couldn't save the role · Something in the form isn't valid. Check the highlighted fields. |
| Role created | 201 on create. | Role created · Contributor is ready. Assign it from Users. |
| Built-in role delete | Reached by link. | Delete Writer? · Built-in roles can't be deleted. |
| Change role: choice is now current | After the re-read the chosen role equals the member's current role. | Change role is disabled. |
### Preview sentence templates (beyond the three examples)
| Setting | Sentence |
|---|---|
| Read: None | No access to entries. |
| Read: Own | Can read only entries they created. |
| Read: All | Can read any entry. |
| Create: On | Can create entries. |
| Update: Own / All | Can edit only their own / any entry. |
| Delete: Own / All | Can delete only their own / any entry. |
| Publish: Own / All | Can publish only their own / any entry. |
| Mixed rows | Listed per type, after "All content types". |
Sentences join with commas and "and"; types that differ from the wildcard get their own line.
+44
View File
@@ -0,0 +1,44 @@
# Schema: later
All states on this page are **Later**: relations, permissions and schema export and import are not built. Drawn now so the model and the layout are settled.
Visual reference: `RenewCMS Screens Schema.dc.html`, groups "Relations", "Permissions", "Export and import". Frames: `SchemaFrame.dc.html` mounted in `AppFrame.dc.html` (shell, sidebar, topbar, drawer). Every state is drawn in light and dark (theme switch) at 1440×900 (shown at 50%) and 390×844 (shown at 75%). Copy is exact. Strings in a Copy cell are separated by " · " and listed in reading order. Nothing is clock-sensitive.
### 7 Relation field and picker (group "7 Relations", 5 states)
The picker exists in the app, so this group is no longer under "Later" in the build order; the route and the permission row stay as written.
Route: `/schema/:key, /content/:key/:id`. API: not built. Auth: Owner or admin (picker), any verified user (form).
All later. The model reads as two sentences, with a live preview of both sides. Three shapes: one to one, one to many, many to many.
| # | State | Trigger | Copy | Scope |
|---|---|---|---|---|
| 1 | Relation in the builder | A seventh type, Relation, in the type gallery. | Relation · Related content type · How are they connected? · One to one · Each Event has one Venue, and each Venue has one Event. · One to many · Each Event has one Venue. A Venue has many Events. · Many to many · Each Event has many Venues, and each Venue has many Events. · Each Event has one Venue · Each Venue has many Events · shown on Venue as the field "Events" | Later |
| 2 | Relation in the entry form | Search and pick; chips for many. The picker loads 25 results per page (sample: 25 of 62 venues for "rue"); the list scrolls and ends with a footer. | Venue · Speakers · Search to add · An event can have many speakers. · Showing 25 of 62 · Load more | Later |
| 3 | Relation picker: loading more | "Load more" pressed. Skeleton rows follow the last loaded result, the button shows a loader and is disabled, the footer count stays. Focus stays on the button, then moves to the first new row. | Showing 25 of 62 · Loading… · (skeleton rows) | Later |
| 4 | Relation in the list | One chip, two chips and +N, or a dash. | One chip for a single relation. Up to two chips and a "+N" for many. Empty shows a dash. | Later |
### 8 Permissions grid (group "8 Permissions", 2 states)
Route: `/settings/permissions`, retired: the grid now lives in the role editor (`/settings/roles/:id`), see `roles.md`. Kept below as the original draft.
All later. Rows are types, columns are actions, one role at a time. Built-in rows are locked. Mobile collapses to one card per type with four checkboxes.
| # | State | Trigger | Copy | Scope |
|---|---|---|---|---|
| 1 | Default: today's rules | Role User selected. Everything on, as today. | Permissions · What each role can do with each content type. · Today's rules are the starting point · Owners and admins edit the schema. Any verified user can read, add, edit and delete entries. · Owner · Admin · User · Read · Create · Update · Delete · Built-in types. RenewCMS sets these rules. · Schema (create, edit and delete content types): owners and admins only. Set by RenewCMS. · No changes · Discard · Save permissions | Later |
| 2 | Changed, not saved | Two types restricted for the User role. | 4 changes not saved · Changed | Later |
### 9 Schema export and import (group "9 Export and import", 3 states)
Route: `/schema (dialogs)`. API: not built. Auth: permissions schema.view (open and read), schema.edit (create and change), schema.delete (delete a type).
All later. Dev-to-production flow: export a file, import it elsewhere, preview, same compatibility review as group 3, explicit confirm.
| # | State | Trigger | Copy | Scope |
|---|---|---|---|---|
| 1 | Export | Export on /schema. | Export schema · Download the fields of your content types as a file. Entries aren't included. · All content types · 3 content types · One content type · Choose which one · File name · renewcms-schema.json · Import this file on another RenewCMS site to create the same content types. · Cancel · Download file | Later |
| 2 | Import preview | A file was chosen. | Import schema · renewcms-schema.json · 3 content types · Create 1, change 1, no change 1 · Recipes · New content type with 4 fields. · Events · Add 2 fields, remove Description. Events has 37 entries on this site. · Removes data · Press mentions · Same as on this site. · Existing entries are checked against the changes when you import. If any entry doesn't fit, nothing is imported. · Cancel · Import schema | Later |
| 3 | Import refused | The server answers schema_incompatible. | Nothing was imported · Some entries on this site don't fit the imported fields. RenewCMS can't tell which entries. The flagged change can cause this. · Try again | Later |
| 4 | Relation picker: load more failed | Load more failed. Loaded rows stay, an inline error replaces the count, the button reads Try again. Focus stays on the button, then moves to the first new row. | Couldn't load more results. The 25 shown are still here. · Try again | Available now |
+90
View File
@@ -0,0 +1,90 @@
# Schema
Route: `/schema`, `/schema/new`, `/schema/:key`. API: GET/POST /v1/content-type · GET/PUT/DELETE /v1/content-type/{key}. Auth: permissions schema.view (open and read), schema.edit (create and change), schema.delete (delete a type).
Custom content types: an owner or admin defines a type (for example Events) made of 1 to 50 ordered fields; any verified user then writes entries. Everything about a type happens on one screen. Primary words: content type, field, choice list, entry. "Key" is kept for the API identifier. The key of a type is chosen at creation and never changes; the key and type of a saved field never change. Built-in types (Articles, Authors, Sites, Media, Users) are listed with a lock and a "Built-in" badge and open a read-only view. Non-owner, non-admin users see no Schema entry; a direct link shows a 403 page.
Visual reference: `RenewCMS Screens Schema.dc.html`, groups "Schema list", "Schema builder", "Review and delete", "Field types". Frames: `SchemaFrame.dc.html` mounted in `AppFrame.dc.html` (shell, sidebar, topbar, drawer). Every state is drawn in light and dark (theme switch) at 1440×900 (shown at 50%) and 390×844 (shown at 75%). Copy is exact. Strings in a Copy cell are separated by " · " and listed in reading order. Nothing is clock-sensitive.
### 1 Schema: list of types (group "1 Schema list", 6 states)
Route: `/schema, /schema/:key (built-in)`. API: GET /v1/content-type. Auth: permissions schema.view (open and read), schema.edit (create and change), schema.delete (delete a type).
Sidebar entry "Schema" for owners and admins only. Built-in types are listed with a lock and a Built-in badge, always after the custom ones. They open a read-only view. Sample data: the field counts of built-ins are illustrative.
| # | State | Trigger | Copy | Scope |
|---|---|---|---|---|
| 1 | Populated | An owner or admin opens /schema. | Schema · Content types and the fields they are made of. · New content type · Name · Key · Fields · Last updated · Built-in types are managed by RenewCMS. You can read their fields, but not change them. · Built-in · 3 content types · 5 built-in | Available now |
| 2 | Loading | First load or retry. Skeleton rows appear after 150 ms. New content type is disabled. | (skeleton) · New content type (disabled) | Available now |
| 3 | Empty: no custom type yet | GET /v1/content-type returns []. Built-ins are still listed below. | No content types yet · Create a content type to manage your own kind of content, like events or recipes. The built-in types stay as they are. · New content type | Available now |
| 4 | Error | 500 or network failure on the list request. | Couldn't load content types · GET /v1/content-type failed. Check your connection and try again. · Try again | Available now |
| 5 | Forbidden (403) | A user who is not owner or admin types /schema. There is no sidebar entry, so the page explains instead of redirecting. | You don't have access to Schema · Only owners and admins can change content types. You can still add and edit entries from the Content menu. · Go to Home | Available now |
| 6 | Built-in type: read-only view | Click Articles in the list. No edit, no delete, no add field. | Articles · Built-in · Managed by RenewCMS · Articles is a built-in type. You can read its fields here, but you can't edit it, delete it or add fields. · Label · Key · Type · Rules · Entries of built-in types are managed from their own screens, like Articles and Authors. | Available now |
### 2 Schema builder (group "2 Schema builder", 16 states)
Route: `/schema/new, /schema/:key`. API: POST /v1/content-type, GET/PUT /v1/content-type/{key}. Auth: permissions schema.view (open and read), schema.edit (create and change), schema.delete (delete a type).
One screen, three regions: type name and key on top, ordered field list on the left, field inspector on the right, collapsible API preview at the bottom. Mobile: the list is the page, the inspector is a 92% sheet, the API preview is a sheet. Field keys and types of saved fields are locked; fields added since the last save can still choose both.
| # | State | Trigger | Copy | Scope |
|---|---|---|---|---|
| 1 | New type | /schema/new. One starter field is added because a type needs at least one. | New content type · Name · For example, Events · Key · events · Used in URLs and the API. Lowercase letters, digits and hyphens. It can't change after you save. · Fields · 1 of 50 · Add field · Field: Title · Label · Key · The name used in the API. Starts with a letter. Lowercase letters, digits and underscores. · Type · Required · An entry can't be saved with this field empty. · Unique values · Default value · Used when an entry is created and this field is left out. · Shortest · Longest · Counted in characters. Entries show a counter. · Save · Discard | Available now |
| 2 | Editing an existing type | /schema/events with 37 entries. Key is locked, saved field keys and types are locked. | 37 entries use this content type. Changes are checked against them when you save. · Key (lock) · Used in the API, so it can't change. · Can't change after saving · Saved | Available now |
| 3 | Locked key: tooltip | Hover or focus the locked key. | The key is used in URLs and the API, so it can't change after the type is created. | Available now |
| 4 | Unsaved changes | Two fields added, Capacity changed, Description removed. Rows carry New and Changed badges; removal shows an undo row. | Unsaved changes · New · Changed · Description will be removed · Undo · Save · Discard | Available now |
| 5 | Field errors | Checked as the user types, and again on Save. The row is outlined and shows an alert icon. Save is disabled until all are fixed. | 3 problems to fix · Give the field a label. · Lowest value can't be above the highest value. · Choices can't be blank. · Two choices are the same. | Available now |
| 6 | Type-level errors | New type with no name, a one-letter key and no fields. | Give the content type a name. · Use 2 to 40 lowercase letters, digits or hyphens. · No fields yet · A content type needs at least one field. · 3 problems to fix | Available now |
| 7 | Field key error | A field added since the last save has an invalid key (hyphen). Checked as the user types. Field keys use underscores; type keys use hyphens. | Key · Start with a letter. Use lowercase letters, digits and underscores. · 1 problem to fix | Available now |
| 8 | Choice list editor | A Choice field is selected. Add, remove and reorder; blanks and duplicates are flagged per row. | Choices · 5 of 50 · Add a choice · Add · Add 1 to 50 choices. Each one needs a different name. Removing a choice that entries use will be refused when you save. · Choices can't be blank. · Two choices are the same. | Available now |
| 9 | Keyboard reorder | Focus the grip, press Space. Up and Down move, Space drops, Esc cancels. Move up, Move down and Remove buttons are on the selected row. | Moving Ends on. Position 2 of 7. Press Up or Down to move, Space to drop, Esc to cancel. (live region, polite) | Available now |
| 10 | API preview open | The panel at the bottom. Updates as fields change. Copy JSON copies the sample entry. | API preview · Where entries of this type live · A sample entry. Values come from each field's type and default. · Updates as you edit. Fields you add or remove here appear after you save. · Copy JSON · Copied | Available now |
| 11 | Saving | PUT in flight. Inputs dim, Save shows the loader, nothing can be dragged. | Saving… | Available now |
| 12 | Saved | 200. A toast confirms, the badges clear. | Saved at 11:02 · Content type saved · Events now has 8 fields. | Available now |
| 13 | Save failed | 500 or network failure. Nothing was sent as saved; the edits stay. | Save failed · Couldn't save the content type · Something went wrong on our side. Your changes are still here. Try again in a moment. · Try again | Available now |
| 14 | Key already taken | 409 content_type_key_taken on POST. | The key "events" is already used by another content type. Choose a different key. | Available now |
| 15 | Schema incompatible | 409 schema_incompatible on PUT. The review dialog (group 3) closes into this state. Changes of the kinds that can cause it are marked. | Nothing was changed · Some entries don't fit the new fields, so the whole change was refused. RenewCMS can't tell which entries. The changes that can cause this are marked. · Review changes · Possible cause | Available now |
| 16 | Offline | navigator.onLine is false. Edits are kept; Save is disabled. | You're offline. Changes will be saved when you reconnect. · Waiting for connection | Available now |
### 3 Schema change review and delete type (group "3 Review and delete", 7 states)
Route: `/schema/:key (dialogs)`. API: PUT /v1/content-type/{key}, DELETE /v1/content-type/{key}. Auth: permissions schema.view (open and read), schema.edit (create and change), schema.delete (delete a type).
The review opens on Save when the type already has entries (37 here). It is informational: the server decides compatibility. Destructive changes (removing a field or a choice, tightening a limit, making a field unique or required) carry a warning flag. Per-change entry counts and a full diff are later.
| # | State | Trigger | Copy | Scope |
|---|---|---|---|---|
| 1 | Review before saving | Save on a type that has entries. | Review changes to Events · Apply them to save the content type. · Add 2 fields, change 1, remove 1 · Events has 37 entries. Each one is checked against the new fields when you apply. · Add Speaker · Text, optional. · Add Room · Choice, required, default "Main hall". Existing entries get the default, so they still fit. · Change Capacity · Highest value goes from 5000 to 2000. · Entries may not fit · Remove Description · Entries stop returning its values. · Removes data · Flagged changes can be refused if existing entries don't fit. If that happens, nothing is changed. · Back to editing · Apply changes | Available now |
| 2 | Applying | PUT in flight. Dialog cannot be dismissed. | Applying… | Available now |
| 3 | Refused (schema_incompatible) | 409 schema_incompatible. States that nothing changed and names the kind of change that likely caused it, never an entry. | Nothing was changed · RenewCMS refused the changes because some entries don't fit the new fields. It doesn't say which entries. · Your content type is as it was · No field was added, changed or removed. · The change that can cause this in your edit · Possible cause · This kind of change is refused when an entry is over the new limit, a new required field has no default, a field becomes unique while entries share a value, or a removed choice is still in use. · Discard changes · Back to editing | Available now |
| 4 | Review with counts and diff | Per-change entry counts and a full diff need an API that does not exist yet. | Affects 0 entries · Affects 37 entries · Affects 3 entries · Affects 31 entries · View full diff · Per-change counts and diff come later | Later |
| 5 | Delete type: blocked | Delete content type on a type with entries. No confirm button. | You can't delete Events yet · A content type can only be deleted when it has no entries. · Events has 37 entries · Delete all of its entries first, then delete the content type. · Close · View entries | Available now |
| 6 | Delete type: confirm | Delete content type on an empty type. | Delete Recipes? · Recipes has no entries. Its fields and its API routes will be deleted. This can't be undone. · Key: recipes · Cancel · Delete content type | Available now |
| 7 | Delete type: server fallback (409) | 409 content_type_has_entries. An entry was added after the page loaded. | Recipes now has entries · Someone added an entry after this page loaded, so nothing was deleted. Delete the entries first, then try again. · Close · View entries | Available now |
### 4 Field-type gallery (group "4 Field types", 6 states)
Route: `reference board`. API: none. Auth: n/a.
One frame per type: inspector options, the entry form control (default and open states), the list cell with its empty state, the error message for each rule, and the filter widget. Filters are later: filtering on custom fields is not in the API. Mobile drops the inspector column (the builder frames show it as a sheet).
| # | State | Trigger | Copy | Scope |
|---|---|---|---|---|
| 1 | Text | type: "text" | Text · Short text, like a title or a name · Shortest · Longest · characters · 74 / 80 · Fill in Title. · Title must be text. · Title needs at least 3 characters. · Title can be up to 80 characters. · Another entry already uses this Title. Enter a different one. | Available now |
| 2 | Rich text | type: "richtext" | Rich text · Long text with formatting, written in Markdown · Write · Preview · Markdown. Switch to Preview to check the result. · 86 / 5000 · Not available for rich text. (Unique) | Available now |
| 3 | Number | type: "number" | Number · Whole or decimal numbers · Lowest value · Highest value · Enter a number · Between 1 and 5000. · Description must be a number. · Capacity must be at least 1. · Capacity must be at most 5000. | Available now |
| 4 | Yes or no | type: "boolean" | Yes or no · An on or off switch · On by default · Yes or no fields have no limits. · Not available for yes or no fields. (Unique) · Choose yes or no for Online. · Online must be yes or no. | Available now |
| 5 | Date | type: "date" | Date · A calendar day, without a time · Earliest date · Latest date · Pick a date · Pick a date for Starts on. · Starts on must be a date, like 2026-11-21. · Starts on can't be before 1 Jan 2026. · Starts on can't be after 31 Dec 2027. | Available now |
| 6 | Choice | type: "select" | Choice · One option from a list you define · Choices · Choose an option · Choose one of: Talk, Workshop or Meetup. · Another entry already uses this choice for Category. | Available now |
## Media field (pass 5)
Spec, states, exact copy, measurements, backend needs and accessibility: `media-field-changes.md`, section 3.1 to 3.4. Board groups: 1 Schema list, 2 Schema builder, 3 Review, 4 Field types.
## Permissions (app sync)
Three keys gate Schema: schema.view (list and read types, and the Schema sidebar entry), schema.edit (create and change types and fields), schema.delete (delete a content type). The built-in Owner and Admin roles hold all three. The app still words the 403 as "Only owners and admins can…"; the design keeps that copy until the app changes. A role with schema.view only sees the builder read-only: Save, Add field, Move and Remove are hidden.
## Mobile field sheet
The field sheet carries Move up, Move down and Remove (44 px, equal widths) above Done. Move up is disabled on the first field, Move down on the last.
## Saving
Save always opens the review first, with or without entries. During Apply the dialog reads Applying… and the entries info bar is hidden.
+14 -10
View File
@@ -106,11 +106,11 @@ The page is one column of cards, top to bottom: Version, Updates, Media cleanup,
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Up to date | Opened with no newer version. Restart is available but not emphasised. | "You're up to date" / "You have the latest version of RenewCMS." / "Check for updates" / "Restart" |
| 1 | Up to date | Opened with no newer version. Restart is available but not emphasised. Clock-sensitive: shows a relative time or a countdown. | "You're up to date" / "You have the latest version of RenewCMS." / "Check for updates" / "Restart" |
| 2 | Checking | Check for updates pressed, or the first open after 24 hours. Announced as "Checking for updates". Buttons are disabled. | "Checking for updates…" / "This usually takes a few seconds." / "Checking…" |
| 3 | Check failed | Offline, or the update server didn't answer (500, network). The last known result is kept. | "Couldn't check for updates" / "RenewCMS couldn't reach the update server. Check your connection and try again. Your site isn't affected." / "Last checked yesterday at 18:12" / "Try again" / "Restart" |
| 4 | Update available | A newer version exists. Release notes are collapsed. Update is the one primary button. | "Version 2.3.0 is available" / "You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart." / "Update" / "Takes about 2 minutes. Your site stays online." |
| 5 | Update available, notes open | "What's new in 2.3.0" expanded. It is a button with aria-expanded, and the list is plain text. | "Version 2.3.0 is available" / "You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart." / "Update" / "Takes about 2 minutes. Your site stays online." |
| 3 | Check failed | Offline, or the update server didn't answer (500, network). The last known result is kept. Clock-sensitive: shows a relative time or a countdown. | "Couldn't check for updates" / "RenewCMS couldn't reach the update server. Check your connection and try again. Your site isn't affected." / "Last checked yesterday at 18:12" / "Try again" / "Restart" |
| 4 | Update available | A newer version exists. Release notes are collapsed. Update is the one primary button. Clock-sensitive: shows a relative time or a countdown. | "Version 2.3.0 is available" / "You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart." / "Update" / "Takes about 2 minutes. Your site stays online." |
| 5 | Update available, notes open | "What's new in 2.3.0" expanded. It is a button with aria-expanded, and the list is plain text. Clock-sensitive: shows a relative time or a countdown. | "Version 2.3.0 is available" / "You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart." / "Update" / "Takes about 2 minutes. Your site stays online." |
| 6 | Downloading | Update pressed (202). Progress polls every second. Announced as "Downloading, 25 percent" and so on, never every tick. | "Downloading version 2.3.0…" / "You can leave this page. The download continues and your site stays online." / "Downloading version 2.3.0" / "42% · 11 of 26 MB" / "Updating…" |
| 7 | Verifying | The download finished. No percentage, so the bar is indeterminate. Announced as "Checking the download". | "Checking the download…" / "Making sure the file is complete and safe to install. This takes a few seconds." / "Checking the download" / "Almost there" / "Updating…" |
| 8 | Ready to install | The file passed its checks. Restart becomes the primary action. "Restart later" is closing the dialog or leaving the page. | "Version 2.3.0 is ready to install" / "The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds." / "Nothing has changed yet." / "Restart" / "Not now? Leave this page. The update waits here, and the banner on Home reminds you." |
@@ -118,19 +118,19 @@ The page is one column of cards, top to bottom: Version, Updates, Media cleanup,
| 10 | Restart: request failed | 500 or network failure before the restart began. The site is still running. | "Version 2.3.0 is ready to install" / "The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds." / "Nothing has changed yet." / "Restart" / "Couldn't start the restart. Nothing was changed and your site is still running. Try again in a moment." |
| 11 | Restarting to install | Full-page overlay, so it survives the server going away. The page polls until the site answers, then reloads. It is a live region announced once as "Installing version 2.3.0. Your site is restarting." | "Version 2.3.0 is ready to install" / "Installing version 2.3.0" / "Your site is restarting with the new version. This takes about 10 seconds. This page reloads on its own when the site is back." / "Started at 09:51" |
| 12 | Restarting, taking longer | No answer after 60 seconds. The copy changes and a manual reload appears. The overlay stays until the site is back. | "Still restarting" / "This is taking longer than usual. Keep this page open. It reloads on its own when the site is back, or you can reload it now." / "Started at 09:51 · 1 minute ago" / "Reload page" |
| 13 | Update done | The page reloaded on the new version. Focus lands on the "Updates" heading (ring shown), the toast is announced, and the version updates. | "You're on version 2.3.0" / "The update is installed and your site is running again." / "Updated today at 09:52" / "Check for updates" / "Restart" / "Read the release notes" / "Update installed" / "RenewCMS is now on version 2.3.0." |
| 13 | Update done | The page reloaded on the new version. Focus lands on the "Updates" heading (ring shown), the toast is announced, and the version updates. Clock-sensitive: shows a relative time or a countdown. | "You're on version 2.3.0" / "The update is installed and your site is running again." / "Updated today at 09:52" / "Check for updates" / "Restart" / "Read the release notes" / "Update installed" / "RenewCMS is now on version 2.3.0." |
| 14 | Update failed, rolled back | The site came back on v2.2.0-beta. The error toast stays until dismissed and the card keeps the explanation. | "The update didn't install" / "RenewCMS put the previous version back, so your site is running as before. Nothing was lost." / "What to do next: try again in a few minutes. If it fails again, ask whoever manages your server to check the update." / "Try again" / "Your site is back on v2.2.0-beta." |
| 15 | Download failed | A 500, or the connection dropped. Focus moves to the card heading. Try again restarts the download. | "The download didn't finish" / "The connection dropped before the file was complete. Nothing was changed and your site is running as before." / "Try again" |
| 16 | Verification failed | The downloaded file isn't trusted. The tone is a warning, and the file is thrown away. Technical detail goes to the server log, not the screen. | "We didn't use this update" / "The file didn't pass our safety check, so it was not installed. Nothing was changed. Try downloading it again. If this keeps happening, contact RenewCMS support." / "Try again" |
| 17 | Too many requests (429) | Retry-After counted down in the button. It re-enables at zero. | "Too many tries" / "Wait a moment before trying again." / "Try again in 0:42" |
| 17 | Too many requests (429) | Retry-After counted down in the button. It re-enables at zero. Clock-sensitive: shows a relative time or a countdown. | "Too many tries" / "Wait a moment before trying again." / "Try again in 0:42" |
| 18 | Not prepared | The server can't update from the browser. No buttons, not even Restart. The command is for the person who runs the server and is a single line. The placeholder command is subject to the real one. | |
| 19 | Not prepared: command copied | Copy pressed. The button reads "Copied" for 2 s and it is announced. | |
| 20 | Plain restart: confirm | Restart with no update waiting. Nothing is installed. | "You're up to date" / "You have the latest version of RenewCMS." / "Check for updates" / "Restart" |
| 20 | Plain restart: confirm | Restart with no update waiting. Nothing is installed. Clock-sensitive: shows a relative time or a countdown. | "You're up to date" / "You have the latest version of RenewCMS." / "Check for updates" / "Restart" |
| 21 | Plain restart: restarting | Same overlay as the update restart with shorter copy. | "Restarting your site" / "This takes about 10 seconds. This page reloads on its own when the site is back." / "Started at 09:51" |
| 22 | Plain restart: done | The page reloaded. Focus lands on the "Updates" heading. | "Your site is back" / "Site restarted" / "Your site is running again." |
| 23 | Admin view | Admins see the version and the note, with no buttons. Up to date: "You're up to date" and "Only the owner can update or restart the site." No Home banner or Settings pill. | "Version 2.3.0 is available" / "You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site." |
| 24 | Role changed (403) | An owner was changed to admin while the page was open and pressed a button. The card reloads in the admin view. | "Version 2.3.0 is available" / "You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site." / "Only the owner can do this" / "Ask the owner of this site to update RenewCMS." |
| 25 | Update already running (409) | Another owner started it. The card switches to that run's progress. | "Version 2.3.0 is available" / "You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart." / "Update" / "An update is already running" / "Another owner started it. Progress shows here." |
| 23 | Admin view | Admins see the version and the note, with no buttons. Up to date: "You're up to date" and "Only the owner can update or restart the site." No Home banner or Settings pill. Clock-sensitive: shows a relative time or a countdown. | "Version 2.3.0 is available" / "You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site." |
| 24 | Role changed (403) | An owner was changed to admin while the page was open and pressed a button. The card reloads in the admin view. Clock-sensitive: shows a relative time or a countdown. | "Version 2.3.0 is available" / "You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site." / "Only the owner can do this" / "Ask the owner of this site to update RenewCMS." |
| 25 | Update already running (409) | Another owner started it. The card switches to that run's progress. Clock-sensitive: shows a relative time or a countdown. | "Version 2.3.0 is available" / "You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart." / "Update" / "An update is already running" / "Another owner started it. Progress shows here." |
### Settings: Media cleanup (group "Settings: Media cleanup", 9 states)
@@ -192,3 +192,7 @@ The last card on the page, same card styling as Version, Updates and Media clean
| 4 | Shortening the period | Confirms before older events are removed. | |
| 5 | Forever | No cleanup scheduled. | |
| 6 | Save error | The previous setting is still in place. | |
## Changed in pass 3: Publishing card and Sites child
Owner only. A "Publishing" card sits between the Media card and Media cleanup: title "Publishing", text "Where your content is shown.", one row "Manage sites" / "Base URLs, default image and robots policy" that opens `/settings/sites`. The sidebar Settings group gains a second heading "Publishing" with one child, "Sites". Existing Settings frames (owner view) show both. Admins and other roles see neither the card nor the sidebar entry (frame "Hidden entry (admin)" in group "Sites"), and `/settings/sites` shows a 403 page (frame "Forbidden (403)").
+109
View File
@@ -0,0 +1,109 @@
# Sites
Route: `/settings/sites`. API: GET /v1/site/ · POST /v1/site/ · PATCH /v1/site/{id} · DELETE /v1/site/{id}?reassign_to={id}.
A site is a frontend that displays content: name, key, base URL, default social image, default robots policy, optional locale and a default flag. Each article belongs to exactly one site. A default site always exists and cannot be deleted; the first is seeded once, at migration, from PUBLIC_URL (the CMS's own address, so only a starting value: the owner is expected to set the frontend's address). The base URL must use https, except for loopback hosts (localhost, 127.0.0.1, ::1), so a local frontend works. is_online stays the global publish switch.
**Roles.** Only the owner can create, edit, set the default or delete a site. Admins and other roles don't see the Sites entry under Settings, and the "Publishing" card is hidden for them. A direct link to `/settings/sites` shows a 403 page. Choosing a site for an article is not site management: anyone who can edit an article can set its site in the editor and read the site list (GET /v1/site/).
**Site key.** The key is set when the site is created and can't be edited afterwards. The edit form shows it read-only with a lock and the text "Used in API paths, so it can't change." There is no editable-key state and no key-change warning.
**Shared pieces.** The list, slide-over, delete-with-reassign dialog, slug field and media field are defined once in `DirectoryFrame.dc.html` and referenced by both Sites and Authors; nothing is drawn separately for either screen. `AppFrame.dc.html` holds the shell (sidebar, Settings card, editor section, Profile card).
Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Sites", "Sites: form", "Sites: delete". Every state is drawn in light and dark (theme switch) at 1440×900 and 390×844. Copy is exact. Strings in a Copy cell are separated by " · " and listed in reading order. Dialog and form submit states ("Saving…", "Moving articles…") use the button loading pattern from the controls matrix and are not drawn per screen.
### Sites (group "Sites", 9 states)
Only the owner can create, edit, set the default or delete a site. Other roles don't see the Sites entry under Settings; a direct link shows a 403 page. A site name can still appear read-only in the article editor and the Articles list. New child of Settings, under a "Publishing" heading in the sidebar (Settings also gets a "Publishing" card with a "Manage sites" row, owner only). Columns: name with key, base URL, article count. The default site carries a "Default" badge and has no delete action. A site with robots set to noindex shows a "Noindex" badge. List, row menu and dialogs come from DirectoryFrame and are shared with Authors.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Default | The list loaded. Four sites, Renew is the default. Owner only. | Sites · The frontends that display your content. · Add site · Site · Base URL · Articles · Default · Noindex · 268 articles · 38 articles · 6 articles · No articles |
| 2 | Only the default site | First run: the seeded site only. A hint under the list, no empty-state wall. | Add another site if your content is shown on more than one frontend. Each article belongs to one site. |
| 3 | Loading | The first load, or a retry. Skeleton rows appear after 150 ms. | |
| 4 | Error | A 500 or network failure on the list request. | Couldn't load sites · Check your connection and try again. · Try again |
| 5 | Row menu | The ellipsis button, or Enter on the focused menu button. Mobile: bottom sheet titled with the site name. | Edit · Set as default · Delete… |
| 6 | Default site: delete unavailable | Menu on the default site. "Delete…" is aria-disabled, stays focusable and explains itself: tooltip on desktop, text in the sheet on touch. | The default site can't be deleted. Make another site the default first. |
| 7 | Set as default: confirm | "Set as default" in the row menu. On 200 the badge moves and a toast confirms. The button reads "Saving…" while the request runs. | Make Journal the default site? · New articles start on this site. Articles that are already on other sites stay where they are. · Cancel · Make default · Default site changed · "Journal" is now the default site. |
| 8 | Hidden entry (admin) | An admin opens Settings. The sidebar has no Publishing group and no Sites entry, and the page has no Publishing card. Roles without Settings have nothing to hide. | No Publishing group · No Sites entry · No "Manage sites" row |
| 9 | Forbidden (403) | A role other than owner opens /settings/sites from a bookmark or a link. The sidebar has no Sites entry, so the page explains instead of redirecting. The API answers 403. | You don't have access to Sites · Only the owner can manage sites. Ask the owner of this site if you need a change. · Back to Settings |
### Sites: add and edit (group "Sites: form", 7 states)
Owner only. A slide-over, because the form has a live preview (the canonical URL). Fields: name, key, base URL, default social image (media picker), default robots policy, optional locale, default switch. The key is set when the site is added and can't be edited afterwards: the edit form shows it read-only with a lock and the text "Used in API paths, so it can't change." Closing with changes asks "Discard changes?" with the existing discard dialog. The slug field, media field and picker are the shared ones from DirectoryFrame (the picker is drawn once, in Authors: form). Locale error: "Use a language tag like en or fr-CH."
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Add: empty | "Add site" in the page header, or the bottom button on mobile. | Add site · Name · e.g. Journal · Key · Generated from the name · Base URL · https://blog.example.com · The public address of the frontend. Use https and no path. · Default social image (optional) · Choose image · Used when an article has no image of its own. · Default robots policy · Index · Noindex · Search engines may list this site's pages. · Locale (optional) · e.g. en or fr-CH · A language tag. Leave empty if the site has no single language. · Make this the default site · New articles start here. The current default is Renew. · Canonical URL preview · Cancel · Add site |
| 2 | Add: key from the name | Typing a name fills the key. The pencil unlocks it until the site is added. | journal · Auto · Lowercase letters, numbers and hyphens. Used in API paths. It can't be changed after the site is added. |
| 3 | Add: URL not valid | On blur or submit. Nothing is sent. A URL with a path and text that is not a URL get their own messages, drawn the same way. | Use https. For example https://blog.example.com. · Remove the path. Use only the host, like https://blog.example.com. · Enter a full URL, like https://blog.example.com. |
| 4 | Add: duplicate key | 409 on submit, or on blur after a check. | A site with the key "journal" already exists. Choose another key. · Use journal-2 |
| 5 | Add: save failed | 500 or network failure. The values stay. | Couldn't save the site. Your changes are still here. Try again in a moment. |
| 6 | Edit: default site | Edit on the default site. The key is read-only with a lock, because it is used in API paths. The switch is on and locked. | Edit Renew · Key · renew · Used in API paths, so it can't change. · Default site · New articles start on the default site. To change it, make another site the default. · Save changes |
| 7 | Edit: other site, noindex | Edit on a non-default site. | Edit Projects · Key · projects · Used in API paths, so it can't change. · Noindex · Asks search engines not to list this site's pages. · Make this the default site |
### Sites: delete (group "Sites: delete", 3 states)
Owner only. A site with articles cannot be deleted without a destination: the dialog has a select and shows how many articles move. Destination defaults to the default site. Without reassign_to the server answers 409. This is the shared ReassignDialog from DirectoryFrame; its failed state is drawn once, in Authors: delete.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | With articles | "Delete…" on a site that has articles. The button reads "Moving articles…" while the request runs. On 204 the row leaves the list and a toast reads "Site deleted" · "38 articles moved to Renew." | Delete this site? · "Journal" will be deleted. 38 articles belong to it, so choose a site to move them to. This can't be undone. · Move articles to · Renew · renew.example · 38 articles will move to Renew. Their canonical host changes to renew.example. · Cancel · Move 38 articles and delete |
| 2 | Choose destination | The select is open. The site being deleted is not listed. | Journal is not offered. Options show name and host. |
| 3 | No articles | A site with zero articles: plain confirm, no select. | Delete this site? · "Archive" will be deleted. No articles belong to it. This can't be undone. · Delete site |
### Sites: pagination (group "Sites: pagination", 10 states)
Route: `/settings/sites?page=2&size=50`. API: GET /v1/site/?q=&page=&size= (design assumption to confirm: `q`, `page`, `size` and `X-Total-Count` on the site list; today the list returns every site). Owner only, like the rest of Sites. Drawn with 61 sites (3 pages) and generated rows.
**URL.** `/settings/sites?page=2&size=50`. `page` and `size` are the parameter names (as Articles and Activity). `size` is one of 5, 25, 50, 100; default 25. Defaults are omitted from the URL (`page=1`, `size=25`). Combined with search: `/settings/sites?q=renew&page=2`. A search change or a size change resets `page` to 1 and drops it from the URL. An invalid `size` or `page` (not a positive integer) is replaced silently by the default with `history.replace`. A `page` past the last page falls back to the last page: the URL is replaced by `?page=<last>` and the info notice "That page doesn't exist. Showing the last page." is shown, as on Articles. Browser Back returns to the previous page of results.
**Pager.** The same component as Articles and Media, in the same footer position (below the list, summary on the left, page-size select next to it, right-aligned controls; mobile: summary and select on one row, then Previous, "Page X of Y", Next). Nothing new is drawn. Sizes are the sizes of the existing pattern: 5, 25, 50, 100 (default 25). API: total in `X-Total-Count`, array body unchanged. Keyboard and aria as Articles. The only difference from Articles: on a single page the page-size select is hidden too and the footer holds only the count line "N sites" (Articles keeps its summary and select). Existing frames of this screen that show a short list (the "Default" frames above) don't draw the count line yet; it appears whenever the list has loaded.
**Search on Sites.** The Sites screen has no search field today. States 8 and 9 need one, so the pagination frames add the Authors field with the placeholder "Search by name or key" (name, key and base URL are matched). Treat it as a proposal; if you prefer no search on Sites, drop states 8 and 9 and the `q` parameter.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | First page | /settings/sites. Previous and First are disabled (40% opacity, aria-disabled). Mobile: Previous disabled. | Showing 1–25 of 61 · 25 per page · Page 1 of 3 |
| 2 | Middle page | /settings/sites?page=2. Current page uses --rc-sand and --rc-on-sand with a --ui-primary border; ellipses mark skipped ranges once there are more than 7 pages. | Showing 26–50 of 61 · Page 2 of 3 |
| 3 | Last page | /settings/sites?page=3. Next and Last are disabled. | Showing 51–61 of 61 · Page 3 of 3 |
| 4 | Single page | Everything fits on one page (4 sites). The pager and the page-size select are hidden; the count line stays in the footer position. Zero results use state 8 instead. | 4 sites |
| 5 | Page size menu | The select opens upward. Sizes are the Articles sizes: 5, 25, 50, 100; default 25. The chosen size is saved as size and the page resets to 1. | 5 per page · 25 per page · 50 per page · 100 per page |
| 6 | Loading a page | Next, Previous, a page number or a size change was used. The rows are replaced by the existing skeleton rows (8 desktop, 6 mobile). The pager stays and is disabled (40% opacity, not focusable); the summary reads "Loading page 3…" and the page number shown is still 2. | Loading page 3… |
| 7 | Error loading a page | 500 or network failure on a page change. The rows of page 2 stay, the pager keeps "Page 2", and the inline error with Retry sits above the pager. Retry repeats the request for page 3. | Couldn't load page 3. Showing page 2. · Check your connection and try again. · Retry |
| 8 | Empty after search | A search matched nothing. The existing empty state replaces the list; no pager, no count. Clear search returns to /settings/sites and page 1. | No sites match "blog" · Check the spelling, or search for a shorter part of the name. · Clear search |
| 9 | Search resets to page 1 | Searching "renew" from page 4 goes to /settings/sites?q=renew (page omitted). Any search change, and any size change, resets the page to 1. | Showing 1–25 of 27 |
| 10 | Long labels on mobile | A large total on a 390 px screen. The summary and the page-size select share one row and wrap onto two if needed; Previous, the "Page 6 of 13" label and Next stay on one row (the two buttons flex, the label never wraps). Totals over 999 use thousands separators in both labels. | Showing 126–150 of 312 · Page 6 of 13 |
Route: `/settings/sites`. API: PATCH /v1/site/{id} {site_name, title_template, default_description, social_image_id, default_robots}.
Owner only, like the rest of Sites. The existing add and edit form gains three fields under the default social image (reused, not duplicated): Site name, Title template with a live example, and Default description. The default robots policy keeps its place. A box under the form explains how articles use these defaults. Sites has no separate read-only details view: the edit form is the details, and other roles get the existing 403 page (403 owner_only on PATCH, 401 without a session).
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Edit: SEO defaults | Edit on the default site. | Default social image (optional) · Site name · Shown in titles through the template, and sent as the Open Graph site name. · Title template · Use %title% for the article title and %site% for the site name. · Example · How we price a bathroom renovation \| Renew · Default description (optional) · Used when an article has no description, excerpt or body text. · How articles use these defaults · Title · Article title, then the template. · Description · Used last, after the article's description, excerpt and body. · Image · Used when an article has no cover image. · Robots · Used unless an article is hidden from search engines. |
| 2 | Add: defaults | New sites start with the default template and nothing else. | Site name · e.g. Renew · %title% \| %site% · One or two sentences about the site. |
| 3 | Template: custom | The example updates as the owner types. | Example · How we price a bathroom renovation – Journal |
| 4 | Template: not valid | The template needs %title%. Nothing is sent. | The template must contain %title%. |
| 5 | Save failed | 500 or network failure. | Couldn't save the site. Your changes are still here. Try again in a moment. |
Owner only. Non-owners get the existing 403 page. Sites has no read-only details view; the edit form is the details.
## Sites: SEO defaults (SEO round)
Route: `/settings/sites`. API: PATCH /v1/site/{id} {site_name, title_template, default_description, social_image_id, default_robots}.
Owner only, like the rest of Sites. The existing add and edit form gains three fields under the default social image (reused, not duplicated): Site name, Title template with a live example, and Default description. The default robots policy keeps its place. A box under the form explains how articles use these defaults. Sites has no separate read-only details view: the edit form is the details, and other roles get the existing 403 page (403 owner_only on PATCH, 401 without a session).
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Edit: SEO defaults | Edit on the default site. | Default social image (optional) · Site name · Shown in titles through the template, and sent as the Open Graph site name. · Title template · Use %title% for the article title and %site% for the site name. · Example · How we price a bathroom renovation \| Renew · Default description (optional) · Used when an article has no description, excerpt or body text. · How articles use these defaults · Title · Article title, then the template. · Description · Used last, after the article's description, excerpt and body. · Image · Used when an article has no cover image. · Robots · Used unless an article is hidden from search engines. |
| 2 | Add: defaults | New sites start with the default template and nothing else. | Site name · e.g. Renew · %title% \| %site% · One or two sentences about the site. |
| 3 | Template: custom | The example updates as the owner types. | Example · How we price a bathroom renovation – Journal |
| 4 | Template: not valid | The template needs %title%. Nothing is sent. | The template must contain %title%. |
| 5 | Save failed | 500 or network failure. | Couldn't save the site. Your changes are still here. Try again in a moment. |
Owner only. Non-owners get the existing 403 page. Sites has no read-only details view; the edit form is the details.
+9 -3
View File
@@ -2,7 +2,7 @@
Route: `/users`. API: GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id (pending) · POST /v1/user/invite/:id/renew (pending).
One shared workspace: every user sees the same articles and this same list. Columns: email, display name, role (owner or admin), invite status and created date. A pending invite can be cancelled and an expired one renewed; accepted accounts have no row actions. An invite is Pending until its link is used, Expired after 3 days, and Accepted once the account is created.
One shared workspace: every user sees the same articles and this same list. Columns: email, display name, role badge, invite status and created date. A pending invite can be cancelled and an expired one renewed; accepted accounts have no row actions. An invite is Pending until its link is used, Expired after 3 days, and Accepted once the account is created.
Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Users". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
@@ -29,7 +29,7 @@ Every state below is drawn in light and dark (theme switch on the board), at 144
Route: `/users`. API: GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id (pending) · POST /v1/user/invite/:id/renew (pending).
One shared workspace: every user sees the same articles and this same list. Columns: email, display name, role (owner or admin), invite status and created date. A pending invite can be cancelled and an expired one renewed; accepted accounts have no row actions. An invite is Pending until its link is used, Expired after 3 days, and Accepted once the account is created.
One shared workspace: every user sees the same articles and this same list. Columns: email, display name, role badge, invite status and created date. A pending invite can be cancelled and an expired one renewed; accepted accounts have no row actions. An invite is Pending until its link is used, Expired after 3 days, and Accepted once the account is created.
| # | State | Trigger | Copy |
|---|---|---|---|
@@ -47,6 +47,12 @@ One shared workspace: every user sees the same articles and this same list. Colu
| 12 | Cancel invite: failed | 500 or network failure. The dialog stays open. | "Cancel this invite?" / "The link sent to sam.oliveira@renew.example will stop working. You can invite the address again later." / "Keep invite" / "Cancel invite" / "Couldn't cancel the invite. Try again in a moment." |
| 13 | Cancel invite: done | 204: the row disappears and a toast confirms. | "Invite cancelled" / "sam.oliveira@renew.example can no longer use the link." |
| 14 | Renew invite: sending | "Renew invite" on an Expired row. No confirmation, since it only sends an email. The button shows progress and is disabled. | "Renewing…" |
| 15 | Renew invite: done | 200: the row becomes Pending with today's date, moves to the top, and the old link stops working. | "Today" / "Invite renewed" / "p.huber@gmail.com has 3 days to accept the new link." |
| 15 | Renew invite: done | 200: the row becomes Pending with today's date, moves to the top, and the old link stops working. Clock-sensitive: shows a relative time or a countdown. | "Today" / "Invite renewed" / "p.huber@gmail.com has 3 days to accept the new link." |
| 16 | Renew invite: failed | 500 or network failure. The row stays Expired and the button can be pressed again. | "Couldn't renew the invite" / "Try again in a moment." |
| 17 | Invite sent | 201: the dialog closes, the new invite appears at the top as Pending, and a toast confirms. | "lea.martin@renew.example" / "Today" / "lea.martin@renew.example has 3 days to accept." |
## Spec notes (app sync)
- The Users table has no "Assigned by" column. Columns: email, display name, role, invite status, created.
- A blocked role in the RolePicker shows the generic RolePicker reason, not a per-role sentence. The current role carries a "Current" mark; Owner is disabled with its reason.
- Own row: Change role is disabled with "You can't change your own role". Remove user is disabled with "You can't remove yourself. Ask another owner or admin."
- Remove user: dialog "Remove {0}?" / "They lose access straight away, and the API keys they created stop working. Their articles, entries and media stay." / Remove user (pending: "Removing…"). Success toast "User removed" / "{0} no longer has access." 500: "Couldn't remove the user" / "Nothing was changed. Try again in a moment." 409 target_role_not_found: toast "User not removed" / "{0}'s role changed in the meantime, so nothing was removed. Check it and try again." / Reload. {0} is the display name, falling back to the username.
+32
View File
@@ -0,0 +1,32 @@
# SEO: decisions and slice order
Spec of record for the design is `change-list.md` (section "SEO round"), `screens/editor.md`, `screens/sites.md`, `screens/articles.md`, `screens/integration.md` and `states.md`. This file records the product decisions that settle the design's open questions, and the order of the implementation slices.
## Decisions on the open questions
| # | Question | Decision |
| --- | --- | --- |
| 1 | Is `slug` immutable after publication? | No. It stays editable. RenewCMS does not manage redirects, so the frontend owns them. No extra warning copy is added to the design. |
| 2 | SEO object for drafts | `GET /v1/article/{id}/seo` answers drafts to authenticated callers who can read the article, and 404 to anonymous callers. The editor preview needs it. |
| 3 | Noindex in the sitemap | Included with `noindex: true`, as drawn. The frontend decides. |
| 4 | Who applies `title_template` | The API. `title` in the SEO object is resolved. The raw `seo_title` and the site template stay readable on their own routes. |
| 5 | Backfill | Migration generates a slug for every existing article from its title (same slug rule as authors), unique per site, with `-2`, `-3` suffixes. A title with no letter or digit becomes `article`, then `article-2`, `article-3`. It runs once and is idempotent. |
| 6 | Excerpt in the list payload | Yes. Article items carry `excerpt`. |
## Decisions that the design leaves implicit
- Suggestions come from one server-side rule set. The same function feeds `seo_status` and `seo_suggestions` in the article list, the collapsed section summary, and the editor panel.
- The editor panel's "checking" states and the "slug taken" state use one non-persisting endpoint: `POST /v1/article/{id}/seo/check`. Its body takes the same optional SEO fields as `PATCH /v1/article/{id}`, plus an optional `body` (never `site_id` or `author_ids`), and overrides the saved values for the check only. It answers `{ status, suggestions: [{ code, field, params }], slug: { available, suggestion } }`. Copy for each code lives in the SPA, with a French entry. `seo_dismissed` codes are filtered out of `suggestions` and are never counted in the list.
- The suggestion codes are the ones the board uses: `title_long`, `title_empty`, `desc_short`, `desc_long`, `cover_alt`, `cover_none`, `slug_long`, `no_au`, `no_h`, `body_alt`, `short`.
- Cover, social and site social images are media usages: `GET /media/{id}/usage`, `used_in`, the unused filter, merge, purge and delete protection must count them. This also closes the existing gap where author avatars and site social images are missing from `GET /media/{id}/usage`.
- Roles: anyone who can edit an article edits its SEO fields and runs the check. Only the owner edits site SEO fields (403 "Only the owner can do this."). `GET /v1/site/{key}/sitemap` is public, like the other read routes that serve online articles. SEO edits are logged as an `articles` event; no field values or body text are stored.
- `PATCH /v1/article/{id}` still requires at least one known field and still answers 400 on `null`. The SEO fields join `body`, `site_id` and `author_ids`.
## Slices
1. **SEO data (backend).** Article and site fields, slug rule and uniqueness per site (409 plain-text `slug_taken`; the first free alternative is in the `X-Slug-Suggestion` response header), backfill, `excerpt` in reads and lists, media usage widening. No resolver yet.
2. **SEO output (backend).** `GET /v1/article/{id}/seo` (fallback chain, title template, JSON-LD), `POST /v1/article/{id}/seo/check`, `seo_status`, `seo_suggestions` and `?seo=` on the article list, `GET /v1/site/{key}/sitemap`.
3. **Editor (SPA).** "Search and sharing" section, slide-over and sheet, live previews, suggestions with debounced check, slug field with Auto and Reset, "What the frontend gets" dialog, draft and Save behaviour.
4. **Sites, Articles, Integration (SPA).** Site SEO defaults, status chip and "Needs attention" filter, Integration "Search and sharing" group, French strings, catalogue.
Run them in order and commit between them: 2 uses the fields of 1, 3 uses the endpoints of 2, and 3 and 4 share the article API types.
+29
View File
@@ -0,0 +1,29 @@
# Sites and authors: cross-slice context
The design for Sites and Authors is in `screens/sites.md`, `screens/authors.md`, the "Editor: site and authors", "Profile: author profile" and "Articles: sites and authors" sections of the editor, profile and articles docs, and the board groups listed in `README.md` (pass 3). Behaviour still comes from `features/`; where the two disagree, stop and ask.
## Roles
| Action | Owner | Admin | Other roles |
|---|---|---|---|
| Create, edit, set default, delete a site | yes | no | no |
| Read the site list, choose an article's site | yes | yes | yes, if they can edit the article |
| Create, edit, delete any author | yes | yes | no |
| Read authors, pick authors on an article | yes | yes | yes |
| Create and edit their own linked author (`POST /v1/author/me`) | yes | yes | yes |
| Create an author from the editor ("Create author…", "This is me") | yes | yes | no |
## Decisions already taken
- One site per article (`site_id` on the article). No per-site publish state: `is_online` stays the global switch. API keys stay global.
- The site key is immutable after creation. Exactly one default site exists and it cannot be deleted. Deleting a site with articles requires `reassign_to`.
- Authors are separate from users. An optional, unique link to a user. The slug is globally unique and not tied to a site. Zero authors on an article is allowed and never blocks publishing. Deleting an author with articles requires `reassign_to` (an author id or `none`).
- Media used as an author avatar or a site's default social image counts as media usage.
- The default site is seeded once from `PUBLIC_URL`. That variable is the CMS's own address (email links), so the seed is only a starting value that the owner edits. The base URL must be https, except for loopback hosts.
- Existing articles are backfilled to the default site and get no authors.
## Slices, in order
1. Sites API. 2. Authors API (independent of 1; both touch the article update route and the article response, so merge 1 first and rebase 2).
3. Sites and Authors screens plus the shared directory pieces (`DirectoryFrame.dc.html`). Existing Editor, Articles and Profile screens are not changed here.
4. Editor, Articles and Profile integration, which also makes every existing frame of those screens pass the visual check.
## Open point for the first slice
`PATCH /v1/article/{id}` is served by `UpdateArticleBody` and only updates the body. Extending it with `site_id` and `author_ids[]` or splitting a metadata endpoint is decided in the specifier step of slices 1 and 2.
+131
View File
@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<script src="./account-copy.js"></script>
</helmet>
<div style="display:contents;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px;color:var(--ui-text)">
<sc-if value="{{ pEmail }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;flex-direction:column;gap:6px;min-width:0">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px"><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ c.emailLabel }}</span><sc-if value="{{ showChange }}"><span data-act="ac-change" role="button" style="min-height:{{ linkH }};display:flex;align-items:center;font-weight:500;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px">{{ c.changeEmail }}</span></sc-if></div>
<div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ old }}</span><span style="display:flex;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div>
</div>
</sc-if>
<sc-if value="{{ pPanel }}" hint-placeholder-val="{{ false }}">
<sc-if value="{{ form }}">
<div style="display:flex;flex-direction:column;gap:14px;padding:{{ panelPad }};background:var(--ui-bg-muted);border:1px solid var(--ui-border)">
<sc-if value="{{ form.notice }}"><div role="alert" style="display:flex;gap:8px;padding:10px 12px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat"></span></span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ form.notice }}</span></div></sc-if>
<sc-for list="{{ form.fields }}" as="f" hint-placeholder-count="2">
<div style="display:flex;flex-direction:column;gap:6px">
<label style="font-weight:500;color:var(--ui-text-highlighted)">{{ f.label }}</label>
<div style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;border:1px solid {{ f.bd }};box-shadow:{{ f.sh }};background:{{ f.bg }};color:{{ f.fg }};font-size:{{ inF }};overflow:hidden;white-space:nowrap"><span style="overflow:hidden;text-overflow:ellipsis;letter-spacing:{{ f.ls }}">{{ f.shown }}</span><sc-if value="{{ f.caret }}"><span style="flex-shrink:0;display:inline-block;width:1px;height:18px;background:var(--ui-text-highlighted);margin-left:1px"></span></sc-if></div>
<sc-if value="{{ f.err }}"><span style="display:flex;gap:6px;align-items:center;color:var(--ui-error);font-size:12px;line-height:18px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat"></span>{{ f.err }}</span></sc-if>
</div>
</sc-for>
<span style="color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty">{{ c.formHelp }}</span>
<div style="display:flex;flex-direction:{{ actDir }};justify-content:flex-end;gap:8px">
<span data-act="ac-form-cancel" style="min-height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;text-align:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ form.cancelFg }};font-weight:500">{{ c.cancel }}</span>
<span data-act="ac-form-send" style="min-height:{{ btnH }};padding:6px 12px;display:flex;align-items:center;justify-content:center;gap:6px;text-align:center;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;opacity:{{ form.op }}"><sc-if value="{{ form.sending }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span></sc-if>{{ form.sendLabel }}</span>
</div>
</div>
</sc-if>
<sc-if value="{{ pend }}">
<div role="status" style="display:flex;flex-direction:column;gap:12px;padding:12px 14px;background:var(--rc-info-soft);color:var(--ui-info)">
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/mail.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/mail.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:600">{{ c.pendTitle }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty;overflow-wrap:anywhere">{{ pend.text }}</span></div></div>
<div style="display:flex;flex-direction:{{ colDir }};gap:8px">
<span data-act="ac-resend" style="min-height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;text-align:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;opacity:{{ pend.op }}"><sc-if value="{{ pend.sending }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span></sc-if>{{ pend.resendLabel }}</span>
<span data-act="ac-cancel-change" style="min-height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;text-align:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500">{{ c.cancelChange }}</span>
</div>
<sc-if value="{{ pend.note }}"><span style="display:flex;gap:6px;align-items:center;color:var(--ui-error);font-size:12px;line-height:18px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat"></span>{{ pend.note }}</span></sc-if>
</div>
</sc-if>
</sc-if>
<sc-if value="{{ pCards }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;flex-direction:column;gap:16px;flex-shrink:0">
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;flex-shrink:0;display:flex;flex-direction:column;gap:14px">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">{{ c.dataTitle }}</span><span style="color:var(--ui-text-muted);text-wrap:pretty">{{ c.dataDesc }}</span></div>
<div style="display:grid;grid-template-columns:{{ itemCols }};gap:12px 16px">
<sc-for list="{{ items }}" as="it" hint-placeholder-count="4">
<div style="display:flex;gap:10px;min-width:0"><span style="display:flex;padding-top:2px;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ it.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ it.icon }}.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ it.title }}</span><span style="color:var(--ui-text-muted);text-wrap:pretty">{{ it.text }}</span></div></div>
</sc-for>
</div>
<sc-if value="{{ data.notice }}"><div role="status" style="display:flex;gap:10px;padding:12px 14px;background:{{ data.notice.bg }};color:{{ data.notice.fg }}"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ data.notice.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ data.notice.icon }}.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">{{ data.notice.title }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ data.notice.text }}</span></div></div></sc-if>
<div style="display:flex;flex-direction:{{ colDir }};align-items:{{ rowAlign }};gap:12px">
<span data-act="ac-export" style="min-height:{{ btnH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;text-align:center;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;opacity:{{ data.op }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ data.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ data.icon }}.svg') center / contain no-repeat"></span>{{ data.label }}</span>
<span style="flex:1;min-width:0;color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty">{{ data.hint }}</span>
</div>
</div>
<div style="background:var(--ui-bg);border:1px solid var(--ui-error);display:flex;flex-direction:column;flex-shrink:0">
<div style="padding:16px;display:flex;flex-direction:{{ colDir }};align-items:{{ rowAlign }};justify-content:space-between;gap:14px">
<div style="flex:1;display:flex;flex-direction:column;gap:2px;min-width:0"><span style="display:flex;align-items:center;gap:8px;font-weight:600;font-size:14px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/triangle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/triangle-alert.svg') center / contain no-repeat"></span>{{ c.dzTitle }}</span><span style="color:var(--ui-text-muted);text-wrap:pretty">{{ c.dzDesc }}</span></div>
<span data-act="ac-delete" style="flex-shrink:0;min-height:{{ btnH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;text-align:center;background:var(--ui-error);color:var(--ui-bg);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat"></span>{{ c.dzBtn }}</span>
</div>
</div>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script>
class Component extends DCLogic {
componentDidMount() {
if (!window.ACCOUNT_COPY) this.t = setInterval(() => { if (window.ACCOUNT_COPY) { clearInterval(this.t); this.forceUpdate() } }, 100)
}
componentWillUnmount() { clearInterval(this.t) }
renderVals() {
const p = this.props, m = !!p.mobile, part = p.part || 'cards', ac = p.ac || {}
const C = p.c || (window.ACCOUNT_COPY && window.ACCOUNT_COPY.en) || {}
const old = ac.old || 'marta@renew.example', nw = ac.new || 'marta.alves@atelier-renew.example'
const fmt = s => (s || '').split('{new}').join(nw).split('{old}').join(old)
const tone = t => ({ bg: 'var(--rc-' + t + '-soft)', fg: 'var(--ui-' + t + ')' })
const fld = (label, val, ph, o = {}) => ({
label, shown: val || ph || '', caret: !!o.focus && !o.dis,
fg: o.dis ? 'var(--ui-text-dimmed)' : val ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)',
bd: o.err ? 'var(--ui-error)' : o.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)',
sh: o.err ? 'inset 0 0 0 1px var(--ui-error)' : o.focus ? 'inset 0 0 0 1px var(--ui-primary)' : 'none',
bg: o.dis ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)', err: o.err || '', ls: o.mask && val ? '0.12em' : 'normal'
})
const F = ac.form
const form = F ? {
fields: [
fld(C.newEmail, F.email, C.newEmailPh, { err: F.errEmail, focus: F.focus === 'email', dis: F.sending }),
fld(C.password, F.pw ? '•'.repeat(F.pw.length) : '', '', { err: F.errPw, focus: F.focus === 'pw', dis: F.sending, mask: true })
],
notice: F.notice || '', sending: !!F.sending, sendLabel: F.sending ? C.sending : C.send,
op: F.disabled ? '0.4' : '1', cancelFg: F.sending ? 'var(--ui-text-dimmed)' : 'var(--ui-text-highlighted)'
} : null
const P = ac.pending
const pend = P ? {
text: fmt(C.pendText), sending: P.resend === 'sending',
resendLabel: P.resend === 'sending' ? C.sending : P.resend === 'rate' ? C.resendRate : C.resend,
op: P.resend === 'rate' ? '0.4' : '1',
note: P.resend === 'rate' ? C.rate : P.note || ''
} : null
const D = ac.data || {}, async_ = D.mode === 'async', ds = D.state || 'idle'
let data = { label: async_ ? C.request : C.download, icon: async_ ? 'mail' : 'download', op: '1', hint: async_ ? fmt(C.asyncHint) : C.dlHint, notice: null }
if (!async_ && ds === 'preparing') data = { ...data, label: C.preparing, icon: 'loader-circle', hint: '' }
if (!async_ && ds === 'rate') data = { ...data, label: C.exRateBtn, op: '0.4', icon: 'download', hint: '', notice: { ...tone('info'), icon: 'timer', title: C.exRateT, text: C.exRateB } }
if (ds === 'error') data = { ...data, notice: { ...tone('error'), icon: 'circle-x', title: C.exErrT, text: C.exErrB } }
if (async_ && ds === 'queued') data = { ...data, label: C.queuedBtn, op: '0.4', icon: 'mail-check', hint: '', notice: { ...tone('success'), icon: 'mail-check', title: C.queuedT, text: fmt(C.queuedB) } }
if (async_ && ds === 'inprogress') data = { ...data, label: C.queuedBtn, op: '0.4', icon: 'mail-check', hint: '', notice: { ...tone('info'), icon: 'info', title: C.inprogT, text: fmt(C.inprogB) } }
return {
c: C, old, form, pend, data,
items: (C.dataItems || []).map(x => ({ icon: x[0], title: x[1], text: x[2] })),
pEmail: part === 'email', pPanel: part === 'panel', pCards: part === 'cards',
showChange: !F && !P,
inH: m ? '44px' : '40px', inF: m ? '16px' : '14px', btnH: m ? '44px' : '36px', linkH: m ? '44px' : '20px',
panelPad: '16px', actDir: m ? 'column-reverse' : 'row', colDir: m ? 'column' : 'row', rowAlign: m ? 'stretch' : 'center',
itemCols: m ? 'minmax(0,1fr)' : 'repeat(2,minmax(0,1fr))', delAlign: m ? 'stretch' : 'flex-start'
}
}
}
</script>
</body>
</html>
+6 -5
View File
@@ -16,11 +16,11 @@
<sc-if value="{{ empty }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:48px 24px;display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">No API keys yet</span><span style="color:var(--ui-text-toned);max-width:360px;text-wrap:pretty">Create a key to call the API from a script or service.</span><sc-if value="{{ desk }}"><span style="height:36px;padding:0 14px;display:flex;align-items:center;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">Create key</span></sc-if></div></sc-if>
<sc-if value="{{ showList }}">
<sc-if value="{{ desk }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
<div style="display:grid;grid-template-columns:{{ cols }};gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Name</span><span>Key</span><span>Status</span><sc-if value="{{ owner }}"><span>Created by</span></sc-if><span>Created</span><span>Last used</span><span>Expires</span><span></span></div>
<div style="display:grid;grid-template-columns:{{ cols }};gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Name</span><span>Key</span><span>Status</span><sc-if value="{{ owner }}"><span>Created by</span></sc-if><span>Follows role</span><span>Created</span><span>Last used</span><span>Expires</span><span></span></div>
<sc-for list="{{ rows }}" as="r" hint-placeholder-count="4"><div style="display:grid;grid-template-columns:{{ cols }};gap:12px;height:48px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border);background:{{ r.bg }}">
<sc-if value="{{ r.skel }}"><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:70%;background:var(--ui-bg-accented)"></span><span style="height:20px;width:56px;background:var(--ui-bg-accented)"></span><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span></span><span></span><span></span></sc-if>
<sc-if value="{{ r.skel }}"><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:70%;background:var(--ui-bg-accented)"></span><span style="height:20px;width:56px;background:var(--ui-bg-accented)"></span><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:50%;background:var(--ui-bg-accented)"></span><span></span><span></span><span></span></sc-if>
<sc-if value="{{ r.name }}"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;color:{{ r.fg }}">{{ r.name }}</span><span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ r.fg2 }}">{{ r.prefix }}</span><span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:{{ r.sbg }};color:{{ r.sfg }};font-size:12px;font-weight:500">{{ r.status }}</span></span>
<sc-if value="{{ owner }}"><span style="color:{{ r.fg2 }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ r.by }}</span></sc-if><span style="color:{{ r.fg2 }}">{{ r.created }}</span><span style="color:{{ r.fg2 }}">{{ r.used }}</span><span style="color:{{ r.fg2 }}">{{ r.exp }}</span>
<sc-if value="{{ owner }}"><span style="color:{{ r.fg2 }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ r.by }}</span></sc-if><span><span style="display:inline-flex;align-items:center;height:20px;padding:0 6px;border:1px solid var(--ui-border-accented);color:{{ r.fg2 }};font:400 12px 'IBM Plex Mono',monospace">{{ r.role }}</span></span><span style="color:{{ r.fg2 }}">{{ r.created }}</span><span style="color:{{ r.fg2 }}">{{ r.used }}</span><span style="color:{{ r.fg2 }}">{{ r.exp }}</span>
<span style="display:flex;justify-content:flex-end"><sc-if value="{{ r.canRevoke }}"><span style="height:28px;padding:0 10px;display:flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">Revoke</span></sc-if></span></sc-if>
</div></sc-for></div></sc-if>
<sc-if value="{{ mob }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);margin:0 -16px;border-left:0;border-right:0">
@@ -28,7 +28,7 @@
<sc-if value="{{ r.skel }}"><span style="height:14px;width:55%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:40%;background:var(--ui-bg-accented)"></span></sc-if>
<sc-if value="{{ r.name }}"><div style="display:flex;align-items:center;gap:8px"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;font-size:15px;color:{{ r.fg }}">{{ r.name }}</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:{{ r.sbg }};color:{{ r.sfg }};font-size:12px;font-weight:500">{{ r.status }}</span></div>
<span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ r.fg2 }}">{{ r.prefix }}</span>
<span style="color:{{ r.fg2 }}">Created {{ r.created }}<sc-if value="{{ owner }}"> by {{ r.by }}</sc-if> · Last used {{ r.used }} · Expires {{ r.exp }}</span>
<span style="color:{{ r.fg2 }}">Created {{ r.created }}<sc-if value="{{ owner }}"> by {{ r.by }}</sc-if> · Follows role {{ r.role }} · Last used {{ r.used }} · Expires {{ r.exp }}</span>
<sc-if value="{{ r.canRevoke }}"><span style="align-self:flex-start;height:44px;padding:0 14px;display:flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">Revoke</span></sc-if></sc-if>
</div></sc-for></div></sc-if>
</sc-if>
@@ -69,6 +69,7 @@ class Component extends DCLogic {
let all = owner
? [R('Website build', 'rcms_Kf3x…', 'active', 'Marta Alves', '12 Mar 2026', '3 Oct 2026', 'None'), R('Nightly media import', 'rcms_Mq7b…', k.revoked ? 'revoked' : 'active', 'Jonas', '2 Apr 2026', '3 Oct 2026', '31 Dec 2026'), R('Staging preview', 'rcms_Zt2d…', 'expired', 'Marta Alves', '14 Jun 2026', '20 Aug 2026', '30 Aug 2026'), R('Old migration script', 'rcms_Hn5w…', 'revoked', 'Inès Duarte', '3 Apr 2026', '9 May 2026', 'None')]
: [R('Nightly media import', 'rcms_Mq7b…', 'active', 'Jonas', '2 Apr 2026', '3 Oct 2026', '31 Dec 2026'), R('Old CI token', 'rcms_Lp9c…', 'revoked', 'Jonas', '18 Jan 2026', '2 Feb 2026', 'None')]
all = all.map(r => ({ ...r, role: r.by === (owner ? 'Marta Alves' : 'Jonas') ? (owner ? 'owner' : 'admin') : '—' }))
const st = k.state || 'list'
const rows = st === 'loading' ? Array.from({ length: 4 }, () => ({ skel: true, bg: 'var(--ui-bg)' })) : all
const cp = { reveal: true }
@@ -87,7 +88,7 @@ class Component extends DCLogic {
mob, desk: !mob, owner, rows, pad: mob ? '16px 16px 84px' : '24px', pinned: mob && !m, h1: '20px/28px',
sub: owner ? 'Keys act as the user who created them. As an owner you see everyone\'s keys.' : 'Keys act as you, with your role. You only see your own keys.',
err: st === 'error', empty: st === 'empty', showList: st === 'list' || st === 'loading',
cols: owner ? 'minmax(0,1.3fr) 110px 76px minmax(0,1fr) 100px 100px 100px 80px' : 'minmax(0,1.3fr) 110px 76px 100px 100px 100px 80px',
cols: owner ? 'minmax(0,1.3fr) 110px 76px minmax(0,1fr) 90px 100px 100px 100px 80px' : 'minmax(0,1.3fr) 110px 76px 90px 100px 100px 100px 80px',
toast: !!k.toast, toastPos: mob ? 'left:16px;right:16px;bottom:84px' : 'right:24px;bottom:24px;width:320px',
modal: !!m, m: m || {}, closeSz: mob ? '44px' : '28px', btnH: mob ? '44px' : '32px', mAlign: mob ? 'flex-end' : 'flex-start', mPad: mob ? '0' : '140px 16px 16px', mW: mob ? '100%' : '460px', btnDir: mob ? 'column-reverse' : 'row', keyDir: mob ? 'column' : 'row'
}
+41 -19
View File
@@ -12,8 +12,9 @@
<div style="width:224px;flex-shrink:0;background:var(--ui-bg);border-right:1px solid var(--ui-border);display:flex;flex-direction:column;padding:12px;gap:2px">
<div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">RenewCMS</span></div>
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="3">
<div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span>{{ n.t }}<sc-if value="{{ n.badge }}"><span style="margin-left:auto;height:18px;padding:0 6px;display:inline-flex;align-items:center;background:var(--ui-info);color:var(--ui-bg);font:500 11px 'IBM Plex Sans',sans-serif">Update</span></sc-if><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="margin-left:{{ n.chevML }};display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for></div></sc-if>
<div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span>{{ n.t }}<sc-if value="{{ n.badge }}"><span style="margin-left:auto;height:18px;padding:0 6px;display:inline-flex;align-items:center;background:var(--ui-info);color:var(--ui-bg);font:500 11px 'IBM Plex Sans',sans-serif">Update</span></sc-if><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="margin-left:{{ n.chevML }};display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for><sc-if value="{{ n.hasSites }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Publishing</span></sc-if><sc-for list="{{ n.subs2 }}" as="x2" hint-placeholder-count="1"><div data-act="nav-{{ x2.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x2.bg }};color:{{ x2.fg }};font-weight:{{ x2.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat"></span>{{ x2.t }}</div></sc-for><sc-if value="{{ n.hasAccess }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Access</span></sc-if><sc-for list="{{ n.subs3 }}" as="x3" hint-placeholder-count="1"><div data-act="nav-{{ x3.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x3.bg }};color:{{ x3.fg }};font-weight:{{ x3.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x3.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x3.ic }}.svg') center / contain no-repeat"></span>{{ x3.t }}</div></sc-for></div></sc-if>
</sc-for>
<sc-if value="{{ contentShow }}"><div style="display:flex;flex-direction:column;gap:2px;margin-top:8px"><span style="padding:6px 10px 2px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Content</span><sc-if value="{{ contentColl }}"><div style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat"></span><span style="flex:1">All content types</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ contentCount }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></div></sc-if><sc-for list="{{ contentRows }}" as="c" hint-placeholder-count="3"><div data-act="nav-content-{{ c.key }}" title="{{ c.name }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:{{ c.bg }};color:{{ c.fg }};font-weight:{{ c.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat"></span><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ c.name }}</span></div></sc-for><sc-if value="{{ contentMore }}"><div style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat"></span><span>{{ contentMore }}</span></div></sc-if></div></sc-if>
<div style="flex:1"></div>
<sc-if value="{{ s.boot }}"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px"><span style="width:26px;height:26px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span></div></sc-if>
<sc-if value="{{ notBoot }}"><div data-act="usermenu" role="button" aria-haspopup="menu" aria-expanded="{{ menuOpen }}" title="Account" style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;background:{{ chipBg }};color:{{ chipFg }}"><span style="width:26px;height:26px;flex-shrink:0;background:var(--rc-sand);color:var(--rc-on-sand);display:flex;align-items:center;justify-content:center;font:600 11px 'IBM Plex Sans',sans-serif">{{ ini }}</span><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500">{{ who }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat"></span></div></sc-if>
@@ -83,8 +84,10 @@
<div style="display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap"><div style="flex:1;min-width:200px;display:flex;flex-direction:column;gap:2px"><span style="font:600 20px/28px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Articles</span><span style="color:var(--ui-text-muted)">Everything you've written, online and in draft.</span></div><sc-if value="{{ desk }}"><span data-act="new" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/plus.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/plus.svg') center / contain no-repeat"></span>New article</span></sc-if></div>
<div style="display:flex;flex-direction:{{ tbDir }};gap:{{ tbGap }};border-bottom:{{ tbBorder }}">
<sc-if value="{{ mob }}"><span style="height:44px;padding:0 4px 0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ qFg }};font-size:16px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span><span style="flex:1">{{ qText }}</span><sc-if value="{{ s.list.q }}"><span style="width:36px;height:36px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></sc-if></span></sc-if>
<sc-if value="{{ mob }}"><span role="switch" aria-checked="{{ naOn }}" style="display:flex;flex-direction:column"><span style="height:44px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid {{ naBd }};background:{{ naBg }};color:{{ naFg }};font-size:15px;font-weight:{{ naFw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat"></span>Needs attention<span style="font:500 11px 'IBM Plex Mono',monospace">{{ naN }}</span></span></span></sc-if><sc-if value="{{ multiMob }}"><span style="position:relative;display:flex;flex-direction:column;"><span style="height:44px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ sfFg }};font-size:16px;"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span><span style="flex:1">{{ sfText }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><sc-if value="{{ sfMenu }}"><div role="listbox" aria-label="Site" style="position:absolute;left:0;right:0;top:48px;z-index:5;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><sc-for list="{{ sfOpts }}" as="o" hint-placeholder-count="3"><div role="option" aria-selected="{{ o.on }}" style="height:44px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;background:{{ o.bg }};color:var(--ui-text-highlighted)">{{ o.t }}<span style="opacity:{{ o.op }};display:flex"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></span></div></sc-for></div></sc-if></span></sc-if>
<div style="display:flex;gap:20px;flex:1;border-bottom:{{ tabBorder }}"><sc-for list="{{ tabs }}" as="t" hint-placeholder-count="3"><span style="height:{{ tabH }};display:flex;align-items:center;gap:6px;border-bottom:2px solid {{ t.line }};margin-bottom:-1px;font-weight:{{ t.fw }};color:{{ t.fg }};font-size:{{ tabF }}">{{ t.t }}<span style="font:500 11px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ t.n }}</span></span></sc-for></div>
<sc-if value="{{ desk }}"><span style="width:260px;height:32px;margin-bottom:6px;padding:0 4px 0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ qFg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span><span style="flex:1">{{ qText }}</span><sc-if value="{{ s.list.q }}"><span style="width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></sc-if></span></sc-if>
<sc-if value="{{ desk }}"><span role="switch" aria-checked="{{ naOn }}" style="margin-bottom:6px;display:flex"><span style="height:32px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid {{ naBd }};background:{{ naBg }};color:{{ naFg }};font-size:13px;font-weight:{{ naFw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat"></span>Needs attention<span style="font:500 11px 'IBM Plex Mono',monospace">{{ naN }}</span></span></span></sc-if><sc-if value="{{ desk }}"><span style="width:260px;height:32px;margin-bottom:6px;padding:0 4px 0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ qFg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span><span style="flex:1">{{ qText }}</span><sc-if value="{{ s.list.q }}"><span style="width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></sc-if></span></sc-if>
<sc-if value="{{ multiDesk }}"><span style="position:relative;display:flex;flex-direction:column;width:150px;margin-bottom:6px;"><span style="height:32px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ sfFg }};"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span><span style="flex:1">{{ sfText }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><sc-if value="{{ sfMenu }}"><div role="listbox" aria-label="Site" style="position:absolute;left:0;right:0;top:36px;z-index:5;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><sc-for list="{{ sfOpts }}" as="o" hint-placeholder-count="3"><div role="option" aria-selected="{{ o.on }}" style="height:32px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;background:{{ o.bg }};color:var(--ui-text-highlighted)">{{ o.t }}<span style="opacity:{{ o.op }};display:flex"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></span></div></sc-for></div></sc-if></span></sc-if>
</div>
<sc-if value="{{ s.list.empty }}">
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:56px 24px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center"><span style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--ui-bg-elevated);color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;width:20px;height:20px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.list.empty.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.list.empty.icon }}.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:15px;color:var(--ui-text-highlighted)">{{ s.list.empty.title }}</span><span style="color:var(--ui-text-muted);max-width:280px">{{ s.list.empty.text }}</span><span data-act="empty" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;font-weight:500;background:{{ emptyBg }};color:{{ emptyFg }};border:1px solid {{ emptyBd }}">{{ s.list.empty.action }}</span></div>
@@ -93,13 +96,14 @@
<sc-if value="{{ pgNote }}"><div role="status" style="display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--rc-info-soft);color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span><span style="flex:1;color:var(--ui-text-toned)">{{ pgNote }}</span></div></sc-if>
<sc-if value="{{ showTable }}">
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
<div style="display:grid;grid-template-columns:minmax(0,1fr) 110px 130px 140px 44px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Title</span><span>Status</span><span>Created</span><span style="display:flex;align-items:center;gap:4px;color:var(--ui-text-highlighted)">Updated<span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/arrow-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/arrow-down.svg') center / contain no-repeat"></span></span><span></span></div>
<div style="display:grid;grid-template-columns:{{ gridCols }};height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Title</span><sc-if value="{{ authorsCol }}"><span>Authors</span></sc-if><sc-if value="{{ multi }}"><span>Site</span></sc-if><span>Status</span><span>Created</span><span style="display:flex;align-items:center;gap:4px;color:var(--ui-text-highlighted)">Updated<span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/arrow-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/arrow-down.svg') center / contain no-repeat"></span></span><span></span></div>
<sc-for list="{{ rows }}" as="a" hint-placeholder-count="6">
<div data-act="open-{{ a.id }}" style="display:grid;grid-template-columns:minmax(0,1fr) 110px 130px 140px 44px;height:44px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border);background:{{ a.bg }};opacity:{{ a.op }};position:relative">
<sc-if value="{{ a.skel }}"><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span style="width:60px;height:20px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span><span></span></sc-if>
<div data-act="open-{{ a.id }}" style="display:grid;grid-template-columns:{{ gridCols }};height:44px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border);background:{{ a.bg }};opacity:{{ a.op }};position:relative">
<sc-if value="{{ a.skel }}"><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><sc-if value="{{ authorsCol }}"><span style="width:60px;height:24px;background:var(--ui-bg-accented)"></span></sc-if><sc-if value="{{ multi }}"><span style="width:70px;height:12px;background:var(--ui-bg-accented)"></span></sc-if><span style="width:60px;height:20px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span><span></span></sc-if>
<sc-if value="{{ a.t }}">
<span style="font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:12px">{{ a.t }}</span>
<span><sc-if value="{{ a.busy }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-muted);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span>{{ a.busy }}</span></sc-if><sc-if value="{{ a.showOn }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ a.showDraft }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if></span>
<sc-if value="{{ authorsCol }}"><span title="{{ a.auNames }}" style="display:flex;align-items:center;min-width:0"><sc-if value="{{ a.hasAu }}"><sc-for list="{{ a.avs }}" as="v" hint-placeholder-count="2"><span style="width:24px;height:24px;margin-left:{{ v.ml }};flex-shrink:0;display:flex;align-items:center;justify-content:center;background:{{ v.bg }};color:var(--ui-text-highlighted);border:2px solid var(--ui-bg);font:600 10px/1 'IBM Plex Sans',sans-serif">{{ v.i }}</span></sc-for><sc-if value="{{ a.auMore }}"><span style="margin-left:6px;font-size:12px;color:var(--ui-text-muted)">{{ a.auMore }}</span></sc-if></sc-if><sc-if value="{{ a.noAu }}"><span aria-label="No authors" style="color:var(--ui-text-dimmed)">—</span></sc-if></span></sc-if><sc-if value="{{ multi }}"><span style="color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:12px">{{ a.site }}</span></sc-if>
<span><sc-if value="{{ a.busy }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-muted);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span>{{ a.busy }}</span></sc-if><sc-if value="{{ a.showOn }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ a.showDraft }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if><sc-if value="{{ a.seoSug }}"><span title="{{ a.seoTitle }}" style="margin-left:6px;height:22px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--rc-warning-soft);color:var(--ui-warning);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat"></span>{{ a.seoN }}</span></sc-if><sc-if value="{{ a.seoHid }}"><span title="Hidden from search engines" style="margin-left:6px;height:22px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;flex-shrink:0;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat"></span>Hidden</span></sc-if></span>
<span style="color:var(--ui-text-muted)">{{ a.c }}</span><span style="color:var(--ui-text-muted)">{{ a.u }}</span>
<span data-act="menu-{{ a.id }}" style="width:32px;height:32px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);background:{{ a.menuBg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat"></span></span>
<sc-if value="{{ a.menu }}"><div role="menu" style="position:absolute;right:12px;top:40px;z-index:2;width:220px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div data-act="open-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit</div><div data-act="pub-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat"></span>{{ a.menuPub }}</div><div data-act="copyurl" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat"></span>Copy API URL</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="del-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat"></span>Delete…</div></div></sc-if>
@@ -121,7 +125,7 @@
<sc-for list="{{ rows }}" as="a" hint-placeholder-count="6">
<div data-act="open-{{ a.id }}" style="position:relative;display:flex;align-items:center;gap:8px;padding:12px 4px 12px 16px;border-bottom:1px solid var(--ui-border);min-height:68px;opacity:{{ a.op }}">
<sc-if value="{{ a.skel }}"><div style="flex:1;display:flex;flex-direction:column;gap:8px"><span style="width:70%;height:14px;background:var(--ui-bg-accented)"></span><span style="width:40%;height:12px;background:var(--ui-bg-accented)"></span></div></sc-if>
<sc-if value="{{ a.t }}"><div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:6px"><span style="font-weight:500;font-size:15px;line-height:21px;color:var(--ui-text-highlighted)">{{ a.t }}</span><div style="display:flex;align-items:center;gap:10px;font-size:12px;color:var(--ui-text-muted)"><sc-if value="{{ a.busy }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span>{{ a.busy }}</span></sc-if><sc-if value="{{ a.showOn }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ a.showDraft }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if><span>Updated {{ a.u }}</span></div></div><span data-act="menu-{{ a.id }}" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis-vertical.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis-vertical.svg') center / contain no-repeat"></span></span></sc-if><sc-if value="{{ a.menu }}"><div role="menu" style="position:absolute;right:8px;top:56px;z-index:2;width:220px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div data-act="open-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit</div><div data-act="pub-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat"></span>{{ a.menuPub }}</div><div data-act="copyurl" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat"></span>Copy API URL</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="del-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat"></span>Delete…</div></div></sc-if>
<sc-if value="{{ a.t }}"><div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:6px"><span style="font-weight:500;font-size:15px;line-height:21px;color:var(--ui-text-highlighted)">{{ a.t }}</span><div style="display:flex;align-items:center;gap:10px;font-size:12px;color:var(--ui-text-muted)"><sc-if value="{{ a.busy }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span>{{ a.busy }}</span></sc-if><sc-if value="{{ a.showOn }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ a.showDraft }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if><sc-if value="{{ a.seoSug }}"><span title="{{ a.seoTitle }}" style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--rc-warning-soft);color:var(--ui-warning);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat"></span>{{ a.seoN }}</span></sc-if><sc-if value="{{ a.seoHid }}"><span title="Hidden from search engines" style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;flex-shrink:0;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat"></span>Hidden</span></sc-if><span>Updated {{ a.u }}</span></div><sc-if value="{{ authorsCol }}"><div style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--ui-text-muted);min-width:0"><sc-if value="{{ a.hasAu }}"><span style="display:flex;align-items:center"><sc-for list="{{ a.avs }}" as="v" hint-placeholder-count="2"><span style="width:22px;height:22px;margin-left:{{ v.ml }};flex-shrink:0;display:flex;align-items:center;justify-content:center;background:{{ v.bg }};color:var(--ui-text-highlighted);border:2px solid var(--ui-bg);font:600 9px/1 'IBM Plex Sans',sans-serif">{{ v.i }}</span></sc-for></span></sc-if><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ a.auFirst }}</span><sc-if value="{{ multi }}"><span>· {{ a.site }}</span></sc-if></div></sc-if></div><span data-act="menu-{{ a.id }}" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis-vertical.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis-vertical.svg') center / contain no-repeat"></span></span></sc-if><sc-if value="{{ a.menu }}"><div role="menu" style="position:absolute;right:8px;top:56px;z-index:2;width:220px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div data-act="open-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center Line truncated
</div>
</sc-for>
</div>
@@ -186,8 +190,9 @@
<sc-if value="{{ desk }}">
<div style="width:280px;flex-shrink:0;border-left:1px solid var(--ui-border);background:var(--ui-bg);padding:20px;display:flex;flex-direction:column;gap:16px">
<span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Details</span>
<sc-if value="{{ s.ed.loading }}"><div style="display:flex;flex-direction:column;gap:10px"><span style="height:12px;background:var(--ui-bg-accented)"></span><span style="height:12px;background:var(--ui-bg-accented)"></span><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span></div></sc-if>
<sc-if value="{{ s.ed.loading }}"><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate(-90deg)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><span style="margin-left:22px;width:40%;height:12px;background:var(--ui-bg-accented)"></span></div><div style="display:flex;flex-direction:column;gap:10px"><span style="height:12px;background:var(--ui-bg-accented)"></span><span style="height:12px;background:var(--ui-bg-accented)"></span><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span></div></sc-if>
<sc-if value="{{ edReady }}">
<div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="meta-toggle" role="button" tabindex="0" aria-expanded="{{ metaOpen }}" aria-controls="meta-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ metaRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><sc-if value="{{ metaOpen }}"><div id="meta-body" style="display:flex;flex-direction:column;gap:16px">{{ edMeta }}</div></sc-if><sc-if value="{{ metaClosed }}"><span style="padding-left:22px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ metaSummary }}</span></sc-if></div><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="seo-toggle" role="button" tabindex="0" aria-expanded="{{ seoOpen }}" aria-controls="seo-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ seoRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Search and sharing</span></div><sc-if value="{{ seoOpen }}"><div id="seo-body" style="display:flex;flex-direction:column;gap:12px">{{ edSeo }}</div></sc-if><sc-if value="{{ seoClosed }}"><span style="padding-left:22px;color:{{ seoFg }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ seoSummary }}</span></sc-if></div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:8px 12px"><span style="color:var(--ui-text-muted)">Created</span><span>12 Mar 2026, 09:14</span><span style="color:var(--ui-text-muted)">Updated</span><span>Today, 11:02</span><span style="color:var(--ui-text-muted)">ID</span><span style="font-family:'IBM Plex Mono',monospace">12</span><span style="color:var(--ui-text-muted)">Images</span><span>{{ imgCount }}</span></div>
<div style="display:flex;flex-direction:column;gap:6px"><span style="color:var(--ui-text-muted)">Public API URL</span><span style="display:flex;align-items:center;gap:6px;padding:6px 8px;background:var(--ui-bg-elevated);font:400 12px 'IBM Plex Mono',monospace"><span style="flex:1">/v1/article/12</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/copy.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/copy.svg') center / contain no-repeat"></span></span><span style="color:var(--ui-text-muted);font-size:12px;line-height:18px">{{ apiNote }}</span></div>
<div style="display:flex;flex-direction:column;gap:4px;padding-top:12px;border-top:1px solid var(--ui-border)"><span style="color:var(--ui-text-muted)">Title</span><span style="font-size:12px;line-height:18px;color:var(--ui-text-toned)">Titles can't be changed after an article is created. To use a different title, create a new article and delete this one.</span></div>
@@ -251,13 +256,14 @@
<sc-if value="{{ s.upd.link }}"><span style="align-self:flex-start;display:flex;align-items:center;gap:6px;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;font-weight:500">{{ s.upd.link }}<span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat"></span></span></sc-if>
</div>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Media</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Image sizes, cache and cleanup for your media library.</span></div><div style="display:flex;flex-direction:column"><div style="display:flex;align-items:center;gap:12px;min-height:44px;padding:8px 0;border-top:1px solid var(--ui-border);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat"></span><div style="flex:1;min-width:0;display:flex;flex-direction:column"><span style="font-weight:500">Manage image variants</span><span style="color:var(--ui-text-muted)">Named sizes requested with ?v=</span></div><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></div><div style="display:flex;align-items:center;gap:12px;min-height:44px;padding:8px 0;border-top:1px solid var(--ui-border);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/database.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/database.svg') center / contain no-repeat"></span><div style="flex:1;min-width:0;display:flex;flex-direction:column"><span style="font-weight:500">Cache usage</span><span style="color:var(--ui-text-muted)">Generated images and disk space</span></div><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></div></div></div>
<sc-if value="{{ showSites }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Publishing</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Where your content is shown.</span></div><div style="display:flex;flex-direction:column"><div style="display:flex;align-items:center;gap:12px;min-height:44px;padding:8px 0;border-top:1px solid var(--ui-border);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span><div style="flex:1;min-width:0;display:flex;flex-direction:column"><span style="font-weight:500">Manage sites</span><span style="color:var(--ui-text-muted)">Base URLs, default image and robots policy</span></div><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></div></div></div></sc-if>
<div role="region" aria-labelledby="cleanup-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px"><sc-if value="{{ cl.loading }}"><div aria-busy="true" style="display:flex;flex-direction:column;gap:16px"><span style="display:block;width:140px;height:16px;background:var(--ui-bg-accented)"></span><span style="display:block;width:90%;height:13px;background:var(--ui-bg-accented)"></span><span style="display:block;width:55%;height:13px;background:var(--ui-bg-accented)"></span><div style="display:flex;justify-content:space-between;align-items:center"><div style="display:flex;flex-direction:column;gap:6px"><span style="display:block;width:120px;height:13px;background:var(--ui-bg-accented)"></span><span style="display:block;width:180px;height:11px;background:var(--ui-bg-accented)"></span></div><span style="display:block;width:36px;height:20px;background:var(--ui-bg-accented)"></span></div><span style="display:block;width:60%;height:13px;background:var(--ui-bg-accented)"></span><div style="height:1px;background:var(--ui-border)"></div><div style="display:flex;justify-content:space-between;align-items:center"><div style="display:flex;flex-direction:column;gap:6px"><span style="display:block;width:100px;height:13px;background:var(--ui-bg-accented)"></span><span style="display:block;width:220px;height:11px;background:var(--ui-bg-accented)"></span></div><span style="display:block;width:140px;height:32px;background:var(--ui-bg-accented)"></span></div></div></sc-if><sc-if value="{{ cl.ready }}"><div style="display:flex;align-items:flex-start;gap:12px"><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span id="cleanup-h" tabindex="-1" style="align-self:flex-start;font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Media cleanup</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.</span></div><div style="height:24px;display:flex;align-items:center;flex-shrink:0"><sc-if value="{{ cl.saving }}"><span style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;"></span>Saving…</span></sc-if><sc-if value="{{ cl.saved }}"><span role="status" style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-success)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;"></span>Saved</span></sc-if></div></div><sc-if value="{{ cl.ro }}"><div style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-info-soft);color:var(--ui-info)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;"></span></span><span style="color:var(--ui-text-toned)">Only owners and admins can change this.</span></div></sc-if><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Automatic purge</span><span style="font-size:12px;color:var(--ui-text-muted)">Runs in the background. Off by default.</span></div><div role="switch" aria-checked="{{ cl.on }}" style="display:flex;align-items:center;justify-content:center;width:{{ cl.swW }};height:{{ cl.swH }};flex-shrink:0;opacity:{{ cl.swOp }}"><div style="position:relative;width:36px;height:20px;border:1px solid {{ cl.swBd }};background:{{ cl.swBg }};outline:{{ cl.swRing }};outline-offset:2px"><div style="position:absolute;top:1px;left:{{ cl.knobL }};width:16px;height:16px;background:var(--ui-bg);border:1px solid {{ cl.knobBd }};box-sizing:border-box"></div></div></div></div><sc-if value="{{ cl.on }}"><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Every</span><span style="font-size:12px;color:var(--ui-text-muted)">Between 1 and 365 days.</span></div><div style="display:flex;flex-direction:column;gaLine truncated
<sc-if value="{{ rt.show }}"><div role="region" aria-labelledby="retention-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px"><div style="display:flex;align-items:flex-start;gap:12px"><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span id="retention-h" tabindex="-1" style="align-self:flex-start;font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Retention</span><span style="color:var(--ui-text-muted);text-wrap:pretty">How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events. Only owners can change it.</span></div><div style="height:24px;display:flex;align-items:center;flex-shrink:0"><sc-if value="{{ rt.saving }}"><span style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;"></span>Saving…</span></sc-if><sc-if value="{{ rt.saved }}"><span role="status" style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-success)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;"></span>Saved</span></sc-if></div></div><sc-if value="{{ rt.err }}"><div style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;"></span></span><div style="flex:1;display:flex;flex-direction:column;gap:8px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Couldn't save the retention period</span><span style="color:var(--ui-text-toned)">Your previous setting is still in place.</span></div><span role="button" style="height:28px;padding:0 10px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;font-size:12px;white-space:nowrap;opacity:1">Try again</span></div></div></sc-if><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Keep activity for</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ rt.help }}</span></div><div style="display:flex;align-items:center;width:{{ rt.selW }};min-height:{{ inH }};padding:0 12px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-size:{{ inF }};color:var(--ui-text-highlighted)"><span style="flex:1">{{ rt.val }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;color:var(--ui-text-muted)"></span></div></div><div style="display:flex;align-items:center;gap:12px;color:var(--ui-text-muted);font-size:12px;font-weight:500"><span style="flex:1;height:1px;background:var(--ui-border)"></span><span>or</span><span style="flex:1;height:1px;background:var(--ui-border)"></span></div><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Keep at most</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ rt.sizeHelp }}</span></div><div style="display:flex;align-items:center;width:{{ rt.selW }};min-height:{{ inH }};padding:0 12px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-size:{{ inF }};color:var(--ui-text-highlighted)"><span style="flex:1">{{ rt.sizeVal }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;cLine truncated
</div>
</sc-if>
<sc-if value="{{ isProfile }}">
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:16px;max-width:720px;overflow:hidden">
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:16px;max-width:720px;flex-shrink:0;margin-top:{{ pfShift }}">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font:600 20px/28px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Profile</span><span style="color:var(--ui-text-muted)">Your account on this RenewCMS workspace.</span></div>
<sc-if value="{{ pfLoading }}"><div aria-busy="true" style="display:flex;flex-direction:column;gap:16px"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><span style="width:120px;height:14px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span></div><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><span style="width:90px;height:14px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span></div><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><span style="width:100px;height:14px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span></div></div></sc-if>
<sc-if value="{{ pfReady }}">
@@ -275,13 +281,21 @@
</div>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:14px">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Account</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Set when your account was created. You log in with your username, even after adding a display name.</span></div>
<div style="display:grid;grid-template-columns:{{ pfCols }};gap:14px"><div style="display:flex;flex-direction:column;gap:6px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Username</span><div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">marta</span><span style="display:flex;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div></div><div style="display:flex;flex-direction:column;gap:6px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Email</span><div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">marta@renew.example</span><span style="display:flex;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div></div></div>
<div style="display:grid;grid-template-columns:{{ pfCols }};gap:14px"><div style="display:flex;flex-direction:column;gap:6px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Username</span><div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">marta</span><span style="display:flex;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div></div><dc-import name="AccountCards" part="email" ac="{{ pfAc }}" c="{{ pfC }}" mobile="{{ mob }}" hint-size="100%,60px" style="display:block;height:auto;min-height:0;flex-shrink:0"></dc-import></div>
<sc-if value="{{ pfPanel }}"><dc-import name="AccountCards" part="panel" ac="{{ pfAc }}" c="{{ pfC }}" mobile="{{ mob }}" hint-size="100%,60px" style="display:block;height:auto;min-height:0;flex-shrink:0"></dc-import></sc-if>
</div>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:14px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Author profile</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Your public byline on articles. It's separate from your account.</span></div>
<sc-if value="{{ pAu.err }}"><div role="alert" style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Couldn't create your author profile</span><span style="color:var(--ui-text-toned)">Try again in a moment.</span></div></div></sc-if>
<sc-if value="{{ pAu.none }}"><span style="color:var(--ui-text-toned);text-wrap:pretty">You don't have an author profile yet. Create one to be credited on your own articles.</span><div style="display:flex;flex-direction:{{ pwDir }};align-items:{{ pwAlign }};gap:12px"><span style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;opacity:{{ pAu.op }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ pAu.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ pAu.icon }}.svg') center / contain no-repeat"></span>{{ pAu.btn }}</span><span style="color:var(--ui-text-muted);font-size:12px">{{ pAu.uses }}</span></div></sc-if>
<sc-if value="{{ pAu.has }}"><div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap"><span style="width:40px;height:40px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:var(--rc-sand);color:var(--rc-on-sand);border:1px solid var(--ui-border);font:600 14px/1 'IBM Plex Sans',sans-serif">MA</span><div style="flex:1;min-width:140px;display:flex;flex-direction:column"><span style="font-weight:500;color:var(--ui-text-highlighted)">Marta Alves</span><span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ pAu.slug }}</span></div><span style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit author</span></div>
<sc-if value="{{ pAu.slugNote }}"><div role="status" style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-info-soft);color:var(--ui-info)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Your slug is {{ pAu.slug }}</span><span style="color:var(--ui-text-toned)">marta-alves was already used by another author. You can change it in Edit author.</span></div></div></sc-if>
<div style="display:flex;align-items:center;gap:6px;color:var(--ui-text-muted);font-size:12px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat"></span>Linked to your account. New articles start with this author.</div></sc-if></div>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:14px">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Password</span><span style="color:var(--ui-text-muted);text-wrap:pretty">To change it, we'll email a reset link to marta@renew.example. The link works once, within 1 hour.</span></div>
<div style="display:flex;flex-direction:{{ pwDir }};align-items:{{ pwAlign }};gap:12px"><div style="flex:1;min-width:0"><div style="display:flex;flex-direction:column;gap:6px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Password</span><div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;letter-spacing:0.12em">••••••••••••</span><span style="display:flex;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div></div></div><span data-act="pf-reset" style="height:{{ inH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;flex-shrink:0;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;white-space:nowrap;opacity:{{ rsBtn.op }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ rsBtn.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ rsBtn.icon }}.svg') center / contain no-repeat"></span>{{ rsBtn.t }}</span></div>
<sc-if value="{{ rsNote }}"><div role="status" style="display:flex;gap:10px;padding:12px 14px;background:{{ rsNote.bg }}"><span style="display:flex;padding-top:2px;color:{{ rsNote.fg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ rsNote.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ rsNote.icon }}.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;color:{{ rsNote.fg }}">{{ rsNote.title }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ rsNote.text }}</span></div></div></sc-if>
</div>
<dc-import name="AccountCards" part="cards" ac="{{ pfAc }}" c="{{ pfC }}" mobile="{{ mob }}" hint-size="100%,60px" style="display:block;height:auto;min-height:0;flex-shrink:0"></dc-import>
</sc-if>
</div>
</sc-if>
@@ -304,9 +318,12 @@
<div style="padding:20px 24px 0;display:flex;align-items:flex-start;gap:12px"><div style="flex:1;display:flex;flex-direction:column;gap:4px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.modal.title }}</span><sc-if value="{{ s.modal.desc }}"><span style="color:var(--ui-text-muted);text-wrap:pretty">{{ s.modal.desc }}</span></sc-if></div><sc-if value="{{ s.modal.close }}"><span data-act="modal-close" style="width:{{ closeSz }};height:{{ closeSz }};display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></sc-if></div>
<div style="padding:20px 24px 0;display:flex;flex-direction:column;gap:16px">
<sc-if value="{{ s.modal.notice }}"><div role="alert" style="display:flex;gap:8px;padding:10px 12px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat"></span></span><span style="color:var(--ui-text-toned)">{{ s.modal.notice }}</span></div></sc-if>
<sc-if value="{{ s.modal.list }}"><div style="display:flex;flex-direction:column;gap:6px"><sc-for list="{{ mList }}" as="li" hint-placeholder-count="3"><div style="display:flex;gap:8px;align-items:flex-start;color:var(--ui-text-toned)"><span style="display:flex;padding-top:3px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">{{ li.t }}</span></div></sc-for></div></sc-if>
<sc-if value="{{ mWarn }}"><div role="status" style="display:flex;gap:10px;padding:12px 14px;background:{{ mWarn.bg }};color:{{ mWarn.fg }}"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ mWarn.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ mWarn.icon }}.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">{{ mWarn.title }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ mWarn.text }}</span></div></div></sc-if>
<sc-for list="{{ mFields }}" as="f" hint-placeholder-count="0">
<div style="display:flex;flex-direction:column;gap:6px"><div style="display:flex;justify-content:space-between"><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ f.label }}</span><sc-if value="{{ f.count }}"><span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ f.countFg }}">{{ f.count }}</span></sc-if></div><div style="min-height:{{ f.h }};padding:{{ f.p }};display:flex;align-items:{{ f.ai }};border:1px solid {{ f.bd }};box-shadow:{{ f.sh }};background:{{ f.bg }};color:{{ f.fg }};font-size:{{ inF }}">{{ f.shown }}</div><sc-if value="{{ f.err }}"><span style="display:flex;gap:6px;align-items:center;color:var(--ui-error);font-size:12px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat"></span>{{ f.err }}</span></sc-if><sc-if value="{{ f.help }}"><span style="color:var(--ui-text-muted);font-size:12px">{{ f.help }}</span></sc-if></div>
</sc-for>
<sc-if value="{{ s.modal.link }}"><span data-act="modal-link" role="link" style="align-self:flex-start;min-height:{{ closeSz }};display:flex;align-items:center;gap:6px;color:var(--ui-text-highlighted);font-weight:500;text-decoration:underline;text-underline-offset:3px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/download.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/download.svg') center / contain no-repeat"></span>{{ s.modal.link }}</span></sc-if>
<sc-if value="{{ s.modal.toggle }}"><div style="display:flex;align-items:center;gap:12px;min-height:{{ inH }}"><span style="width:32px;height:18px;padding:2px;display:flex;justify-content:{{ s.modal.toggle.j }};background:{{ s.modal.toggle.bg }}"><span style="width:14px;height:14px;background:var(--ui-bg)"></span></span><span style="color:var(--ui-text-highlighted);font-weight:500">Open in new tab</span></div></sc-if>
<sc-if value="{{ s.modal.removeLink }}"><div style="display:flex;align-items:center;gap:8px;min-height:{{ inH }};color:var(--ui-error);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat"></span>Remove link</div></sc-if>
<sc-if value="{{ s.modal.upload }}">
@@ -350,15 +367,17 @@
</div>
<div style="padding:20px 24px;display:flex;flex-direction:{{ mBtnDir }};justify-content:flex-end;gap:8px">
<sc-if value="{{ s.modal.cancel }}"><span data-act="modal-cancel" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:{{ mCancelFg }};font-weight:500">{{ s.modal.cancel }}</span></sc-if>
<sc-if value="{{ s.modal.confirm }}"><span data-act="modal-confirm" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:{{ mConfirmBg }};color:var(--ui-bg);font-weight:500"><sc-if value="{{ s.modal.loading }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span></sc-if>{{ s.modal.confirm }}</span></sc-if>
<sc-if value="{{ s.modal.confirm }}"><span data-act="modal-confirm" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:{{ mConfirmBg }};color:var(--ui-bg);font-weight:500;opacity:{{ mConfirmOp }};text-align:center"><sc-if value="{{ s.modal.loading }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span></sc-if>{{ s.modal.confirm }}</span></sc-if>
</div>
</div>
</div>
</sc-if>
{{ purgeDlg }}
<sc-if value="{{ navDrawer }}"><div data-act="mnav-close" style="position:absolute;inset:0;z-index:60;display:flex;background:var(--rc-overlay)"><div role="navigation" aria-label="Main" style="width:300px;max-width:85%;height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column;padding:12px;gap:2px"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><span style="flex:1;font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">RenewCMS</span></div><sc-for list="{{ nav }}" as="n" hint-placeholder-count="3"><div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span><span style="flex:1">{{ n.t }}</span><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for></div></sc-if></sc-for><div style="flex:1"></div><div style="display:flex;align-items:center;gap:10px;height:44px;padding:0 6px;color:var(--ui-text-highlighted)"><span style="width:26px;height:26px;flex-shrink:0;background:var(--rc-sand);color:var(--rc-on-sand);display:flex;align-items:center;justify-content:center;font:600 11px 'IBM Plex Sans',sans-serif">{{ ini }}</span><span style="flex:1;font-weight:500">{{ who }}</span></div></div></div></sc-if>
<sc-if value="{{ navDrawer }}"><div data-act="mnav-close" style="position:absolute;inset:0;z-index:60;display:flex;background:var(--rc-overlay)"><div role="navigation" aria-label="Main" style="width:300px;max-width:85%;height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column;padding:12px;gap:2px"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><span style="flex:1;font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">RenewCMS</span></div><sc-for list="{{ nav }}" as="n" hint-placeholder-count="3"><div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span><span style="flex:1">{{ n.t }}</span><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for><sc-if value="{{ n.hasSites }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Publishing</span></sc-if><sc-for list="{{ n.subs2 }}" as="x2" hint-placeholder-count="1"><div data-act="nav-{{ x2.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x2.bg }};color:{{ x2.fg }};font-weight:{{ x2.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat"></span>{{ x2.t }}</div></sc-for><sc-if value="{{ n.hasAccess }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Access</span></sc-if><sc-for list="{{ n.subs3 }}" as="x3" hint-placeholder-count="1"><div data-act="nav-{{ x3.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x3.bg }};color:{{ x3.fg }};font-weight:{{ x3.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x3.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x3.ic }}.svg') center / contain no-repeat"></span>{{ x3.t }}</div></sc-for></div></sc-if></sc-for><sc-if value="{{ contentShow }}"><div style="display:flex;flex-direction:column;gap:2px;margin-top:8px"><span style="padding:6px 10px 2px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Content</span><sc-if value="{{ contentColl }}"><div style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat"></span><span style="flex:1">All content types</span><span stLine truncated
<sc-if value="{{ lk.sheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-label="Link" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="display:flex;flex-direction:column;gap:2px;padding:12px 16px 12px;border-bottom:1px solid var(--ui-border)"><span style="font-weight:600;font-size:16px;line-height:24px;color:var(--ui-text-highlighted)">timber windows</span><span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">/articles/winter-checklist</span></div><div style="display:flex;flex-direction:column;padding:8px 0 16px"><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat"></span>Open link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat"></span>Remove link</span></div></div></div></sc-if>
<sc-if value="{{ edSheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-modal="true" aria-label="Details" style="width:100%;height:92%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px;flex-shrink:0"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="padding:8px 16px 0 16px;display:flex;align-items:center;gap:12px;flex-shrink:0"><span style="flex:1;font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Details</span><span data-act="modal-close" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></div><div style="flex:1;min-height:0;overflow:hidden;padding:12px 16px 16px;display:flex;flex-direction:column;gap:16px"><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="meta-toggle" role="button" tabindex="0" aria-expanded="{{ metaOpen }}" aria-controls="meta-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ metaRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><sc-if value="{{ metaOpen }}"><div id="meta-body" style="display:flex;flex-direction:column;gap:16px">{{ edMeta }}</div></sc-if><sc-if value="{{ metaClosed }}"><span style="padding-left:22px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ metaSummary }}</span></sc-if></div><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="seo-toggle" role="button" tabindex="0" aria-expanded="{{ seoOpen }}" aria-controls="seo-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ seoRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Search and sharing</span></div><sc-if value="{{ seoOpen }}"><div id="seo-body" style="display:flex;flex-direction:column;gap:12px">{{ edSeo }}</div></sc-if><sc-if value="{{ seoClosed }}"><span style="padding-left:22px;color:{{ seoFg }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ seoSummary }}</span></sc-if></div><div style="display:grid;grid-template-columns:auto 1fr;gap:8px 12px"><span style="color:var(--ui-text-muted)">Created</span><span>12 Mar 2026, 09:14</span><span style="color:var(--ui-text-muted)">Updated</span><span>Today, 11:02</span><span style="color:var(--ui-text-muted)">ID</span><span style="font-family:'IBM Plex Mono',monospace">12</span></div><div style="display:flex;flex-direction:column;gap:6px"><span style="color:var(--ui-text-muted)">Public API URL</span><span style="padding:6px 8px;background:var(--ui-bg-elevated);font:400 12px 'IBM Plex Mono',monospace">/v1/article/12</span></div></div></div></div></sc-if>
<sc-if value="{{ integSearch }}"><dc-import name="IntegrationSearch" s="{{ s.search }}" mobile="{{ mob }}" hint-size="100%,100%"></dc-import></sc-if>
{{ slotOverlays }}
<sc-if value="{{ hasToasts }}">
<div style="position:absolute;z-index:60;display:flex;flex-direction:column;gap:8px;left:{{ tL }};right:{{ tR }};bottom:{{ tB }};width:{{ tW }}">
@@ -375,14 +394,17 @@ class Component extends DCLogic {
const s = this.props.s || {}
const m = !!this.props.mobile
const k = s.kind || 'home'
const navItems = s.role === 'user' ? [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['activity', 'history', 'Activity']] : [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['users', 'users', 'Users'], ['activity', 'history', 'Activity'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
const navItems = s.role === 'user' ? [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['authors', 'feather', 'Authors'], ['activity', 'history', 'Activity']] : [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['authors', 'feather', 'Authors'], ['users', 'users', 'Users'], ['schema', 'shapes', 'Schema'], ['activity', 'history', 'Activity'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
const cr0 = s.crumbs || [], setOpen = s.nav === 'settings' || !!s.sideOpen, parentOn = (key) => s.nav === key && (key !== 'settings' || cr0.length <= 1)
const nav = navItems.map(([key, icon, t]) => { const on = parentOn(key), open = key === 'settings' && setOpen; return { key, icon, t, bg: on ? 'var(--rc-sand)' : 'transparent', badge: key === 'settings' && !!s.navBadge, fg: on ? 'var(--rc-on-sand)' : open ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on || open ? 500 : 400, chev: key === 'settings' ? (open ? 'chevron-down' : 'chevron-right') : '', chevML: key === 'settings' && s.navBadge ? '8px' : 'auto', sub: open, subs: ['Variants', 'Cache'].map(x => { const o = cr0[cr0.length - 1] === x; return { t: x, ic: x === 'Variants' ? 'layers' : 'database', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 } }) } })
const nav = navItems.map(([key, icon, t]) => { const on = parentOn(key), open = key === 'settings' && setOpen; return { key, icon, t, bg: on ? 'var(--rc-sand)' : 'transparent', badge: key === 'settings' && !!s.navBadge, fg: on ? 'var(--rc-on-sand)' : open ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on || open ? 500 : 400, chev: key === 'settings' ? (open ? 'chevron-down' : 'chevron-right') : '', chevML: key === 'settings' && s.navBadge ? '8px' : 'auto', sub: open, hasSites: !s.role || s.role === 'owner', subs2: (!s.role || s.role === 'owner') ? (() => { const o = cr0[cr0.length - 1] === 'Sites'; return [{ t: 'Sites', ic: 'globe', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 }] })() : [], hasAccess: s.role !== 'user', subs3: s.role === 'user' ? [] : [(() => { const o = cr0[1] === 'Roles'; return { t: 'Roles', ic: 'shield', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 } })()], subs: ['Variants', 'Cache'].map(x => { const o = cr0[cr0.length - 1] === x; return { t: x, ic: x === 'Variants' ? 'layers' : 'database', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 } }) } })
const CT = s.content || [], ctOn = s.contentOn, ctMax = 5, ctVis = CT.slice(0, Math.max(ctMax, CT.findIndex(x => x.key === ctOn) + 1)), contentRows = (m ? (s.contentOpen ? ctVis : []) : ctVis).map(x => { const on = x.key === ctOn; return { key: x.key, name: x.name, icon: x.icon || 'file-text', bg: on ? 'var(--rc-sand)' : 'transparent', fg: on ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: on ? 500 : 400 } })
const crumbs = (s.crumbs || []).map((t, i, a) => ({ t, sep: i > 0, fg: i === a.length - 1 ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', fw: i === a.length - 1 ? 500 : 400 }))
const L = s.list || {}
const counts = L.counts || [6, 4, 2]
const tabs = ['All', 'Online', 'Drafts'].map((t, i) => { const on = (L.tab || 0) === i; return { t, n: counts[i], line: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on ? 500 : 400 } })
const rows = (L.loading ? Array.from({ length: 6 }, () => ({ skel: true })) : (L.rows || [])).map(a => ({ ...a, showOn: !a.busy && a.on, showDraft: !a.busy && a.t && !a.on, op: a.busy ? '0.55' : '1', bg: a.menu ? 'var(--ui-bg-elevated)' : 'transparent', menuBg: a.menu ? 'var(--ui-bg-accented)' : 'transparent', menuPub: a.on ? 'Unpublish' : 'Publish', menuIcon: a.on ? 'globe-lock' : 'globe' }))
const AVT = ['var(--rc-sand)', 'var(--ui-bg-accented)', 'var(--ui-bg-elevated)'], ini2 = n => n.replace(/^Dr\.\s*/, '').split(/\s+/).slice(0, 2).map(x => x[0]).join('').toUpperCase()
const auInfo = a => { if (a.skel) return {}; const au = a.au || []; return { hasAu: au.length > 0, noAu: au.length === 0, avs: au.slice(0, 3).map((n, i) => ({ i: ini2(n), bg: AVT[(n.length + i) % 3], ml: i ? '-6px' : '0' })), auMore: au.length > 3 ? '+' + (au.length - 3) : '', auNames: au.join(', '), auFirst: au.length ? au[0] + (au.length > 1 ? ' +' + (au.length - 1) : '') : 'No authors', site: a.site || '' } }
const rows = (L.loading ? Array.from({ length: 6 }, () => ({ skel: true })) : (L.rows || [])).map(a => ({ ...a, seoSug: a.seo > 0, seoN: a.seo, seoTitle: a.seo + (a.seo === 1 ? ' suggestion' : ' suggestions'), seoHid: a.seo === 'hidden', showOn: !a.busy && a.on, showDraft: !a.busy && a.t && !a.on, op: a.busy ? '0.55' : '1', bg: a.menu ? 'var(--ui-bg-elevated)' : 'transparent', menuBg: a.menu ? 'var(--ui-bg-accented)' : 'transparent', menuPub: a.on ? 'Unpublish' : 'Publish', menuIcon: a.on ? 'globe-lock' : 'globe', ...auInfo(a) }))
const e = s.ed || {}
const saveMap = { clean: ['check', 'Saved', 'var(--ui-text-muted)'], dirty: ['circle-dot', 'Unsaved changes', 'var(--ui-warning)'], saving: ['loader-circle', 'Saving…', 'var(--ui-text-muted)'], saved: ['check', 'Saved at 11:02', 'var(--ui-text-muted)'], error: ['circle-alert', 'Save failed', 'var(--ui-error)'], offline: ['wifi-off', 'Waiting for connection', 'var(--ui-text-muted)'] }
const sv = saveMap[e.save || 'saved']
@@ -522,15 +544,15 @@ class Component extends DCLogic {
const edReady = k === 'editor' && !e.loading && !e.notfound
const edTitle = e.title || 'How we price a bathroom renovation'
return {
lk, tbOverflow: m ? 'hidden' : 'visible', tbShift: m ? -(LK.shift || 0) + 'px' : '0px', tbFadeL: m && !!LK.shift, tbFadeR: m && !LK.end, act, cl, rt, purgeDlg, rowDir: m ? 'column' : 'row', rowAlign: m ? 'stretch' : 'flex-start', rowJust: m ? 'flex-start' : 'space-between', rowGap: m ? '12px' : '16px', setScroll: -(s.scrollY || 0) + 'px', offText: s.offText || 'You\'re offline. You can keep writing, but saving and publishing will wait until you\'re back online.', cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn,
lk, edSeo: this.props.edSeo || null, seoOpen: !!this.props.edSeo, seoClosed: !this.props.edSeo, seoRot: this.props.edSeo ? '0deg' : '-90deg', seoSummary: e.seoSummary || 'Ready', seoFg: /suggest/.test(e.seoSummary || '') ? 'var(--ui-warning)' : 'var(--ui-text-muted)', naOn: !!L.needs, naBg: L.needs ? 'var(--rc-sand)' : 'var(--ui-bg)', naFg: L.needs ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', naBd: L.needs ? 'var(--rc-sand)' : 'var(--ui-border-accented)', naFw: L.needs ? 500 : 400, naN: L.naState === 'pending' ? '…' : L.naState === 'fail' ? '!' : L.naState === 'absent' ? '' : String(L.needsN ?? 3), edMeta: this.props.edMeta || null, metaOpen: !!this.props.edMeta, metaClosed: !this.props.edMeta, metaRot: this.props.edMeta ? '0deg' : '-90deg', metaSummary: e.metaSummary || 'Inès Duarte', edSheet: m && !!e.sheet, pAu: (() => { const q = s.pa || {}; return { none: q.k !== 'has', has: q.k === 'has', err: !!q.err, slug: q.slug || 'marta-alves', slugNote: !!q.slug, btn: q.loading ? 'Creating…' : 'Create my author profile', icon: q.loading ? 'loader-circle' : 'feather', op: q.loading ? 0.7 : 1, uses: q.user ? 'Uses your username, marta. You can change it afterwards.' : 'Uses your display name, Marta Alves. You can change it afterwards.' } })(), showSites: !s.role || s.role === 'owner', authorsCol: L.authors !== false, multi: !!L.multi, multiDesk: !m && !!L.multi, multiMob: m && !!L.multi, gridCols: (L.authors !== false ? 'minmax(0,1fr) 104px ' : 'minmax(0,1fr) ') + (L.multi ? '130px ' : '') + '110px 150px 140px 44px', sfText: L.site || 'All sites', sfFg: L.site ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', sfMenu: !!L.siteMenu, sfOpts: ['All sites', 'Renew', 'Journal', 'Projects', 'Archive'].map(x => { const on = (L.site || 'All sites') === x; return { t: x, on, op: on ? 1 : 0, bg: on ? 'var(--ui-bg-elevated)' : 'transparent' } }), tbOverflow: m ? 'hidden' : 'visible', tbShift: m ? -(LK.shift || 0) + 'px' : '0px', tbFadeL: m && !!LK.shift, tbFadeR: m && !LK.end, act, cl, rt, purgeDlg, rowDir: m ? 'column' : 'row', rowAlign: m ? 'stretch' : 'flex-start', rowJust: m ? 'flex-start' : 'space-between', rowGap: m ? '12px' : '16px', setScroll: -(s.scrollY || 0) + 'px', offText: s.offText || 'You\'re offline. You can keep writing, but saving and publishing will wait until you\'re back online.', cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn,
langSeg: [['en','English'],['fr','Français']].map(([k,tt])=>{const on=(s.lang||'en')===k;return {act:'lang-'+k,code:k,t:tt,on,op:on?1:0}}), themeSeg: [['system','monitor','System'],['light','sun','Light'],['dark','moon','Dark']].map(([k,icon,tt])=>{const on=(s.mode||'light')===k;return {act:'theme-'+k,icon,t:tt,on,op:on?1:0}}), menuOpen: !!s.userMenu, deskMenu: !m && !!s.userMenu, mobMenu: m && !!s.userMenu, pfItemBg: 'var(--ui-bg-elevated)',
chipBg: s.userMenu || s.nav === 'profile' ? 'var(--rc-sand)' : 'transparent', chipFg: s.userMenu || s.nav === 'profile' ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)',
isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading,
upBtnDir: m ? 'column' : 'row', upBtnAlign: m ? 'stretch' : 'flex-start', cardPad: '16px', cardPadX: m ? '16px' : '20px', inH: m ? '44px' : '40px', pfCols: m ? 'minmax(0,1fr)' : 'minmax(0,1fr) minmax(0,1.4fr)', pwDir: m ? 'column' : 'row', pwAlign: m ? 'stretch' : 'flex-end', edTitle, edBody: e.body || '', sampleBody: !e.body, tabs, rows, save, counter, pub, mFields, up, toasts, endpoints, toolbar,
desk: !m, mob: m, notBoot: !s.boot,
contentShow: CT.length > 0, contentRows, contentColl: m && !s.contentOpen, contentCount: String(CT.length), contentMore: (!m || s.contentOpen) && CT.length > ctVis.length ? 'Show all ' + CT.length : '', desk: !m, mob: m, notBoot: !s.boot,
fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row',
mLead: s.mLead || 'menu',
navDrawer: m && !!s.drawer, isSlot: !!this.props.slotBody, slotBody: this.props.slotBody || null, slotOverlays: this.props.slotOverlays || null, isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isKeys: k === 'apikeys', isError: k === 'error', isUsers: k === 'users',
navDrawer: m && !!s.drawer, integSearch: k === 'integration' && !!s.search, isSlot: !!this.props.slotBody, slotBody: this.props.slotBody || null, slotOverlays: this.props.slotOverlays || null, isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isKeys: k === 'apikeys', isError: k === 'error', isUsers: k === 'users',
usRows, usError: !!US.error, usOnly: !!US.only && !US.error, usTable: !m && !US.error, usCards: m && !US.error,
pad: m ? '16px 16px 88px' : '24px', gap: '16px', cols3: m ? 'repeat(3,minmax(0,1fr))' : 'repeat(3,minmax(0,1fr))',
upBannerAlign: m ? 'stretch' : 'center', upCmdDir: m ? 'column' : 'row', btnH: m ? '44px' : '32px', rowH: m ? '52px' : '44px', inF: m ? '16px' : '14px',
@@ -563,7 +585,7 @@ class Component extends DCLogic {
saveOp: (dirty && !blocked) ? '1' : '0.4', imgCount: e.imgs != null ? String(e.imgs) : (e.image ? '4' : '3'), apiNote: online ? 'Live. Your website can read this article.' : 'Returns 404 until the article is online.',
copyIcon: s.copied ? 'check' : 'copy', copyText: s.copied ? 'Copied' : 'Copy', copyFg: s.copied ? 'var(--ui-success)' : 'var(--ui-text-highlighted)',
errPad: m ? '40px 20px' : '64px 48px',
closeSz: m ? '44px' : '28px', modalAlign: m ? 'flex-end' : 'flex-start', modalPad: m ? '0' : (md.crop ? '56px 16px 16px' : '140px 16px 16px'), modalW: m ? '100%' : (md.crop ? '640px' : md.wide ? '560px' : '460px'), mBtnDir: m ? 'column-reverse' : 'row',
closeSz: m ? '44px' : '28px', modalAlign: m ? 'flex-end' : 'flex-start', modalPad: m ? '0' : (md.crop ? '56px 16px 16px' : '140px 16px 16px'), modalW: m ? '100%' : (md.crop ? '640px' : md.wide ? '560px' : '460px'), mBtnDir: m ? 'column-reverse' : 'row', mConfirmOp: md.disabled ? '0.4' : '1', mList: (md.list || []).map(t => ({ t })), mWarn: md.warn ? { ...md.warn, bg: 'var(--rc-' + md.warn.tone + '-soft)', fg: 'var(--ui-' + md.warn.tone + ')' } : null, pfShift: pf.shift ? '-' + (m ? pf.shift[1] : pf.shift[0]) + 'px' : '0px', pfAc: pf.ac || {}, pfC: s.copy || null, pfPanel: !!(pf.ac && (pf.ac.form || pf.ac.pending)),
mConfirmBg: md.tone === 'danger' ? 'var(--ui-error)' : 'var(--ui-primary)', mCancelFg: md.loading ? 'var(--ui-text-dimmed)' : 'var(--ui-text-highlighted)',
hasToasts: toasts.length > 0, tL: m ? '16px' : 'auto', tR: m ? '16px' : '24px', tB: m ? '16px' : '24px', tW: m ? 'auto' : '356px'
}
+442
View File
@@ -0,0 +1,442 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<dc-import name="AppFrame" s="{{ shell }}" mobile="{{ mob }}" slot-body="{{ main }}" slot-overlays="{{ ov }}" ed-meta="{{ edMeta }}" ed-seo="{{ edSeo }}" hint-size="100%,100%"></dc-import>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:1440,&quot;height&quot;:900},&quot;s&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;object&quot;},&quot;mobile&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;boolean&quot;}}">
class Component extends DCLogic {
renderVals() {
const s = this.props.s || {}, m = !!this.props.mobile
const P = t => Object.fromEntries(t.split(';').filter(x => x.trim()).map(x => { const i = x.indexOf(':'); const k = x.slice(0, i).trim(); return [k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase()), x.slice(i + 1).trim()] }))
const h = React.createElement
const D = (t, ...c) => h('div', { style: P(t) }, ...c)
const S = (t, ...c) => h('span', { style: P(t) }, ...c)
const I = (n, z = 16, t = '') => h('span', { 'aria-hidden': 'true', style: { display: 'inline-block', flexShrink: 0, width: z, height: z, backgroundColor: 'currentColor', WebkitMask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', mask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', ...(t ? P(t) : {}) } })
const bh = m ? 44 : 32, ih = m ? 44 : 40, fs = m ? 16 : 13
const B = (label, o = {}) => {
const k = o.k || 'outline'
const bg = k === 'primary' ? 'var(--ui-primary)' : k === 'danger' ? 'var(--ui-error)' : 'var(--ui-bg)'
const fg = k === 'primary' || k === 'danger' ? 'var(--ui-bg)' : k === 'del' ? 'var(--ui-error)' : 'var(--ui-text-highlighted)'
const bd = k === 'primary' || k === 'danger' ? bg : 'var(--ui-border-accented)'
return S('height:' + (o.h || bh) + 'px;padding:0 ' + (o.px || 12) + 'px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + bg + ';color:' + fg + ';border:1px solid ' + bd + ';font-weight:500;font-size:' + (o.fs || 13) + 'px;white-space:nowrap;opacity:' + (o.dis ? 0.4 : 1) + ';' + (o.full ? 'flex:1;' : '') + (o.ring ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : ''), o.icon && I(o.icon, o.z || 16), label)
}
const Alert = (tone, icon, title, text, action) => D('display:flex;gap:10px;padding:12px 14px;background:var(--rc-' + tone + '-soft);color:var(--ui-' + tone + ')', S('display:flex;padding-top:2px', I(icon)), D('flex:1;display:flex;flex-direction:column;gap:8px', D('display:flex;flex-direction:column;gap:2px', S('font-weight:600', title), text && S('color:var(--ui-text-toned)', text)), action && B(action, { icon: 'rotate-cw', z: 14, h: m ? 44 : 28, fs: 12, px: 10 })))
const Empty = (icon, title, text, action, primary) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:56px 24px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--ui-bg-elevated);color:var(--ui-text-toned)', I(icon, 20)), S('font-weight:600;font-size:15px;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted);max-width:340px;text-wrap:pretty', text), action && B(action, { k: primary ? 'primary' : 'outline', icon: primary ? 'plus' : null, px: 16 }))
const Note = t => S('align-self:flex-start;min-height:20px;padding:1px 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px/16px \'IBM Plex Mono\',monospace', t)
const BG = { def: ['background:var(--rc-sand);color:var(--rc-on-sand)', 'star'], linked: ['background:var(--rc-info-soft);color:var(--ui-info)', 'link'], guest: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', 'user'], noidx: ['background:var(--rc-warning-soft);color:var(--ui-warning)', 'eye-off'], prim: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)'] }
const Bd = (t, k, sz = 20) => S('height:' + sz + 'px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:500;white-space:nowrap;flex-shrink:0;' + BG[k][0], BG[k][1] && I(BG[k][1], 12), t)
const mono = 'font-family:\'IBM Plex Mono\',monospace;'
// ---------- data
const SITES = [
{ n: 'Renew', key: 'renew', url: 'https://renew.example', c: 268, def: true, robots: 'Index', loc: 'en', img: 'north-wall.jpg' },
{ n: 'Journal', key: 'journal', url: 'https://journal.renew.example', c: 38, robots: 'Index', loc: '', img: null },
{ n: 'Projects', key: 'projects', url: 'https://projects.renew.example', c: 6, robots: 'Noindex', loc: 'fr', img: 'kitchen-after.webp' },
{ n: 'Archive', key: 'archive', url: 'https://archive.renew.example', c: 0, robots: 'Noindex', loc: '', img: null }
]
const AUTH = [
{ name: 'Inès Duarte', slug: 'ines-duarte', user: 'ines.duarte@renew.example', c: 9, img: true, web: 'https://ines-duarte.example', bio: 'Site manager and restoration carpenter. Writes about timber and joinery.' },
{ name: 'Jonas Pereira', slug: 'jonas-pereira', user: 'jonas@renew.example', c: 6, img: true, bio: 'Heat pumps, insulation and the numbers behind them.' },
{ name: 'Camille Roche', slug: 'camille-roche', c: 3, bio: 'Freelance writer on lime and stone.' },
{ name: 'Paul Huber', slug: 'paul-huber', c: 2, img: true, bio: '' },
{ name: 'Léa Fontaine', slug: 'lea-fontaine', c: 0, bio: '' },
{ name: 'Dr. Alexandra Konstantinopoulou-Whitfield', slug: 'dr-alexandra-konstantinopoulou-whitfield', c: 1, bio: 'Conservation architect.' }
]
const AV = n => AUTH.find(a => a.name === n) || { name: n, slug: n.toLowerCase().replace(/[^a-z0-9]+/g, '-'), c: 0 }
const TONE = ['var(--rc-sand)', 'var(--ui-bg-accented)', 'var(--ui-bg-elevated)']
const ini = n => n.replace(/^Dr\.\s*/, '').split(/\s+/).slice(0, 2).map(x => x[0]).join('').toUpperCase()
const Av = (a, z = 32, ring) => S('width:' + z + 'px;height:' + z + 'px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:' + (a.img ? 'repeating-linear-gradient(135deg, var(--ui-border) 0 1px, transparent 1px 8px), ' : '') + TONE[a.name.length % 3] + ';color:var(--ui-text-highlighted);border:' + (ring ? '2px solid var(--ui-bg)' : '1px solid var(--ui-border)') + ';font:600 ' + Math.round(z * 0.36) + 'px/1 \'IBM Plex Sans\',sans-serif', ini(a.name))
const pl = (n, w) => n === 0 ? 'No ' + w + 's' : n + ' ' + w + (n === 1 ? '' : 's')
const host = u => u.replace('https://', '')
// ---------- fields
const F = (lab, val, o = {}) => D('display:flex;flex-direction:column;gap:6px;min-width:0', (lab || o.count) && D('display:flex;justify-content:space-between;gap:8px', S('font-weight:500;color:var(--ui-text-highlighted)', lab, o.opt && S('font-weight:400;color:var(--ui-text-muted)', ' (optional)')), o.count && S('font:400 12px \'IBM Plex Mono\',monospace;color:' + (o.countErr ? 'var(--ui-error)' : 'var(--ui-text-muted)'), o.count)),
D('display:flex;align-items:' + (o.area ? 'flex-start' : 'center') + ';min-height:' + (o.area ? (m ? 120 : 96) : ih) + 'px;border:1px solid ' + (o.err ? 'var(--ui-error)' : o.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';' + (o.err ? 'box-shadow:inset 0 0 0 1px var(--ui-error);' : o.focus ? 'box-shadow:inset 0 0 0 1px var(--ui-primary);' : '') + 'background:' + (o.dis ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';font-size:' + fs + 'px', o.pre, S('flex:1;min-width:0;padding:' + (o.area ? '8px 10px' : '0 10px') + ';color:' + (o.val === false ? 'var(--ui-text-muted)' : 'var(--ui-text-highlighted)') + ';' + (o.area ? '' : 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;') + (o.mono ? mono + 'font-size:' + (m ? 14 : 12) + 'px;' : ''), val), o.sel && I('chevron-down', 14, 'margin-right:10px;color:var(--ui-text-muted)'), o.suffix),
o.err && S('display:flex;gap:6px;align-items:flex-start;color:var(--ui-error);font-size:12px;line-height:18px', I('circle-alert', 14, 'margin-top:2px'), o.err), o.help && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', o.help))
const Link = (t, icon) => S('display:inline-flex;align-items:center;gap:4px;min-height:' + (m ? 44 : 20) + 'px;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;font-size:12px;font-weight:500', icon && I(icon, 12), t)
const Slug = (lab, o) => {
const mode = o.mode || 'empty'
if (mode === 'saved') return F(lab, o.value, { mono: true, dis: !!o.locked, help: o.locked ? o.lockHelp : o.help2, suffix: o.locked && I('lock', 14, 'margin-right:10px;color:var(--ui-text-muted)') })
if (mode === 'empty') return F(lab, o.emptyText || 'Generated from the name', { val: false, dis: true, mono: true, help: o.help })
if (mode === 'auto') return F(lab, o.value, { mono: true, dis: true, help: o.help, suffix: S('display:flex;align-items:center;gap:6px;padding:0 4px', S('height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);font:500 11px \'IBM Plex Mono\',monospace;color:var(--ui-text-toned)', 'Auto'), S('width:' + (m ? 44 : 28) + 'px;height:' + (m ? 44 : 28) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-highlighted)', I('pencil', 14))) })
return D('display:flex;flex-direction:column;gap:6px', F(lab, o.value, { mono: true, focus: !o.err, err: o.err, help: o.err ? null : o.help2 || o.help }), o.suggest && D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', B('Use ' + o.suggest, { h: m ? 44 : 28, fs: 12, px: 10, icon: 'wand-sparkles', z: 12 })), mode === 'manual' && Link(o.reset || 'Reset to match the name', 'rotate-ccw'))
}
const Seg = (opts, on) => D('display:flex;border:1px solid var(--ui-border-accented);width:100%', ...opts.map(([k, t]) => S('flex:1;height:' + (m ? 44 : 32) + 'px;display:flex;align-items:center;justify-content:center;font-weight:' + (on === k ? 500 : 400) + ';background:' + (on === k ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on === k ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)') + ';font-size:' + (m ? 14 : 13) + 'px', t)))
const Sw = (on, lab, help, dis) => D('display:flex;gap:12px;align-items:flex-start;opacity:' + (dis ? 0.6 : 1), S('width:32px;height:18px;margin-top:2px;padding:2px;display:flex;flex-shrink:0;justify-content:' + (on ? 'flex-end' : 'flex-start') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg-accented)'), S('width:14px;height:14px;background:var(--ui-bg)')), D('display:flex;flex-direction:column;gap:2px', S('font-weight:500;color:var(--ui-text-highlighted)', lab), help && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', help)))
const Opts = (items, on, hdr) => D('border:1px solid var(--ui-border);background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);padding:4px', hdr, ...items.map(([t, sub, k, av]) => D('min-height:' + (m ? 48 : 36) + 'px;padding:4px 8px;display:flex;align-items:center;gap:8px;background:' + (on === t ? 'var(--ui-bg-elevated)' : 'transparent') + ';color:var(--ui-text-highlighted)', av, D('flex:1;min-width:0;display:flex;flex-direction:column', S('overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500', t), sub && S('font-size:12px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', sub)), on === t && I('check', 14))))
const Pick = (img, lab, help, o = {}) => D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', lab, S('font-weight:400;color:var(--ui-text-muted)', ' (optional)')),
D('display:flex;align-items:center;gap:12px', o.av ? Av(o.av, 64) : S('width:' + (img ? 96 : 96) + 'px;aspect-ratio:1.91/1;flex-shrink:0;display:flex;align-items:center;justify-content:center;border:1px ' + (img ? 'solid' : 'dashed') + ' var(--ui-border-accented);background:' + (img ? 'repeating-linear-gradient(135deg, var(--ui-border) 0 1px, transparent 1px 10px), var(--rc-sand)' : 'var(--ui-bg)') + ';color:var(--ui-text-muted)', I('image', 18)),
D('flex:1;min-width:0;display:flex;flex-direction:column;gap:6px', img && S('font-size:12px;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' + mono, img), D('display:flex;gap:8px;flex-wrap:wrap', B(img ? 'Change image' : 'Choose image', { icon: 'images', h: m ? 44 : 28, fs: 12, px: 10 }), img && B('Remove', { h: m ? 44 : 28, fs: 12, px: 10 })))),
help && S('color:var(--ui-text-muted);font-size:12px;line-height:18px', help))
// ---------- overlays
const Shell = (o, body) => D('position:absolute;inset:0;z-index:' + (o.z || 50) + ';background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'flex-start') + ';justify-content:center;padding:' + (m ? '0' : (o.top || 140) + 'px 16px 16px'),
D('width:' + (m ? '100%' : (o.w || 460) + 'px') + ';max-height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column', m && D('display:flex;justify-content:center;padding-top:8px;flex-shrink:0', S('display:block;width:36px;height:4px;background:var(--ui-border-accented)')),
D('padding:20px 24px 0;display:flex;align-items:flex-start;gap:12px', D('flex:1;display:flex;flex-direction:column;gap:4px', S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', o.title), o.desc && S('color:var(--ui-text-muted);text-wrap:pretty', o.desc)), o.close && S('width:' + (m ? 44 : 28) + 'px;height:' + (m ? 44 : 28) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
D('padding:20px 24px 0;display:flex;flex-direction:column;gap:16px;min-height:0;overflow:hidden', body),
D('padding:20px 24px;display:flex;flex-direction:' + (m ? 'column-reverse' : 'row') + ';justify-content:flex-end;gap:8px;flex-shrink:0', o.cancel && B(o.cancel, { dis: o.loading }), o.confirm && S('height:' + bh + 'px;padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + (o.tone === 'danger' ? 'var(--ui-error)' : 'var(--ui-primary)') + ';color:var(--ui-bg);font-weight:500;white-space:nowrap;opacity:' + (o.cdis ? 0.4 : 1), o.loading && I('loader-circle'), o.confirm))))
const Panel = (o, body, ...pv) => D('position:absolute;inset:0;z-index:' + (o.z || 40) + ';display:flex;justify-content:' + (m ? 'center' : 'flex-end') + ';align-items:' + (m ? 'flex-end' : 'stretch') + ';background:var(--rc-overlay)',
D('width:' + (m ? '100%' : (o.w || 480) + 'px') + ';height:' + (m ? '92%' : '100%') + ';background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column', m && D('display:flex;justify-content:center;padding-top:8px;flex-shrink:0', S('display:block;width:36px;height:4px;background:var(--ui-border-accented)')),
D('flex-shrink:0;display:flex;align-items:center;gap:12px;padding:' + (m ? '4px 6px 4px 16px' : '0 12px 0 20px') + ';height:56px;border-bottom:1px solid var(--ui-border)', S('flex:1;min-width:0;font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', o.title), S('width:' + (m ? 44 : 28) + 'px;height:' + (m ? 44 : 28) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? 16 : 20) + 'px;display:flex;flex-direction:column;gap:18px', o.notice && Alert('error', 'circle-x', o.notice), body, ...pv),
D('flex-shrink:0;padding:' + (m ? '12px 16px' : '12px 20px') + ';border-top:1px solid var(--ui-border);display:flex;flex-direction:' + (m ? 'column-reverse' : 'row') + ';justify-content:' + (o.ro ? 'flex-end' : 'space-between') + ';gap:8px', B(o.ro ? 'Close' : 'Cancel', { dis: o.saving }), !o.ro && S('height:' + bh + 'px;padding:0 16px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;white-space:nowrap;opacity:' + (o.cdis ? 0.4 : 1), o.saving && I('loader-circle'), o.confirm))))
const Toasts = () => D('position:absolute;z-index:60;display:flex;flex-direction:column;gap:8px;' + (m ? 'left:16px;right:16px;bottom:16px' : 'right:24px;bottom:24px;width:356px'), ...s.toasts.map(t => D('display:flex;gap:10px;padding:12px 14px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop)', S('margin-top:2px;display:flex;color:var(--ui-' + t[0] + ')', I(t[1])), D('flex:1;display:flex;flex-direction:column;gap:6px', D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;color:var(--ui-text-highlighted)', t[2]), S('color:var(--ui-text-muted)', t[3])), t[4] && B(t[4], { icon: 'rotate-cw', z: 14, h: m ? 44 : 28, fs: 12, px: 10 })), I('x'))))
// media picker (shared by site image and author avatar)
const IMGS = ['north-wall.jpg', 'kitchen-after.webp', 'kitchen-before.webp', 'logo-renew.svg', 'window-frame.jpg', 'lime-plaster.jpg', 'floorboards.jpg', 'bathroom-tiles.png']
const Picker = pk => {
const cols = m ? 3 : 4
return Shell({ title: pk.title, desc: pk.desc, close: true, w: 640, top: 40, z: pk.z || 55, cancel: 'Cancel', confirm: 'Use image', cdis: pk.sel == null }, D('display:flex;flex-direction:column;gap:12px',
D('display:flex;gap:20px;border-bottom:1px solid var(--ui-border)', ...['Upload', 'Library'].map(t => S('height:' + (m ? 44 : 36) + 'px;display:flex;align-items:center;gap:6px;border-bottom:2px solid ' + (t === 'Library' ? 'var(--ui-primary)' : 'transparent') + ';margin-bottom:-1px;font-weight:' + (t === 'Library' ? 500 : 400) + ';color:' + (t === 'Library' ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)'), I(t === 'Upload' ? 'upload' : 'images', 14), t))),
D('height:' + ih + 'px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);color:var(--ui-text-muted);font-size:' + fs + 'px', I('search', 14), 'Search by filename'),
D('display:grid;grid-template-columns:repeat(' + cols + ',minmax(0,1fr));gap:10px', ...IMGS.slice(0, m ? 6 : 8).map((n, i) => D('display:flex;flex-direction:column;gap:4px', S('position:relative;display:flex;align-items:center;justify-content:center;aspect-ratio:' + (pk.sq ? '1/1' : '4/3') + ';background:repeating-linear-gradient(135deg, var(--ui-border) 0 1px, transparent 1px 12px), ' + TONE[i % 3] + ';color:var(--ui-text-muted);border:1px solid ' + (pk.sel === i ? 'var(--ui-primary)' : 'var(--ui-border)') + ';' + (pk.sel === i ? 'box-shadow:inset 0 0 0 1px var(--ui-primary);' : ''), I('image', 18), pk.sel === i && S('position:absolute;top:4px;right:4px;width:20px;height:20px;display:flex;align-items:center;justify-content:center;background:var(--ui-primary);color:var(--ui-bg)', I('check', 14))), S('font-size:12px;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', n)))),
pk.sq && S('color:var(--ui-text-muted);font-size:12px', 'Shown as a square. Choose an image with the face or logo near the middle.')))
}
// ---------- list parts
const menuItem = (icon, t, o = {}) => D('min-height:' + (m ? 48 : 32) + 'px;padding:' + (m ? '0 16px' : '0 8px') + ';display:flex;align-items:center;gap:' + (m ? 12 : 8) + 'px;color:' + (o.err ? 'var(--ui-error)' : 'var(--ui-text-highlighted)') + ';opacity:' + (o.dis ? 0.4 : 1) + ';background:' + (o.hv ? 'var(--ui-bg-elevated)' : 'transparent') + ';font-size:' + (m ? 15 : 13) + 'px', I(icon, m ? 18 : 16), t)
const sep = D('height:1px;background:var(--ui-border);margin:4px 0')
const MenuBox = (items, title, desc) => m ?
D('position:absolute;inset:0;z-index:45;background:var(--rc-overlay);display:flex;align-items:flex-end', D('width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);padding:8px 0 24px', D('display:flex;justify-content:center;padding-bottom:8px', S('display:block;width:36px;height:4px;background:var(--ui-border-accented)')), D('display:flex;align-items:center;padding:0 6px 0 16px;min-height:44px', S('flex:1;font-weight:600;font-size:15px;color:var(--ui-text-highlighted)', title), S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))), ...items, desc && S('display:block;padding:4px 16px 0 46px;color:var(--ui-text-muted);font-size:12px;line-height:18px', desc))) :
D('position:absolute;right:12px;top:44px;z-index:5;width:220px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px', ...items)
const Dots = () => S('width:' + (m ? 44 : 32) + 'px;height:' + (m ? 44 : 32) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', s.ro ? I('chevron-right', 14) : I(m ? 'ellipsis-vertical' : 'ellipsis', m ? 18 : 16))
const ph = 'Search by name or slug', q = s.q
const SearchBox = (ph2 = ph) => D('height:' + bh + 'px;' + (m ? '' : 'width:280px;') + 'padding:0 4px 0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:' + (q ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + (m ? ';font-size:16px' : ''), I('search', 14), S('flex:1', q || ph2), q && S('width:' + (m ? 36 : 24) + 'px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 14)))
const Head = (title, desc, btn) => D('display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap', D('flex:1;min-width:200px;display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted)', desc)), !m && btn && B(btn, { k: 'primary', icon: 'plus' }))
const TH = (cols, labels) => D('display:grid;grid-template-columns:' + cols + ';gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', ...labels.map(l => S('', l)))
const Skel = n => D('background:var(--ui-bg);border:1px solid var(--ui-border);' + (m ? 'margin:0 -16px;border-left:0;border-right:0' : ''), ...Array.from({ length: n }, () => D('display:flex;align-items:center;gap:12px;height:' + (m ? 72 : 52) + 'px;padding:0 16px;border-bottom:1px solid var(--ui-border-muted)', S('display:block;width:' + (m ? 32 : 28) + 'px;height:' + (m ? 32 : 28) + 'px;background:var(--ui-bg-accented)'), D('flex:1;display:flex;flex-direction:column;gap:8px', S('display:block;width:40%;height:13px;background:var(--ui-bg-accented)'), S('display:block;width:60%;height:11px;background:var(--ui-bg-accented)')))))
// ---------- pagination (same Paginator as Articles and Media)
const fmtN = n => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
const slugify = t => t.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
const SN = ['Atelier', 'Birchwood', 'Cedar Row', 'Dovetail', 'Eastgate', 'Fieldnotes', 'Granary', 'Harbour', 'Ironwork', 'Joinery Guide', 'Kiln', 'Larch', 'Millstone', 'Nettle', 'Orchard', 'Planing', 'Quarry', 'Ridgeway', 'Slate', 'Tannery', 'Underhill', 'Veneer', 'Workshop', 'Yardline', 'Zinc Works']
const RN = ['Journal', 'Projects', 'Archive', 'Press', 'Careers', 'Docs', 'Events', 'Shop']
const genSites = g => { const st = (g.page - 1) * g.per, n = Math.min(12, g.per, g.total - st); return Array.from({ length: n }, (_, i) => { const gi = st + i; let name
if (g.q) name = 'Renew ' + RN[gi % RN.length] + (gi >= RN.length ? ' ' + (Math.floor(gi / RN.length) + 1) : '')
else if (gi < SITES.length) return SITES[gi]
else { const k = gi - SITES.length; name = SN[k % SN.length] + (k >= SN.length ? ' ' + (Math.floor(k / SN.length) + 1) : '') }
const key = slugify(name); return { n: name, key, url: 'https://' + (g.q ? key.replace(/^renew-/, '') : key) + '.renew.example', c: (gi * 37) % 90, robots: 'Index' } }) }
const FN = ['Anaïs', 'Bruno', 'Clara', 'Diego', 'Elise', 'Félix', 'Greta', 'Hugo', 'Iris', 'Julien', 'Karim', 'Lucie', 'Mathieu', 'Nora', 'Oscar', 'Pilar', 'Quentin', 'Rosa', 'Samir', 'Tess']
const QF = ['Marc', 'Marie', 'Marta', 'Mario', 'Marina', 'Marius', 'Marlène', 'Marcel']
const LN = ['Marchand', 'Nunes', 'Oliveira', 'Perrin', 'Quintela', 'Rocha', 'Santos', 'Teixeira', 'Vidal', 'Weber']
const genAuthors = g => { const st = (g.page - 1) * g.per, n = Math.min(12, g.per, g.total - st); return Array.from({ length: n }, (_, i) => { const gi = st + i
if (!g.q && gi < AUTH.length) return AUTH[gi]
const name = (g.q ? QF[gi % QF.length] : FN[gi % FN.length]) + ' ' + LN[(gi * 3 + 1) % LN.length], sl = slugify(name)
return { name, slug: sl, user: gi % 3 === 0 ? sl.split('-')[0] + '@renew.example' : undefined, c: (gi * 5) % 12, img: gi % 2 === 0 } }) }
const PagerBlock = (g, noun) => {
const pages = Math.max(1, Math.ceil(g.total / g.per)), busy = !!g.loading, dim = busy ? 0.4 : 1
const out = []
if (g.err) out.push(Alert('error', 'circle-x', g.err, 'Check your connection and try again.', 'Retry'))
if (pages === 1) return D('flex-shrink:0;display:flex;flex-direction:column;gap:12px', ...out, S('display:block;color:var(--ui-text-muted)', pl(g.total, noun)))
const from = (g.page - 1) * g.per + 1, to = Math.min(g.page * g.per, g.total)
const back = g.page === 1 ? 0.4 : 1, fwd = g.page === pages ? 0.4 : 1
const PB = (ic, op) => S('width:' + bh + 'px;height:' + bh + 'px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);opacity:' + op, I(ic))
const set = new Set([1, pages, g.page - 1, g.page, g.page + 1].filter(n => n >= 1 && n <= pages))
const arr = pages <= 7 ? Array.from({ length: pages }, (_, i) => i + 1) : [...set].sort((a, b) => a - b)
const nums = []
arr.forEach((n, i) => {
if (i && n - arr[i - 1] > 1) nums.push(S('min-width:' + bh + 'px;height:' + bh + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', '…'))
const cur = n === g.page
nums.push(S('min-width:' + bh + 'px;height:' + bh + 'px;padding:0 6px;display:flex;align-items:center;justify-content:center;border:1px solid ' + (cur ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (cur ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (cur ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';font-weight:' + (cur ? 600 : 400), String(n)))
})
const Size = () => D('position:relative;opacity:' + dim, S('height:' + bh + 'px;padding:0 8px 0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted)', g.per + ' per page', I('chevron-down', 14)),
g.sizeOpen && D('position:absolute;' + (m ? 'right' : 'left') + ':0;bottom:calc(100% + 4px);z-index:40;width:160px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px', ...[5, 25, 50, 100].map(n => D('height:' + bh + 'px;padding:0 8px;display:flex;align-items:center;background:' + (n === g.per ? 'var(--rc-sand)' : 'transparent') + ';color:' + (n === g.per ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';font-weight:' + (n === g.per ? 600 : 400), n + ' per page'))))
const sum = S('color:var(--ui-text-muted)', busy ? 'Loading page ' + g.loading + '…' : 'Showing ' + from + '–' + to + ' of ' + fmtN(g.total))
const NB = (t, left) => S('flex:1;height:44px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;opacity:' + (left ? back : fwd), left && I('chevron-left'), t, !left && I('chevron-right'))
if (m) out.push(D('display:flex;flex-direction:column;gap:12px', D('display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:space-between', sum, Size()), D('display:flex;align-items:center;gap:8px;opacity:' + dim, NB('Previous', true), S('padding:0 4px;color:var(--ui-text-highlighted);font-weight:500;white-space:nowrap', 'Page ' + g.page + ' of ' + fmtN(pages)), NB('Next', false))))
else out.push(D('display:flex;align-items:center;gap:16px', D('flex:1;display:flex', sum), Size(), D('display:flex;align-items:center;gap:4px;justify-content:flex-end;opacity:' + dim, PB('chevrons-left', back), PB('chevron-left', back), ...nums, PB('chevron-right', fwd), PB('chevrons-right', fwd))))
return D('flex-shrink:0;display:flex;flex-direction:column;gap:12px', ...out)
}
const fab = label => m && !s.ro && !s.panel && !s.modal && !s.menu && s.list !== 'empty' && D('position:absolute;left:16px;right:16px;bottom:16px;height:48px;display:flex;align-items:center;justify-content:center;gap:8px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;font-size:15px;box-shadow:var(--rc-shadow-pop)', I('plus', 18), label)
const shellBase = { kind: 'slot', display: 'Marta Alves' }
let main = null, shell = s.shell, edMeta = null, edSeo = null
const ov = []
// ---------- Sites
const siteForm = () => {
const p = s.panel, f = { name: '', key: '', keyMode: 'empty', url: '', img: null, sname: p.mode === 'add' ? '' : (p.f || {}).name, tpl: '%title% | %site%', ddesc: '', robots: 'Index', loc: '', def: false, ...p.f }, er = p.err || {}, add = p.mode === 'add'
const isDef = !add && SITES[p.i || 0].def
const prev = (f.url && !er.url ? f.url : 'https://journal.example.com') + '/how-we-price-a-bathroom-renovation'
return Panel({ title: add ? 'Add site' : 'Edit ' + f.name, notice: p.notice, saving: p.saving, confirm: p.saving ? 'Saving…' : add ? 'Add site' : 'Save changes' },
D('display:flex;flex-direction:column;gap:18px', F('Name', f.name || 'e.g. Journal', { val: !!f.name, focus: p.focus === 'name', err: er.name }),
Slug('Key', { mode: f.keyMode, value: f.key, err: er.key, suggest: er.keySuggest, locked: !add, lockHelp: 'Used in API paths, so it can\'t change.', help: 'Lowercase letters, numbers and hyphens. Used in API paths. It can\'t be changed after the site is added.', help2: 'Lowercase letters, numbers and hyphens. Used in API paths. It can\'t be changed after the site is added.' }),
F('Base URL', f.url || 'https://blog.example.com', { val: !!f.url, mono: true, focus: p.focus === 'url', err: er.url, help: er.url ? null : 'The public address of the frontend. Use https and no path.' }),
Pick(f.img, 'Default social image', 'Used when an article has no image of its own.'),
F('Site name', f.sname || f.name || 'Renew', { val: !!f.sname, focus: p.focus === 'sname', help: 'Shown in titles through the template, and sent as the Open Graph site name. Empty uses the site name, then "Renew".' }),
D('display:flex;flex-direction:column;gap:6px', F('Title template', f.tpl, { mono: true, focus: p.focus === 'tpl', err: er.tpl, help: er.tpl ? null : 'Use %title% for the article title and %site% for the site name.' }), !er.tpl && D('display:flex;gap:6px;align-items:center;padding:8px 10px;background:var(--ui-bg-elevated);min-width:0', S('flex-shrink:0;font-size:12px;color:var(--ui-text-muted)', 'Example'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ui-text-highlighted)', f.tpl.replace('%title%', 'How we price a bathroom renovation').replace('%site%', f.sname || 'Renew')))),
F('Default description', f.ddesc || 'One or two sentences about the site.', { opt: true, area: true, val: !!f.ddesc, count: [...f.ddesc].length + ' / 320', help: 'Used when an article has no description, excerpt or body text.' }),
D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Default robots policy'), Seg([['Index', 'Index'], ['Noindex', 'Noindex']], f.robots), S('color:var(--ui-text-muted);font-size:12px;line-height:18px', f.robots === 'Index' ? 'Search engines may list this site\'s pages.' : 'Asks search engines not to list this site\'s pages.')),
F('Locale', f.loc || 'e.g. en or fr-CH', { opt: true, val: !!f.loc, mono: true, err: er.loc, help: er.loc ? null : 'A language tag. Leave empty if the site has no single language.' }),
Sw(isDef || f.def, isDef ? 'Default site' : 'Make this the default site', isDef ? 'New articles start on the default site. To change it, make another site the default.' : 'New articles start here. The current default is Renew.', isDef)),
D('display:flex;flex-direction:column;gap:4px;padding:12px;background:var(--ui-bg-elevated)', S('font-size:12px;color:var(--ui-text-muted)', 'Canonical URL preview'), S('font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-highlighted);word-break:break-all', prev), er.url && S('font-size:12px;color:var(--ui-text-muted)', 'Shown when the base URL is valid.')),
D('display:flex;flex-direction:column;gap:6px;padding:12px;background:var(--ui-bg-elevated)', S('font-size:12px;color:var(--ui-text-muted)', 'How articles use these defaults'), ...[['Title', 'Article title, then the template.'], ['Description', 'Used last, after the article\'s description, excerpt and body.'], ['Image', 'Used when an article has no cover image.'], ['Robots', 'Used unless an article is hidden from search engines.']].map(([a, b]) => D('display:flex;gap:8px', S('width:84px;flex-shrink:0;font-weight:500;color:var(--ui-text-highlighted)', a), S('color:var(--ui-text-toned);text-wrap:pretty', b)))))
}
const sitesPage = () => {
if (s.forbidden) return D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? '16px' : '24px') + ';display:flex;flex-direction:column;gap:16px', Empty('lock', 'You don\'t have access to Sites', 'Only the owner can manage sites. Ask the owner of this site if you need a change.', 'Back to Settings'))
const pg = s.pg && { ...s.pg, q }, st = s.sites || {}, rows = pg ? (pg.empty ? [] : genSites(pg)) : st.only ? [{ ...SITES[0], c: 312 }] : st.gone ? SITES.filter((_, i) => i !== 1) : SITES
const grid = 'minmax(0,1.1fr) minmax(0,1.5fr) 110px 44px'
let body
if (st.state === 'loading') body = Skel(3)
else if (st.state === 'error') body = Alert('error', 'circle-x', 'Couldn\'t load sites', 'Check your connection and try again.', 'Try again')
else if (pg && pg.loading) body = Skel(m ? 6 : 8)
else if (pg && pg.empty) body = Empty('search', 'No sites match "' + q + '"', 'Check the spelling, or search for a shorter part of the name.', 'Clear search')
else {
const mi = st.menu
const items = i => { const x = rows[i]; return x.def ? [menuItem('pencil', 'Edit', { hv: !m }), menuItem('trash-2', 'Delete…', { dis: true, err: true })] : [menuItem('pencil', 'Edit', { hv: !m }), menuItem('star', 'Set as default'), sep, menuItem('trash-2', 'Delete…', { err: true })] }
const tip = 'The default site can\'t be deleted. Make another site the default first.'
const trs = rows.map((x, i) => {
const open = mi === i
const nameEl = D('display:flex;align-items:center;gap:8px;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', x.n), x.def && Bd('Default', 'def'), x.robots === 'Noindex' && Bd('Noindex', 'noidx'))
if (m) return D('position:relative;display:flex;align-items:center;gap:8px;padding:12px 4px 12px 16px;min-height:76px;border-bottom:1px solid var(--ui-border);background:' + (open ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)'), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:4px', nameEl, S('font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', host(x.url)), S('font-size:12px;color:var(--ui-text-muted)', pl(x.c, 'article'))), Dots())
return D('position:relative;display:grid;grid-template-columns:' + grid + ';gap:12px;min-height:52px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border);background:' + (open ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)'), D('display:flex;flex-direction:column;gap:2px;min-width:0', nameEl, S('font:400 12px/16px \'IBM Plex Mono\',monospace;color:var(--ui-text-muted)', x.key)), S('font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', x.url), S('color:var(--ui-text-muted)', pl(x.c, 'article')), Dots(),
open && !m && MenuBox(items(i)), open && !m && rows[i].def && s.tip && D('position:absolute;right:240px;top:76px;z-index:6;max-width:240px;padding:6px 8px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', tip))
})
if (mi != null && m) ov.push(MenuBox(items(mi), rows[mi].n, rows[mi].def ? tip : null))
body = D('display:flex;flex-direction:column;gap:12px', D('background:var(--ui-bg);border:1px solid var(--ui-border);' + (m ? 'margin:0 -16px;border-left:0;border-right:0' : ''), !m && TH(grid, ['Site', 'Base URL', 'Articles', '']), ...trs),
st.only && S('color:var(--ui-text-muted);text-wrap:pretty', 'Add another site if your content is shown on more than one frontend. Each article belongs to one site.'))
}
return D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? '16px 16px 88px' : '24px') + ';display:flex;flex-direction:column;gap:16px;position:relative', Head('Sites', 'The frontends that display your content.', 'Add site'), pg && SearchBox('Search by name or key'), pg ? D('flex:1;min-height:0;overflow:hidden', body) : body, pg && !pg.empty && PagerBlock(pg, 'site'))
}
const sitesModal = () => {
const k = s.modal.k, x = s.modal, tgt = SITES[x.i ?? 1]
if (k === 'setdef') return Shell({ title: 'Make ' + tgt.n + ' the default site?', desc: 'New articles start on this site. Articles that are already on other sites stay where they are.', cancel: 'Cancel', confirm: x.loading ? 'Saving…' : 'Make default', loading: x.loading, close: true }, D('display:none'))
if (k === 'pick') return Picker({ title: 'Choose an image', desc: 'The default social image for ' + (x.n || 'this site') + '.', sel: x.sel })
const n = tgt.c
if (k === 'del0') return Shell({ title: 'Delete this site?', desc: '"' + tgt.n + '" will be deleted. No articles belong to it. This can\'t be undone.', cancel: 'Cancel', confirm: x.loading ? 'Deleting…' : 'Delete site', tone: 'danger', loading: x.loading }, x.notice ? Alert('error', 'circle-x', 'Couldn\'t delete the site, so it\'s still there. Try again in a moment.') : D('display:none'))
const to = SITES[0]
return Shell({ title: 'Delete this site?', desc: '"' + tgt.n + '" will be deleted. ' + pl(n, 'article') + ' belong to it, so choose a site to move them to. This can\'t be undone.', cancel: 'Cancel', confirm: x.loading ? 'Moving articles…' : 'Move ' + n + ' articles and delete', tone: 'danger', loading: x.loading, cdis: x.dis },
D('display:flex;flex-direction:column;gap:16px', x.notice && Alert('error', 'circle-x', 'Couldn\'t delete the site', 'No articles were moved. Try again in a moment.'),
F('Move articles to', to.n + ' · ' + host(to.url), { sel: true, focus: !!x.open, help: x.open ? null : pl(n, 'article') + ' will move to ' + to.n + '. Their canonical host changes to ' + host(to.url) + '.', dis: x.loading }),
x.open && Opts(SITES.filter(y => y.n !== tgt.n).map(y => [y.n, host(y.url), 0]), to.n)))
}
// ---------- Authors
const socials = { X: 'https://x.com/username', LinkedIn: 'https://www.linkedin.com/in/username', GitHub: 'https://github.com/username', Mastodon: 'https://mastodon.social/@username', Custom: 'https://example.com' }
const authorForm = () => {
const p = s.panel, f = { av: null, name: '', slug: '', slugMode: 'empty', bio: '', web: '', links: [], user: null, ...p.f }, er = p.err || {}, add = p.mode === 'add'
const bl = [...f.bio].length
const sl = f.links.map((l, i) => D('display:flex;gap:8px;align-items:flex-start', D('width:' + (m ? 112 : 108) + 'px;flex-shrink:0', F(null, l.t, { sel: true })), D('flex:1;min-width:0', F(null, l.v || socials[l.t], { val: !!l.v, mono: true, err: l.err, focus: l.focus })), S('width:' + (m ? 44 : 40) + 'px;height:' + ih + 'px;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);border:1px solid var(--ui-border-accented)', I('trash-2', 16))))
const linked = f.user && !f.unlinkAsk
const ownUser = D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Linked user'), D('display:flex;align-items:center;gap:10px;min-height:' + ih + 'px;padding:0 10px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated)', I('link', 14, 'color:var(--ui-info)'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ui-text-highlighted)', f.user), I('lock', 14, 'color:var(--ui-text-muted)')), S('color:var(--ui-text-muted);font-size:12px;line-height:18px', 'This is your author profile. Only an owner or admin can change the link.'))
const userF = p.own ? ownUser : f.user ? D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Linked user', S('font-weight:400;color:var(--ui-text-muted)', ' (optional)')), D('display:flex;align-items:center;gap:10px;min-height:' + ih + 'px;padding:0 6px 0 10px;border:1px solid var(--ui-border-accented)', I('link', 14, 'color:var(--ui-info)'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ui-text-highlighted)', f.user), B('Unlink', { h: m ? 44 : 28, fs: 12, px: 10 })), S('color:var(--ui-text-muted);font-size:12px;line-height:18px', 'New articles by this user start with this author.')) :
D('display:flex;flex-direction:column;gap:6px', F('Linked user', f.uopen ? 'Choose a user' : 'No linked user', { opt: true, val: false, sel: true, focus: !!f.uopen, help: f.uopen ? null : 'Link a CMS user if this author has an account. Guest writers need no link.' }), f.uopen && (f.noUsers ? D('padding:12px;border:1px solid var(--ui-border);background:var(--ui-bg);color:var(--ui-text-muted)', 'Every user already has an author.') : Opts([['Marta Alves', 'marta@renew.example', 0, Av({ name: 'Marta Alves' }, 24)], ['Rui Santos', 'rui@renew.example', 0, Av({ name: 'Rui Santos' }, 24)]], null, S('display:block;padding:4px 8px 6px;font-size:12px;color:var(--ui-text-muted)', 'Users without an author'))))
const pv = D('display:flex;gap:12px;padding:12px;background:var(--ui-bg-elevated);align-items:flex-start', Av({ name: f.name || 'Author', img: !!f.av }, 40), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S('font-size:12px;color:var(--ui-text-muted)', 'Byline preview'), S('font-weight:600;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', f.name || 'Author name'), S('color:var(--ui-text-toned);font-size:12px;line-height:18px;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical', f.bio || 'The bio appears here.')))
return Panel({ title: add ? 'Add author' : 'Edit ' + f.name, notice: p.notice, saving: p.saving, confirm: p.saving ? 'Saving…' : add ? 'Add author' : 'Save changes' },
D('display:flex;flex-direction:column;gap:18px', Pick(f.av, 'Avatar', null, { av: { name: f.name || 'Author', img: !!f.av } }),
F('Name', f.name || 'e.g. Camille Roche', { val: !!f.name, focus: p.focus === 'name', err: er.name }),
Slug('Slug', { mode: f.slugMode, value: f.slug, err: er.slug, suggest: er.suggest, help: 'Generated from the name. Used in public author URLs and API paths. Unique across all authors.', help2: 'Lowercase letters, numbers and hyphens. Unique across all authors.' }),
F('Bio', f.bio || 'A sentence or two about the author.', { opt: true, area: true, val: !!f.bio, count: bl + ' / 280', countErr: bl > 280, err: er.bio, focus: p.focus === 'bio' }),
F('Website', f.web || 'https://example.com', { opt: true, val: !!f.web, mono: true, err: er.web }),
D('display:flex;flex-direction:column;gap:8px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Social links', S('font-weight:400;color:var(--ui-text-muted)', ' (optional)')), ...sl, D('display:flex;align-items:center;gap:12px', f.links.length < 5 ? B('Add link', { icon: 'plus', h: m ? 44 : 28, fs: 12, px: 10 }) : S('color:var(--ui-text-muted);font-size:12px', 'You can add up to 5 links.'), S('color:var(--ui-text-muted);font-size:12px', f.links.length + ' of 5'))),
userF), pv)
}
const authorView = () => {
const a = AUTH[s.panel.i ?? 0]
const lab = (t, v) => D('display:flex;flex-direction:column;gap:4px', S('font-weight:500;color:var(--ui-text-highlighted)', t), v)
return Panel({ title: a.name, ro: true }, D('display:flex;flex-direction:column;gap:18px',
D('display:flex;gap:14px;align-items:center', Av(a, 64), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:4px', S('font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-muted)', a.slug), D('display:flex;align-items:center;gap:8px;font-size:12px;color:var(--ui-text-muted)', a.user ? Bd('Linked', 'linked') : Bd('Guest', 'guest'), pl(a.c, 'article')))),
a.bio && lab('Bio', S('color:var(--ui-text-toned);text-wrap:pretty', a.bio)),
a.web && lab('Website', S('color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;' + mono, a.web)),
D('display:flex;gap:10px;padding:12px;background:var(--ui-bg-elevated);color:var(--ui-text-toned)', I('lock', 14, 'margin-top:3px'), 'Only an owner or admin can change this author.')))
}
const authorsPage = () => {
const st = s.list || 'ok'
const pg = s.pg && { ...s.pg, q }
let rows = pg ? (pg.empty ? [] : genAuthors(pg)) : AUTH
if (q && !pg) rows = AUTH.filter(a => (a.name + a.slug).toLowerCase().includes(q.toLowerCase()))
const grid = 'minmax(0,1.4fr) minmax(0,1fr) 110px 100px 44px'
let body
if (st === 'loading') body = Skel(6)
else if (pg && pg.loading) body = Skel(m ? 6 : 8)
else if (st === 'error') body = Alert('error', 'circle-x', 'Couldn\'t load authors', 'Check your connection and try again.', 'Try again')
else if (st === 'empty') body = Empty('feather', 'No authors yet', 'Authors are the names readers see on articles. They don\'t need an account.', 'Add author', true)
else if (q && !rows.length) body = Empty('search', 'No authors match "' + q + '"', 'Check the spelling, or search for a shorter part of the name.', 'Clear search')
else {
const mi = s.menu
const items = [menuItem('pencil', 'Edit', { hv: !m }), sep, menuItem('trash-2', 'Delete…', { err: true })]
if (mi != null && m) ov.push(MenuBox(items, rows[mi].name))
const acct = a => a.user ? Bd('Linked', 'linked') : Bd('Guest', 'guest')
const trs = rows.map((a, i) => {
const open = mi === i
if (m) return D('position:relative;display:flex;align-items:center;gap:12px;padding:12px 4px 12px 16px;min-height:76px;border-bottom:1px solid var(--ui-border);background:' + (open ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)'), Av(a, 40), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:3px', S('font-weight:500;font-size:15px;line-height:21px;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', a.name), S('font:400 12px/16px \'IBM Plex Mono\',monospace;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', a.slug), D('display:flex;align-items:center;gap:8px;font-size:12px;color:var(--ui-text-muted)', acct(a), pl(a.c, 'article'))), Dots())
return D('position:relative;display:grid;grid-template-columns:' + grid + ';gap:12px;min-height:52px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border);background:' + (open ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)'), D('display:flex;align-items:center;gap:10px;min-width:0', Av(a, 32), S('font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', a.name)), S('font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', a.slug), D('display:flex', acct(a)), S('color:var(--ui-text-muted)', pl(a.c, 'article')), Dots(), open && MenuBox(items))
})
body = D('background:var(--ui-bg);border:1px solid var(--ui-border);' + (m ? 'margin:0 -16px;border-left:0;border-right:0' : ''), !m && TH(grid, ['Author', 'Slug', 'Account', 'Articles', '']), ...trs)
}
const showTool = st !== 'empty' && st !== 'error'
return D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? '16px 16px 88px' : '24px') + ';display:flex;flex-direction:column;gap:16px;position:relative', Head('Authors', s.ro ? 'The names readers see on your articles. Only an owner or admin can change authors.' : 'The names readers see on your articles. An author doesn\'t need an account.', s.ro ? null : 'Add author'), showTool && SearchBox(), pg ? D('flex:1;min-height:0;overflow:hidden', body) : body, pg && !pg.empty && PagerBlock(pg, 'author'))
}
const authorsModal = () => {
const x = s.modal, k = x.k, a = AUTH[x.i ?? 2]
if (k === 'pick') return Picker({ title: 'Choose an image', desc: 'The avatar for ' + (x.n || 'this author') + '.', sel: x.sel, sq: true })
if (k === 'unlink') return Shell({ title: 'Unlink ' + a.name + '?', desc: a.name + ' will stay on their articles. New articles by ' + a.user + ' will no longer start with this author.', cancel: 'Cancel', confirm: x.loading ? 'Unlinking…' : 'Unlink user', loading: x.loading, close: true }, D('display:none'))
if (k === 'relink') return Shell({ title: 'Link ' + a.name + ' to a user?', desc: 'Only users without an author are listed.', cancel: 'Cancel', confirm: 'Link user', cdis: true, close: true }, D('display:flex;flex-direction:column;gap:12px', F('Linked user', 'Choose a user', { sel: true, val: false, focus: true }), Opts([['Marta Alves', 'marta@renew.example', 0, Av({ name: 'Marta Alves' }, 24)], [ 'Rui Santos', 'rui@renew.example', 0, Av({ name: 'Rui Santos' }, 24)]])))
const n = a.c
if (k === 'del0') return Shell({ title: 'Delete this author?', desc: '"' + a.name + '" will be deleted. No articles credit this author. This can\'t be undone.', cancel: 'Cancel', confirm: x.loading ? 'Deleting…' : 'Delete author', tone: 'danger', loading: x.loading }, D('display:none'))
if (k === 'blocked') return Shell({ title: 'Can\'t delete this author', desc: '"' + a.name + '" is credited on ' + pl(n, 'article') + '. Choose another author to credit instead, or remove ' + a.name + ' from those articles first.', cancel: 'Close', confirm: 'Reassign articles', close: true }, a.user ? Alert('info', 'info', 'Linked to ' + a.user, 'Deleting the author doesn\'t affect the user account.') : D('display:none'))
const to = AUTH[0]
return Shell({ title: 'Delete this author?', desc: '"' + a.name + '" will be deleted and is credited on ' + pl(n, 'article') + '. This can\'t be undone.', cancel: 'Cancel', confirm: x.loading ? 'Reassigning…' : 'Delete and reassign ' + n + ' articles', tone: 'danger', loading: x.loading, close: !x.loading },
D('display:flex;flex-direction:column;gap:16px', x.notice && Alert('error', 'circle-x', 'Couldn\'t delete the author', 'Nothing was changed. Try again in a moment.'),
F('Credit these articles to', x.none ? 'Nobody' : to.name, { sel: true, focus: !!x.open, dis: x.loading, help: x.open ? null : x.none ? 'The articles keep any other authors. Articles with only this author will have no byline.' : 'Where an article has several authors, ' + a.name + ' is removed and the others stay.' }),
x.open && Opts([...AUTH.filter(y => y.name !== a.name).slice(0, 4).map(y => [y.name, y.slug, 0, Av(y, 24)]), ['Nobody', 'Remove the byline', 0, S('width:24px;height:24px;display:flex;align-items:center;justify-content:center;border:1px dashed var(--ui-border-accented);color:var(--ui-text-muted)', I('user-x', 14))]], x.none ? 'Nobody' : to.name),
a.user && S('color:var(--ui-text-muted);font-size:12px', 'Linked to ' + a.user + '. The user account isn\'t affected.')))
}
// ---------- Editor: site and authors
const metaUI = () => {
const mt = s.meta || {}, au = (mt.authors || []).map(AV), n = au.length, st = mt.state || 'ok'
const out = []
if (mt.hint) out.push(D('display:flex;gap:10px;padding:12px;background:var(--rc-info-soft);color:var(--ui-info)', S('display:flex;padding-top:2px', I('info')), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:8px', D('display:flex;flex-direction:column;gap:2px', S('font-weight:600', 'You don\'t have an author profile yet'), S('color:var(--ui-text-toned)', 'Create one to be credited on your articles.')), B('Create my author profile', { icon: 'feather', h: m ? 44 : 28, fs: 12, px: 10 })), S('width:' + (m ? 44 : 24) + 'px;height:' + (m ? 44 : 24) + 'px;margin:' + (m ? '-10px -10px 0 0' : '-4px -4px 0 0') + ';display:flex;align-items:center;justify-content:center;color:var(--ui-text-toned)', I('x', 14))))
if (mt.sites) out.push(D('display:flex;flex-direction:column;gap:6px', F('Site', (mt.site || 'Journal') + ' · ' + host(SITES.find(x => x.n === (mt.site || 'Journal')).url), { sel: true, focus: mt.open === 'site' }), mt.open === 'site' ? Opts(SITES.map(x => [x.n, host(x.url), 0]), mt.site || 'Journal') : D('display:flex;align-items:center;gap:6px;color:var(--ui-text-muted);font-size:12px;line-height:18px', I('link', 12), S('', 'Canonical host: ', S('color:var(--ui-text-toned);' + mono, host(SITES.find(x => x.n === (mt.site || 'Journal')).url))))))
const chip = (a, i) => {
const grab = mt.grab === i, lift = mt.drag === i
return D('display:flex;align-items:center;gap:' + (m ? 8 : 6) + 'px;min-height:' + (m ? 48 : 32) + 'px;padding:0 ' + (m ? 4 : 2) + 'px 0 2px;border:1px solid ' + (grab || lift ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (lift ? 'var(--ui-bg)' : 'var(--ui-bg)') + ';' + (grab ? 'outline:2px solid var(--ui-primary);outline-offset:1px;' : '') + (lift ? 'box-shadow:var(--rc-shadow-pop);position:relative;z-index:2;transform:translateX(4px);' : ''),
S('width:' + (m ? 36 : 24) + 'px;height:' + (m ? 36 : 24) + 'px;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:' + (grab || lift ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)'), I('grip-vertical', 14)), Av(a, m ? 28 : 22),
S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;color:var(--ui-text-highlighted)', a.name),
i === 0 && n > 1 && Bd('Primary', 'prim', 18),
S('width:' + (m ? 44 : 24) + 'px;height:' + (m ? 44 : 24) + 'px;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 14)))
}
const list = []
au.forEach((a, i) => { if (mt.drop === i) list.push(D('height:2px;background:var(--ui-primary);margin:-1px 0')); list.push(chip(a, i)) })
const body = [S('font-weight:500;color:var(--ui-text-highlighted)', 'Authors')]
if (!n) body.push(D('padding:12px;border:1px dashed var(--ui-border-accented);color:var(--ui-text-muted);text-wrap:pretty', 'No authors. This article will have no byline.'))
else body.push(D('display:flex;flex-direction:column;gap:4px', ...list))
const trig = D('display:flex;align-items:center;gap:8px;min-height:' + ih + 'px;padding:0 10px;border:1px solid ' + (mt.open === 'authors' ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';' + (mt.open === 'authors' ? 'box-shadow:inset 0 0 0 1px var(--ui-primary);' : '') + 'background:var(--ui-bg);color:var(--ui-text-muted);font-size:' + fs + 'px', I('plus', 14), S('flex:1;color:' + (mt.q ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)'), mt.open === 'authors' ? (mt.q || 'Search authors') : 'Add an author'), I('chevron-down', 14))
body.push(trig)
if (mt.open === 'authors') {
const chosen = au.map(a => a.name), q2 = mt.q
let opts = AUTH.filter(a => !q2 || a.name.toLowerCase().includes(q2.toLowerCase()))
const create = D('min-height:' + (m ? 48 : 36) + 'px;padding:4px 8px;display:flex;align-items:center;gap:8px;color:var(--ui-text-highlighted);font-weight:500;border-top:1px solid var(--ui-border)', I('plus', 16), q2 ? 'Create author "' + q2 + '"…' : 'Create author…')
let inner
if (st === 'loading') inner = [D('display:flex;flex-direction:column;gap:10px;padding:10px 8px', ...[0, 1, 2].map(() => D('display:flex;gap:8px;align-items:center', S('display:block;width:24px;height:24px;background:var(--ui-bg-accented)'), S('display:block;width:50%;height:12px;background:var(--ui-bg-accented)'))))]
else if (st === 'error') inner = [D('padding:10px 8px;display:flex;flex-direction:column;gap:8px', S('color:var(--ui-error);font-weight:500', 'Couldn\'t load authors'), S('color:var(--ui-text-muted)', 'Check your connection and try again.'), B('Try again', { icon: 'rotate-cw', z: 14, h: m ? 44 : 28, fs: 12, px: 10 }))]
else if (!opts.length) inner = [D('padding:10px 8px;color:var(--ui-text-muted)', 'No author matches "' + q2 + '".')]
else inner = opts.map(a => D('min-height:' + (m ? 48 : 36) + 'px;padding:4px 8px;display:flex;align-items:center;gap:8px;color:var(--ui-text-highlighted);opacity:' + (chosen.includes(a.name) ? 0.6 : 1), Av(a, 24), D('flex:1;min-width:0;display:flex;flex-direction:column', S('overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500', a.name), S('font-size:12px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' + mono, a.slug)), chosen.includes(a.name) && I('check', 14)))
body.push(D('border:1px solid var(--ui-border);background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);' + (m ? '' : ''), D('padding:4px', ...inner), st === 'error' || mt.noCreate ? null : create))
}
if (mt.grab != null) body.push(S('font-size:12px;line-height:18px;color:var(--ui-text-toned);padding:6px 8px;background:var(--ui-bg-elevated)', au[mt.grab].name + ' grabbed. Position ' + (mt.grab + 1) + ' of ' + n + '. Use the up and down arrow keys to move, Space to drop, Escape to cancel.'))
else if (mt.live) body.push(S('font-size:12px;line-height:18px;color:var(--ui-text-toned);padding:6px 8px;background:var(--ui-bg-elevated)', mt.live))
else if (n > 1 && mt.open !== 'authors') body.push(S('font-size:12px;line-height:18px;color:var(--ui-text-muted)', 'Drag to reorder. The first author is the primary byline.'))
out.push(D('display:flex;flex-direction:column;gap:8px', ...body))
return D('display:flex;flex-direction:column;gap:16px', ...out)
}
const newAuthorModal = () => {
const x = s.modal
return Shell({ z: 60, title: 'Create author', desc: 'Adds a byline for this article. You can add a bio and avatar later in Authors.', cancel: 'Cancel', confirm: x.loading ? 'Creating…' : 'Create author', loading: x.loading, close: true, cdis: !x.name },
D('display:flex;flex-direction:column;gap:16px', x.notice && Alert('error', 'circle-x', 'Couldn\'t create the author', 'The article is unchanged. Try again in a moment.'),
F('Name', x.name || 'e.g. Camille Roche', { val: !!x.name, focus: !x.name && !x.loading, err: x.err, dis: x.loading }),
Slug('Slug', { mode: x.name ? (x.err ? 'manual' : 'auto') : 'empty', value: x.slug, help: 'Generated from the name. Unique across all authors.', err: x.slugErr, suggest: x.suggest }),
x.me && D('display:flex;gap:10px;align-items:flex-start', S('width:20px;height:20px;flex-shrink:0;margin-top:1px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-primary);background:var(--ui-primary);color:var(--ui-bg)', I('check', 14)), D('display:flex;flex-direction:column', S('font-weight:500;color:var(--ui-text-highlighted)', 'This is me'), S('color:var(--ui-text-muted);font-size:12px', 'Links the author to marta@renew.example.')))))
}
// ---------- Editor: search and sharing
const ATT = 'How we price a bathroom renovation', ASLUG = 'how-we-price-a-bathroom-renovation', AD1 = 'A bathroom renovation is priced by room size, plumbing changes and finish level.'
const SUG = {
title_long: ['The meta title is 74 characters. Search engines may cut it off after about 60.', 'meta title'],
title_empty: ['No meta title yet. The article title is used instead.', 'meta title'],
desc_short: ['The description is 41 characters. About 70 to 160 reads best in results.', 'meta description'],
desc_long: ['The description is 212 characters. Search engines may cut it off after about 160.', 'meta description'],
cover_alt: ['The cover image has no alt text. Add a short description of it.', 'cover image'],
cover_none: ['No cover image. The site\'s social image is used.', 'cover image'],
slug_long: ['The slug is 86 characters. Shorter addresses are easier to share.', 'URL slug'],
no_au: ['This article has no authors, so the structured data has no author.', 'authors'],
no_h: ['The body has no headings. H2 headings help readers scan the article.', 'body'],
body_alt: ['2 images in the body have no alt text.', 'body'],
short: ['The article is under 300 words. That is fine for a short note.', 'body']
}
const Chip = t => S('height:20px;padding:0 6px;align-self:flex-start;display:inline-flex;align-items:center;gap:4px;border:1px dashed var(--ui-border-accented);font:500 11px/16px \'IBM Plex Mono\',monospace;color:var(--ui-text-toned);white-space:nowrap', I('corner-down-right', 10), t)
const Ct = (n, lo, hi) => S('font:400 12px \'IBM Plex Mono\',monospace;color:' + (n && (n < lo || n > hi) ? 'var(--ui-warning)' : 'var(--ui-text-muted)'), n + ' / ' + hi)
const FW = (lab, val, o = {}) => D('display:flex;flex-direction:column;gap:6px;min-width:0', D('display:flex;justify-content:space-between;align-items:center;gap:8px', S('font-weight:500;color:var(--ui-text-highlighted)', lab, !o.req && S('font-weight:400;color:var(--ui-text-muted)', ' (optional)')), o.ct), F(null, val, o), o.fb && Chip(o.fb))
const Dis = (t, o = {}) => D('display:flex;flex-direction:column;gap:12px;padding:12px;border:1px solid var(--ui-border)', D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 44 : 24) + 'px;color:var(--ui-text-highlighted)', I(o.open ? 'chevron-down' : 'chevron-right', 14), S('font-weight:600', t), o.tag && Note(o.tag), S('flex:1;text-align:right;font-size:12px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', o.sum)), ...(o.open ? o.kids : []))
const SugRow = (c, o = {}, i = 0) => D('display:flex;flex-direction:column;gap:6px', D('display:flex;align-items:flex-start;gap:8px;opacity:' + (o.busy === i ? 0.6 : 1), S('display:flex;padding-top:2px;color:var(--ui-warning)', I('lightbulb', 14)), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S('text-wrap:pretty;color:var(--ui-text-highlighted)', SUG[c][0]), Link('Go to ' + SUG[c][1], 'arrow-right')), !o.compact && S('width:' + (m ? 44 : 24) + 'px;height:' + (m ? 44 : 24) + 'px;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I(o.busy === i ? 'loader-circle' : 'x', 14))), o.fail === i && D('margin-left:22px;display:flex;flex-direction:column;gap:4px;padding:8px 10px;background:var(--ui-bg);border:1px solid var(--ui-error)', S('font-weight:600;color:var(--ui-error)', 'Couldn\'t dismiss the suggestion'), S('color:var(--ui-text-muted)', 'Try again in a moment.'), Link('Try again', 'rotate-cw')))
const Sugs = (codes, st, o = {}) => {
const box = (...c) => D('display:flex;flex-direction:column;gap:12px;padding:12px;background:var(--ui-bg-elevated)', ...c)
if (st === 'loading') return box(D('display:flex;align-items:center;gap:8px;color:var(--ui-text-toned)', I('loader-circle', 14), 'Checking this article…'), ...[70, 90, 55].map(w => S('display:block;height:12px;width:' + w + '%;background:var(--ui-bg-accented)')))
if (st === 'error') return box(Alert('error', 'circle-x', 'Couldn\'t check this article.', 'Your article is unchanged. Search and sharing still saves.', 'Try again'))
if (!codes.length) return box(D('display:flex;align-items:flex-start;gap:8px', S('display:flex;padding-top:2px;color:var(--ui-success)', I('circle-check', 14)), D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;color:var(--ui-text-highlighted)', 'All good'), S('color:var(--ui-text-muted)', o.dismissed ? o.dismissed + ' dismissed suggestions are hidden.' : 'Nothing to suggest for this article.')), o.dismissed && Link('Show dismissed')))
const shown = o.compact ? codes.slice(0, 3) : codes
return box(...shown.map((c, i) => SugRow(c, o, i)), o.compact && codes.length > 3 && S('color:var(--ui-text-muted);font-size:12px', '+' + (codes.length - 3) + ' more in Search and sharing'), !o.compact && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', 'Suggestions never block saving or publishing. Dismiss one and it stays hidden for this article.' ))
}
const SEOSIDE = (k) => {
const codes = k.codes || [], st = k.state || 'ok'
return D('display:flex;flex-direction:column;gap:12px',
D('display:flex;flex-direction:column;gap:4px;padding:8px;background:var(--ui-bg-elevated)', S('font-size:12px;color:var(--ui-text-muted)', 'Address'), S('font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-highlighted);word-break:break-all', host(SITES[k.site || 0].url) + '/' + (k.slug || ASLUG))),
k.hidden && D('display:flex;gap:8px;color:var(--ui-warning)', S('display:flex;padding-top:2px', I('eye-off', 14)), S('color:var(--ui-text-toned);text-wrap:pretty', 'Hidden from search engines. This only sets a flag in the API.')),
Sugs(codes, st, { compact: true, dismissed: k.dismissed }),
D('display:flex;flex-direction:column;gap:8px', B('Edit search and sharing', { icon: 'search', h: m ? 44 : 32 }), B('What the frontend gets', { icon: 'braces', h: m ? 44 : 32 })))
}
const trunc = (t, n) => [...t].length > n ? [...t].slice(0, n - 1).join('').trimEnd() + '…' : t
const SERP = (p, site) => {
const dev = p.dev || 'desktop', tt = trunc((p.mt || ATT) + ' | ' + site.n, dev === 'desktop' ? 60 : 70)
const dd = p.md || p.ex || AD1, dt = trunc(dd, dev === 'desktop' ? 160 : 120)
const fb = [!p.mt && 'Using article title', !p.md && p.ex && 'Using excerpt', !p.md && !p.ex && 'Using first sentence of the body'].filter(Boolean)
return D('display:flex;flex-direction:column;gap:8px', D('display:flex;justify-content:space-between;align-items:center;gap:8px', S('font-weight:600;color:var(--ui-text-highlighted)', 'Search result'), D('display:flex;border:1px solid var(--ui-border-accented);width:150px', ...[['desktop', 'Desktop'], ['mobile', 'Mobile']].map(([k, t]) => S('flex:1;height:' + (m ? 44 : 24) + 'px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:' + (dev === k ? 500 : 400) + ';background:' + (dev === k ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (dev === k ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)'), t)))),
D('padding:14px;border:1px solid var(--ui-border);background:var(--ui-bg);display:flex;flex-direction:column;gap:4px;' + (dev === 'mobile' && !m ? 'width:280px;' : ''), D('display:flex;align-items:center;gap:8px;min-width:0', S('width:22px;height:22px;flex-shrink:0;display:flex;align-items:center;justify-content:center;border-radius:50%;background:var(--ui-bg-elevated);font:600 11px/1 \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', site.n[0]), D('display:flex;flex-direction:column;min-width:0', S('font-size:12px;line-height:16px;color:var(--ui-text-highlighted)', site.n), S('font-size:11px;line-height:14px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', host(site.url) + ' › ' + (p.slug || ASLUG).slice(0, 36)))), S('font:400 ' + (dev === 'desktop' ? 18 : 16) + 'px/1.3 \'IBM Plex Sans\',sans-serif;color:var(--ui-info);margin-top:4px;word-break:break-word', tt), S('font-size:13px;line-height:19px;color:var(--ui-text-toned);word-break:break-word', dt)),
fb.length ? D('display:flex;gap:6px;flex-wrap:wrap', ...fb.map(Chip)) : null, (p.mt || ATT).length + site.n.length + 3 > (dev === 'desktop' ? 60 : 70) ? S('font-size:12px;color:var(--ui-text-muted)', 'Cut off here, the way a search engine would.') : null)
}
const SOC = (p, site) => {
const sx = p.soc || {}, hasImg = !!(sx.img || p.cover), t = sx.t || p.mt || ATT, d = sx.d || p.md || p.ex || AD1
return D('display:flex;flex-direction:column;gap:8px', S('font-weight:600;color:var(--ui-text-highlighted)', 'Social card'),
D('border:1px solid var(--ui-border);background:var(--ui-bg)', S('display:flex;align-items:center;justify-content:center;aspect-ratio:1.91/1;background:repeating-linear-gradient(135deg, var(--ui-border) 0 1px, transparent 1px 12px), var(--rc-sand);color:var(--ui-text-muted);font:400 11px \'IBM Plex Mono\',monospace', hasImg ? (sx.img || 'north-wall.jpg') : (site.img || 'site social image')), D('padding:10px 12px;display:flex;flex-direction:column;gap:2px;border-top:1px solid var(--ui-border)', S('font-size:11px;line-height:16px;text-transform:uppercase;color:var(--ui-text-muted)', host(site.url)), S('font-weight:600;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', t), S('font-size:12px;line-height:18px;color:var(--ui-text-toned);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical', d))),
D('display:flex;gap:6px;flex-wrap:wrap', ...[!sx.t && 'Using meta title', !sx.d && 'Using meta description', !sx.img && (p.cover ? 'Using cover image' : 'Using site social image')].filter(Boolean).map(Chip)))
}
const seoPanel = () => {
const p = s.seo.panel, site = SITES[p.site || 0], dis = p.state === 'saving', er = p.err || {}
const sl = p.slug || ASLUG, sm = p.slugMode || 'auto'
const cv = p.cover, soc = p.soc || {}
const left = D('display:flex;flex-direction:column;gap:18px;margin-top:' + (p.scrollY ? -p.scrollY + 'px' : '0'),
D('display:flex;flex-direction:column;gap:8px', Slug('URL slug', { mode: sm === 'taken' ? 'manual' : sm, value: sl, err: sm === 'taken' ? 'The slug "' + sl + '" is already used on ' + site.n + '.' : er.slug, suggest: sm === 'taken' ? sl + '-2' : null, reset: 'Reset to match the title', emptyText: 'Generated from the title', help: 'Generated from the title until you edit it. Lowercase letters, numbers and hyphens. Unique on ' + site.n + '.', help2: 'Lowercase letters, numbers and hyphens. Unique on ' + site.n + '.' }), D('display:flex;gap:6px;align-items:center;padding:8px 10px;background:var(--ui-bg-elevated);min-width:0', I('link', 12, 'color:var(--ui-text-muted)'), S('font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-highlighted);word-break:break-all', site.url + '/' + sl))),
FW('Meta title', p.mt || ATT, { val: !!p.mt, ct: Ct([...(p.mt || '')].length, 30, 60), focus: p.focus === 'mt', dis, fb: p.mt ? null : 'Using article title', help: 'Recommended 30 to 60 characters. This is a guide, not a limit.' }),
FW('Meta description', p.md || p.ex || AD1, { val: !!p.md, area: true, ct: Ct([...(p.md || '')].length, 70, 160), focus: p.focus === 'md', dis, fb: p.md ? null : p.ex ? 'Using excerpt' : 'Using first sentence of the body', help: 'Recommended 70 to 160 characters.' }),
FW('Excerpt', p.ex || 'A short summary. It is also sent as the article teaser.', { val: !!p.ex, area: true, ct: S('font:400 12px \'IBM Plex Mono\',monospace;color:' + (p.exErr ? 'var(--ui-error)' : 'var(--ui-text-muted)'), [...(p.ex || '')].length + ' / 300'), err: p.exErr, dis, help: p.exErr ? null : 'Used as the description when there is none, and as the teaser for the frontend.' }),
D('display:flex;flex-direction:column;gap:10px', Pick(cv ? 'north-wall.jpg' : null, 'Cover image', cv ? null : 'Using the site social image' + (site.img ? ' (' + site.img + ')' : '') + '.'), cv && FW('Alt text', cv.alt || 'Describe the image for people who can\'t see it', { req: true, val: !!cv.alt, dis, help: cv.alt ? null : 'Needed before the cover counts as ready. Sent as image alt in the API.' })),
Dis('Social sharing', { open: p.social, tag: 'Optional', sum: p.social ? '' : (soc.t || soc.d || soc.img) ? 'Customised' : 'Uses the meta values', kids: [FW('Social title', soc.t || p.mt || ATT, { val: !!soc.t, ct: Ct([...(soc.t || '')].length, 0, 90), dis, fb: soc.t ? null : 'Using meta title' }), FW('Social description', soc.d || p.md || p.ex || AD1, { val: !!soc.d, area: true, dis, fb: soc.d ? null : 'Using meta description' }), Pick(soc.img || null, 'Social image', soc.img ? null : 'Using the cover image.')] }),
Dis('Advanced', { open: p.adv, sum: p.adv ? '' : p.canon ? 'Canonical URL set' : 'Canonical URL: automatic', kids: [FW('Canonical URL', p.canon || site.url + '/' + sl, { val: !!p.canon, mono: true, dis, err: er.canon, focus: p.focus === 'canon', fb: p.canon ? null : 'Using site address and slug', help: er.canon ? null : 'Only change this if the same content lives at another address.' })] }),
Sw(!!p.noindex, 'Hide from search engines', 'Sets noindex in the API. It doesn\'t change who can read the article.', dis))
const right = D('display:flex;flex-direction:column;gap:18px;min-width:0', SERP(p, site), SOC(p, site), D('display:flex;flex-direction:column;gap:8px', S('font-weight:600;color:var(--ui-text-highlighted)', 'Suggestions'), Sugs(p.codes || [], p.sugState, { dismissed: p.dismissed, busy: p.busy, fail: p.fail })), D('display:flex;gap:8px;align-items:center', B('What the frontend gets', { icon: 'braces', h: m ? 44 : 28, fs: 12, px: 10 })))
const body = m ? D('display:flex;flex-direction:column;gap:18px', Sugs(p.codes || [], p.sugState, { dismissed: p.dismissed, busy: p.busy, fail: p.fail }), left, SERP(p, site), SOC(p, site), B('What the frontend gets', { icon: 'braces' })) : D('display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:28px;height:100%;overflow:hidden', D('overflow:hidden;min-height:0', left), right)
return Panel({ title: 'Search and sharing', w: 960, z: 60, notice: p.notice, saving: dis, cdis: p.noChange, confirm: dis ? 'Saving…' : 'Save changes' }, body)
}
const API = { title: ATT + ' | Renew', description: AD1, canonical_url: 'https://renew.example/' + ASLUG, robots: 'index, follow', og: { type: 'article', title: ATT + ' | Renew', description: AD1, url: 'https://renew.example/' + ASLUG, site_name: 'Renew', image: 'https://renew.example/media/north-wall.jpg', image_alt: 'A tiled bathroom wall during renovation', locale: 'en' }, twitter: { card: 'summary_large_image', title: ATT + ' | Renew', description: AD1, image: 'https://renew.example/media/north-wall.jpg', image_alt: 'A tiled bathroom wall during renovation' }, image: { url: 'https://renew.example/media/north-wall.jpg', alt: 'A tiled bathroom wall during renovation', source: 'cover_image' }, authors: [{ name: 'Inès Duarte', url: 'https://ines-duarte.example', same_as: ['https://www.linkedin.com/in/ines-duarte'] }], published_at: '2026-03-12T09:14:00Z', modified_at: '2026-10-15T11:02:00Z', fallbacks: { title: 'article_title', description: 'body_first_sentence', image: 'cover_image', canonical: 'site_base_url_and_slug' }, json_ld: { '@context': 'https://schema.org', '@type': 'Article', headline: ATT, description: AD1, image: ['https://renew.example/media/north-wall.jpg'], author: [{ '@type': 'Person', name: 'Inès Duarte', url: 'https://ines-duarte.example', sameAs: ['https://www.linkedin.com/in/ines-duarte'] }], datePublished: '2026-03-12T09:14:00Z', dateModified: '2026-10-15T11:02:00Z', mainEntityOfPage: 'https://renew.example/' + ASLUG } }
const apiDialog = () => {
const x = s.seo.api, raw = x.view === 'raw', st = x.state || 'ok'
const bar = D('display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap', D('display:flex;border:1px solid var(--ui-border-accented);width:200px', ...[['formatted', 'Formatted'], ['raw', 'Raw JSON']].map(([k, t]) => S('flex:1;height:' + bh + 'px;display:flex;align-items:center;justify-content:center;font-weight:' + ((raw ? 'raw' : 'formatted') === k ? 500 : 400) + ';background:' + ((raw ? 'raw' : 'formatted') === k ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + ((raw ? 'raw' : 'formatted') === k ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)'), t))), B(x.copied ? 'Copied' : 'Copy JSON', { icon: x.copied ? 'check' : 'copy', dis: st !== 'ok' }))
const KV = (k, v, fbk) => D('display:grid;grid-template-columns:' + (m ? '1fr' : '130px minmax(0,1fr)') + ';gap:' + (m ? 2 : 12) + 'px;padding:6px 0;border-bottom:1px solid var(--ui-border-muted)', S('color:var(--ui-text-muted);font:400 12px/20px \'IBM Plex Mono\',monospace', k), D('display:flex;gap:8px;flex-wrap:wrap;align-items:center;min-width:0', S('color:var(--ui-text-highlighted);word-break:break-all;' + (m ? '' : ''), v), fbk && Chip(fbk)))
const cf = x.copyFail && D('display:flex;gap:8px;padding:10px 12px;background:var(--rc-warning-soft, var(--ui-bg-elevated));color:var(--ui-warning)', I('triangle-alert', 14, 'margin-top:3px'), S('color:var(--ui-text-toned);text-wrap:pretty', 'The browser blocked copying. The JSON is selected below: press Ctrl+C or Cmd+C to copy it.'))
let body
if (st === 'loading') body = D('display:flex;flex-direction:column;gap:12px', ...[40, 80, 60, 70, 50, 75].map(w => S('display:block;height:14px;width:' + w + '%;background:var(--ui-bg-accented)')))
else if (st === 'error') body = Alert('error', 'circle-x', 'Couldn\'t load the SEO data', 'GET /v1/article/12/seo failed. Your article is unchanged. Try again in a moment.', 'Try again')
else if (raw) body = S('display:block;max-height:' + (m ? 440 : 400) + 'px;overflow:hidden;padding:12px;background:' + (x.copyFail ? 'var(--ui-info-soft, var(--rc-info-soft))' : 'var(--ui-bg-elevated)') + ';' + (x.copyFail ? 'outline:2px solid var(--ui-info);' : '') + 'white-space:pre;font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-highlighted)', JSON.stringify(API, null, 2))
else body = D('display:flex;flex-direction:column', KV('title', API.title, 'article title + template'), KV('description', API.description, 'first sentence of the body'), KV('canonical_url', API.canonical_url, 'site address + slug'), KV('robots', API.robots, 'site default'), KV('og.type', 'article'), KV('og.image', API.image.url, 'cover image'), KV('og.image_alt', API.image.alt), KV('twitter.card', 'summary_large_image'), KV('authors', 'Inès Duarte · ines-duarte.example · 1 social link'), KV('published_at', '12 Mar 2026, 09:14'), KV('modified_at', '15 Oct 2026, 11:02'), KV('json_ld', 'Article · headline, image, author, datePublished, dateModified, mainEntityOfPage'))
return Shell({ title: 'What the frontend gets', desc: 'The SEO data returned by GET /v1/article/12/seo, read only. RenewCMS doesn\'t render pages or add meta tags: your frontend does.', close: true, w: 720, top: 40, z: 60, cancel: 'Close' }, D('display:flex;flex-direction:column;gap:12px', cf, x.draft && D('display:flex;gap:8px;padding:10px 12px;background:var(--rc-info-soft);color:var(--ui-info)', I('info', 14, 'margin-top:3px'), S('color:var(--ui-text-toned)', 'This article is a draft. The API returns this data only to signed-in callers until it is published.')), bar, body))
}
// ---------- compose
const v = s.v
if (v === 'sites') {
Object.assign(shellBase, { nav: 'settings', crumbs: ['Settings', 'Sites'], mTitle: 'Sites', role: s.role }); shell = shellBase
main = D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;position:relative', sitesPage())
if (s.panel) ov.push(siteForm())
if (s.modal) ov.push(sitesModal())
if (!s.menu && s.fab !== false && !(s.sites && ['loading', 'error'].includes(s.sites.state))) ov.push(fab('Add site'))
} else if (v === 'authors') {
Object.assign(shellBase, { nav: 'authors', crumbs: ['Authors'], mTitle: 'Authors', role: s.role }); shell = shellBase
main = D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;position:relative', authorsPage())
if (s.panel) ov.push(s.panel.k === 'authorView' ? authorView() : authorForm())
if (s.modal) ov.push(authorsModal())
if (s.list !== 'empty' && s.list !== 'error' && s.list !== 'loading') ov.push(fab('Add author'))
} else if (v === 'editor') {
if (s.metaOpen) edMeta = metaUI()
if (s.seo && s.seo.side) edSeo = SEOSIDE(s.seo.side)
if (s.seo && s.seo.panel) ov.push(seoPanel())
if (s.seo && s.seo.pick) ov.push(Picker({ title: 'Choose a cover image', desc: 'The cover for this article.', sel: 1, z: 70 }))
if (s.seo && s.seo.api) ov.push(apiDialog())
if (s.modal && m) { ov.push(Panel({ title: 'Details', z: 55, ro: true, confirm: '' }, metaUI())); edMeta = null }
if (s.modal) ov.push(newAuthorModal())
if (s.ann) ov.push(D('position:absolute;z-index:20;' + (m ? 'left:16px;right:16px;bottom:88px' : 'left:248px;bottom:20px'), Note(s.ann)))
} else if (v === 'profile') {
if (s.panel) ov.push(authorForm())
}
if (s.toasts) ov.push(Toasts())
return { shell, mob: m, main, ov: D('display:contents', ...ov), edMeta, edSeo }
}
}
</script>
</body>
</html>
+1 -1
View File
@@ -104,7 +104,7 @@ class Component extends DCLogic {
const dims = cur[1].split('×')
const ratio = dims.length === 2 && !missing ? dims[0] + ' / ' + dims[1] : '4 / 3'
const rows = list.map((v, i) => ({ n: v[0], dim: v[1], fmt: v[2], url: '/media/' + img + '?v=' + v[0], bg: sel === v[0] ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)' }))
const resp = [['/media/photo.png?v=thumb', '200', 'The variant image. Generated on first request.'], ['/media/photo.png?v=unknown', '400', 'Unknown variant', '{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "unknown" }'], ['/media/photo.png?width=400', '400', 'Unsupported parameter', '{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" }'], ['/media/missing.png?v=thumb', '404', 'Not found', '']].map((r, i) => ({ req: r[0], st: r[1], res: r[2], body: r[3], bt: i ? '1px solid var(--ui-border)' : '0' }))
const resp = [['/static/uploadedImages/photo.png?v=thumb', '200', 'The variant image. Generated on first request.'], ['/media/photo.png?v=unknown', '400', 'Unknown variant', '{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "unknown" }'], ['/media/photo.png?width=400', '400', 'Unsupported parameter', '{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" }'], ['/media/missing.png?v=thumb', '404', 'Not found', '']].map((r, i) => ({ req: r[0], st: r[1], res: r[2], body: r[3], bt: i ? '1px solid var(--ui-border)' : '0' }))
const hasVariants = list.length > 0
return {
gap: m ? '20px' : '24px', btn: m ? '44px' : '32px', inH: m ? '44px' : '32px', pad: m ? '16px' : '20px', bCols: m ? 'minmax(0,1fr)' : 'minmax(0,1fr) minmax(0,1fr)',
+91 -32
View File
@@ -9,8 +9,9 @@
<x-dc>
<div ref="{{ rootRef }}" onScroll="{{ onScroll }}" style="position:absolute;inset:0;overflow-y:auto;overflow-x:hidden;scrollbar-width:thin;background:var(--ui-bg-muted);color:var(--ui-text);font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px">
<sc-if value="{{ mob }}">
<div style="position:sticky;top:0;z-index:5;background:var(--ui-bg);border-bottom:1px solid var(--ui-border);padding:8px 16px;display:flex;flex-direction:column;gap:4px">
<button aria-haspopup="listbox" aria-expanded="{{ jumpOpen }}" aria-label="Jump to section" onClick="{{ toggleJump }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:44px;padding:0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-size:14px" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px"><span style="color:var(--ui-text-muted)">Jump to</span><span style="flex:1;font-weight:500;color:var(--ui-text-highlighted)">{{ activeLabel }}</span><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:var(--ui-text-muted);-webkit-mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat"></span></button>
<div style="position:sticky;top:0;z-index:5;background:var(--ui-bg);border-bottom:1px solid var(--ui-border);padding:8px 16px;display:flex;align-items:center;gap:8px">
<button aria-haspopup="listbox" aria-expanded="{{ jumpOpen }}" aria-label="Jump to section" onClick="{{ toggleJump }}" style="all:unset;box-sizing:border-box;cursor:pointer;flex:1;min-width:0;height:44px;padding:0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-size:14px" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px"><span style="color:var(--ui-text-muted)">Jump to</span><span style="flex:1;font-weight:500;color:var(--ui-text-highlighted)">{{ activeLabel }}</span><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:var(--ui-text-muted);-webkit-mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat"></span></button>
<button aria-label="Search the API reference" aria-haspopup="dialog" style="all:unset;box-sizing:border-box;cursor:pointer;flex-shrink:0;width:44px;height:44px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px"><span aria-hidden="true" style="display:inline-block;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span></button>
<sc-if value="{{ jumpOpen }}"><div role="listbox" aria-label="Sections" style="position:absolute;left:16px;right:16px;top:56px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px">
<sc-for list="{{ index }}" as="i" hint-placeholder-count="8"><button role="option" aria-selected="{{ i.on }}" onClick="{{ i.go }}" style="all:unset;box-sizing:border-box;cursor:pointer;width:100%;height:44px;padding:0 10px;display:flex;align-items:center;justify-content:space-between;font-size:14px;background:{{ i.bg }};color:var(--ui-text-highlighted);font-weight:{{ i.fw }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">{{ i.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ i.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></button></sc-for>
</div></sc-if>
@@ -19,12 +20,15 @@
<div style="position:relative;padding:{{ pad }};display:flex;gap:40px;align-items:flex-start">
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:{{ secGap }}">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px">
<div style="display:flex;flex-direction:column;gap:6px">
<span style="color:var(--ui-text-muted)">Integration</span>
<span style="font:600 {{ h1 }} 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API reference</span>
<span style="color:var(--ui-text-muted);font-size:14px;line-height:22px">Everything a developer needs to build on RenewCMS.</span>
</div>
<p style="margin:0;font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty;max-width:720px">RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office.</p>
<sc-if value="{{ desk }}"><button aria-label="Search the API reference" aria-keyshortcuts="/ Control+K Meta+K" aria-haspopup="dialog" style="all:unset;box-sizing:border-box;cursor:pointer;width:320px;flex-shrink:0;height:36px;padding:0 8px 0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);outline:{{ trigRing }};outline-offset:2px" style-hover="border-color:var(--ui-text-muted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:var(--ui-text-muted);-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span><span style="flex:1;min-width:0;color:var(--ui-text-muted);font-size:14px">Search the API reference…</span><span aria-hidden="true" style="height:20px;min-width:20px;padding:0 6px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">/</span></button></sc-if>
</div>
<p style="margin:0;font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty;max-width:720px">RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users, and manage content types and entries, without the back-office.</p>
<div style="display:flex;flex-direction:column;gap:6px">
<span style="font-weight:500;color:var(--ui-text-highlighted)">Base URL</span>
@@ -41,14 +45,14 @@
</div>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:10px;min-width:0">
<div style="display:flex;align-items:center;gap:8px"><span style="font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API key</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:var(--rc-success-soft);color:var(--ui-success);font-size:12px;font-weight:500">Full access</span></div>
<span style="color:var(--ui-text-toned);text-wrap:pretty">Send <code>Authorization: Bearer rcms_…</code>. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator.</span>
<span style="color:var(--ui-text-toned);text-wrap:pretty">Send <code>Authorization: Bearer rcms_…</code>. Requests act as the user who created the key, with that user's role as it is now. A key never has more rights than its creator. A route that needs a permission answers 403 "missing_permission" to a key whose creator's role lacks it.</span>
<dc-import name="CodeSnippet" tabs="{{ authSnips }}" hint-size="100%,160px"></dc-import>
<span style="color:var(--ui-text-muted);text-wrap:pretty">Keys start with <code>rcms_</code> and are shown in full once, when you create them.</span>
<a href="#" style="align-self:flex-start;display:inline-flex;align-items:center;gap:6px;min-height:{{ linkH }};font-weight:500;color:var(--ui-text-highlighted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">Manage API keys<span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/arrow-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/arrow-right.svg') center / contain no-repeat"></span></a>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px;font-size:14px;line-height:22px;color:var(--ui-text-toned)">
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under <code>/v1/system</code>. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified.</span></div>
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under <code>/v1/system</code>. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified. Owner and admin routes answer 403 to a key whose creator is neither.</span></div>
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Each key can make 120 requests per minute. Beyond that the API answers 429 with a <code>Retry-After</code> header.</span></div>
</div>
</section>
@@ -57,10 +61,15 @@
<section data-anchor="{{ g.key }}" style="display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap"><h2 style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2><span style="color:var(--ui-text-muted)">{{ g.count }}</span></div>
<span style="color:var(--ui-text-toned);text-wrap:pretty">{{ g.desc }}</span>
<sc-if value="{{ g.hasNote }}"><div style="display:flex;gap:10px;font-size:14px;line-height:22px;color:var(--ui-text-toned)"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty"><sc-for list="{{ g.noteParts }}" as="p" hint-placeholder-count="2"><sc-if value="{{ p.c }}"><code>{{ p.t }}</code></sc-if><sc-if value="{{ p.p }}">{{ p.t }}</sc-if></sc-for></span></div></sc-if>
<sc-if value="{{ g.hasFt }}"><div data-anchor="{{ g.ftA }}" style="display:flex;flex-direction:column;gap:6px"><span style="font-weight:600;color:var(--ui-text-highlighted)">Field types</span><div style="border:1px solid var(--ui-border)">
<div style="display:grid;grid-template-columns:{{ g.ftCols }};gap:4px 12px;padding:6px 10px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Type</span><span>Value in an entry</span><sc-if value="{{ desk }}"><span>Rules</span></sc-if></div>
<sc-for list="{{ g.ftRows }}" as="r" hint-placeholder-count="8"><div style="display:grid;grid-template-columns:{{ g.ftCols }};gap:2px 12px;padding:8px 10px;border-top:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.t }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty"><sc-for list="{{ r.v }}" as="p" hint-placeholder-count="2"><sc-if value="{{ p.c }}"><code>{{ p.t }}</code></sc-if><sc-if value="{{ p.p }}">{{ p.t }}</sc-if></sc-for></span><span style="grid-column:{{ g.ftRule }};color:var(--ui-text-toned);text-wrap:pretty"><sc-for list="{{ r.r }}" as="p" hint-placeholder-count="2"><sc-if value="{{ p.c }}"><code>{{ p.t }}</code></sc-if><sc-if value="{{ p.p }}">{{ p.t }}</sc-if></sc-for></span></div></sc-for></div></div></sc-if>
<sc-if value="{{ g.hasSnip }}"><dc-import name="CodeSnippet" tabs="{{ g.snip }}" hint-size="100%,160px"></dc-import></sc-if>
<div style="border:1px solid var(--ui-border);border-top:0;background:var(--ui-bg)">
<sc-for list="{{ g.eps }}" as="ep" hint-placeholder-count="6">
<div data-anchor="{{ ep.anchor }}" style="border-top:1px solid var(--ui-border)">
<button aria-expanded="{{ ep.open }}" aria-label="{{ ep.label }}" onClick="{{ ep.toggle }}" style="all:unset;box-sizing:border-box;cursor:pointer;width:100%;display:{{ rowDisplay }};grid-template-columns:{{ rowCols }};flex-direction:column;align-items:{{ rowAlign }};gap:{{ rowGap }};padding:{{ rowPad }};min-height:44px;background:{{ ep.rowBg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">
<button aria-expanded="{{ ep.open }}" aria-label="{{ ep.label }}" onClick="{{ ep.toggle }}" style="all:unset;box-sizing:border-box;cursor:pointer;width:100%;display:{{ rowDisplay }};grid-template-columns:{{ rowCols }};flex-direction:column;align-items:{{ rowAlign }};gap:{{ rowGap }};padding:{{ rowPad }};min-height:44px;background:{{ ep.rowBg }};outline:{{ ep.ring }};outline-offset:-2px" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">
<span style="display:flex;align-items:center;gap:10px;min-width:0"><span style="width:56px;height:22px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:{{ ep.mBg }};color:{{ ep.mFg }};border:1px solid {{ ep.mBd }};font:500 11px 'IBM Plex Mono',monospace">{{ ep.m }}</span><span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ ep.p }}</span><sc-if value="{{ mob }}"><span style="flex:1"></span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;color:var(--ui-text-muted);background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ ep.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ ep.chev }}.svg') center / contain no-repeat"></span></sc-if></span>
<span style="min-width:0;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:{{ descWrap }}">{{ ep.d }}</span>
<span style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-start"><sc-for list="{{ ep.chips }}" as="c" hint-placeholder-count="2"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;background:{{ c.bg }};color:{{ c.fg }};border:1px {{ c.bs }} {{ c.bd }};font-size:12px;font-weight:{{ c.fw }}"><sc-if value="{{ c.icon }}"><span aria-hidden="true" style="display:inline-block;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat"></span></sc-if>{{ c.t }}</span></sc-for></span>
@@ -73,9 +82,9 @@
<div style="display:grid;grid-template-columns:{{ fCols }};gap:4px 12px;padding:6px 10px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Name</span><span>Type</span><span>Required</span><sc-if value="{{ desk }}"><span>Rule</span></sc-if></div>
<sc-for list="{{ ep.fields }}" as="f" hint-placeholder-count="2"><div style="display:grid;grid-template-columns:{{ fCols }};gap:2px 12px;padding:8px 10px;border-top:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ f.n }}</span><span style="font:400 12px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)">{{ f.t }}</span><span style="color:var(--ui-text-toned)">{{ f.req }}</span><span style="grid-column:{{ ruleCol }};color:var(--ui-text-toned);text-wrap:pretty">{{ f.rule }}</span></div></sc-for></div></sc-if>
<sc-if value="{{ ep.noFields }}"><span style="color:var(--ui-text-muted)">No request body.</span></sc-if></div>
<dc-import name="CodeSnippet" tabs="{{ ep.snips }}" hint-size="100%,200px"></dc-import>
<dc-import name="CodeSnippet" tabs="{{ ep.snips }}" active="{{ ep.tabSet }}" hint-size="100%,200px"></dc-import>
<div style="display:flex;flex-direction:column;gap:6px"><span style="font-weight:600;color:var(--ui-text-highlighted)">Errors</span>
<sc-for list="{{ ep.errs }}" as="e" hint-placeholder-count="2"><div title="{{ e.cap }}" style="display:flex;align-items:baseline;gap:10px"><span style="width:36px;height:20px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">{{ e.c }}</span><span style="min-width:0;color:var(--ui-text-toned);text-wrap:pretty">{{ e.cap }}</span></div></sc-for></div>
<sc-for list="{{ ep.errs }}" as="e" hint-placeholder-count="2"><div title="{{ e.cap }}" style="display:flex;align-items:baseline;gap:10px"><span style="width:36px;height:20px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">{{ e.c }}</span><sc-if value="{{ e.plain }}"><span style="min-width:0;color:var(--ui-text-toned);text-wrap:pretty">{{ e.cap }}</span></sc-if><sc-if value="{{ e.code }}"><span style="min-width:0;color:var(--ui-text-toned)"><code>{{ e.cap }}</code><sc-if value="{{ e.hasTail }}"><span style="margin-left:6px">{{ e.tail }}</span></sc-if></span></sc-if></div></sc-for></div>
</div>
</sc-if>
</div>
@@ -85,7 +94,7 @@
</sc-for>
<section data-anchor="session" aria-labelledby="h-sess" style="display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap"><h2 id="h-sess" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Session only</h2><span style="color:var(--ui-text-muted)">12 routes, not available to API keys</span></div>
<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap"><h2 id="h-sess" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Session only</h2><span style="color:var(--ui-text-muted)">{{ sessCount }}</span></div>
<div style="background:var(--ui-bg-muted);border:1px dashed var(--ui-border-accented)">
<div style="display:flex;gap:10px;padding:12px 14px;color:var(--ui-text-toned);text-wrap:pretty"><span style="display:flex;padding-top:2px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span><span>These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session."</span></div>
<sc-for list="{{ sessGroups }}" as="sg" hint-placeholder-count="3">
@@ -104,7 +113,7 @@
<span style="color:var(--ui-text-toned);text-wrap:pretty">Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain.</span>
<div style="border:1px solid var(--ui-border);background:var(--ui-bg);overflow:hidden">
<div style="display:grid;grid-template-columns:{{ errCols }};gap:12px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Meaning</span><sc-if value="{{ desk }}"><span>Typical cause</span></sc-if></div>
<sc-for list="{{ errRows }}" as="r" hint-placeholder-count="8"><div style="display:grid;grid-template-columns:{{ errCols }};gap:2px 12px;padding:10px 12px;border-top:1px solid var(--ui-border)"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.c }}</span><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ r.m }}</span><span style="grid-column:{{ errCause }};color:var(--ui-text-toned);text-wrap:pretty">{{ r.w }}</span></div></sc-for>
<sc-for list="{{ errRows }}" as="r" hint-placeholder-count="8"><div style="display:grid;grid-template-columns:{{ errCols }};gap:2px 12px;padding:10px 12px;border-top:1px solid var(--ui-border)"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.c }}</span><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ r.m }}</span><span style="grid-column:{{ errCause }};color:var(--ui-text-toned);text-wrap:pretty"><sc-for list="{{ r.parts }}" as="p" hint-placeholder-count="2"><sc-if value="{{ p.c }}"><code>{{ p.t }}</code></sc-if><sc-if value="{{ p.p }}">{{ p.t }}</sc-if></sc-for></span><sc-if value="{{ r.n }}"><span style="grid-column:1 / -1;padding-top:6px;color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty"><sc-for list="{{ r.nparts }}" as="p" hint-placeholder-count="2"><sc-if value="{{ p.c }}"><code>{{ p.t }}</code></sc-if><sc-if value="{{ p.p }}">{{ p.t }}</sc-if></sc-for></span></sc-if></div></sc-for>
</div>
<dc-import name="CodeSnippet" tabs="{{ errSnips }}" hint-size="100%,120px"></dc-import>
</section>
@@ -130,11 +139,16 @@
</x-dc>
<script type="text/x-dc" data-dc-script>
class Component extends DCLogic {
state = (() => { const i = (this.props.s && this.props.s.int) || {}; return { open: i.open ? { [i.open]: true } : {}, tab: i.open ? { [i.open]: i.tab || 'fetch' } : {}, copied: i.copied || null, jump: !!i.jump, active: i.scroll === 'ep-' + i.open ? 'media' : (i.scroll || 'auth'), authTab: i.authTab || 'curl' } })()
state = (() => { const i = (this.props.s && this.props.s.int) || {}; return { open: i.open ? { [i.open]: true } : {}, tab: i.open ? { [i.open]: i.tab || 'fetch' } : {}, copied: i.copied || null, jump: !!i.jump, active: this.grpOf(i.scroll || 'auth'), authTab: i.authTab || 'curl', flash: i.flash || null } })()
rootRef = React.createRef()
BASE = 'https://cms.renew.example'
KEY = 'rcms_Kf3x9QwL0aBc…'
IDX = [['auth', 'Authentication'], ['articles', 'Articles'], ['media', 'Media'], ['images', 'Images and variants'], ['users', 'Users and invites'], ['settings', 'Settings'], ['session', 'Session only'], ['errors', 'Errors'], ['notes', 'Notes']]
IDX = [['auth', 'Authentication'], ['articles', 'Articles'], ['seo', 'Search and sharing'], ['ctypes', 'Content types'], ['entries', 'Entries'], ['builtin', 'Built-in types'], ['media', 'Media'], ['sites', 'Sites and authors'], ['images', 'Images and variants'], ['users', 'Users and invites'], ['settings', 'Settings'], ['session', 'Session only'], ['errors', 'Errors'], ['notes', 'Notes']]
grpOf(s) {
if (s.indexOf('ep-') !== 0 && s !== 'ct-fields') return s
const id = s.replace('ep-', ''), M = [['ct-', 'ctypes'], ['en-', 'entries'], ['bt-', 'builtin'], ['med-', 'media'], ['site-', 'sites'], ['author-', 'sites'], ['art-', 'articles'], ['seo-', 'seo'], ['sitemap', 'seo'], ['usr-', 'users'], ['auth-', 'users'], ['set-', 'settings']]
const m = M.find(x => id.indexOf(x[0]) === 0); return m ? m[1] : 'auth'
}
componentDidMount() {
const i = (this.props.s && this.props.s.int) || {}
const go = () => { if (i.scroll) this.scrollTo(i.scroll, !!this.props.mobile) }
@@ -150,8 +164,8 @@ class Component extends DCLogic {
yOf(el, r) { const w = el.offsetParent; return el.offsetTop + (w && w !== r ? w.offsetTop : 0) }
onScroll = () => {
const r = this.rootRef.current; if (!r) return
let cur = this.IDX[0][0]
for (const [k] of this.IDX) { const el = r.querySelector('[data-anchor="' + k + '"]'); if (el && this.yOf(el, r) <= r.scrollTop + 120) cur = k }
let cur = this.IDX[0][0], best = -1
for (const [k] of this.IDX) { const el = r.querySelector('[data-anchor="' + k + '"]'); if (!el) continue; const y = this.yOf(el, r); if (y <= r.scrollTop + 120 && y > best) { best = y; cur = k } }
if (cur !== this.state.active) this.setState({ active: cur })
}
flash(k) {
@@ -177,23 +191,63 @@ class Component extends DCLogic {
const i404 = [404, 'Invite not found']
const mm = 'The request cannot be processed due to a mismatch in the format of the body.'
const j = o => JSON.stringify(o, null, 2)
const eAd = [403, 'missing_permission', true], ctnf = [404, 'content_type_not_found', true], enf = [404, 'entry_not_found', true]
const Pp = s => s.split('`').map((x, i) => ({ t: x, c: i % 2 === 1, p: i % 2 === 0 })).filter(x => x.t)
const F = (key, label, type, o = {}) => ({ key, label, type, required: false, unique: false, default: null, min: null, max: null, options: null, relation: null, ...o })
const evF = [F('title', 'Title', 'text', { required: true, min: 3, max: 120 }), F('starts_on', 'Starts on', 'date', { required: true }), F('capacity', 'Capacity', 'number', { required: true, min: 1, max: 5000 }), F('online', 'Online', 'boolean'), F('category', 'Category', 'select', { options: ['Workshop', 'Talk', 'Open day'] }), F('cover', 'Cover', 'media')]
const evT = { id: 2, key: 'events', name: 'Events', icon: 'calendar', fields: evF, created_at: '2026-09-02T08:30:00Z', updated_at: '2026-10-20T14:05:00Z' }
const evD = { title: 'Open day', starts_on: '2026-11-14', capacity: 120, online: false, category: 'Open day', cover: 4 }
const ent = { id: 31, content_type: 'events', data: evD, created_by: 1, updated_by: 1, created_at: '2026-10-20T14:05:00Z', updated_at: '2026-10-22T09:41:00Z', media: { cover: { id: 4, filename: 'north-wall.jpg', width: 1600, height: 1067, size: 3900000, thumbnail_url: '/static/uploadedImages/3f9c-thumb.jpg' } }, relations: {} }
const FT = [['text', 'string', 'min/max = length in characters. Can be unique.'], ['richtext', 'string (Markdown)', 'min/max = length. Cannot be unique.'], ['number', 'number', 'min/max = value. Can be unique.'], ['boolean', 'true/false', 'No min/max. Cannot be unique.'], ['date', 'string "YYYY-MM-DD"', 'min/max = earliest/latest date. Can be unique.'], ['select', 'one string from `options` (1 to 50 choices)', 'Can be unique.'], ['media', 'the numeric id of an uploaded image (see Media)', '\u2014'], ['relation', 'ids of entries of another content type; `relation: { content_type, kind }`, kind one_to_one | one_to_many | many_to_many', '\u2014']]
const btA = [F('title', 'Title', 'text', { required: true, min: 3, max: 200 }), F('slug', 'Slug', 'text', { required: true, unique: true }), F('body', 'Body', 'richtext')], btU = [F('name', 'Name', 'text', { required: true, min: 1, max: 100 }), F('slug', 'Slug', 'text', { required: true, unique: true })]
const nameRule = 'Up to 100 characters.', fieldsRule = '1 to 50 fields, in order. See Field types.'
const G = {
articles: { title: 'Articles', desc: 'Read, write and publish articles. Titles can\'t be changed after creation.', eps: [
A('art-list', 'GET', '/v1/article/', 'List articles, one page at a time. Anonymous callers get online articles only, and the total counts only those. The total is in the X-Total-Count response header; the body stays a JSON array.', 'Public', 1, [[400, 'Invalid page or size.'], e401], [S('page', 'integer', 'No', '1 or higher. Default 1. A page past the end returns an empty array.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.')], { qp: 1, pub: 1, r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }),
A('art-list', 'GET', '/v1/article/', 'List articles, one page at a time. Anonymous callers get online articles only, and the total counts only those. The body is an object: {items, total, page, size, counts}. counts holds the number of articles per tab and for suggestions.', 'Public', 1, [[400, mm], e401], [S('page', 'integer', 'No', '1 or higher. Default 1. A page past the end returns empty items.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.')], { qp: 1, pub: 1, r: j({ items: [{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }], total: 12, page: 1, size: 25, counts: { all: 12, online: 8, draft: 4, suggestions: 3 } }) }),
A('art-get', 'GET', '/v1/article/{id}', 'Get one article. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ id: 12, title: 'How we price a bathroom renovation', status: 'online', body: 'A bathroom renovation is priced by room size, plumbing changes and finish level.', body_format: 'markdown' }) }),
A('art-create', 'POST', '/v1/article/', 'Create a draft article.', 'Verified user', 1, [[400, mm], e401], [S('title', 'string', 'Yes', '3 to 200 characters. Can\'t be changed later.'), S('body', 'string', 'No', 'Optional. Any length.')], { body: { title: 'Choosing tiles for a small bathroom' }, r: j({ id: 13, status: 'draft' }) }),
A('art-update', 'PATCH', '/v1/article/{id}', 'Update the body of an article.', 'Verified user', 1, [[400, mm], e401, e404], [S('body', 'string', 'Yes', 'Any length.')], { body: { body: 'Updated text.' }, r: j({ id: 12, status: 'draft' }) }),
A('art-update', 'PATCH', '/v1/article/{id}', 'Update the body of an article.', 'Verified user', 1, [[400, mm], e401, e404], [S('body', 'string', 'No', 'Any length.'), S('slug', 'string', 'No', 'Lowercase letters, numbers and hyphens. Unique on the article\'s site. 409 slug_taken if used.'), S('excerpt', 'string', 'No', 'Up to 300 characters. Also the article teaser.'), S('seo_title', 'string', 'No', 'Up to 120 characters. Falls back to the article title.'), S('seo_description', 'string', 'No', 'Up to 320 characters. Falls back to the excerpt, then the first sentence of the body.'), S('cover_image_id', 'integer', 'No', 'A media item. Falls back to the site social image.'), S('cover_image_alt', 'string', 'No', 'Up to 200 characters.'), S('social_title', 'string', 'No', 'Open Graph and Twitter title. Falls back to the resolved title.'), S('social_description', 'string', 'No', 'Falls back to the resolved description.'), S('social_image_id', 'integer', 'No', 'Falls back to the cover image.'), S('canonical_url', 'string', 'No', 'An absolute https URL. Default is the site base URL plus the slug.'), S('noindex', 'boolean', 'No', 'Sets robots to noindex in the SEO object. It doesn\'t change who can read the article.'), S('seo_dismissed', 'string[]', 'No', 'Suggestion codes the editor dismissed. Used by the back-office only.')], { body: { slug: 'bathroom-costs', seo_title: 'What a bathroom renovation costs' }, r: j({ id: 12, status: 'draft', slug: 'bathroom-costs' }) }),
A('art-pub', 'PATCH', '/v1/article/{id}/publish', 'Publish an article.', 'Verified user', 1, [e401, e404], [], { r: j({ id: 12, status: 'online' }) }),
A('art-unpub', 'PATCH', '/v1/article/{id}/unpublish', 'Unpublish an article.', 'Verified user', 1, [e401, e404], [], { r: j({ id: 12, status: 'draft' }) }),
A('art-del', 'DELETE', '/v1/article/{id}', 'Delete an article and its images.', 'Verified user', 1, [e401, e404], [], { r: '204 No Content' })
] },
seo: { title: 'Search and sharing', desc: 'RenewCMS doesn\'t render pages or add meta tags. These endpoints give your frontend the resolved SEO data and the list of published URLs.', eps: [
A('seo-get', 'GET', '/v1/article/{id}/seo', 'The resolved SEO object for one article: title (with the site template applied), description, canonical URL, robots, Open Graph and Twitter values, image with alt, authors, dates and ready-made Article JSON-LD. Every empty field already holds its fallback. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ title: 'How we price a bathroom renovation | Renew', description: 'A bathroom renovation is priced by room size, plumbing changes and finish level.', canonical_url: 'https://renew.example/how-we-price-a-bathroom-renovation', robots: 'index, follow', og: { type: 'article', title: 'How we price a bathroom renovation | Renew', image: 'https://renew.example/media/north-wall.jpg', image_alt: 'A tiled bathroom wall', site_name: 'Renew' }, twitter: { card: 'summary_large_image' }, authors: [{ name: 'Inès Duarte', url: 'https://ines-duarte.example', same_as: [] }], published_at: '2026-03-12T09:14:00Z', modified_at: '2026-10-15T11:02:00Z', json_ld: { '@type': 'Article' } }) }),
A('sitemap', 'GET', '/v1/site/{key}/sitemap', 'Published article URLs for one site, each with its last-modified date and a noindex flag, one page at a time. Use it to build your sitemap.xml. The total is in the X-Total-Count response header.', 'Public', 1, [[400, mm], e401, [404, 'site_not_found', true]], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 25, 50, 100 or 500. Default 100.')], { qp: 1, pub: 1, r: j([{ url: 'https://renew.example/how-we-price-a-bathroom-renovation', modified_at: '2026-10-15T11:02:00Z', noindex: false }]) })
] },
ctypes: { title: 'Content types', desc: 'Define your own kind of content, like events or recipes. A user whose role can create and edit content types defines a type made of 1 to 50 ordered fields; the type then has its own entry routes.', note: 'A content type has a `key` (2 to 40 lowercase letters, digits or hyphens, chosen at creation and never changed) and fields. Each field has a `key` (letters, digits and underscores, starting with a letter), a `label`, a `type` and rules.', ft: 1, snip: [{ k: 'field', label: 'Field object', code: j(evF[2]) }], eps: [
A('ct-list', 'GET', '/v1/content-type/', 'List all custom content types.', 'Verified user', 1, [e401], [], { r: j([{ ...evT, fields: evF.slice(0, 3) }]) }),
A('ct-get', 'GET', '/v1/content-type/{key}', 'Get one content type with its fields.', 'Verified user', 1, [e401, ctnf], [], { r: j(evT) }),
A('ct-create', 'POST', '/v1/content-type/', 'Create a content type. Needs the permission "Create and edit content types".', 'Needs permission', 1, [[400, mm], e401, eAd, [409, 'content_type_key_taken', true]], [S('key', 'string', 'Yes', '2 to 40 characters. Lowercase letters, digits and hyphens. Can\'t be changed later.'), S('name', 'string', 'Yes', 'Up to 100 characters.'), S('icon', 'string', 'No', 'Optional.'), S('fields', 'object[]', 'Yes', fieldsRule)], { pretty: 1, body: { key: 'events', name: 'Events', icon: 'calendar', fields: evF.slice(0, 3) }, r: j(evT) }),
A('ct-update', 'PUT', '/v1/content-type/{key}', 'Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can\'t change. Needs the permission "Create and edit content types".', 'Needs permission', 1, [[400, mm], e401, eAd, ctnf, [409, 'schema_incompatible', true, 'Some existing entries don\'t fit the new fields. Nothing was changed.']], [S('name', 'string', 'Yes', nameRule), S('icon', 'string', 'No', 'Optional.'), S('fields', 'object[]', 'Yes', fieldsRule)], { pretty: 1, body: { name: 'Events', icon: 'calendar', fields: evF.slice(0, 3) }, r: j(evT) }),
A('ct-delete', 'DELETE', '/v1/content-type/{key}', 'Delete a content type. Only possible when it has no entries and no relation field points at it. Needs the permission "Delete content types".', 'Needs permission', 1, [e401, eAd, ctnf, [409, 'content_type_has_entries', true], [409, 'content_type_referenced', true]], [], { r: '204 No Content' })
] },
entries: { title: 'Entries', desc: 'Read and write the content of a custom type. Writing entries needs the entry permissions for that type.', note: 'An entry\'s `data` is an object keyed by field key, validated against the type. Responses add `media` (the resolved images for media fields: id, filename, width, height, size and thumbnail_url, or { id, missing: true } if the image was deleted) and `relations` (the resolved first field of related entries).', snip: [{ k: 'entry', label: 'Entry', code: j(ent) }], eps: [
A('en-list', 'GET', '/v1/content-type/{key}/entry', 'List entries of a type, one page at a time. The response is an object with items, total, page and size, not a bare array like articles.', 'Verified user', 1, [[400, 'Invalid page, size or sort.'], e401, ctnf], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.'), S('sort', 'string', 'No', 'id, created_at or updated_at, then :asc or :desc. For example updated_at:desc.')], { qp: 1, qs: '?page=1&size=25&sort=updated_at:desc', r: j({ items: [ent], total: 1, page: 1, size: 25 }) }),
A('en-create', 'POST', '/v1/content-type/{key}/entry', 'Create an entry.', 'Verified user', ['data', 'title', 'starts_on', 'capacity', 'online', 'category', 'cover'], [[400, mm], e401, ctnf, [409, 'invalid_entry: title must be unique', true], [422, 'invalid_entry: capacity must be at most 5000', true]], [S('data', 'object', 'Yes', 'Keys are the type\'s field keys. For events: title, starts_on, capacity, online, category and cover.')], { body: { data: evD }, r: j(ent) }),
A('en-get', 'GET', '/v1/content-type/{key}/entry/{id}', 'Get one entry.', 'Verified user', 1, [[400, 'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).'], e401, enf, ctnf], [], { r: j(ent) }),
A('en-update', 'PUT', '/v1/content-type/{key}/entry/{id}', 'Replace the data of an entry.', 'Verified user', 1, [[400, mm], e401, enf, ctnf, [409, 'invalid_entry: title must be unique', true], [422, 'invalid_entry: capacity must be at most 5000', true]], [S('data', 'object', 'Yes', 'Keys are the type\'s field keys. For events: title, starts_on, capacity, online, category and cover.')], { body: { data: { ...evD, capacity: 150 } }, r: j({ ...ent, data: { ...evD, capacity: 150 } }) }),
A('en-delete', 'DELETE', '/v1/content-type/{key}/entry/{id}', 'Delete an entry.', 'Verified user', 1, [e401, enf, ctnf], [], { r: '204 No Content' })
] },
builtin: { title: 'Built-in types', desc: 'The fields of Articles, Authors, Sites, Media and Users, in the same shape as the fields of a content type.', note: 'Built-in types are read-only: they cannot be changed through the API.', eps: [
A('bt-list', 'GET', '/v1/builtin-type/', 'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user. Needs the permission "View content types".', 'Needs permission', 1, [e401, eAd], [], { r: j([{ key: 'article', name: 'Articles', fields: btA }, { key: 'author', name: 'Authors', fields: btU }]) }),
A('bt-get', 'GET', '/v1/builtin-type/{key}', 'Get one built-in type. Keys are article, author, site, media and user. Needs the permission "View content types".', 'Needs permission', 1, [e401, eAd, [404, 'builtin_type_not_found', true]], [], { r: j({ key: 'author', name: 'Authors', fields: btU }) })
] },
media: { title: 'Media', desc: 'The image library. One image can be used in many articles.', eps: [
A('med-list', 'GET', '/v1/media/', 'List images.', 'Verified user', 1, [e401], [], { r: j([{ id: 4, filename: 'north-wall.jpg', size: 3900000 }]) }),
A('med-list', 'GET', '/v1/media/', 'List images, one page at a time. The total is in the X-Total-Count response header; the body stays a JSON array.', 'Verified user', 1, [[400, mm], e401], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.')], { qp: 1, r: j([{ id: 4, filename: 'north-wall.jpg', size: 3900000 }]) }),
A('med-up', 'POST', '/v1/media/', 'Upload an image (multipart).', 'Verified user', 1, [[400, mm], e401, [413, 'The file is over 10 MB.'], [415, 'Not a PNG, JPEG, WEBP or SVG image.']], [S('file', 'file', 'Yes', 'PNG, JPEG, WEBP or SVG, up to 10 MB.')], { multi: true, form: [['file', '@north-wall.jpg']], r: j({ id: 4, filename: 'north-wall.jpg', url: '/static/uploadedImages/3f9c.jpg' }) }),
A('med-ren', 'PATCH', '/v1/media/{id}', 'Rename an image. The URL stays the same.', 'Verified user', 1, [[400, mm], e401, m404], [S('filename', 'string', 'Yes', 'The new filename.')], { body: { filename: 'north-wall-01.jpg' }, r: j({ id: 4, filename: 'north-wall-01.jpg' }) }),
A('med-del', 'DELETE', '/v1/media/{id}', 'Delete an image. Articles that use it show a broken image.', 'Verified user', 1, [e401, m404], [], { r: '204 No Content' }),
A('med-merge', 'POST', '/v1/media/merge', 'Merge identical copies into one image.', 'Verified user', 1, [[400, mm], e401, [409, 'Some of the images to merge changed. Refresh and try again.']], [S('keep_id', 'number', 'Yes', 'Greater than 0. The image to keep.'), S('merge_ids', 'number[]', 'Yes', 'At least 1, each greater than 0. Identical copies to delete. Articles switch to the kept image.')], { body: { keep_id: 4, merge_ids: [9, 11] }, r: j({ merged: 2, articlesUpdated: 5 }) }),
A('med-use', 'GET', '/v1/media/{id}/usage', 'List the articles that use an image.', 'Verified user', 1, [e401, m404], [], { r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) })
A('med-use', 'GET', '/v1/media/{id}/usage', 'List the articles that use an image.', 'Verified user', 1, [e401, m404], [], { r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }),
A('med-purge-settings-get', 'GET', '/v1/media/purge-settings', 'Get the settings for purging unused images. Needs the permission "Delete files".', 'Needs permission', 1, [e401, eAd], [], { r: j({ auto_purge_enabled: true, auto_purge_interval_days: 30, last_purge_at: '2026-10-20T03:00:00Z', last_purge_merged: 4 }) }),
A('med-purge-settings-patch', 'PATCH', '/v1/media/purge-settings', 'Update the settings for purging unused images. Needs the permission "Delete files".', 'Needs permission', 1, [[400, mm], e401, eAd], [S('auto_purge_enabled', 'boolean', 'No', 'Turns the automatic purge on or off.'), S('auto_purge_interval_days', 'integer', 'No', '1 to 365. Days between automatic purges.')], { body: { auto_purge_enabled: true, auto_purge_interval_days: 14 }, r: j({ auto_purge_enabled: true, auto_purge_interval_days: 14, last_purge_at: '2026-10-20T03:00:00Z', last_purge_merged: 4 }) }),
A('med-purge-preview', 'GET', '/v1/media/purge-preview', 'Preview the images a purge would delete. Needs the permission "Delete files".', 'Needs permission', 1, [e401, eAd], [], { r: j({ fingerprint: 'c41f9a7e', count: 2, items: [{ id: 9, filename: 'old-wall.jpg' }, { id: 11, filename: 'old-wall-2.jpg' }] }) }),
A('med-purge', 'POST', '/v1/media/purge', 'Delete the images a purge would remove. failed_groups counts groups that could not be purged; the others were still purged. Needs the permission "Delete files".', 'Needs permission', 1, [e401, eAd], [S('fingerprint', 'string', 'No', 'Optional. Taken from purge-preview.')], { body: { fingerprint: 'c41f9a7e' }, r: '{ "groups_total": 3, "groups_purged": 2, "merged": 2, "articles_updated": 5, "sites_updated": 0, "authors_updated": 1, "failed_groups": 1 }' })
] },
sites: { title: 'Sites and authors', desc: 'Read the sites and authors your content belongs to. Creating and changing them needs a session.', eps: [
A('site-list', 'GET', '/v1/site/', 'List sites', 'Verified user', 1, [e401], [], { r: j([{ id: 1, key: 'renew', name: 'Renew', default: true }]) }),
A('author-list', 'GET', '/v1/author/', 'List authors, one page at a time', 'Verified user', 1, [[400, mm], e401], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.')], { qp: 1, r: j({ items: [{ id: 3, name: 'In\u00e8s Duarte', slug: 'ines-duarte' }], total: 1, page: 1, size: 25 }) })
] },
users: { title: 'Users and invites', desc: 'People who can sign in, and pending invitations.', eps: [
A('usr-list', 'GET', '/v1/user/', 'List users and invites.', 'Verified user', 1, [e401], [], { r: j([{ email: 'marta@renew.example', role: 'owner', status: 'accepted' }]) }),
@@ -201,41 +255,42 @@ class Component extends DCLogic {
A('usr-inv', 'POST', '/v1/user/invite', 'Invite a user by email.', 'Verified user', 1, [[400, mm], e401, [409, 'This address already has an account.']], [S('email', 'string', 'Yes', 'A valid email address. The link expires after 3 days.')], { body: { email: 'lea.martin@renew.example' }, r: j({ id: 7, status: 'pending' }) }),
A('usr-cancel', 'DELETE', '/v1/user/invite/{id}', 'Cancel a pending invite.', 'Verified user', 1, [e401, i404], [], { r: '204 No Content' }),
A('usr-renew', 'POST', '/v1/user/invite/{id}/renew', 'Renew an expired invite.', 'Verified user', 1, [e401, i404], [], { r: j({ id: 7, status: 'pending' }) }),
A('usr-del', 'DELETE', '/v1/user/{id}?expected_role_id=N', 'Remove a user. expected_role_id is the role the user had when you loaded them; if it changed since, nothing is removed.', 'Needs permission', 1, [e401, eAd, [409, 'target_role_not_found']], [S('expected_role_id', 'integer', 'Yes', 'Query parameter. The id of the role you saw.')], { qp: 1, r: '204 No Content' }),
A('auth-me', 'GET', '/v1/auth/me', 'Get the current user. With a key, the key\'s owner.', 'Verified user', 1, [e401], [], { r: j({ username: 'marta', email: 'marta@renew.example', role: 'owner' }) })
] },
settings: { title: 'Settings', desc: 'Read the site settings.', eps: [
A('set-get', 'GET', '/v1/settings', 'Get settings.', 'Verified user', 1, [e401], [], { r: j({ version: 'v2.2.0-beta' }) })
] }
}
const MB = { GET: ['var(--ui-bg-inverted)', 'var(--ui-text-inverted)', 'var(--ui-bg-inverted)'], POST: ['var(--rc-success-soft)', 'var(--ui-success)', 'var(--ui-success)'], PATCH: ['var(--rc-warning-soft)', 'var(--ui-warning)', 'var(--ui-warning)'], DELETE: ['var(--rc-error-soft)', 'var(--ui-error)', 'var(--ui-error)'] }
const MB = { GET: ['var(--ui-bg-inverted)', 'var(--ui-text-inverted)', 'var(--ui-bg-inverted)'], POST: ['var(--rc-success-soft)', 'var(--ui-success)', 'var(--ui-success)'], PATCH: ['var(--rc-warning-soft)', 'var(--ui-warning)', 'var(--ui-warning)'], PUT: ['var(--rc-warning-soft)', 'var(--ui-warning)', 'var(--ui-warning)'], DELETE: ['var(--rc-error-soft)', 'var(--ui-error)', 'var(--ui-error)'] }
const mob = !!this.props.mobile
return Object.keys(G).map(key => {
const g = G[key]
return { key, title: g.title, desc: g.desc, count: g.eps.length + (g.eps.length === 1 ? ' route' : ' routes'), eps: g.eps.map(e => {
const open = !!this.state.open[e.id], tab = this.state.tab[e.id] || 'fetch', url = this.BASE + e.p.replace('{id}', '12')
return { key, title: g.title, desc: g.desc, hasNote: !!g.note, noteParts: g.note ? Pp(g.note) : [], hasFt: !!g.ft, ftA: g.ft ? 'ct-fields' : '', ftCols: mob ? '64px minmax(0,1fr)' : '88px minmax(0,1.3fr) minmax(0,1fr)', ftRule: mob ? '2' : 'auto', ftRows: g.ft ? FT.map(r => ({ t: r[0], v: Pp(r[1]), r: Pp(r[2]) })) : [], hasSnip: !!g.snip, snip: g.snip || [], count: g.eps.length + (g.eps.length === 1 ? ' route' : ' routes'), eps: g.eps.map(e => {
const open = !!this.state.open[e.id], tab = this.state.tab[e.id] || 'fetch', url = this.BASE + e.p.replace('{key}', e.p.indexOf('/v1/content-type') === 0 ? 'events' : e.p.indexOf('/v1/builtin-type') === 0 ? 'author' : 'renew').replace('{id}', '12') + (e.qs || '')
const hdr = e.pub ? [] : ['Authorization: Bearer ' + this.KEY]
let curl = 'curl ' + (e.m === 'GET' ? '' : '-X ' + e.m + ' ') + '"' + url + '"'
hdr.forEach(h => curl += ' \\\n -H "' + h + '"')
if (e.body) curl += ' \\\n -H "Content-Type: application/json" \\\n -d \'' + JSON.stringify(e.body) + '\''
if (e.body) curl += ' \\\n -H "Content-Type: application/json" \\\n -d \'' + (e.pretty ? JSON.stringify(e.body, null, 2) : JSON.stringify(e.body)) + '\''
if (e.form) e.form.forEach(f => curl += ' \\\n -F "' + f[0] + '=' + f[1] + '"')
let fe = (e.form ? 'const form = new FormData()\n' + e.form.map(f => f[1][0] === '@' ? "form.append('file', file)" : "form.append('" + f[0] + "', '" + f[1] + "')").join('\n') + '\n\n' : '') + "const res = await fetch('" + url + "'"
const opts = []
if (e.m !== 'GET') opts.push("method: '" + e.m + "'")
if (hdr.length || e.body) opts.push('headers: { ' + [hdr.length ? "Authorization: 'Bearer " + this.KEY + "'" : null, e.body ? "'Content-Type': 'application/json'" : null].filter(Boolean).join(', ') + ' }')
if (e.body) opts.push('body: JSON.stringify(' + JSON.stringify(e.body) + ')')
if (e.body) opts.push('body: JSON.stringify(' + (e.pretty ? JSON.stringify(e.body, null, 2).replace(/\n/g, '\n ') : JSON.stringify(e.body)) + ')')
if (e.form) opts.push('body: form')
fe += opts.length ? ', {\n ' + opts.join(',\n ') + '\n})' : ')'
const code = tab === 'curl' ? curl : tab === 'fetch' ? fe : e.r
const lang = tab === 'curl' ? 'Shell' : tab === 'fetch' ? 'JavaScript' : 'JSON'
const cp = this.state.copied === e.id
const chip = (t, o) => ({ t, bg: 'transparent', fg: 'var(--ui-text-toned)', bd: 'var(--ui-border-accented)', bs: 'solid', fw: 400, ...o })
const chips = [chip(e.acc, e.acc === 'Owner only' ? { bg: 'var(--ui-bg-elevated)', fg: 'var(--ui-text-highlighted)', fw: 500 } : {}), chip('API key OK', { icon: 'check', bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', bd: 'transparent', fw: 500 })]
return { anchor: 'ep-' + e.id, open, m: e.m, p: e.p, d: e.d, mBg: MB[e.m][0], mFg: MB[e.m][1], mBd: MB[e.m][2], chev: open ? 'chevron-up' : 'chevron-down', rowBg: open ? 'var(--ui-bg-muted)' : 'transparent', label: e.m + ' ' + e.p + '. ' + e.d, chips,
const chips = [chip(e.acc, e.acc === 'Owner only' || e.acc === 'Needs permission' ? { bg: 'var(--ui-bg-elevated)', fg: 'var(--ui-text-highlighted)', fw: 500 } : {}), chip('API key OK', { icon: 'check', bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', bd: 'transparent', fw: 500 })]
return { anchor: 'ep-' + e.id, open, m: e.m, p: e.p, d: e.d, mBg: MB[e.m][0], mFg: MB[e.m][1], mBd: MB[e.m][2], chev: open ? 'chevron-up' : 'chevron-down', rowBg: this.state.flash === e.id ? 'var(--rc-sand)' : open ? 'var(--ui-bg-muted)' : 'transparent', ring: this.state.flash === e.id ? '2px solid var(--ui-primary)' : 'none', label: e.m + ' ' + e.p + '. ' + e.d, chips,
toggle: () => this.setState(s => ({ open: { ...s.open, [e.id]: !s.open[e.id] } })),
bodyTitle: e.qp ? 'Query parameters' : e.multi ? 'Request body · multipart/form-data, 1 file field' : e.body ? 'Request body · JSON' : 'Request body', hasFields: e.fields.length > 0, noFields: e.fields.length === 0, fields: e.fields,
tabs: ['fetch', 'curl', 'Response'].map(t => { const k = t === 'Response' ? 'response' : t, on = (k === tab) || (t === tab); return { t, on, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', pick: () => this.setState(s => ({ tab: { ...s.tab, [e.id]: k } })) } }),
snips: [{ k: 'fetch', label: 'fetch', code: fe }, { k: 'curl', label: 'curl', code: curl }, { k: 'response', label: 'Response', code: e.r }],
errs: e.errs.map(([c, cap]) => ({ c, cap })) }
errs: e.errs.map(([c, cap, code, tail]) => ({ c, cap, code: !!code, plain: !code, hasTail: !!tail, tail })), tabSet: this.state.tab[e.id] }
}) }
})
}
@@ -251,8 +306,11 @@ class Component extends DCLogic {
const index = this.IDX.map(([k, t]) => { const on = st.active === k; return { t, on, cur: on ? 'true' : undefined, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on ? 500 : 400, bg: on ? 'var(--ui-bg-elevated)' : 'transparent', op: on ? 1 : 0, go: () => { this.setState({ jump: false, active: k }); this.scrollTo(k, mob) } } })
const sg = (t, rows) => ({ t, rows: rows.map(([m, p, d]) => ({ m, p, d })) })
const D = (a) => 'rowDisplay'
const sessGroups = [sg('System · owner only', [['GET', '/v1/system/update', 'Update status'], ['POST', '/v1/system/update/check', 'Check for update'], ['POST', '/v1/system/update/download', 'Download update'], ['POST', '/v1/system/update/restart', 'Restart']]), sg('Sign in and account', [['POST', '/v1/auth/login', 'Log in'], ['POST', '/v1/auth/logout', 'Log out'], ['POST', '/v1/auth/register', 'Register'], ['POST', '/v1/auth/verify', 'Verify email'], ['POST', '/v1/auth/password-reset', 'Reset password']]), sg('API key management', [['GET', '/v1/api-key', 'List keys'], ['POST', '/v1/api-key', 'Create a key'], ['DELETE', '/v1/api-key/{id}', 'Revoke a key']]), sg('Sites and authors writes', [['POST', '/v1/site/', 'Create a site'], ['PATCH', '/v1/site/{id}', 'Update a site'], ['PATCH', '/v1/site/{id}/default', 'Set the default site'], ['DELETE', '/v1/site/{id}', 'Delete a site (owner)'], ['POST', '/v1/author/me', 'Create my author profile'], ['POST', '/v1/author/', 'Create an author'], ['PATCH', '/v1/author/{id}', 'Update an author'], ['DELETE', '/v1/author/{id}', 'Delete an author']]), sg('Image variants', [['GET', '/v1/image-variants/', 'List variants'], ['POST', '/v1/image-variants/', 'Create a variant'], ['PUT', '/v1/image-variants/{name}', 'Replace a variant'], ['PATCH', '/v1/image-variants/{name}', 'Update a variant'], ['DELETE', '/v1/image-variants/{name}', 'Delete a variant'], ['POST', '/v1/image-variants/{name}/purge', 'Purge the cache of a variant'], ['GET', '/v1/image-variants/cache', 'Image cache status'], ['POST', '/v1/image-variants/cache/purge', 'Purge the image cache'], ['GET', '/v1/media/{id}/variants', 'List the variants of an image'], ['POST', '/v1/media/{id}/variants', 'Add variants to an image'], ['POST', '/v1/media/{id}/variants/{name}', 'Generate one variant of an image'], ['DELETE', '/v1/media/{id}/variants/{name}', 'Delete one variant of an image']]), sg('Activity', [['GET', '/v1/activity/', 'List activity'], ['GET', '/v1/activity/summary', 'Activity summary'], ['GET', '/v1/activity/export', 'Export activity']])]
const sessN = sessGroups.reduce((n, g) => n + g.rows.length, 0)
return {
rootRef: this.rootRef, onScroll: this.onScroll, imgState, mob, desk: !mob, jumpOpen: st.jump, toggleJump: () => this.setState(s => ({ jump: !s.jump })),
sessGroups, sessCount: sessN + ' routes, not available to API keys',
rootRef: this.rootRef, onScroll: this.onScroll, trigRing: ((this.props.s && this.props.s.int) || {}).trig === 'focus' ? '2px solid var(--ui-primary)' : 'none', imgState, mob, desk: !mob, jumpOpen: st.jump, toggleJump: () => this.setState(s => ({ jump: !s.jump })),
activeLabel: (this.IDX.find(x => x[0] === st.active) || this.IDX[0])[1], index,
pad: mob ? '16px 16px 96px' : '24px', secGap: mob ? '28px' : '32px', h1: '20px/28px', detX: mob ? '12px' : '16px', linkH: mob ? '44px' : '28px', tabH: mob ? '44px' : '36px',
authCols: mob ? 'minmax(0,1fr)' : 'minmax(0,5fr) minmax(0,7fr)',
@@ -261,17 +319,18 @@ class Component extends DCLogic {
groups: this.eps(),
rowDisplay: mob ? 'flex' : 'grid', rowCols: mob ? 'none' : '300px minmax(0,1fr) auto 16px', rowAlign: mob ? 'stretch' : 'center', rowGap: mob ? '6px' : '16px', rowPad: mob ? '12px 12px' : '0 16px', descWrap: mob ? 'normal' : 'nowrap',
fCols: mob ? 'minmax(0,1fr) auto auto' : '120px 72px 72px minmax(0,1fr)', ruleCol: mob ? '1 / -1' : 'auto',
sessGroups: [sg('System · owner only', [['GET', '/v1/system/update', 'Update status'], ['POST', '/v1/system/update/check', 'Check for update'], ['POST', '/v1/system/update/download', 'Download update'], ['POST', '/v1/system/update/restart', 'Restart']]), sg('Sign in and account', [['POST', '/v1/auth/login', 'Log in'], ['POST', '/v1/auth/logout', 'Log out'], ['POST', '/v1/auth/register', 'Register'], ['POST', '/v1/auth/verify', 'Verify email'], ['POST', '/v1/auth/password-reset', 'Reset password']]), sg('API key management', [['GET', '/v1/api-key', 'List keys'], ['POST', '/v1/api-key', 'Create a key'], ['DELETE', '/v1/api-key/{id}', 'Revoke a key']])],
sessCols: mob ? 'none' : '280px minmax(0,1fr) auto', sessGap: mob ? '2px' : '16px', sessPad: mob ? '8px 14px' : '0 14px', chipSelf: mob ? 'flex-start' : 'center',
errCols: mob ? '56px minmax(0,1fr)' : '64px 200px minmax(0,1fr)', errCause: mob ? '2' : 'auto',
errCols: mob ? '56px minmax(0,1fr)' : '64px 200px minmax(0,1fr)', errCause: mob ? '2' : 'auto', errNoteCol: mob ? '2' : '3',
errRows: [
{ c: 400, m: 'Bad request', w: '"The request cannot be processed due to a mismatch in the format of the body." or "The expiry must be a date in the future." Image requests answer with a JSON body whose error is "unknown_variant" or "unsupported_parameter".' },
{ c: 401, m: 'Unauthorized', w: '"You must be logged in to access this resource." Sent for an invalid, unknown, expired or revoked key, even on public routes, and for a key whose user was deleted or unverified.' },
{ c: 403, m: 'Forbidden', w: '"This action needs a real session." (a session-only route called with a key), "Only the owner can do this." or "You must verify your account to access this resource."' },
{ c: 404, m: 'Not found', w: '"Article not found", "Image not found", "Invite not found" or "API key not found". Drafts answer 404 to anonymous callers.' },
{ c: 403, m: 'Forbidden', n: '`missing_permission` is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article.', w: '"This action needs a real session." (a session-only route called with a key), `missing_permission` (the role of the user, or of the key\'s creator, lacks the permission the route needs) or "You must verify your account to access this resource."' },
{ c: 404, m: 'Not found', n: 'Text in code style is the literal body of the response, not a sentence.', w: '"Article not found", "Image not found", "Invite not found" or "API key not found". Content types and entries answer `content_type_not_found` or `entry_not_found`. An unknown site key on the sitemap answers `site_not_found`. Drafts answer 404 to anonymous callers.' },
{ c: 409, m: 'Conflict', w: 'Content types: `content_type_key_taken`, `content_type_has_entries`, `content_type_referenced` or `schema_incompatible`. Entries: \`invalid_entry: <reason>\`, only when a unique field clashes. Media and users: "Some of the images to merge changed. Refresh and try again." or "This address already has an account." Articles: `slug_taken`. Removing a user whose role changed first: `target_role_not_found`.' },
{ c: 422, m: 'Unprocessable', w: 'An entry doesn\'t fit its content type. The body is `invalid_entry`, then ": " and the reason, for example `invalid_entry: capacity must be at most 5000`.' },
{ c: 429, m: 'Too many requests', w: '"Too many requests. Please try again later." A per-key limit of 120 requests per minute also sends a Retry-After header.' },
{ c: 500, m: 'Server error', w: '"Internal server error"' }
],
].map(r => ({ ...r, parts: r.w.split('`').map((x, i) => ({ t: x, c: i % 2 === 1, p: i % 2 === 0 })).filter(x => x.t), nparts: r.n ? r.n.split('`').map((x, i) => ({ t: x, c: i % 2 === 1, p: i % 2 === 0 })).filter(x => x.t) : [] })),
errBody: 'HTTP/1.1 403 Forbidden\nContent-Type: text/plain\n\nThis action needs a real session.'
}
}
@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<div style="position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;justify-content:center;align-items:flex-start;padding:{{ shellPad }};font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px;color:var(--ui-text)">
<div role="dialog" aria-modal="true" aria-label="Search the API reference" style="width:{{ w }};height:{{ h }};max-height:100%;background:var(--ui-bg);border:{{ bd }};box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column;min-height:0">
<div style="display:flex;align-items:center;gap:10px;height:{{ inH }};padding:0 {{ inPx }};border-bottom:1px solid var(--ui-border);box-shadow:inset 0 0 0 2px var(--ui-primary);flex-shrink:0">
<span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:var(--ui-text-muted);-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span>
<input role="combobox" aria-expanded="true" aria-controls="rc-sr-list" aria-autocomplete="list" aria-activedescendant="{{ activeId }}" aria-label="Search the API reference" placeholder="Search the API reference…" value="{{ q }}" onChange="{{ onInput }}" onKeyDown="{{ onKey }}" autocomplete="off" spellcheck="false" style="all:unset;box-sizing:border-box;flex:1;min-width:0;height:100%;font:400 {{ inFs }} 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)"/>
<sc-if value="{{ desk }}"><span aria-hidden="true" style="height:20px;min-width:20px;padding:0 6px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">Esc</span></sc-if>
<sc-if value="{{ mob }}"><button aria-label="Close search" onClick="{{ onClose }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:44px;padding:0 8px;margin-right:-8px;display:inline-flex;align-items:center;font-weight:500;font-size:14px;color:var(--ui-text-highlighted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">Cancel</button></sc-if>
</div>
<span role="status" aria-live="polite" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap">{{ live }}</span>
<div id="rc-sr-list" role="listbox" aria-label="Search results" style="flex:1;min-height:0;max-height:{{ listMax }};overflow-y:auto;padding:4px 0;scrollbar-width:thin">
<sc-if value="{{ none }}"><div style="padding:40px 24px;display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center"><span aria-hidden="true" style="display:inline-block;width:20px;height:20px;background-color:var(--ui-text-muted);-webkit-mask:url('https://api.iconify.design/lucide/search-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search-x.svg') center / contain no-repeat"></span><span style="max-width:360px;font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty">{{ noneText }}</span></div></sc-if>
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="2">
<div role="group" aria-labelledby="{{ g.hid }}">
<div id="{{ g.hid }}" style="padding:8px {{ rowPx }} 4px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">{{ g.title }}</div>
<sc-for list="{{ g.items }}" as="it" hint-placeholder-count="4">
<div id="{{ it.id }}" role="option" aria-selected="{{ it.sel }}" aria-label="{{ it.label }}" onClick="{{ it.go }}" style="cursor:pointer;display:grid;grid-template-columns:{{ cols }};align-items:start;gap:{{ colGap }};padding:{{ rowPad }};min-height:{{ rowH }};background:{{ it.bg }};box-shadow:{{ it.bar }}" style-hover="background:var(--ui-bg-elevated)">
<sc-if value="{{ it.isEp }}"><span style="width:56px;height:22px;display:inline-flex;align-items:center;justify-content:center;background:{{ it.mBg }};color:{{ it.mFg }};border:1px {{ it.mBs }} {{ it.mBd }};font:500 11px 'IBM Plex Mono',monospace">{{ it.m }}</span></sc-if>
<sc-if value="{{ it.isSec }}"><span style="width:56px;height:22px;display:inline-flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ it.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ it.icon }}.svg') center / contain no-repeat"></span></span></sc-if>
<div style="min-width:0;display:flex;flex-direction:column;gap:2px">
<sc-if value="{{ it.isEp }}"><div style="display:flex;align-items:center;gap:12px;min-height:22px;min-width:0"><span style="flex-shrink:{{ pathShrink }};min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:{{ it.pFw }} 13px 'IBM Plex Mono',monospace;color:{{ it.pFg }}"><sc-for list="{{ it.pSeg }}" as="s" hint-placeholder-count="1"><span style="background:{{ s.bg }};color:{{ s.fg }}">{{ s.t }}</span></sc-for></span><sc-if value="{{ desk }}"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:{{ it.dFg }}"><sc-for list="{{ it.dSeg }}" as="s" hint-placeholder-count="1"><span style="background:{{ s.bg }};color:{{ s.fg }}">{{ s.t }}</span></sc-for></span></sc-if></div></sc-if>
<sc-if value="{{ it.isSec }}"><div style="display:flex;align-items:center;gap:12px;min-height:22px;min-width:0"><span style="flex-shrink:0;font-weight:500;color:var(--ui-text-highlighted)"><sc-for list="{{ it.pSeg }}" as="s" hint-placeholder-count="1"><span style="background:{{ s.bg }};color:{{ s.fg }}">{{ s.t }}</span></sc-for></span><sc-if value="{{ desk }}"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ui-text-muted)">{{ it.desc }}</span></sc-if></div></sc-if>
<sc-if value="{{ mob }}"><div style="display:flex;align-items:center;gap:8px;min-width:0"><sc-if value="{{ it.hasChip }}"><span style="flex-shrink:0;height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;background:{{ it.cBg }};color:{{ it.cFg }};border:1px {{ it.cBs }} {{ it.cBd }};font-size:12px;font-weight:{{ it.cFw }}"><sc-if value="{{ it.sess }}"><span aria-hidden="true" style="display:inline-block;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></sc-if>{{ it.chip }}</span></sc-if><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:{{ it.dFg }}"><sc-if value="{{ it.isEp }}"><sc-for list="{{ it.dSeg }}" as="s" hint-placeholder-count="1"><span style="background:{{ s.bg }};color:{{ s.fg }}">{{ s.t }}</span></sc-for></sc-if><sc-if value="{{ it.isSec }}">{{ it.desc }}</sc-if></span></div></sc-if>
<sc-if value="{{ it.via }}"><div style="display:flex;align-items:baseline;gap:8px;min-width:0;font-size:12px;line-height:18px;color:var(--ui-text-muted)"><span style="flex-shrink:0">{{ it.viaLabel }}</span><span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><sc-for list="{{ it.viaSeg }}" as="s" hint-placeholder-count="1"><span style="background:{{ s.bg }};color:{{ s.fg }}">{{ s.t }}</span></sc-for></span></div></sc-if>
</div>
<sc-if value="{{ desk }}"><sc-if value="{{ it.hasChip }}"><span style="height:20px;margin-top:1px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;background:{{ it.cBg }};color:{{ it.cFg }};border:1px {{ it.cBs }} {{ it.cBd }};font-size:12px;font-weight:{{ it.cFw }}"><sc-if value="{{ it.sess }}"><span aria-hidden="true" style="display:inline-block;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></sc-if>{{ it.chip }}</span></sc-if></sc-if>
</div>
</sc-for>
</div>
</sc-for>
<sc-if value="{{ more }}"><div style="padding:8px {{ rowPx }};color:var(--ui-text-muted)">{{ moreText }}</div></sc-if>
</div>
<div style="flex-shrink:0;display:flex;align-items:center;gap:16px;height:{{ ftH }};padding:0 {{ inPx }};border-top:1px solid var(--ui-border);background:var(--ui-bg-muted);color:var(--ui-text-muted)">
<sc-if value="{{ desk }}"><span aria-hidden="true" style="display:inline-flex;align-items:center;gap:6px"><span style="height:20px;min-width:20px;padding:0 4px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">↑</span><span style="height:20px;min-width:20px;padding:0 4px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">↓</span>Move</span>
<span aria-hidden="true" style="display:inline-flex;align-items:center;gap:6px"><span style="height:20px;min-width:20px;padding:0 4px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">↵</span>Open</span>
<span aria-hidden="true" style="display:inline-flex;align-items:center;gap:6px"><span style="height:20px;min-width:20px;padding:0 6px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">Esc</span>Close</span></sc-if>
<span style="flex:1"></span><span aria-hidden="true">{{ count }}</span>
</div>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:1440,&quot;height&quot;:900}}">
class Component extends DCLogic {
state = { q: (this.props.s && this.props.s.q) || '', sel: (this.props.s && this.props.s.sel) || 0 }
L = 'You must be logged in to access this resource.'
V = 'You must verify your account to access this resource.'
MM = 'The request cannot be processed due to a mismatch in the format of the body.'
data() {
const L = this.L, V = this.V, MM = this.MM, SS = 'This action needs a real session.'
const E = (id, m, p, d, acc, f, e, x = {}) => ({ id, m, p, d, acc, f, e: acc === 'Verified user' ? e.flatMap(r => r[0] === 401 ? [r, [403, V]] : [r]) : e, ...x })
const S = (id, m, p, d) => ({ id, m, p, d, acc: 'Session only', f: [], e: [[403, SS]], sess: 1 })
const a404 = [404, 'Article not found'], i404 = [404, 'Image not found'], eAd = [403, 'Your role doesn\'t include this action. Ask an owner or admin.'], ctnf = [404, 'content_type_not_found'], enf = [404, 'entry_not_found']
const uq = [409, 'invalid_entry: title must be unique'], ie = [422, 'invalid_entry: capacity must be at most 5000'], evf = ['data', 'title', 'starts_on', 'capacity', 'online', 'category', 'cover']
const eps = [
E('art-list', 'GET', '/v1/article/', 'List articles, one page at a time.', 'Public', ['page', 'size'], [[400, MM], [401, L]], { qp: 1 }),
E('art-get', 'GET', '/v1/article/{id}', 'Get one article.', 'Public', [], [[401, L], a404]),
E('art-create', 'POST', '/v1/article/', 'Create a draft article.', 'Verified user', ['title', 'body'], [[400, MM], [401, L]]),
E('art-update', 'PATCH', '/v1/article/{id}', 'Update the body of an article.', 'Verified user', ['body', 'slug', 'excerpt', 'seo_title', 'seo_description', 'cover_image_id', 'cover_image_alt', 'social_title', 'social_description', 'social_image_id', 'canonical_url', 'noindex', 'seo_dismissed'], [[400, MM], [401, L], a404, [409, 'slug_taken']]),
E('art-pub', 'PATCH', '/v1/article/{id}/publish', 'Publish an article.', 'Verified user', [], [[401, L], a404]),
E('art-unpub', 'PATCH', '/v1/article/{id}/unpublish', 'Unpublish an article.', 'Verified user', [], [[401, L], a404]),
E('art-del', 'DELETE', '/v1/article/{id}', 'Delete an article and its images.', 'Verified user', [], [[401, L], a404]),
E('seo-get', 'GET', '/v1/article/{id}/seo', 'The resolved SEO object for one article.', 'Public', [], [[401, L], a404]),
E('sitemap', 'GET', '/v1/site/{key}/sitemap', 'Published article URLs for one site.', 'Public', ['page', 'size'], [[400, MM], [401, L], [404, 'site_not_found']], { qp: 1 }),
E('ct-list', 'GET', '/v1/content-type/', 'List all custom content types.', 'Verified user', [], [[401, L]]),
E('ct-get', 'GET', '/v1/content-type/{key}', 'Get one content type with its fields.', 'Verified user', [], [[401, L], ctnf]),
E('ct-create', 'POST', '/v1/content-type/', 'Create a content type.', 'schema.edit', ['key', 'name', 'icon', 'fields'], [[400, MM], [401, L], eAd, [409, 'content_type_key_taken']]),
E('ct-update', 'PUT', '/v1/content-type/{key}', 'Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can\'t change.', 'schema.edit', ['name', 'icon', 'fields'], [[400, MM], [401, L], eAd, ctnf, [409, 'schema_incompatible Some existing entries don\'t fit the new fields. Nothing was changed.']]),
E('ct-delete', 'DELETE', '/v1/content-type/{key}', 'Delete a content type. Only possible when it has no entries and no relation field points at it.', 'schema.delete', [], [[401, L], eAd, ctnf, [409, 'content_type_has_entries'], [409, 'content_type_referenced']]),
E('en-list', 'GET', '/v1/content-type/{key}/entry', 'List entries of a type, one page at a time. The response is an object with items, total, page and size, not a bare array like articles.', 'Verified user', ['page', 'size', 'sort'], [[400, 'Invalid page, size or sort.'], [401, L], ctnf], { qp: 1 }),
E('en-create', 'POST', '/v1/content-type/{key}/entry', 'Create an entry.', 'Verified user', evf, [[400, MM], [401, L], ctnf, uq, ie]),
E('en-get', 'GET', '/v1/content-type/{key}/entry/{id}', 'Get one entry.', 'Verified user', [], [[400, 'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).'], [401, L], enf, ctnf]),
E('en-update', 'PUT', '/v1/content-type/{key}/entry/{id}', 'Replace the data of an entry.', 'Verified user', evf, [[400, MM], [401, L], enf, ctnf, uq, ie]),
E('en-delete', 'DELETE', '/v1/content-type/{key}/entry/{id}', 'Delete an entry.', 'Verified user', [], [[401, L], enf, ctnf]),
E('bt-list', 'GET', '/v1/builtin-type/', 'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user.', 'schema.view', [], [[401, L], eAd]),
E('bt-get', 'GET', '/v1/builtin-type/{key}', 'Get one built-in type. Keys are article, author, site, media and user.', 'schema.view', [], [[401, L], eAd, [404, 'builtin_type_not_found']]),
E('med-list', 'GET', '/v1/media/', 'List images, one page at a time. The total is in the X-Total-Count response header; the body stays a JSON array.', 'Verified user', ['page', 'size'], [[400, MM], [401, L]], { qp: 1 }),
E('med-up', 'POST', '/v1/media/', 'Upload an image (multipart).', 'Verified user', ['file'], [[400, MM], [401, L], [413, 'The file is over 10 MB.'], [415, 'Not a PNG, JPEG, WEBP or SVG image.']]),
E('med-ren', 'PATCH', '/v1/media/{id}', 'Rename an image. The URL stays the same.', 'Verified user', ['filename'], [[400, MM], [401, L], i404]),
E('med-del', 'DELETE', '/v1/media/{id}', 'Delete an image.', 'Verified user', [], [[401, L], i404]),
E('med-merge', 'POST', '/v1/media/merge', 'Merge identical copies into one image.', 'Verified user', ['keep_id', 'merge_ids'], [[400, MM], [401, L], [409, 'Some of the images to merge changed. Refresh and try again.']]),
E('med-use', 'GET', '/v1/media/{id}/usage', 'List the articles that use an image.', 'Verified user', [], [[401, L], i404]),
E('med-purge-settings-get', 'GET', '/v1/media/purge-settings', 'Get the settings for purging unused images.', 'Needs permission', [], [[401, L], eAd]),
E('med-purge-settings-patch', 'PATCH', '/v1/media/purge-settings', 'Update the settings for purging unused images.', 'Needs permission', ['auto_purge_enabled', 'auto_purge_interval_days'], [[400, MM], [401, L], eAd]),
E('med-purge-preview', 'GET', '/v1/media/purge-preview', 'Preview the images a purge would delete.', 'Needs permission', [], [[401, L], eAd]),
E('med-purge', 'POST', '/v1/media/purge', 'Delete the images a purge would remove. failed_groups counts groups that could not be purged; the others were still purged.', 'Needs permission', ['fingerprint'], [[401, L], eAd]),
E('site-list', 'GET', '/v1/site/', 'List sites', 'Verified user', [], [[401, L]]),
E('author-list', 'GET', '/v1/author/', 'List authors, one page at a time', 'Verified user', ['page', 'size'], [[400, MM], [401, L]], { qp: 1 }),
E('usr-list', 'GET', '/v1/user/', 'List users and invites.', 'Verified user', [], [[401, L]]),
E('usr-me', 'PATCH', '/v1/user/me', 'Update my profile.', 'Verified user', ['display_name'], [[400, MM], [401, L]]),
E('usr-inv', 'POST', '/v1/user/invite', 'Invite a user by email.', 'Verified user', ['email'], [[400, MM], [401, L], [409, 'This address already has an account.']]),
E('usr-cancel', 'DELETE', '/v1/user/invite/{id}', 'Cancel a pending invite.', 'Verified user', [], [[401, L], [404, 'Invite not found']]),
E('usr-renew', 'POST', '/v1/user/invite/{id}/renew', 'Renew an expired invite.', 'Verified user', [], [[401, L], [404, 'Invite not found']]),
E('auth-me', 'GET', '/v1/auth/me', 'Get the current user.', 'Verified user', [], [[401, L]]),
E('set-get', 'GET', '/v1/settings', 'Get settings.', 'Verified user', [], [[401, L]]),
S('s-login', 'POST', '/v1/auth/login', 'Log in'), S('s-logout', 'POST', '/v1/auth/logout', 'Log out'), S('s-reg', 'POST', '/v1/auth/register', 'Register'), S('s-verify', 'POST', '/v1/auth/verify', 'Verify email'), S('s-reset', 'POST', '/v1/auth/password-reset', 'Reset password'),
S('s-keys', 'GET', '/v1/api-key', 'List keys'), S('s-kcreate', 'POST', '/v1/api-key', 'Create a key'), S('s-krev', 'DELETE', '/v1/api-key/{id}', 'Revoke a key'),
S('s-site-c', 'POST', '/v1/site/', 'Create a site'), S('s-site-u', 'PATCH', '/v1/site/{id}', 'Update a site'), S('s-site-d', 'PATCH', '/v1/site/{id}/default', 'Set the default site'), S('s-site-x', 'DELETE', '/v1/site/{id}', 'Delete a site (owner)'),
S('s-au-me', 'POST', '/v1/author/me', 'Create my author profile'), S('s-au-c', 'POST', '/v1/author/', 'Create an author'), S('s-au-u', 'PATCH', '/v1/author/{id}', 'Update an author'), S('s-au-x', 'DELETE', '/v1/author/{id}', 'Delete an author'),
S('s-iv-l', 'GET', '/v1/image-variants/', 'List variants'), S('s-iv-c', 'POST', '/v1/image-variants/', 'Create a variant'), S('s-iv-p', 'PUT', '/v1/image-variants/{name}', 'Replace a variant'), S('s-iv-pa', 'PATCH', '/v1/image-variants/{name}', 'Update a variant'), S('s-iv-d', 'DELETE', '/v1/image-variants/{name}', 'Delete a variant'), S('s-iv-pu', 'POST', '/v1/image-variants/{name}/purge', 'Purge the cache of a variant'), S('s-iv-ca', 'GET', '/v1/image-variants/cache', 'Image cache status'), S('s-iv-cp', 'POST', '/v1/image-variants/cache/purge', 'Purge the image cache'),
S('s-mv-l', 'GET', '/v1/media/{id}/variants', 'List the variants of an image'), S('s-mv-c', 'POST', '/v1/media/{id}/variants', 'Add variants to an image'), S('s-mv-g', 'POST', '/v1/media/{id}/variants/{name}', 'Generate one variant of an image'), S('s-mv-d', 'DELETE', '/v1/media/{id}/variants/{name}', 'Delete one variant of an image'),
S('s-ac-l', 'GET', '/v1/activity/', 'List activity'), S('s-ac-s', 'GET', '/v1/activity/summary', 'Activity summary'), S('s-ac-e', 'GET', '/v1/activity/export', 'Export activity'),
S('s-upd', 'GET', '/v1/system/update', 'Update status'), S('s-chk', 'POST', '/v1/system/update/check', 'Check for update'), S('s-dl', 'POST', '/v1/system/update/download', 'Download update'), S('s-rst', 'POST', '/v1/system/update/restart', 'Restart')
]
const secs = [
['auth', 'Authentication', 'Public access and API keys', 'bearer api key rcms rate limit 429 base url public hash'],
['articles', 'Articles', '7 routes', ''], ['seo', 'Search and sharing', '2 routes', 'seo sitemap'], ['ctypes', 'Content types', '5 routes', 'custom fields field types schema'], ['entries', 'Entries', '5 routes', 'content type custom data'], ['builtin', 'Built-in types', '2 routes', 'builtin fields schema'], ['media', 'Media', '10 routes', 'purge'], ['sites', 'Sites and authors', '2 routes', 'site author'],
['images', 'Images and variants', 'The ?v= parameter', 'variant image width height format quality'],
['users', 'Users and invites', '6 routes', ''], ['settings', 'Settings', '1 route', ''],
['session', 'Session only', '35 routes, not available to API keys', 'login log in register password real session'],
['errors', 'Errors', 'Status codes and messages', '400 401 403 404 409 422 429 500 status error plain text'],
['notes', 'Notes', 'Images, CORS and ?format=', 'cors origins format markdown html json body_format']
]
return { eps, secs }
}
has(s, t) { return s.includes(t) || (t === 'entry' && s.includes('entries')) }
hl(t, toks) {
if (!toks.length) return [{ t, bg: 'transparent', fg: 'inherit' }]
const re = new RegExp('(' + toks.map(x => x === 'entry' ? 'entr(?:y|ies)' : x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')', 'ig')
return t.split(re).filter(x => x !== '').map(x => { const on = toks.some(k => k === x.toLowerCase() || (k === 'entry' && x.toLowerCase() === 'entries')); return { t: x, bg: on ? 'var(--rc-sand)' : 'transparent', fg: on ? 'var(--rc-on-sand)' : 'inherit' } })
}
compute() {
const { eps, secs } = this.data(), q = this.state.q.trim().toLowerCase(), toks = q ? q.split(/\s+/) : []
const byId = id => eps.find(e => e.id === id), bySec = k => secs.find(s => s[0] === k)
let ep = [], sc = [], title = 'Endpoints'
if (!toks.length) { ep = ['art-list', 'med-up', 'usr-me'].map(byId).map(e => ({ e })); sc = ['auth', 'errors'].map(bySec); title = null }
else {
eps.forEach(e => {
const hayP = (e.m + ' ' + e.p + ' ' + e.d + ' ' + e.acc).toLowerCase()
let via = null, ok = true
toks.forEach(t => {
if (this.has(hayP, t)) return
const f = e.f.find(x => this.has(x.toLowerCase(), t))
if (f) { via = via || { label: e.qp ? 'Query parameter' : 'Body field', text: f }; return }
const r = e.e.find(([c, m]) => this.has((c + ' ' + m).toLowerCase(), t))
if (r) { via = via || { label: 'Error', text: r[0] + ' ' + r[1] }; return }
ok = false
})
if (ok) ep.push({ e, via })
})
ep.sort((a, b) => (a.via ? 1 : 0) - (b.via ? 1 : 0))
sc = secs.filter(s => toks.every(t => this.has((s[1] + ' ' + s[2] + ' ' + s[3]).toLowerCase(), t)))
}
return { ep, sc, toks, title }
}
renderVals() {
const mob = !!this.props.mobile, st = this.state, { ep, sc, toks, title } = this.compute()
const CAP = 6, shown = ep.slice(0, CAP), total = ep.length + sc.length
const MB = { GET: ['var(--ui-bg-inverted)', 'var(--ui-text-inverted)', 'var(--ui-bg-inverted)'], POST: ['var(--rc-success-soft)', 'var(--ui-success)', 'var(--ui-success)'], PATCH: ['var(--rc-warning-soft)', 'var(--ui-warning)', 'var(--ui-warning)'], PUT: ['var(--rc-warning-soft)', 'var(--ui-warning)', 'var(--ui-warning)'], DELETE: ['var(--rc-error-soft)', 'var(--ui-error)', 'var(--ui-error)'] }
const sel = total ? Math.min(st.sel, shown.length + sc.length - 1) : 0
let idx = 0
const mkItem = (o) => o
const epItems = shown.map(({ e, via }) => { const s = !!e.sess, mc = MB[e.m]; return mkItem({
isEp: true, isSec: false, sess: s, m: e.m, mBg: s ? 'transparent' : mc[0], mFg: s ? 'var(--ui-text-muted)' : mc[1], mBd: s ? 'var(--ui-border-accented)' : mc[2], mBs: s ? 'dashed' : 'solid',
pSeg: this.hl(e.p, toks), pFw: s ? 400 : 500, pFg: s ? 'var(--ui-text-toned)' : 'var(--ui-text-highlighted)', dSeg: this.hl(e.d, toks), dFg: s ? 'var(--ui-text-muted)' : 'var(--ui-text-toned)',
hasChip: true, chip: e.acc, cBg: 'transparent', cFg: s ? 'var(--ui-text-muted)' : 'var(--ui-text-toned)', cBd: 'var(--ui-border-accented)', cBs: s ? 'dashed' : 'solid', cFw: 400,
via: !!via, viaLabel: via ? via.label : '', viaSeg: via ? this.hl(via.text, toks) : [], label: e.m + ' ' + e.p + '. ' + e.d + ' ' + e.acc })
})
const secItems = sc.map(s => mkItem({ isEp: false, isSec: true, icon: s[0] === 'session' ? 'lock' : 'hash', pSeg: this.hl(s[1], toks), desc: s[2], hasChip: false, via: false, dSeg: [], dFg: 'var(--ui-text-muted)', label: 'Section ' + s[1] + '. ' + s[2] }))
const groups = []
if (epItems.length) groups.push({ hid: 'rc-sr-g0', title: title || 'Suggested', items: epItems })
if (secItems.length) groups.push({ hid: 'rc-sr-g1', title: title ? 'Sections' : 'Suggested', items: secItems })
if (!title) { const all = [...epItems, ...secItems]; const ord = [secItems[0], epItems[1], secItems[1], epItems[0], epItems[2]]; groups.length = 0; groups.push({ hid: 'rc-sr-g0', title: 'Suggested', items: ord }) }
const none = !!toks.length && total === 0
const more = ep.length > CAP
const plural = (n, w) => n + ' ' + w + (n === 1 ? '' : 's')
const live = none ? 'No results' : !toks.length ? '5 suggestions' : plural(total, 'result') + (more ? ', showing ' + (shown.length + sc.length) : '')
const activeId = (!none && total) ? 'rc-sr-' + sel : undefined
const flat = groups.flatMap(g => g.items)
flat.forEach((it, i) => { const on = i === sel; Object.assign(it, { id: 'rc-sr-' + i, sel: on, bg: on ? 'var(--ui-bg-elevated)' : 'transparent', bar: on ? 'inset 2px 0 0 var(--ui-primary)' : 'none', go: () => this.setState({ sel: i }) }) })
return {
groups, none, noneText: 'No match for "' + this.state.q.trim() + '". Try a path, a method or a status code like 404.', more, moreText: (ep.length - CAP) + ' more endpoint' + (ep.length - CAP === 1 ? '' : 's') + '. Keep typing to narrow the list.',
live, count: none ? '0 results' : !toks.length ? '5 suggestions' : plural(total, 'result'), activeId, q: st.q,
desk: !mob, mob, w: mob ? '100%' : '720px', h: mob ? '100%' : 'auto', bd: mob ? '0' : '1px solid var(--ui-border)', shellPad: mob ? '0' : '96px 24px 24px',
inH: mob ? '56px' : '52px', inPx: mob ? '16px' : '16px', inFs: mob ? '16px' : '15px', ftH: mob ? '40px' : '40px', listMax: mob ? 'none' : '440px', rowPx: mob ? '16px' : '16px',
cols: mob ? '56px minmax(0,1fr)' : '56px minmax(0,1fr) auto', colGap: '12px', rowPad: mob ? '10px 16px' : '8px 16px', rowH: mob ? '56px' : '40px', pathShrink: mob ? 1 : 0,
onInput: e => this.setState({ q: e.target.value, sel: 0 }),
onClose: () => this.props.onClose && this.props.onClose(),
onKey: e => {
const n = flat.length
if (e.key === 'ArrowDown') { e.preventDefault(); this.setState({ sel: n ? (sel + 1) % n : 0 }) }
else if (e.key === 'ArrowUp') { e.preventDefault(); this.setState({ sel: n ? (sel - 1 + n) % n : 0 }) }
else if (e.key === 'Enter' && n) { e.preventDefault(); this.props.onOpen && this.props.onOpen(flat[sel].id) }
else if (e.key === 'Escape') { this.props.onClose && this.props.onClose() }
}
}
}
}
</script>
</body>
</html>
Loaded 100 of 1508 files, more files were not shown because too many files have changed in this diff. Show more