Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efecdee200 | ||
|
|
34dffe222f | ||
|
|
3db490a615 | ||
|
|
907949aa12 | ||
|
|
80f7037d3a | ||
|
|
094df63a7f | ||
|
|
a043f7f41a | ||
|
|
de142f6c5f | ||
|
|
8c6c6456da | ||
|
|
44cca46d5e | ||
|
|
7c0ee21906 | ||
|
|
25d9c30b8a | ||
|
|
a8fea8d900 | ||
|
|
a0fdf03be0 | ||
|
|
9ab98fd50c | ||
|
|
d2af39da61 | ||
|
|
4cff43fefb | ||
|
|
daba3c04c0 | ||
|
|
f2cdbcd3cb | ||
|
|
37a77c9c22 | ||
|
|
b45c99be3b | ||
|
|
fc51ab8c10 | ||
|
|
07f5c5d1ee | ||
|
|
ea98c5ec05 | ||
|
|
e2812ee0b9 | ||
|
|
aa5d3582f8 | ||
|
|
c68e9a7858 | ||
|
|
cfcf108488 | ||
|
|
b43cc6861f | ||
|
|
074944c99a | ||
|
|
0fc3511e2b | ||
|
|
3e7fc9a4a5 | ||
|
|
a2d596f4ca | ||
|
|
ad5faf3d19 | ||
|
|
89051b3f1f | ||
|
|
fba20f83c5 | ||
|
|
91d916fb74 | ||
|
|
b4cbcb0395 | ||
|
|
5404291af2 | ||
|
|
76e9e1220c | ||
|
|
8e344b2500 | ||
|
|
083cc9e23e | ||
|
|
f514146fed | ||
|
|
3c67cd2314 | ||
|
|
73a75d5cb9 | ||
|
|
e6e088c963 | ||
|
|
58d58f5a04 | ||
|
|
ed67b7e70d | ||
|
|
23e88190d9 | ||
|
|
340a0ad461 | ||
|
|
500ad59458 | ||
|
|
18600ab49d | ||
|
|
daf3594316 | ||
|
|
9f4ce426a8 | ||
|
|
02fb8751fd | ||
|
|
cb737b7ae3 | ||
|
|
470584400f | ||
|
|
eb9876fde3 | ||
|
|
0eb46b0d74 | ||
|
|
cb1410f0b0 | ||
|
|
6b8990c55e | ||
|
|
3d81d2c717 | ||
|
|
b34a173d83 | ||
|
|
a11338094e | ||
|
|
45fcbd890d |
No files matched your search
@@ -14,6 +14,7 @@ CORS_ALLOWED_ORIGINS=
|
|||||||
AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
|
AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
|
||||||
AUTH_RATE_LIMIT_WINDOW_SECONDS=60
|
AUTH_RATE_LIMIT_WINDOW_SECONDS=60
|
||||||
TRUSTED_PROXIES=
|
TRUSTED_PROXIES=
|
||||||
|
SMTP_ALLOW_INSECURE=false
|
||||||
UPDATE_FEED_URL=https://git.floriansylvain.fr
|
UPDATE_FEED_URL=https://git.floriansylvain.fr
|
||||||
RENEWCMS_CONTAINER=false
|
RENEWCMS_CONTAINER=false
|
||||||
IMAGE_VARIANT_MAX_SOURCE_BYTES=10485760
|
IMAGE_VARIANT_MAX_SOURCE_BYTES=10485760
|
||||||
|
|||||||
@@ -28,3 +28,8 @@ CLAUDE.md
|
|||||||
.agents/
|
.agents/
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
codex-workflow.md
|
codex-workflow.md
|
||||||
|
CODING_STANDARDS.md
|
||||||
|
|
||||||
|
# Generated LLM docs
|
||||||
|
internal/infrastructure/llmdocs/generated/*
|
||||||
|
!internal/infrastructure/llmdocs/generated/.gitkeep
|
||||||
@@ -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
@@ -4,8 +4,12 @@ COPY web/package.json web/bun.lock ./
|
|||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
COPY web/ ./
|
COPY web/ ./
|
||||||
RUN bun run build-only
|
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
|
ARG VERSION=dev
|
||||||
ENV CGO_ENABLED=0
|
ENV CGO_ENABLED=0
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
@@ -13,6 +17,7 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=web /src/internal/infrastructure/assets/dist ./internal/infrastructure/assets/dist
|
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
|
RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/renewcms ./cmd/server
|
||||||
|
|
||||||
FROM alpine:3.22
|
FROM alpine:3.22
|
||||||
|
|||||||
@@ -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/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/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/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/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.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/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/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 |
|
| [docs/release-notes.md](docs/release-notes.md) | Release notes format, the published releases and how to publish one |
|
||||||
|
|
||||||
|
|||||||
+18
-2
@@ -3,6 +3,7 @@ version: '3'
|
|||||||
vars:
|
vars:
|
||||||
BINARY: renewcms
|
BINARY: renewcms
|
||||||
DIST_DIR: internal/infrastructure/assets/dist
|
DIST_DIR: internal/infrastructure/assets/dist
|
||||||
|
LLMS_DIR: internal/infrastructure/llmdocs/generated
|
||||||
VERSION:
|
VERSION:
|
||||||
sh: git describe --tags --always --dirty
|
sh: git describe --tags --always --dirty
|
||||||
LDFLAGS: -s -w -X main.version={{.VERSION}}
|
LDFLAGS: -s -w -X main.version={{.VERSION}}
|
||||||
@@ -32,6 +33,7 @@ tasks:
|
|||||||
|
|
||||||
dev:api:
|
dev:api:
|
||||||
desc: Run the API with hot reload on :8080 (SPA not embedded)
|
desc: Run the API with hot reload on :8080 (SPA not embedded)
|
||||||
|
deps: [build:llms]
|
||||||
cmds:
|
cmds:
|
||||||
- air
|
- air
|
||||||
|
|
||||||
@@ -231,22 +233,36 @@ tasks:
|
|||||||
desc: Type-check and build the SPA into {{.DIST_DIR}}
|
desc: Type-check and build the SPA into {{.DIST_DIR}}
|
||||||
dir: web
|
dir: web
|
||||||
deps: [web:install]
|
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']
|
generates: ['../{{.DIST_DIR}}/index.html']
|
||||||
cmds:
|
cmds:
|
||||||
- bun run build
|
- 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:
|
build:
|
||||||
desc: Production binary for this machine → bin/{{.BINARY}}
|
desc: Production binary for this machine → bin/{{.BINARY}}
|
||||||
deps: [build:web]
|
deps: [build:web, build:llms]
|
||||||
cmds:
|
cmds:
|
||||||
- go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}{{exeExt}} ./cmd/server
|
- 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:
|
release:
|
||||||
desc: Cross-compile and sign production binaries for all release targets → bin/ (needs RELEASE_SIGNING_KEY)
|
desc: Cross-compile and sign production binaries for all release targets → bin/ (needs RELEASE_SIGNING_KEY)
|
||||||
cmds:
|
cmds:
|
||||||
- task: release:check
|
- task: release:check
|
||||||
- task: build:web
|
- task: build:web
|
||||||
|
- task: build:llms
|
||||||
- for: { var: RELEASE_TARGETS }
|
- for: { var: RELEASE_TARGETS }
|
||||||
task: build:target
|
task: build:target
|
||||||
vars:
|
vars:
|
||||||
|
|||||||
+121
-23
@@ -1,13 +1,21 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"RenewCMS/api/pkg/authtoken"
|
||||||
domainAPIKey "RenewCMS/internal/domain/apikey"
|
domainAPIKey "RenewCMS/internal/domain/apikey"
|
||||||
domainArticle "RenewCMS/internal/domain/article"
|
domainArticle "RenewCMS/internal/domain/article"
|
||||||
domainAudit "RenewCMS/internal/domain/audit"
|
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"
|
domainImage "RenewCMS/internal/domain/image"
|
||||||
domainInvite "RenewCMS/internal/domain/invite"
|
domainInvite "RenewCMS/internal/domain/invite"
|
||||||
domainMail "RenewCMS/internal/domain/mail"
|
domainMail "RenewCMS/internal/domain/mail"
|
||||||
domainPurge "RenewCMS/internal/domain/purge"
|
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"
|
domainUpdate "RenewCMS/internal/domain/update"
|
||||||
domainUser "RenewCMS/internal/domain/user"
|
domainUser "RenewCMS/internal/domain/user"
|
||||||
"RenewCMS/internal/infrastructure/auditing"
|
"RenewCMS/internal/infrastructure/auditing"
|
||||||
@@ -32,21 +40,56 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/jwtauth/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type UseCases struct {
|
type UseCases struct {
|
||||||
apiKeyAuthOnce sync.Once
|
apiKeyAuthOnce sync.Once
|
||||||
apiKeyAuthMiddleware func(http.Handler) http.Handler
|
apiKeyAuthMiddleware func(http.Handler) http.Handler
|
||||||
Token *jwtauth.JWTAuth
|
Token *authtoken.Auth
|
||||||
PurgeScheduler *scheduler.Runner
|
PurgeScheduler *scheduler.Runner
|
||||||
ActivityCleanupScheduler *scheduler.Runner
|
ActivityCleanupScheduler *scheduler.Runner
|
||||||
|
RetentionScheduler *scheduler.Runner
|
||||||
CreateArticleUseCase *useCases.CreateArticleUseCase
|
CreateArticleUseCase *useCases.CreateArticleUseCase
|
||||||
GetArticleUseCase *useCases.GetArticleUseCase
|
GetArticleUseCase *useCases.GetArticleUseCase
|
||||||
ListArticlesUseCase *useCases.ListArticlesUseCase
|
ListArticlesUseCase *useCases.ListArticlesUseCase
|
||||||
UpdateArticleUseCase *useCases.UpdateArticleUseCase
|
UpdateArticleUseCase *useCases.UpdateArticleUseCase
|
||||||
|
ResolveArticleSEOUseCase *useCases.ResolveArticleSEOUseCase
|
||||||
|
CheckArticleSEOUseCase *useCases.CheckArticleSEOUseCase
|
||||||
|
ListSitemapUseCase *useCases.ListSitemapUseCase
|
||||||
DeleteArticleUseCase *useCases.DeleteArticleUseCase
|
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
|
GetUserUseCase *useCases.GetUserUseCase
|
||||||
CreateUserUseCase *useCases.CreateUserUseCase
|
CreateUserUseCase *useCases.CreateUserUseCase
|
||||||
AuthenticateUserUseCase *useCases.AuthenticateUserUseCase
|
AuthenticateUserUseCase *useCases.AuthenticateUserUseCase
|
||||||
@@ -60,6 +103,8 @@ type UseCases struct {
|
|||||||
CancelInviteUseCase *useCases.CancelInviteUseCase
|
CancelInviteUseCase *useCases.CancelInviteUseCase
|
||||||
RenewInviteUseCase *useCases.RenewInviteUseCase
|
RenewInviteUseCase *useCases.RenewInviteUseCase
|
||||||
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
|
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
|
||||||
|
ChangeUserRoleUseCase *useCases.ChangeUserRoleUseCase
|
||||||
|
TransferOwnershipUseCase *useCases.TransferOwnershipUseCase
|
||||||
GetInviteUseCase *useCases.GetInviteUseCase
|
GetInviteUseCase *useCases.GetInviteUseCase
|
||||||
AcceptInviteUseCase *useCases.AcceptInviteUseCase
|
AcceptInviteUseCase *useCases.AcceptInviteUseCase
|
||||||
SendMailUseCase *useCases.SendMailUseCase
|
SendMailUseCase *useCases.SendMailUseCase
|
||||||
@@ -108,7 +153,7 @@ type UseCases struct {
|
|||||||
|
|
||||||
func (c *UseCases) RunSchedulers(ctx context.Context) {
|
func (c *UseCases) RunSchedulers(ctx context.Context) {
|
||||||
var running sync.WaitGroup
|
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 {
|
if runner == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -123,8 +168,14 @@ func (c *UseCases) RunSchedulers(ctx context.Context) {
|
|||||||
|
|
||||||
type Repositories struct {
|
type Repositories struct {
|
||||||
ArticleRepo domainArticle.Repository
|
ArticleRepo domainArticle.Repository
|
||||||
|
SiteRepo domainSite.Repository
|
||||||
|
ContentTypeRepo domainContentType.Repository
|
||||||
|
EntryRepo domainEntry.Repository
|
||||||
|
AuthorRepo domainAuthor.Repository
|
||||||
UserRepo domainUser.Repository
|
UserRepo domainUser.Repository
|
||||||
|
RoleRepo domainRole.Repository
|
||||||
ImageRepo domainImage.Repository
|
ImageRepo domainImage.Repository
|
||||||
|
Transactor domainTransaction.Transactor
|
||||||
VariantRepo domainImage.VariantRepository
|
VariantRepo domainImage.VariantRepository
|
||||||
VariantFileRepo domainImage.VariantFileRepository
|
VariantFileRepo domainImage.VariantFileRepository
|
||||||
VariantCacheRepo domainImage.VariantCacheRepository
|
VariantCacheRepo domainImage.VariantCacheRepository
|
||||||
@@ -132,6 +183,7 @@ type Repositories struct {
|
|||||||
VariantRenderer domainImage.VariantRenderer
|
VariantRenderer domainImage.VariantRenderer
|
||||||
InviteRepo domainInvite.Repository
|
InviteRepo domainInvite.Repository
|
||||||
APIKeyRepo domainAPIKey.Repository
|
APIKeyRepo domainAPIKey.Repository
|
||||||
|
SessionRepo domainSession.Repository
|
||||||
PurgeSettingsRepo domainPurge.Repository
|
PurgeSettingsRepo domainPurge.Repository
|
||||||
AuditRepo domainAudit.Repository
|
AuditRepo domainAudit.Repository
|
||||||
AuditSettingsRepo domainAudit.SettingsRepository
|
AuditSettingsRepo domainAudit.SettingsRepository
|
||||||
@@ -241,7 +293,7 @@ func openDatabase(settings config.Settings) *database.Connection {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
panic("Unable to open the database: " + err.Error())
|
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())
|
panic("Failed to migrate database: " + err.Error())
|
||||||
}
|
}
|
||||||
return connection
|
return connection
|
||||||
@@ -261,8 +313,14 @@ func getRepositories(connection *database.Connection, settings config.Settings)
|
|||||||
variantFiles := persistence.NewVariantFileRepository(db)
|
variantFiles := persistence.NewVariantFileRepository(db)
|
||||||
return &Repositories{
|
return &Repositories{
|
||||||
ArticleRepo: persistence.NewArticleRepository(db),
|
ArticleRepo: persistence.NewArticleRepository(db),
|
||||||
|
SiteRepo: persistence.NewSiteRepository(db),
|
||||||
|
ContentTypeRepo: persistence.NewContentTypeRepository(db),
|
||||||
|
EntryRepo: persistence.NewEntryRepository(db),
|
||||||
|
AuthorRepo: persistence.NewAuthorRepository(db),
|
||||||
UserRepo: persistence.NewUserRepository(db),
|
UserRepo: persistence.NewUserRepository(db),
|
||||||
|
RoleRepo: persistence.NewRoleRepository(db),
|
||||||
ImageRepo: persistence.NewImageRepository(db),
|
ImageRepo: persistence.NewImageRepository(db),
|
||||||
|
Transactor: persistence.NewTransactor(db),
|
||||||
VariantRepo: persistence.NewVariantRepository(db),
|
VariantRepo: persistence.NewVariantRepository(db),
|
||||||
VariantFileRepo: variantFiles,
|
VariantFileRepo: variantFiles,
|
||||||
VariantCacheRepo: variantFiles,
|
VariantCacheRepo: variantFiles,
|
||||||
@@ -270,6 +328,7 @@ func getRepositories(connection *database.Connection, settings config.Settings)
|
|||||||
VariantRenderer: imaging.NewVariantRenderer(),
|
VariantRenderer: imaging.NewVariantRenderer(),
|
||||||
InviteRepo: persistence.NewInviteRepository(db),
|
InviteRepo: persistence.NewInviteRepository(db),
|
||||||
APIKeyRepo: persistence.NewAPIKeyRepository(db),
|
APIKeyRepo: persistence.NewAPIKeyRepository(db),
|
||||||
|
SessionRepo: persistence.NewRevokedSessionRepository(db),
|
||||||
PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db),
|
PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db),
|
||||||
AuditRepo: persistence.NewAuditEventRepository(db),
|
AuditRepo: persistence.NewAuditEventRepository(db),
|
||||||
AuditSettingsRepo: persistence.NewAuditSettingsRepository(db),
|
AuditSettingsRepo: persistence.NewAuditSettingsRepository(db),
|
||||||
@@ -293,9 +352,9 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
|||||||
renderer := markdown.NewConverter()
|
renderer := markdown.NewConverter()
|
||||||
keyGenerator := hashing.NewSHA256KeyGenerator()
|
keyGenerator := hashing.NewSHA256KeyGenerator()
|
||||||
recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default())
|
recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default())
|
||||||
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, repos.VariantFileRepo, recorder)
|
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)
|
purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge, recorder)
|
||||||
activityCleanup := useCases.NewCleanupActivityUseCase(repos.AuditRepo, repos.AuditSettingsRepo, recorder)
|
activityCleanup := useCases.NewCleanupActivityUseCase(repos.AuditRepo, repos.AuditSettingsRepo, recorder)
|
||||||
|
|
||||||
@@ -305,14 +364,51 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
|||||||
return &UseCases{
|
return &UseCases{
|
||||||
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
|
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
|
||||||
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
|
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
|
||||||
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
|
RetentionScheduler: scheduler.NewSystemRetentionRunner(useCases.NewEnforceRetentionUseCase(repos.UserRepo, repos.InviteRepo, repos.SessionRepo), log.Default()),
|
||||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder),
|
Token: authtoken.New([]byte(settings.JWTSecret), authtoken.WithRevocationStore(useCases.NewSessionRevocationUseCase(repos.SessionRepo, time.Now))),
|
||||||
|
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo),
|
||||||
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
|
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
|
||||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer),
|
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
|
||||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder),
|
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ImageRepo),
|
||||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder),
|
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),
|
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),
|
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder),
|
||||||
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher, recorder),
|
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher, recorder),
|
||||||
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
|
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
|
||||||
@@ -320,23 +416,25 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
|||||||
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder),
|
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder),
|
||||||
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder),
|
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder),
|
||||||
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
|
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),
|
CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder),
|
||||||
RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
|
RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, repos.ContentTypeRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
|
||||||
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo),
|
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo),
|
||||||
|
ChangeUserRoleUseCase: useCases.NewChangeUserRoleUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder),
|
||||||
|
TransferOwnershipUseCase: useCases.NewTransferOwnershipUseCase(repos.UserRepo, repos.RoleRepo, recorder),
|
||||||
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
|
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),
|
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),
|
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),
|
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,
|
MergeMediaUseCase: mergeMedia,
|
||||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
|
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo),
|
||||||
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
|
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
|
||||||
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
|
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,
|
RunPurgeUseCase: runPurge,
|
||||||
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
|
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
|
||||||
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now),
|
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now),
|
||||||
@@ -346,9 +444,9 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
|||||||
RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now),
|
RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now),
|
||||||
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder),
|
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder),
|
||||||
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
|
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),
|
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),
|
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now),
|
||||||
ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now),
|
ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now),
|
||||||
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, time.Now),
|
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, time.Now),
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package activity
|
package activity
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"RenewCMS/api/pkg/access"
|
||||||
"RenewCMS/api/pkg/apperr"
|
"RenewCMS/api/pkg/apperr"
|
||||||
"RenewCMS/api/pkg/claims"
|
"RenewCMS/api/pkg/claims"
|
||||||
validator "RenewCMS/api/pkg/validator"
|
validator "RenewCMS/api/pkg/validator"
|
||||||
"RenewCMS/internal/domain/audit"
|
"RenewCMS/internal/domain/audit"
|
||||||
"RenewCMS/internal/domain/user"
|
"RenewCMS/internal/domain/role"
|
||||||
"RenewCMS/internal/infrastructure/useCases"
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -29,7 +30,7 @@ type Handler struct {
|
|||||||
type filterQuery struct {
|
type filterQuery struct {
|
||||||
Q string `validate:"max=200"`
|
Q string `validate:"max=200"`
|
||||||
Actor string `validate:"max=40"`
|
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"`
|
Outcome string `validate:"omitempty,oneof=all success failed"`
|
||||||
Security string `validate:"omitempty,oneof=true"`
|
Security string `validate:"omitempty,oneof=true"`
|
||||||
Range string `validate:"omitempty,oneof=today 7d 30d all custom"`
|
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):
|
case errors.Is(err, audit.ErrInvalidFilter):
|
||||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||||
case errors.Is(err, audit.ErrActorNotAllowed):
|
case errors.Is(err, audit.ErrActorNotAllowed):
|
||||||
http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden)
|
access.WriteMissingPermission(w, role.KeyActivityView)
|
||||||
case errors.Is(err, user.ErrUserNotFound):
|
case access.WriteIfMissingPermission(w, err):
|
||||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
|
||||||
default:
|
default:
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
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 := toActivityQuery(query.filterQuery)
|
||||||
activityQuery.Page, activityQuery.Size = query.Page, query.Size
|
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 {
|
if err != nil {
|
||||||
writeActivityError(w, err)
|
writeActivityError(w, err)
|
||||||
return
|
return
|
||||||
@@ -220,7 +220,13 @@ func (h *Handler) Export(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
writeActivityError(w, err)
|
writeActivityError(w, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package apikey
|
package apikey
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"RenewCMS/api/pkg/access"
|
||||||
"RenewCMS/api/pkg/apperr"
|
"RenewCMS/api/pkg/apperr"
|
||||||
"RenewCMS/api/pkg/claims"
|
"RenewCMS/api/pkg/claims"
|
||||||
validator "RenewCMS/api/pkg/validator"
|
validator "RenewCMS/api/pkg/validator"
|
||||||
@@ -109,8 +110,11 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := h.ListUseCase.ListAPIKeys(userId)
|
entries, err := h.ListUseCase.ListAPIKeys(userId, access.Grants(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if access.WriteIfMissingPermission(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if errors.Is(err, user.ErrUserNotFound) {
|
if errors.Is(err, user.ErrUserNotFound) {
|
||||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
@@ -161,8 +165,9 @@ func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
switch {
|
||||||
|
case access.WriteIfMissingPermission(w, err):
|
||||||
case errors.Is(err, domain.ErrAPIKeyNotFound):
|
case errors.Is(err, domain.ErrAPIKeyNotFound):
|
||||||
http.Error(w, apperr.APIKeyNotFoundErrorMessage, http.StatusNotFound)
|
http.Error(w, apperr.APIKeyNotFoundErrorMessage, http.StatusNotFound)
|
||||||
case errors.Is(err, user.ErrUserNotFound):
|
case errors.Is(err, user.ErrUserNotFound):
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
package article
|
package article
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"RenewCMS/api/pkg/access"
|
||||||
"RenewCMS/api/pkg/apperr"
|
"RenewCMS/api/pkg/apperr"
|
||||||
"RenewCMS/api/pkg/claims"
|
|
||||||
validator "RenewCMS/api/pkg/validator"
|
validator "RenewCMS/api/pkg/validator"
|
||||||
domain "RenewCMS/internal/domain/article"
|
domain "RenewCMS/internal/domain/article"
|
||||||
|
"RenewCMS/internal/domain/author"
|
||||||
|
"RenewCMS/internal/domain/image"
|
||||||
|
"RenewCMS/internal/domain/site"
|
||||||
"RenewCMS/internal/infrastructure/useCases"
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
playground "github.com/go-playground/validator/v10"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
@@ -25,7 +31,6 @@ type Handler struct {
|
|||||||
ListUseCase *useCases.ListArticlesUseCase
|
ListUseCase *useCases.ListArticlesUseCase
|
||||||
DeleteUseCase *useCases.DeleteArticleUseCase
|
DeleteUseCase *useCases.DeleteArticleUseCase
|
||||||
UpdateUseCase *useCases.UpdateArticleUseCase
|
UpdateUseCase *useCases.UpdateArticleUseCase
|
||||||
GetUserUseCase *useCases.GetUserUseCase
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PostArticle struct {
|
type PostArticle struct {
|
||||||
@@ -33,49 +38,124 @@ type PostArticle struct {
|
|||||||
Body string `json:"body"`
|
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{
|
return &Handler{
|
||||||
CreateUseCase: create,
|
CreateUseCase: create,
|
||||||
GetUseCase: get,
|
GetUseCase: get,
|
||||||
ListUseCase: list,
|
ListUseCase: list,
|
||||||
DeleteUseCase: delete,
|
DeleteUseCase: delete,
|
||||||
UpdateUseCase: update,
|
UpdateUseCase: update,
|
||||||
GetUserUseCase: getUser,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type articleResponse struct {
|
type baseArticleResponse struct {
|
||||||
ID uint32 `json:"id"`
|
ID uint32 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Body json.RawMessage `json:"body"`
|
Body json.RawMessage `json:"body"`
|
||||||
BodyFormat domain.BodyFormat `json:"body_format"`
|
BodyFormat domain.BodyFormat `json:"body_format"`
|
||||||
IsOnline bool `json:"is_online"`
|
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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_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)
|
body := json.RawMessage(article.Body)
|
||||||
if format != domain.BodyFormatJSON {
|
if format != domain.BodyFormatJSON {
|
||||||
encoded, err := json.Marshal(article.Body)
|
encoded, err := json.Marshal(article.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return articleResponse{}, err
|
return baseArticleResponse{}, err
|
||||||
}
|
}
|
||||||
body = encoded
|
body = encoded
|
||||||
}
|
}
|
||||||
return articleResponse{
|
authors := article.Authors
|
||||||
|
if authors == nil {
|
||||||
|
authors = []author.Byline{}
|
||||||
|
}
|
||||||
|
return baseArticleResponse{
|
||||||
ID: article.ID,
|
ID: article.ID,
|
||||||
Title: article.Title,
|
Title: article.Title,
|
||||||
Body: body,
|
Body: body,
|
||||||
BodyFormat: format,
|
BodyFormat: format,
|
||||||
IsOnline: article.IsOnline,
|
IsOnline: article.IsOnline,
|
||||||
|
SiteID: article.SiteID,
|
||||||
|
CreatedBy: creatorFor(article, viewer),
|
||||||
|
Authors: authors,
|
||||||
CreatedAt: article.CreatedAt,
|
CreatedAt: article.CreatedAt,
|
||||||
UpdatedAt: article.UpdatedAt,
|
UpdatedAt: article.UpdatedAt,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func encodeArticle(article domain.Article, format domain.BodyFormat) ([]byte, error) {
|
func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer access.Caller) (articleResponse, error) {
|
||||||
response, err := toArticleResponse(article, format)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -108,12 +188,7 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var article domain.Article
|
article, err := h.GetUseCase.GetArticle(id, format, access.CallerOf(r))
|
||||||
if h.callerIsVerified(r) {
|
|
||||||
article, err = h.GetUseCase.GetArticle(id, format)
|
|
||||||
} else {
|
|
||||||
article, err = h.GetUseCase.GetOnlineArticle(id, format)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, domain.ErrArticleNotFound) {
|
if errors.Is(err, domain.ErrArticleNotFound) {
|
||||||
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
||||||
@@ -123,7 +198,7 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
articleJson, err := encodeArticle(article, format)
|
articleJson, err := encodeArticle(article, format, access.CallerOf(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -155,29 +230,86 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
|
|||||||
Title: localArticle.Title,
|
Title: localArticle.Title,
|
||||||
Body: localArticle.Body,
|
Body: localArticle.Body,
|
||||||
Format: format,
|
Format: format,
|
||||||
|
Caller: access.CallerOf(r),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, domain.ErrInvalidTitle) {
|
if errors.Is(err, domain.ErrInvalidTitle) {
|
||||||
http.Error(w, apperr.ArticleTitleErrorMessage, http.StatusBadRequest)
|
http.Error(w, apperr.ArticleTitleErrorMessage, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !writeKnownArticleError(w, err) {
|
||||||
http.Error(w, "Failed to create article", http.StatusInternalServerError)
|
http.Error(w, "Failed to create article", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writeArticleJsonResponse(w, http.StatusCreated, createdArticle, format)
|
writeArticleJsonResponse(w, r, http.StatusCreated, createdArticle, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeArticleErrorResponse(w http.ResponseWriter, err error) {
|
func writeArticleErrorResponse(w http.ResponseWriter, err error) {
|
||||||
if errors.Is(err, domain.ErrArticleNotFound) {
|
if !writeKnownArticleError(w, err) {
|
||||||
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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) {
|
func writeKnownArticleError(w http.ResponseWriter, err error) bool {
|
||||||
articleJson, err := encodeArticle(article, format)
|
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 {
|
if err != nil {
|
||||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -203,17 +335,108 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
writeArticleErrorResponse(w, err)
|
writeArticleErrorResponse(w, err)
|
||||||
return
|
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 {
|
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) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var localArticle UpdateArticleBody
|
request, ok := readUpdateArticleBody(r)
|
||||||
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
|
if !ok {
|
||||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validator.Validate.Struct(localArticle); err != nil {
|
if err := validator.Validate.Struct(request); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
writeValidationError(w, err)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeArticleErrorResponse(w, err)
|
writeArticleErrorResponse(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
|
writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -256,20 +496,6 @@ func (h *Handler) UnpublishArticle(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.setIsOnline(w, r, false)
|
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 (
|
const (
|
||||||
defaultListPage = 1
|
defaultListPage = 1
|
||||||
defaultListSize = 25
|
defaultListSize = 25
|
||||||
@@ -280,6 +506,8 @@ type listQuery struct {
|
|||||||
Size int `validate:"oneof=5 25 50 100"`
|
Size int `validate:"oneof=5 25 50 100"`
|
||||||
Q string `validate:"max=200"`
|
Q string `validate:"max=200"`
|
||||||
Status string `validate:"omitempty,oneof=online draft"`
|
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"`
|
Sort string `validate:"oneof=title:asc title:desc status:asc status:desc created_at:asc created_at:desc updated_at:asc updated_at:desc"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +519,7 @@ func parseSort(value string) domain.Sort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type listResponse struct {
|
type listResponse struct {
|
||||||
Items []articleResponse `json:"items"`
|
Items []listItemResponse `json:"items"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
Page int `json:"page"`
|
Page int `json:"page"`
|
||||||
Size int `json:"size"`
|
Size int `json:"size"`
|
||||||
@@ -322,7 +550,19 @@ func readListQuery(values url.Values) (listQuery, error) {
|
|||||||
if values.Has("sort") {
|
if values.Has("sort") {
|
||||||
sortValue = values.Get("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) {
|
func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -339,21 +579,23 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
page, err := h.ListUseCase.ListArticlesPage(useCases.ListArticlesCommand{
|
page, err := h.ListUseCase.ListArticlesPage(useCases.ListArticlesCommand{
|
||||||
Format: format,
|
Format: format,
|
||||||
Verified: h.callerIsVerified(r),
|
Caller: access.CallerOf(r),
|
||||||
Page: query.Page,
|
Page: query.Page,
|
||||||
Size: query.Size,
|
Size: query.Size,
|
||||||
Q: query.Q,
|
Q: query.Q,
|
||||||
Status: query.Status,
|
Status: query.Status,
|
||||||
Sort: parseSort(query.Sort),
|
Sort: parseSort(query.Sort),
|
||||||
|
SiteKey: query.Site,
|
||||||
|
SEO: query.SEO,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError)
|
http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
responses := make([]articleResponse, 0, len(page.Items))
|
responses := make([]listItemResponse, 0, len(page.Items))
|
||||||
for _, article := range 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 {
|
if err != nil {
|
||||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -379,11 +621,14 @@ func (h *Handler) DeleteArticle(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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) {
|
if errors.Is(err, domain.ErrArticleNotFound) {
|
||||||
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if access.WriteIfMissingPermission(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Error(w, "Failed to delete article", http.StatusInternalServerError)
|
http.Error(w, "Failed to delete article", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"RenewCMS/api/pkg/apperr"
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
"RenewCMS/api/pkg/authtoken"
|
||||||
validator "RenewCMS/api/pkg/validator"
|
validator "RenewCMS/api/pkg/validator"
|
||||||
"RenewCMS/internal/domain/invite"
|
"RenewCMS/internal/domain/invite"
|
||||||
"RenewCMS/internal/domain/user"
|
"RenewCMS/internal/domain/user"
|
||||||
@@ -9,12 +10,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/jwtauth/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type AcceptInviteHandler struct {
|
type AcceptInviteHandler struct {
|
||||||
Token *jwtauth.JWTAuth
|
Token *authtoken.Auth
|
||||||
GetInviteUseCase *useCases.GetInviteUseCase
|
GetInviteUseCase *useCases.GetInviteUseCase
|
||||||
AcceptInviteUseCase *useCases.AcceptInviteUseCase
|
AcceptInviteUseCase *useCases.AcceptInviteUseCase
|
||||||
IsProduction bool
|
IsProduction bool
|
||||||
@@ -30,7 +29,7 @@ type inviteEmailResponse struct {
|
|||||||
Email string `json:"email"`
|
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{
|
return &AcceptInviteHandler{
|
||||||
Token: token,
|
Token: token,
|
||||||
GetInviteUseCase: getInvite,
|
GetInviteUseCase: getInvite,
|
||||||
@@ -96,6 +95,8 @@ func writeAcceptInviteError(w http.ResponseWriter, err error) {
|
|||||||
http.Error(w, apperr.UsernameTakenErrorMessage, http.StatusConflict)
|
http.Error(w, apperr.UsernameTakenErrorMessage, http.StatusConflict)
|
||||||
case errors.Is(err, invite.ErrEmailHasAccount):
|
case errors.Is(err, invite.ErrEmailHasAccount):
|
||||||
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
|
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
|
||||||
|
case errors.Is(err, user.ErrPasswordTooLong):
|
||||||
|
http.Error(w, apperr.PasswordTooLongErrorMessage, http.StatusBadRequest)
|
||||||
default:
|
default:
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"RenewCMS/api/pkg/apperr"
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
"RenewCMS/api/pkg/authtoken"
|
||||||
validator "RenewCMS/api/pkg/validator"
|
validator "RenewCMS/api/pkg/validator"
|
||||||
"RenewCMS/internal/domain/user"
|
"RenewCMS/internal/domain/user"
|
||||||
"RenewCMS/internal/infrastructure/useCases"
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
@@ -9,12 +10,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/jwtauth/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type LoginHandler struct {
|
type LoginHandler struct {
|
||||||
Token *jwtauth.JWTAuth
|
Token *authtoken.Auth
|
||||||
CreateUseCase *useCases.CreateUserUseCase
|
CreateUseCase *useCases.CreateUserUseCase
|
||||||
AuthenticateUseCase *useCases.AuthenticateUserUseCase
|
AuthenticateUseCase *useCases.AuthenticateUserUseCase
|
||||||
IsProduction bool
|
IsProduction bool
|
||||||
@@ -31,7 +30,7 @@ type LoginCredentials struct {
|
|||||||
Password string `json:"password" validate:"required,min=8"`
|
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{
|
return &LoginHandler{
|
||||||
Token: token,
|
Token: token,
|
||||||
CreateUseCase: create,
|
CreateUseCase: create,
|
||||||
@@ -42,27 +41,29 @@ func NewLoginHandler(token *jwtauth.JWTAuth, create *useCases.CreateUserUseCase,
|
|||||||
|
|
||||||
func removeJwtCookie(w http.ResponseWriter, isProduction bool) {
|
func removeJwtCookie(w http.ResponseWriter, isProduction bool) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "jwt",
|
Name: authtoken.CookieName,
|
||||||
Value: "",
|
Value: "",
|
||||||
Expires: time.Now(),
|
Expires: time.Now(),
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
Secure: isProduction,
|
Secure: isProduction,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func setJwtCookie(w http.ResponseWriter, token *jwtauth.JWTAuth, isProduction bool, u user.User) error {
|
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})
|
tokenString, err := token.Encode(map[string]any{"user_id": u.ID, "token_version": u.TokenVersion})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "jwt",
|
Name: authtoken.CookieName,
|
||||||
Value: tokenString,
|
Value: tokenString,
|
||||||
Expires: time.Now().Add(24 * time.Hour),
|
Expires: time.Now().Add(authtoken.SessionLifetime),
|
||||||
Secure: isProduction,
|
Secure: isProduction,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
@@ -123,6 +124,10 @@ func (h *LoginHandler) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, apperr.RegistrationClosedErrorMessage, http.StatusForbidden)
|
http.Error(w, apperr.RegistrationClosedErrorMessage, http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, user.ErrPasswordTooLong) {
|
||||||
|
http.Error(w, apperr.PasswordTooLongErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Error(w, apperr.RegistrationFailedErrorMessage, http.StatusInternalServerError)
|
http.Error(w, apperr.RegistrationFailedErrorMessage, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -140,8 +145,14 @@ func (h *LoginHandler) RegistrationStatus(w http.ResponseWriter, _ *http.Request
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *LoginHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
func (h *LoginHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
h.AuthenticateUseCase.Logout(r.Context())
|
|
||||||
removeJwtCookie(w, h.IsProduction)
|
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"})
|
message, _ := json.Marshal(map[string]any{"message": "User logged out! HTTPonly jwt cookie deleted"})
|
||||||
_, _ = w.Write(message)
|
_, _ = w.Write(message)
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"RenewCMS/api/pkg/apperr"
|
"RenewCMS/api/pkg/apperr"
|
||||||
"RenewCMS/api/pkg/claims"
|
"RenewCMS/api/pkg/claims"
|
||||||
"RenewCMS/api/pkg/publicuser"
|
"RenewCMS/api/pkg/publicuser"
|
||||||
|
"RenewCMS/internal/domain/author"
|
||||||
|
"RenewCMS/internal/domain/role"
|
||||||
"RenewCMS/internal/domain/user"
|
"RenewCMS/internal/domain/user"
|
||||||
"RenewCMS/internal/infrastructure/useCases"
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -13,14 +15,50 @@ import (
|
|||||||
|
|
||||||
type MeHandler struct {
|
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{
|
return &MeHandler{
|
||||||
GetUseCase: getUser,
|
GetUseCase: getUser,
|
||||||
|
GetAuthorUseCase: getAuthor,
|
||||||
|
GetAccessUseCase: getAccess,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type meAuthor struct {
|
||||||
|
author.Byline
|
||||||
|
ArticleCount int `json:"article_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type meAccess struct {
|
||||||
|
Role useCases.RoleRef `json:"role"`
|
||||||
|
Permissions []role.Grant `json:"permissions"`
|
||||||
|
RoleAssignedBy *useCases.RoleAssigner `json:"role_assigned_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type meResponse struct {
|
||||||
|
publicuser.PublicUser
|
||||||
|
Author *meAuthor `json:"author"`
|
||||||
|
Access meAccess `json:"access"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toMeAccess(access useCases.Access) meAccess {
|
||||||
|
return meAccess{
|
||||||
|
Role: useCases.NewRoleRef(access.Role),
|
||||||
|
Permissions: access.Permissions,
|
||||||
|
RoleAssignedBy: access.RoleAssignedBy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toMeAuthor(found *author.Author) *meAuthor {
|
||||||
|
if found == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &meAuthor{Byline: found.Byline(), ArticleCount: found.ArticleCount}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
|
func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||||
userId, ok := claims.UserId(r)
|
userId, ok := claims.UserId(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -38,7 +76,19 @@ func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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), Author: toMeAuthor(linked), Access: toMeAccess(access)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ func (h *PasswordResetHandler) ResetPassword(w http.ResponseWriter, r *http.Requ
|
|||||||
http.Error(w, apperr.PasswordResetFailedErrorMessage, http.StatusForbidden)
|
http.Error(w, apperr.PasswordResetFailedErrorMessage, http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, user.ErrPasswordTooLong) {
|
||||||
|
http.Error(w, apperr.PasswordTooLongErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -90,13 +90,35 @@ func intQueryParam(values url.Values, name string, fallback int) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type usageResponse struct {
|
type usageResponse struct {
|
||||||
|
Type string `json:"type"`
|
||||||
ID uint32 `json:"id"`
|
ID uint32 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title,omitempty"`
|
||||||
Published bool `json:"published"`
|
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 {
|
type mergeResponse struct {
|
||||||
ArticlesUpdated int `json:"articles_updated"`
|
ArticlesUpdated int `json:"articles_updated"`
|
||||||
|
SitesUpdated int `json:"sites_updated"`
|
||||||
|
AuthorsUpdated int `json:"authors_updated"`
|
||||||
Merged int `json:"merged"`
|
Merged int `json:"merged"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +268,19 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, response)
|
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) {
|
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||||
userId, _ := claims.UserId(r)
|
userId, _ := claims.UserId(r)
|
||||||
input, closeFile, ok := upload.ReadImageInput(w, r, userId)
|
input, closeFile, ok := upload.ReadImageInput(w, r, userId)
|
||||||
@@ -322,15 +357,15 @@ func (h *Handler) Usage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
articles, err := h.UsageUseCase.Usage(id)
|
usages, err := h.UsageUseCase.Usage(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeImageError(w, err)
|
writeImageError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response := make([]usageResponse, 0, len(articles))
|
response := make([]usageResponse, 0, len(usages))
|
||||||
for _, a := range articles {
|
for _, usage := range usages {
|
||||||
response = append(response, usageResponse{ID: a.ID, Title: a.Title, Published: a.IsOnline})
|
response = append(response, toUsageResponse(usage))
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, response)
|
writeJSON(w, http.StatusOK, response)
|
||||||
}
|
}
|
||||||
@@ -352,7 +387,7 @@ func (h *Handler) Merge(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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) {
|
func writeMergeError(w http.ResponseWriter, err error) {
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ type purgeRunResponse struct {
|
|||||||
GroupsPurged int `json:"groups_purged"`
|
GroupsPurged int `json:"groups_purged"`
|
||||||
Merged int `json:"merged"`
|
Merged int `json:"merged"`
|
||||||
ArticlesUpdated int `json:"articles_updated"`
|
ArticlesUpdated int `json:"articles_updated"`
|
||||||
|
SitesUpdated int `json:"sites_updated"`
|
||||||
|
AuthorsUpdated int `json:"authors_updated"`
|
||||||
FailedGroups int `json:"failed_groups"`
|
FailedGroups int `json:"failed_groups"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +166,8 @@ func (h *Handler) RunPurge(w http.ResponseWriter, r *http.Request) {
|
|||||||
GroupsPurged: result.GroupsPurged,
|
GroupsPurged: result.GroupsPurged,
|
||||||
Merged: result.Merged,
|
Merged: result.Merged,
|
||||||
ArticlesUpdated: result.ArticlesUpdated,
|
ArticlesUpdated: result.ArticlesUpdated,
|
||||||
|
SitesUpdated: result.SitesUpdated,
|
||||||
|
AuthorsUpdated: result.AuthorsUpdated,
|
||||||
FailedGroups: result.FailedGroups,
|
FailedGroups: result.FailedGroups,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package ownership
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api/pkg/access"
|
||||||
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
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(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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package role
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api/pkg/access"
|
||||||
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
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.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, 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, 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, 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, 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, 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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
package update
|
package update
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"RenewCMS/api/pkg/access"
|
||||||
"RenewCMS/api/pkg/apperr"
|
"RenewCMS/api/pkg/apperr"
|
||||||
"RenewCMS/api/pkg/claims"
|
"RenewCMS/api/pkg/claims"
|
||||||
validator "RenewCMS/api/pkg/validator"
|
validator "RenewCMS/api/pkg/validator"
|
||||||
|
"RenewCMS/internal/domain/role"
|
||||||
domain "RenewCMS/internal/domain/update"
|
domain "RenewCMS/internal/domain/update"
|
||||||
"RenewCMS/internal/domain/user"
|
|
||||||
"RenewCMS/internal/infrastructure/useCases"
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -21,29 +22,23 @@ type Handler struct {
|
|||||||
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
|
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
|
||||||
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
|
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
|
||||||
RequestRestartUseCase *useCases.RequestRestartUseCase
|
RequestRestartUseCase *useCases.RequestRestartUseCase
|
||||||
GetUserUseCase *useCases.GetUserUseCase
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type restartRequest struct {
|
type restartRequest struct {
|
||||||
Kind *string `json:"kind" validate:"omitempty,oneof=update restart"`
|
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{
|
return &Handler{
|
||||||
GetUpdateStatusUseCase: getStatus,
|
GetUpdateStatusUseCase: getStatus,
|
||||||
CheckForUpdateUseCase: checkForUpdate,
|
CheckForUpdateUseCase: checkForUpdate,
|
||||||
StartUpdateDownloadUseCase: startDownload,
|
StartUpdateDownloadUseCase: startDownload,
|
||||||
RequestRestartUseCase: requestRestart,
|
RequestRestartUseCase: requestRestart,
|
||||||
GetUserUseCase: getUser,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) GetStatus(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
isOwner, ok := h.isOwner(w, r)
|
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(canApply(r)))
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Check(w http.ResponseWriter, r *http.Request) {
|
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)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
isOwner, ok := h.isOwner(w, r)
|
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(canApply(r)))
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
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
|
return *request.Kind, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) isOwner(w http.ResponseWriter, r *http.Request) (bool, bool) {
|
func canApply(r *http.Request) bool {
|
||||||
userId, ok := claims.UserId(r)
|
return access.Grants(r).Holds(role.KeySystemApplyUpdates)
|
||||||
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 writeDownloadError(w http.ResponseWriter, err error) {
|
func writeDownloadError(w http.ResponseWriter, err error) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"RenewCMS/api/pkg/access"
|
||||||
"RenewCMS/api/pkg/apperr"
|
"RenewCMS/api/pkg/apperr"
|
||||||
"RenewCMS/api/pkg/claims"
|
"RenewCMS/api/pkg/claims"
|
||||||
validator "RenewCMS/api/pkg/validator"
|
validator "RenewCMS/api/pkg/validator"
|
||||||
@@ -25,6 +26,7 @@ type InviteHandler struct {
|
|||||||
|
|
||||||
type CreateInviteRequest 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 {
|
type inviteResponse struct {
|
||||||
@@ -32,6 +34,7 @@ type inviteResponse struct {
|
|||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
Role useCases.RoleRef `json:"role"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
}
|
}
|
||||||
@@ -40,6 +43,7 @@ type inviteListItem struct {
|
|||||||
ID uint32 `json:"id"`
|
ID uint32 `json:"id"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
Role *useCases.RoleRef `json:"role"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
@@ -50,7 +54,8 @@ type userListItem struct {
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Role string `json:"role"`
|
Role *useCases.RoleRef `json:"role"`
|
||||||
|
RoleAssignedBy *useCases.RoleAssigner `json:"role_assigned_by"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
@@ -83,8 +88,10 @@ func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
created, err := h.CreateInviteUseCase.CreateInvite(r.Context(), useCases.CreateInviteCommand{
|
created, err := h.CreateInviteUseCase.CreateInvite(r.Context(), useCases.CreateInviteCommand{
|
||||||
|
Caller: access.CallerOf(r),
|
||||||
Email: request.Email,
|
Email: request.Email,
|
||||||
InvitedBy: userId,
|
InvitedBy: userId,
|
||||||
|
RoleID: request.RoleID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeCreateInviteError(w, err)
|
writeCreateInviteError(w, err)
|
||||||
@@ -94,12 +101,13 @@ func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeInviteResponse(w, http.StatusCreated, created)
|
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{
|
body, err := json.Marshal(inviteResponse{
|
||||||
ID: entry.ID,
|
ID: entry.ID,
|
||||||
Kind: useCases.EntryKindInvite,
|
Kind: useCases.EntryKindInvite,
|
||||||
Email: entry.Email,
|
Email: entry.Email,
|
||||||
Status: string(entry.StatusAt(time.Now())),
|
Status: string(entry.StatusAt(time.Now())),
|
||||||
|
Role: entry.Role,
|
||||||
CreatedAt: entry.CreatedAt,
|
CreatedAt: entry.CreatedAt,
|
||||||
ExpiresAt: entry.ExpiresAt,
|
ExpiresAt: entry.ExpiresAt,
|
||||||
})
|
})
|
||||||
@@ -144,7 +152,7 @@ func (h *InviteHandler) RenewInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
writeManageInviteError(w, err)
|
writeManageInviteError(w, err)
|
||||||
return
|
return
|
||||||
@@ -153,6 +161,9 @@ func (h *InviteHandler) RenewInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeManageInviteError(w http.ResponseWriter, err error) {
|
func writeManageInviteError(w http.ResponseWriter, err error) {
|
||||||
|
if writeRoleRefusal(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, invite.ErrInviteNotFound):
|
case errors.Is(err, invite.ErrInviteNotFound):
|
||||||
http.Error(w, apperr.InviteNotFoundErrorMessage, http.StatusNotFound)
|
http.Error(w, apperr.InviteNotFoundErrorMessage, http.StatusNotFound)
|
||||||
@@ -168,10 +179,15 @@ func writeManageInviteError(w http.ResponseWriter, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeCreateInviteError(w http.ResponseWriter, err error) {
|
func writeCreateInviteError(w http.ResponseWriter, err error) {
|
||||||
|
if writeRoleRefusal(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, invite.ErrEmailHasAccount):
|
case errors.Is(err, invite.ErrEmailHasAccount):
|
||||||
|
w.Header().Set(apperr.ErrorCodeHeader, apperr.EmailHasAccountErrorCode)
|
||||||
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
|
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
|
||||||
case errors.Is(err, invite.ErrPendingInvite):
|
case errors.Is(err, invite.ErrPendingInvite):
|
||||||
|
w.Header().Set(apperr.ErrorCodeHeader, apperr.InvitePendingErrorCode)
|
||||||
http.Error(w, apperr.PendingInviteErrorMessage, http.StatusConflict)
|
http.Error(w, apperr.PendingInviteErrorMessage, http.StatusConflict)
|
||||||
case errors.Is(err, invite.ErrInviteEmailNotSent):
|
case errors.Is(err, invite.ErrInviteEmailNotSent):
|
||||||
http.Error(w, apperr.InviteNotSentErrorMessage, http.StatusInternalServerError)
|
http.Error(w, apperr.InviteNotSentErrorMessage, http.StatusInternalServerError)
|
||||||
@@ -183,22 +199,27 @@ func writeCreateInviteError(w http.ResponseWriter, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *InviteHandler) ListUsers(w http.ResponseWriter, _ *http.Request) {
|
func (h *InviteHandler) ListUsers(w http.ResponseWriter, _ *http.Request) {
|
||||||
entries := h.ListUsersAndInvitesUseCase.ListUsersAndInvites()
|
entries, err := 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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
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: entry.Role,
|
||||||
|
Status: entry.Status,
|
||||||
|
CreatedAt: entry.CreatedAt,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, userListItemOf(entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
writeNoStoreJSON(w, http.StatusOK, items)
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
"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: entry.Role,
|
||||||
|
RoleAssignedBy: 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
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"RenewCMS/api/pkg/apperr"
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
"RenewCMS/api/pkg/authtoken"
|
||||||
"RenewCMS/internal/domain/apikey"
|
"RenewCMS/internal/domain/apikey"
|
||||||
"RenewCMS/internal/infrastructure/useCases"
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
"context"
|
"context"
|
||||||
@@ -10,9 +11,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/jwtauth/v5"
|
|
||||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const bearerScheme = "bearer "
|
const bearerScheme = "bearer "
|
||||||
@@ -79,13 +77,7 @@ func APIKeyAuth(authenticate *useCases.AuthenticateAPIKeyUseCase, maxRequests in
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := sessionTokenFor(authenticated)
|
ctx := authtoken.NewContext(r.Context(), sessionClaimsFor(authenticated), nil)
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := jwtauth.NewContext(r.Context(), token, nil)
|
|
||||||
ctx = context.WithValue(ctx, apiKeyIdContextKey{}, authenticated.Key.ID)
|
ctx = context.WithValue(ctx, apiKeyIdContextKey{}, authenticated.Key.ID)
|
||||||
ctx = context.WithValue(ctx, apiKeyNameContextKey{}, authenticated.Key.Name)
|
ctx = context.WithValue(ctx, apiKeyNameContextKey{}, authenticated.Key.Name)
|
||||||
limited.ServeHTTP(w, r.WithContext(ctx))
|
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)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sessionTokenFor(authenticated useCases.AuthenticatedAPIKey) (jwt.Token, error) {
|
func sessionClaimsFor(authenticated useCases.AuthenticatedAPIKey) authtoken.Claims {
|
||||||
token := jwt.New()
|
return authtoken.Claims{
|
||||||
if err := token.Set("user_id", float64(authenticated.User.ID)); err != nil {
|
"user_id": float64(authenticated.User.ID),
|
||||||
return nil, err
|
"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 {
|
func apiKeyRateLimitKey(r *http.Request) string {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"RenewCMS/api/pkg/access"
|
||||||
"RenewCMS/api/pkg/claims"
|
"RenewCMS/api/pkg/claims"
|
||||||
"RenewCMS/internal/domain/audit"
|
"RenewCMS/internal/domain/audit"
|
||||||
"RenewCMS/internal/infrastructure/useCases"
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
func AuditContext(getUser *useCases.GetUserUseCase, trustedProxies []string) func(http.Handler) http.Handler {
|
func AuditContext(getUser *useCases.GetUserUseCase, trustedProxies []string) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r = access.WithCurrentUser(r)
|
||||||
origin := audit.Origin{
|
origin := audit.Origin{
|
||||||
Actor: auditActor(getUser, r),
|
Actor: auditActor(getUser, r),
|
||||||
Source: auditSource(r),
|
Source: auditSource(r),
|
||||||
@@ -29,7 +31,7 @@ func auditActor(getUser *useCases.GetUserUseCase, r *http.Request) audit.Actor {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return audit.AnonymousActor("")
|
return audit.AnonymousActor("")
|
||||||
}
|
}
|
||||||
current, err := getUser.GetUser(userId)
|
current, err := access.LoadUser(getUser, r, userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return audit.AnonymousActor("")
|
return audit.AnonymousActor("")
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-37
@@ -1,6 +1,7 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"RenewCMS/api/pkg/access"
|
||||||
"RenewCMS/api/pkg/apperr"
|
"RenewCMS/api/pkg/apperr"
|
||||||
"RenewCMS/api/pkg/claims"
|
"RenewCMS/api/pkg/claims"
|
||||||
"RenewCMS/internal/domain/user"
|
"RenewCMS/internal/domain/user"
|
||||||
@@ -23,7 +24,7 @@ func resolveCurrentUser(getUser *useCases.GetUserUseCase, w http.ResponseWriter,
|
|||||||
return user.User{}, false
|
return user.User{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
currentUser, err := getUser.GetUser(userId)
|
currentUser, err := access.LoadUser(getUser, r, userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, user.ErrUserNotFound) {
|
if errors.Is(err, user.ErrUserNotFound) {
|
||||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
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 {
|
func RequireCurrentToken(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r = access.WithCurrentUser(r)
|
||||||
currentUser, ok := resolveCurrentUser(getUser, w, r)
|
currentUser, ok := resolveCurrentUser(getUser, w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -63,6 +65,7 @@ func RequireCurrentToken(getUser *useCases.GetUserUseCase) func(http.Handler) ht
|
|||||||
func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
|
func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r = access.WithCurrentUser(r)
|
||||||
currentUser, ok := resolveCurrentUser(getUser, w, r)
|
currentUser, ok := resolveCurrentUser(getUser, w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -14,8 +14,19 @@ type rateLimitWindow struct {
|
|||||||
count int
|
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 {
|
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 {
|
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 {
|
func newKeyedRateLimiter(maxAttempts int, window time.Duration, keyOf func(*http.Request) string) func(http.Handler) http.Handler {
|
||||||
var mu sync.Mutex
|
counters := &rateLimitCounters{window: window, windows: make(map[string]*rateLimitWindow)}
|
||||||
windows := make(map[string]*rateLimitWindow)
|
|
||||||
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if !allowed {
|
||||||
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
||||||
http.Error(w, apperr.TooManyRequestsErrorMessage, http.StatusTooManyRequests)
|
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) {
|
func (c *rateLimitCounters) recordAttempt(key string, maxAttempts int, now time.Time) (int, bool) {
|
||||||
mu.Lock()
|
c.mu.Lock()
|
||||||
defer mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
now := time.Now()
|
c.sweepExpired(now)
|
||||||
current, exists := windows[key]
|
current, exists := c.windows[key]
|
||||||
if !exists || now.Sub(current.start) >= window {
|
if !exists || now.Sub(current.start) >= c.window {
|
||||||
current = &rateLimitWindow{start: now}
|
current = &rateLimitWindow{start: now}
|
||||||
windows[key] = current
|
c.windows[key] = current
|
||||||
}
|
}
|
||||||
|
|
||||||
current.count++
|
current.count++
|
||||||
if current.count > maxAttempts {
|
if current.count > maxAttempts {
|
||||||
return retryAfterSeconds(window - now.Sub(current.start)), false
|
return retryAfterSeconds(c.window - now.Sub(current.start)), false
|
||||||
}
|
}
|
||||||
return 0, true
|
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 {
|
func retryAfterSeconds(remaining time.Duration) int {
|
||||||
seconds := int(remaining / time.Second)
|
seconds := int(remaining / time.Second)
|
||||||
if remaining%time.Second > 0 {
|
if remaining%time.Second > 0 {
|
||||||
|
|||||||
@@ -2,16 +2,13 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"RenewCMS/api/pkg/apperr"
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
"RenewCMS/api/pkg/authtoken"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/jwtauth/v5"
|
|
||||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func RequireSignedIn(next http.Handler) http.Handler {
|
func RequireSignedIn(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
token, _, err := jwtauth.FromContext(r.Context())
|
if _, err := authtoken.FromContext(r.Context()); err != nil {
|
||||||
if err != nil || token == nil || jwt.Validate(token) != nil {
|
|
||||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import "net/http"
|
|||||||
|
|
||||||
const contentSecurityPolicy = "default-src 'self'; " +
|
const contentSecurityPolicy = "default-src 'self'; " +
|
||||||
"script-src 'self' 'wasm-unsafe-eval'; " +
|
"script-src 'self' 'wasm-unsafe-eval'; " +
|
||||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
|
"style-src 'self' 'unsafe-inline'; " +
|
||||||
"font-src 'self' https://fonts.gstatic.com; " +
|
"font-src 'self'; " +
|
||||||
"img-src 'self' data: blob: https:; " +
|
"img-src 'self' data: blob: https:; " +
|
||||||
"connect-src 'self'; " +
|
"connect-src 'self'; " +
|
||||||
"object-src 'none'; " +
|
"object-src 'none'; " +
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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{}, ¤tUserHolder{}))
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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 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 PasswordResetFailedErrorMessage = "The password reset code is invalid or has expired."
|
||||||
const TooManyRequestsErrorMessage = "Too many requests. Please try again later."
|
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 EmailHasAccountErrorMessage = "This address already has an account."
|
||||||
const PendingInviteErrorMessage = "There's already a pending invite for this address."
|
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 InviteNotSentErrorMessage = "Couldn't send the invite. Try again in a moment."
|
||||||
const InviteNotFoundErrorMessage = "Invite not found"
|
const InviteNotFoundErrorMessage = "Invite not found"
|
||||||
const InviteAlreadyAcceptedErrorMessage = "This invite has already been accepted."
|
const InviteAlreadyAcceptedErrorMessage = "This invite has already been accepted."
|
||||||
const InviteInvalidErrorMessage = "This invite link doesn't work."
|
const InviteInvalidErrorMessage = "This invite link doesn't work."
|
||||||
const InviteExpiredErrorMessage = "This invite has expired."
|
const InviteExpiredErrorMessage = "This invite has expired."
|
||||||
const OwnerRequiredErrorMessage = "Only the owner can do this."
|
|
||||||
const UsernameTakenErrorMessage = "That username is taken. Try another."
|
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 MediaQueryErrorMessage = "The media query is invalid."
|
||||||
const MediaFilenameErrorMessage = "Enter a filename."
|
const MediaFilenameErrorMessage = "Enter a filename."
|
||||||
const MediaMergeConflictErrorMessage = "Some of the images to merge changed. Refresh and try again."
|
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 ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html, json."
|
||||||
const APIKeyNotFoundErrorMessage = "API key not found"
|
const APIKeyNotFoundErrorMessage = "API key not found"
|
||||||
const APIKeyExpiryErrorMessage = "The expiry must be a date in the future."
|
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 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 PurgeConflictErrorMessage = "The library changed, so nothing was purged. Check the new summary and try again."
|
||||||
const PurgeBusyErrorMessage = "A purge is already running."
|
const PurgeBusyErrorMessage = "A purge is already running."
|
||||||
const PurgeIntervalErrorMessage = "Enter a whole number of days between 1 and 365."
|
const PurgeIntervalErrorMessage = "Enter a whole number of days between 1 and 365."
|
||||||
@@ -57,3 +62,67 @@ 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 CachePurgeTargetErrorMessage = "A variant name or an image id is required for this scope."
|
||||||
const VariantUnknownMessage = "Unknown variant"
|
const VariantUnknownMessage = "Unknown variant"
|
||||||
const VariantUnsupportedParameterMessage = "Unsupported parameter"
|
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 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"
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
package claims
|
package claims
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"RenewCMS/api/pkg/authtoken"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/jwtauth/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func UserId(r *http.Request) (uint32, bool) {
|
func UserId(r *http.Request) (uint32, bool) {
|
||||||
_, claims, err := jwtauth.FromContext(r.Context())
|
claims, err := authtoken.FromContext(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
@@ -31,7 +30,7 @@ func UserId(r *http.Request) (uint32, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TokenVersion(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 {
|
if err != nil {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ type PublicUser struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Role user.Role `json:"role"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(u user.User) PublicUser {
|
func New(u user.User) PublicUser {
|
||||||
@@ -25,6 +24,5 @@ func New(u user.User) PublicUser {
|
|||||||
CreatedAt: u.CreatedAt,
|
CreatedAt: u.CreatedAt,
|
||||||
UpdatedAt: u.UpdatedAt,
|
UpdatedAt: u.UpdatedAt,
|
||||||
DisplayName: u.DisplayName,
|
DisplayName: u.DisplayName,
|
||||||
Role: u.Role,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
package validator
|
package validator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"RenewCMS/internal/domain/article"
|
||||||
|
"RenewCMS/internal/domain/author"
|
||||||
"RenewCMS/internal/domain/image"
|
"RenewCMS/internal/domain/image"
|
||||||
|
"RenewCMS/internal/domain/site"
|
||||||
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
@@ -9,6 +13,12 @@ import (
|
|||||||
|
|
||||||
const inviteEmailTag = "inviteemail"
|
const inviteEmailTag = "inviteemail"
|
||||||
const variantNameTag = "variantname"
|
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@.]+)+$`)
|
var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`)
|
||||||
|
|
||||||
@@ -18,6 +28,44 @@ func InitValidator() {
|
|||||||
Validate = validator.New()
|
Validate = validator.New()
|
||||||
_ = Validate.RegisterValidation(inviteEmailTag, isInviteEmail)
|
_ = Validate.RegisterValidation(inviteEmailTag, isInviteEmail)
|
||||||
_ = Validate.RegisterValidation(variantNameTag, isVariantName)
|
_ = 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 {
|
func isVariantName(field validator.FieldLevel) bool {
|
||||||
|
|||||||
+292
-86
@@ -5,29 +5,42 @@ import (
|
|||||||
"RenewCMS/api/handlers/apikey"
|
"RenewCMS/api/handlers/apikey"
|
||||||
"RenewCMS/api/handlers/article"
|
"RenewCMS/api/handlers/article"
|
||||||
"RenewCMS/api/handlers/auth"
|
"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/frontend"
|
||||||
"RenewCMS/api/handlers/imagevariant"
|
"RenewCMS/api/handlers/imagevariant"
|
||||||
|
"RenewCMS/api/handlers/llms"
|
||||||
"RenewCMS/api/handlers/media"
|
"RenewCMS/api/handlers/media"
|
||||||
|
"RenewCMS/api/handlers/ownership"
|
||||||
|
"RenewCMS/api/handlers/permission"
|
||||||
|
rolehandler "RenewCMS/api/handlers/role"
|
||||||
"RenewCMS/api/handlers/settings"
|
"RenewCMS/api/handlers/settings"
|
||||||
|
"RenewCMS/api/handlers/site"
|
||||||
"RenewCMS/api/handlers/staticimages"
|
"RenewCMS/api/handlers/staticimages"
|
||||||
"RenewCMS/api/handlers/update"
|
"RenewCMS/api/handlers/update"
|
||||||
"RenewCMS/api/handlers/user"
|
"RenewCMS/api/handlers/user"
|
||||||
"RenewCMS/api/middleware"
|
"RenewCMS/api/middleware"
|
||||||
|
"RenewCMS/api/pkg/authtoken"
|
||||||
|
"RenewCMS/internal/domain/role"
|
||||||
"RenewCMS/internal/infrastructure/config"
|
"RenewCMS/internal/infrastructure/config"
|
||||||
|
"RenewCMS/internal/infrastructure/llmdocs"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/MadAppGang/httplog"
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
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) {
|
func enableLoggingIfDev(r *chi.Mux, routerName string, environment string) {
|
||||||
if environment == "development" {
|
if environment == "development" {
|
||||||
r.Use(httplog.LoggerWithName(routerName))
|
r.Use(chimiddleware.RequestLogger(&chimiddleware.DefaultLogFormatter{
|
||||||
|
Logger: log.New(os.Stdout, "["+routerName+"] ", log.LstdFlags),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,38 +61,63 @@ func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
|
|||||||
return c.apiKeyAuthMiddleware
|
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 {
|
func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(container.apiKeyAuth())
|
r.Use(container.apiKeyAuth())
|
||||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
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.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.OptionalAccess(container.GetUserUseCase, container.CheckPermissionUseCase))
|
||||||
r.Get("/", articleHandler.ListArticles)
|
r.Get("/", articleHandler.ListArticles)
|
||||||
r.Get("/{id}", articleHandler.GetArticle)
|
r.Get("/{id}", articleHandler.GetArticle)
|
||||||
|
r.Get("/{id}/seo", seoHandler.Resolve)
|
||||||
})
|
})
|
||||||
r.Group(func(r chi.Router) {
|
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.RequireCurrentToken(container.GetUserUseCase))
|
||||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
r.Post("/", articleHandler.PostArticle)
|
r.With(container.requireArticle(role.KeyContentCreate)).Post("/", articleHandler.PostArticle)
|
||||||
r.Delete("/{id}", articleHandler.DeleteArticle)
|
r.With(container.requireArticle(role.KeyContentDelete)).Delete("/{id}", articleHandler.DeleteArticle)
|
||||||
r.Patch("/{id}", articleHandler.UpdateArticleBody)
|
r.With(container.requireArticle(role.KeyContentUpdate)).Patch("/{id}", articleHandler.UpdateArticleBody)
|
||||||
r.Patch("/{id}/publish", articleHandler.PublishArticle)
|
r.With(container.requireArticle(role.KeyContentPublish)).Patch("/{id}/publish", articleHandler.PublishArticle)
|
||||||
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
|
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
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
|
func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(jwtauth.Verifier(container.Token))
|
r.Use(authtoken.Verifier(container.Token))
|
||||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||||
|
|
||||||
loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction)
|
loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction)
|
||||||
verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, 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)
|
passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase)
|
||||||
acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction)
|
acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction)
|
||||||
|
|
||||||
@@ -105,16 +143,16 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
|||||||
})
|
})
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.RequireSession)
|
r.Use(middleware.RequireSession)
|
||||||
r.Use(jwtauth.Verifier(container.Token))
|
r.Use(authtoken.Verifier(container.Token))
|
||||||
r.Use(jwtauth.Authenticator(container.Token))
|
r.Use(authtoken.Authenticator)
|
||||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
r.Post("/verify", verificationHandler.Verify)
|
r.Post("/verify", verificationHandler.Verify)
|
||||||
r.Delete("/register", verificationHandler.CancelRegistration)
|
r.Delete("/register", verificationHandler.CancelRegistration)
|
||||||
})
|
})
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(jwtauth.Verifier(container.Token))
|
r.Use(authtoken.Verifier(container.Token))
|
||||||
r.Use(container.apiKeyAuth())
|
r.Use(container.apiKeyAuth())
|
||||||
r.Use(jwtauth.Authenticator(container.Token))
|
r.Use(authtoken.Authenticator)
|
||||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
r.Get("/me", meHandler.Me)
|
r.Get("/me", meHandler.Me)
|
||||||
})
|
})
|
||||||
@@ -125,24 +163,40 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
userHandler := user.NewHandler(container.UpdateUserProfileUseCase)
|
userHandler := user.NewHandler(container.UpdateUserProfileUseCase)
|
||||||
|
lookupHandler := user.NewLookupHandler(container.LookupUserNamesUseCase)
|
||||||
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
|
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
|
||||||
|
roleHandler := user.NewRoleHandler(container.ChangeUserRoleUseCase)
|
||||||
|
|
||||||
r.Use(jwtauth.Verifier(container.Token))
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(authtoken.Verifier(container.Token))
|
||||||
r.Use(container.apiKeyAuth())
|
r.Use(container.apiKeyAuth())
|
||||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
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.RequireCurrentToken(container.GetUserUseCase))
|
||||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
r.Patch("/me", userHandler.UpdateMe)
|
r.Patch("/me", userHandler.UpdateMe)
|
||||||
r.Get("/", inviteHandler.ListUsers)
|
r.With(container.require(role.KeyUsersView)).Get("/", inviteHandler.ListUsers)
|
||||||
|
r.Get("/lookup", lookupHandler.Lookup)
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
r.Use(container.require(role.KeyUsersInvite))
|
||||||
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
|
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
|
||||||
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
|
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
|
||||||
r.Post("/invite", inviteHandler.CreateInvite)
|
r.Post("/invite", inviteHandler.CreateInvite)
|
||||||
r.Delete("/invite/{id}", inviteHandler.CancelInvite)
|
r.Delete("/invite/{id}", inviteHandler.CancelInvite)
|
||||||
r.Post("/invite/{id}/renew", inviteHandler.RenewInvite)
|
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.Use(container.require(role.KeyUsersChangeRole))
|
||||||
|
r.Put("/{id}/role", roleHandler.Change)
|
||||||
|
})
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,45 +206,186 @@ func NewSettingsRouter(container *UseCases, trustedProxies []string) http.Handle
|
|||||||
settingsHandler := settings.NewHandler(container.GetSettingsUseCase, container.GetActivityRetentionUseCase, container.UpdateActivityRetentionUseCase)
|
settingsHandler := settings.NewHandler(container.GetSettingsUseCase, container.GetActivityRetentionUseCase, container.UpdateActivityRetentionUseCase)
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(jwtauth.Verifier(container.Token))
|
r.Use(authtoken.Verifier(container.Token))
|
||||||
r.Use(container.apiKeyAuth())
|
r.Use(container.apiKeyAuth())
|
||||||
r.Use(jwtauth.Authenticator(container.Token))
|
r.Use(authtoken.Authenticator)
|
||||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
|
r.Use(container.require(role.KeySettingsView))
|
||||||
r.Get("/", settingsHandler.GetSettings)
|
r.Get("/", settingsHandler.GetSettings)
|
||||||
})
|
})
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.RequireSession)
|
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.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||||
r.Use(middleware.RequireSignedIn)
|
r.Use(middleware.RequireSignedIn)
|
||||||
r.Use(jwtauth.Authenticator(container.Token))
|
r.Use(authtoken.Authenticator)
|
||||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
r.Use(container.require(role.KeySystemEditRetention))
|
||||||
r.Patch("/", settingsHandler.UpdateSettings)
|
r.Patch("/", settingsHandler.UpdateSettings)
|
||||||
})
|
})
|
||||||
return r
|
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 {
|
func NewActivityRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
activityHandler := activity.NewHandler(container.ListActivityUseCase, container.GetActivitySummaryUseCase, container.ExportActivityUseCase)
|
activityHandler := activity.NewHandler(container.ListActivityUseCase, container.GetActivitySummaryUseCase, container.ExportActivityUseCase)
|
||||||
|
|
||||||
r.Use(middleware.RequireSession)
|
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.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||||
r.Use(middleware.RequireSignedIn)
|
r.Use(middleware.RequireSignedIn)
|
||||||
r.Use(jwtauth.Authenticator(container.Token))
|
r.Use(authtoken.Authenticator)
|
||||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
r.Get("/", activityHandler.List)
|
r.With(container.require(role.KeyActivityView)).Get("/", activityHandler.List)
|
||||||
r.Group(func(r chi.Router) {
|
r.With(container.requireAll(role.KeyActivityView)).Get("/summary", activityHandler.Summary)
|
||||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
r.With(container.require(role.KeyActivityExport), container.require(role.KeyActivityView)).Get("/export", activityHandler.Export)
|
||||||
r.Get("/summary", activityHandler.Summary)
|
|
||||||
r.Get("/export", activityHandler.Export)
|
|
||||||
})
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,27 +399,27 @@ const (
|
|||||||
func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler {
|
func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||||
r := chi.NewRouter()
|
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(middleware.RequireSession)
|
||||||
r.Use(jwtauth.Verifier(container.Token))
|
r.Use(authtoken.Verifier(container.Token))
|
||||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
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.RequireCurrentToken(container.GetUserUseCase))
|
||||||
r.Use(middleware.RequireVerifiedUser(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.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
r.Use(container.require(role.KeySystemApplyUpdates))
|
||||||
r.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow))
|
r.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow))
|
||||||
r.Post("/update/check", updateHandler.Check)
|
r.Post("/update/check", updateHandler.Check)
|
||||||
})
|
})
|
||||||
r.Group(func(r chi.Router) {
|
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.Use(middleware.NewUserRateLimiter(updateDownloadMaxAttempts, updateDownloadWindow))
|
||||||
r.Post("/update/download", updateHandler.Download)
|
r.Post("/update/download", updateHandler.Download)
|
||||||
})
|
})
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
r.Use(container.require(role.KeySystemApplyUpdates))
|
||||||
r.Post("/update/restart", updateHandler.Restart)
|
r.Post("/update/restart", updateHandler.Restart)
|
||||||
})
|
})
|
||||||
return r
|
return r
|
||||||
@@ -236,14 +431,28 @@ func NewAPIKeyRouter(container *UseCases, trustedProxies []string) http.Handler
|
|||||||
apiKeyHandler := apikey.NewHandler(container.CreateAPIKeyUseCase, container.ListAPIKeysUseCase, container.RevokeAPIKeyUseCase)
|
apiKeyHandler := apikey.NewHandler(container.CreateAPIKeyUseCase, container.ListAPIKeysUseCase, container.RevokeAPIKeyUseCase)
|
||||||
|
|
||||||
r.Use(middleware.RequireSession)
|
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.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||||
r.Use(jwtauth.Authenticator(container.Token))
|
r.Use(authtoken.Authenticator)
|
||||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
r.Post("/", apiKeyHandler.Create)
|
r.With(container.require(role.KeyAPIKeysCreate)).Post("/", apiKeyHandler.Create)
|
||||||
r.Get("/", apiKeyHandler.List)
|
r.With(container.require(role.KeyAPIKeysView)).Get("/", apiKeyHandler.List)
|
||||||
r.Delete("/{id}", apiKeyHandler.Revoke)
|
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
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,34 +461,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)
|
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(container.apiKeyAuth())
|
||||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||||
r.Use(middleware.RequireSignedIn)
|
r.Use(middleware.RequireSignedIn)
|
||||||
r.Use(jwtauth.Authenticator(container.Token))
|
r.Use(authtoken.Authenticator)
|
||||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
r.Group(func(r chi.Router) {
|
r.With(container.require(role.KeySettingsView)).Get("/purge-settings", mediaHandler.GetPurgeSettings)
|
||||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
r.With(container.require(role.KeySettingsEditGeneral)).Patch("/purge-settings", mediaHandler.UpdatePurgeSettings)
|
||||||
r.Get("/purge-settings", mediaHandler.GetPurgeSettings)
|
r.With(container.requireAll(role.KeyMediaDelete)).Get("/purge-preview", mediaHandler.PreviewPurge)
|
||||||
r.Patch("/purge-settings", mediaHandler.UpdatePurgeSettings)
|
r.With(container.requireAll(role.KeyMediaDelete)).Post("/purge", mediaHandler.RunPurge)
|
||||||
r.Get("/purge-preview", mediaHandler.PreviewPurge)
|
|
||||||
r.Post("/purge", mediaHandler.RunPurge)
|
|
||||||
})
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.RejectAPIKey)
|
r.Use(middleware.RejectAPIKey)
|
||||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
r.With(container.require(role.KeyMediaView)).Get("/{id}/variants", mediaHandler.ListVariants)
|
||||||
r.Get("/{id}/variants", mediaHandler.ListVariants)
|
r.With(container.require(role.KeySettingsEditGeneral)).Post("/{id}/variants", mediaHandler.GenerateAllVariants)
|
||||||
r.Post("/{id}/variants", mediaHandler.GenerateAllVariants)
|
r.With(container.require(role.KeySettingsEditGeneral)).Post("/{id}/variants/{name}", mediaHandler.GenerateVariant)
|
||||||
r.Post("/{id}/variants/{name}", mediaHandler.GenerateVariant)
|
r.With(container.require(role.KeySettingsEditGeneral)).Delete("/{id}/variants/{name}", mediaHandler.PurgeVariant)
|
||||||
r.Delete("/{id}/variants/{name}", mediaHandler.PurgeVariant)
|
|
||||||
})
|
})
|
||||||
r.Get("/", mediaHandler.List)
|
r.With(container.require(role.KeyMediaView)).Get("/", mediaHandler.List)
|
||||||
r.Post("/", mediaHandler.Upload)
|
r.With(container.require(role.KeyMediaUpload)).Post("/", mediaHandler.Upload)
|
||||||
r.Post("/merge", mediaHandler.Merge)
|
r.With(container.requireAll(role.KeyMediaDelete)).Post("/merge", mediaHandler.Merge)
|
||||||
r.Patch("/{id}", mediaHandler.Rename)
|
r.With(container.require(role.KeyMediaUpload)).Patch("/{id}", mediaHandler.Rename)
|
||||||
r.Delete("/{id}", mediaHandler.Delete)
|
r.With(container.requireAll(role.KeyMediaDelete)).Delete("/{id}", mediaHandler.Delete)
|
||||||
r.Get("/{id}/usage", mediaHandler.Usage)
|
r.With(container.require(role.KeyMediaView)).Get("/{id}", mediaHandler.Get)
|
||||||
|
r.With(container.require(role.KeyMediaView)).Get("/{id}/usage", mediaHandler.Usage)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,21 +494,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)
|
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.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||||
r.Use(middleware.RequireSignedIn)
|
r.Use(middleware.RequireSignedIn)
|
||||||
r.Use(jwtauth.Authenticator(container.Token))
|
r.Use(authtoken.Authenticator)
|
||||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
r.With(container.require(role.KeyMediaView)).Get("/", variantHandler.List)
|
||||||
r.Get("/", variantHandler.List)
|
r.With(container.require(role.KeySettingsEditGeneral)).Post("/", variantHandler.Create)
|
||||||
r.Post("/", variantHandler.Create)
|
r.With(container.require(role.KeySettingsView)).Get("/cache", variantHandler.CacheStats)
|
||||||
r.Get("/cache", variantHandler.CacheStats)
|
r.With(container.require(role.KeySettingsEditGeneral)).Post("/cache/purge", variantHandler.PurgeCache)
|
||||||
r.Post("/cache/purge", variantHandler.PurgeCache)
|
r.With(container.require(role.KeySettingsEditGeneral)).Put("/{name}", variantHandler.Update)
|
||||||
r.Put("/{name}", variantHandler.Update)
|
r.With(container.require(role.KeySettingsEditGeneral)).Patch("/{name}", variantHandler.SetEnabled)
|
||||||
r.Patch("/{name}", variantHandler.SetEnabled)
|
r.With(container.require(role.KeySettingsEditGeneral)).Delete("/{name}", variantHandler.Delete)
|
||||||
r.Delete("/{name}", variantHandler.Delete)
|
r.With(container.require(role.KeySettingsEditGeneral)).Post("/{name}/purge", variantHandler.Purge)
|
||||||
r.Post("/{name}/purge", variantHandler.Purge)
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,15 +520,21 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
|||||||
r.Use(middleware.JsonContentTypeMiddleware)
|
r.Use(middleware.JsonContentTypeMiddleware)
|
||||||
r.Get("/", GetHelloWorld)
|
r.Get("/", GetHelloWorld)
|
||||||
r.Group(func(r chi.Router) {
|
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("/article", NewArticleRouter(container, settings.TrustedProxies))
|
||||||
})
|
})
|
||||||
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
|
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
|
||||||
r.Mount("/settings", NewSettingsRouter(container, 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("/activity", NewActivityRouter(container, settings.TrustedProxies))
|
||||||
r.Mount("/media", NewMediaRouter(container, settings.TrustedProxies))
|
r.Mount("/media", NewMediaRouter(container, settings.TrustedProxies))
|
||||||
r.Mount("/image-variants", NewImageVariantRouter(container, settings.TrustedProxies))
|
r.Mount("/image-variants", NewImageVariantRouter(container, settings.TrustedProxies))
|
||||||
r.Mount("/api-key", NewAPIKeyRouter(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("/system", NewSystemRouter(container, settings.TrustedProxies))
|
||||||
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
||||||
|
|
||||||
@@ -349,14 +560,7 @@ func enableCorsIfConfigured(r *chi.Mux, origins []string) {
|
|||||||
if len(origins) == 0 {
|
if len(origins) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.Use(cors.Handler(cors.Options{
|
r.Use(middleware.CORS(origins))
|
||||||
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,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||||
@@ -366,8 +570,10 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
|||||||
apiRouter := chi.NewRouter()
|
apiRouter := chi.NewRouter()
|
||||||
apiRouter.Use(middleware.SecurityHeaders(settings.IsProduction()))
|
apiRouter.Use(middleware.SecurityHeaders(settings.IsProduction()))
|
||||||
enableCorsIfConfigured(apiRouter, settings.CorsAllowedOrigins)
|
enableCorsIfConfigured(apiRouter, settings.CorsAllowedOrigins)
|
||||||
|
apiRouter.Use(middleware.CrossOriginProtection(settings.PublicURL))
|
||||||
apiRouter.Use(chimiddleware.GetHead)
|
apiRouter.Use(chimiddleware.GetHead)
|
||||||
apiRouter.Get("/healthz", GetHealth)
|
apiRouter.Get("/healthz", GetHealth)
|
||||||
|
llms.NewHandler(llmdocs.Docs, settings.Version, settings.PublicURL).Register(apiRouter)
|
||||||
apiRouter.Mount("/v1", backend)
|
apiRouter.Mount("/v1", backend)
|
||||||
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase, container.ServeVariantUseCase))
|
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase, container.ServeVariantUseCase))
|
||||||
apiRouter.Mount("/", frontend)
|
apiRouter.Mount("/", frontend)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-3
@@ -7,6 +7,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -15,8 +16,6 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
@@ -59,6 +58,7 @@ var envDefaults = map[string]string{
|
|||||||
"AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5",
|
"AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5",
|
||||||
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
|
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
|
||||||
"TRUSTED_PROXIES": "",
|
"TRUSTED_PROXIES": "",
|
||||||
|
config.EnvSmtpAllowInsecure: config.DefaultSmtpAllowInsecure,
|
||||||
"UPDATE_FEED_URL": "https://git.floriansylvain.fr",
|
"UPDATE_FEED_URL": "https://git.floriansylvain.fr",
|
||||||
"RENEWCMS_CONTAINER": "false",
|
"RENEWCMS_CONTAINER": "false",
|
||||||
config.EnvImageVariantMaxSourceBytes: config.DefaultImageVariantMaxSourceBytes,
|
config.EnvImageVariantMaxSourceBytes: config.DefaultImageVariantMaxSourceBytes,
|
||||||
@@ -106,9 +106,13 @@ func initEnvVariables() {
|
|||||||
|
|
||||||
func loadEnvFile() {
|
func loadEnvFile() {
|
||||||
for _, envLocation := range possibleEnvFileLocations {
|
for _, envLocation := range possibleEnvFileLocations {
|
||||||
if godotenv.Load(envLocation) == nil {
|
err := config.LoadDotenv(envLocation)
|
||||||
|
if err == nil {
|
||||||
return
|
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")
|
log.Println("WARNING: Could not load any .env file")
|
||||||
}
|
}
|
||||||
|
|||||||
+345
-16
@@ -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/`.
|
for example `https://cms.example/v1/article/`.
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
- Public routes need nothing: `GET /v1/article/` and `GET /v1/article/{id}`. They return online
|
- Public routes need nothing: `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo`
|
||||||
articles only.
|
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:
|
- 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
|
`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.
|
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
|
- 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
|
`/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.
|
- 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 |
|
| 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} |
|
| 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/, 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) |
|
| 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/, 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 |
|
| Users and invites | GET /v1/user/ (`users.view`), PATCH /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 |
|
| Settings | GET /v1/settings |
|
||||||
|
| Sites | GET /v1/site/, GET /v1/site/{key}/sitemap |
|
||||||
|
| Authors | GET /v1/author/ |
|
||||||
|
|
||||||
## Activity log
|
## Activity log
|
||||||
The activity routes are session only: a key answers 403 "This action needs a real session." and a
|
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 |
|
| 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/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. |
|
| `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`,
|
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
|
(`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
|
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`).
|
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`,
|
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_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
|
`{"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.
|
removes events past the age limit, then the oldest events until the log fits the size limit.
|
||||||
Whichever limit is reached first wins.
|
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` |
|
| `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` |
|
| `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.change_role`, `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` |
|
| `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).
|
`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
|
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. A
|
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
|
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
|
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`);
|
`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`
|
`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).
|
(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`
|
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.
|
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
|
## Image variants
|
||||||
Owners and admins manage named variants of the stored images. These routes are session only (no API
|
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
|
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).
|
||||||
this." Behaviour, caching and the public `?v=<name>` contract are in [image-variants.md](image-variants.md).
|
|
||||||
|
|
||||||
| Route | Purpose |
|
| Route | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -184,10 +353,170 @@ 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
|
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".
|
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). |
|
||||||
|
| 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, the Owner role, unknown). |
|
||||||
|
| 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` 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.
|
||||||
|
|
||||||
|
## 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 `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. |
|
||||||
|
| `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` | 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
|
## Errors
|
||||||
Every error is a plain-text body with one sentence; the status is in the HTTP status line. The
|
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.
|
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
|
## Notes
|
||||||
- Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL.
|
- 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.
|
- Browsers can call the API only from origins in the server's CORS setting.
|
||||||
+96
-3
@@ -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.
|
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`
|
## Routes that honour `format`
|
||||||
|
|
||||||
- `GET /v1/article/`
|
- `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}/publish`
|
||||||
- `PATCH /v1/article/{id}/unpublish`
|
- `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
|
## Pagination, search and counts
|
||||||
|
|
||||||
@@ -23,7 +25,7 @@ Visibility is unchanged: anonymous and unverified callers only see online articl
|
|||||||
"total": 30,
|
"total": 30,
|
||||||
"page": 1,
|
"page": 1,
|
||||||
"size": 25,
|
"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` |
|
| `size` | `5`, `25`, `50` or `100` | `25` |
|
||||||
| `q` | up to 200 characters, matched anywhere in the title, ignoring case and accents | none |
|
| `q` | up to 200 characters, matched anywhere in the title, ignoring case and accents | none |
|
||||||
| `status` | `online` or `draft` | 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` |
|
| `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` 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.
|
- 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.
|
- `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`.
|
- 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.
|
- 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": "## Plan\n\nReplace the **old** deck.",
|
||||||
"body_format": "markdown",
|
"body_format": "markdown",
|
||||||
"is_online": true,
|
"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",
|
"created_at": "2026-03-12T09:14:00Z",
|
||||||
"updated_at": "2026-09-10T07:00: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
|
## 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.
|
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 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` | `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` |
|
| `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 |
|
| `500` | `Internal server error` when the body cannot be converted |
|
||||||
+44
-3
@@ -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. |
|
| 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`. |
|
| 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`. |
|
| 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`. |
|
| 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. |
|
| 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. |
|
| 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 |
|
| 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/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/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. |
|
| `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. |
|
| `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. |
|
| `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
|
### 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.
|
`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.
|
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
|
### 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`.
|
`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).
|
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.
|
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
|
## Frontend ↔ backend
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Configuration
|
# 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.
|
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 |
|
| 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` |
|
| `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. Use a long random value; changing it logs everyone out. | `openssl rand -hex 32` |
|
| `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_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_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. | |
|
| `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. |
|
| `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). |
|
| `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`. |
|
| `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_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. |
|
| `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). |
|
| `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. |
|
| `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. |
|
| `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. |
|
||||||
|
|||||||
@@ -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
@@ -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.
|
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
|
## Using MySQL or MariaDB
|
||||||
|
|
||||||
|
|||||||
+26
-4
@@ -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.
|
Nuxt UI v4 back-office. Tokens only, no raw hex (brand mark excepted). Light and dark. 1440×900 and 390×844. Radius 0.
|
||||||
|
|
||||||
## Boards
|
## 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 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).
|
- `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.
|
- 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, 97 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 3, 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
|
## 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
|
## Status
|
||||||
| Section | 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 |
|
| 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 |
|
| 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 = 409)
|
||||||
| Group | States |
|
| Group | States |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Boot | 1 |
|
| Boot | 1 |
|
||||||
@@ -65,7 +83,7 @@ routes.md · components.md · states.md · foundations.md · change-list.md · a
|
|||||||
| Cache panel | 10 |
|
| Cache panel | 10 |
|
||||||
| Editor picker | 5 |
|
| 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) = 409. 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 471 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)
|
## 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.
|
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.
|
||||||
@@ -118,4 +136,8 @@ 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.
|
- 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 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.
|
- 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.
|
- The sandbox cannot export; no files were bundled.
|
||||||
|
|
||||||
|
## Pass 6: roles and permissions
|
||||||
|
Board `RenewCMS Screens Roles.dc.html` (frame `RolesFrame`, data `roles-spec.js`): 11 groups, 61 states, all Available now. 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. AppFrame gained the Access group with a Roles item (sidebar and drawer). Not done: Schema board group 8 (Permissions) should be marked superseded.
|
||||||
@@ -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.
|
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 |
|
| 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 |
|
| 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 |
|
| 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. |
|
||||||
+106
-1
@@ -33,7 +33,9 @@ One component for every code or copyable value. Props: `tabs` (`{ k, label, code
|
|||||||
- **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.
|
- **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
|
## 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
|
## 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.
|
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,106 @@ 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.
|
- **MediaCleanupCard**: square `USwitch`, `UInputNumber` with "days" suffix, saves on change.
|
||||||
- **MediaLinksCard**: rows "Manage image variants" and "Cache usage", 44 px min height.
|
- **MediaLinksCard**: rows "Manage image variants" and "Cache usage", 44 px min height.
|
||||||
- **StatesMatrix**: the board "States matrix: controls".
|
- **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. |
|
||||||
@@ -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.
|
||||||
@@ -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) |
|
| /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) |
|
| /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 |
|
| /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} |
|
| /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)
|
## 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.
|
`/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,35 @@
|
|||||||
|
|
||||||
## Public image request
|
## 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.
|
`/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.created, role.updated (with a diff of added and removed permissions), role.deleted, user.role_changed, site.ownership_transferred. |
|
||||||
@@ -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" |
|
| 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" |
|
| 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…" |
|
| 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" |
|
| 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." |
|
| 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. | |
|
| 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" |
|
| 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. | |
|
| 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. | |
|
| 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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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" |
|
| 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. | |
|
| 40 | Offline | The connection dropped. Writing continues, and Save and Publish wait. | |
|
||||||
| 41 | Not found (404) | Unknown id, or the article was deleted elsewhere. | |
|
| 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 |
|
||||||
@@ -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.
|
||||||
@@ -15,8 +15,8 @@ Sticky section index on the right (desktop), "Jump to" select (mobile). A refere
|
|||||||
- 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 (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".
|
||||||
- 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 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."
|
- 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.""
|
- 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. 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.`).
|
- 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."
|
- 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
|
## EndpointCard 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.
|
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".
|
- 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."
|
- 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."
|
- 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." Note under the 403 row: "Site SEO defaults are owner only. 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"
|
- 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`.
|
- 429: "Too many requests. Please try again later." A per-key limit also sends `Retry-After`.
|
||||||
- 500: "Internal server error"
|
- 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.
|
- 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,93 @@ New section "Images and variants" on the Integration page, after Media and befor
|
|||||||
## Pass 2 notes
|
## Pass 2 notes
|
||||||
- The base URL field and the image URL builder output are CodeSnippets with a single tab.
|
- 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).
|
- 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.
|
||||||
|
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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" |
|
| 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." |
|
| 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." |
|
| 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.
|
||||||
@@ -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" |
|
| 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" |
|
| 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" |
|
| 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. | |
|
| 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. | |
|
| 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. | |
|
| 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,6 @@ A secondary "Purge duplicates" button (icon merge) in the toolbar next to the so
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1 | Duplicates tab with the button | Owner or admin. | |
|
| 1 | Duplicates tab with the button | Owner or admin. | |
|
||||||
| 2 | Duplicates tab: offline | The button is disabled with the existing banner. | |
|
| 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.
|
||||||
@@ -46,3 +46,21 @@ 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. | |
|
| 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. | |
|
| 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. | |
|
| 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 |
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
# 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: You can see roles but not change them · Ask an owner or admin if a role needs to change. · 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/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 · Her 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 he transfers 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/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: Five new events.
|
||||||
|
|
||||||
|
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 · 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.
|
||||||
|
|
||||||
|
## 9 Profile: your role
|
||||||
|
|
||||||
|
Route: /profile · API: GET /v1/me · Auth: Any
|
||||||
|
|
||||||
|
The person sees their own role and a read-only summary.
|
||||||
|
|
||||||
|
### Role card (Available now)
|
||||||
|
|
||||||
|
Trigger: Any signed-in user.
|
||||||
|
|
||||||
|
Copy: 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. 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.
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|
||||||
|
### 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. |
|
||||||
|
| 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). |
|
||||||
|
| Activity events | role.created, role.updated (with a diff of added and removed permissions), role.deleted, user.role_changed, site.ownership_transferred. |
|
||||||
|
|
||||||
|
### 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. |
|
||||||
|
|
||||||
|
### 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. |
|
||||||
|
|
||||||
|
|
||||||
|
State count: 61.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 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", 3 states)
|
||||||
|
|
||||||
|
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. | Venue · Speakers · Search to add · An event can have many speakers. | Later |
|
||||||
|
| 3 | 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: Owner or admin.
|
||||||
|
|
||||||
|
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 |
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Schema
|
||||||
|
|
||||||
|
Route: `/schema`, `/schema/new`, `/schema/:key`. API: GET/POST /v1/content-type · GET/PUT/DELETE /v1/content-type/{key}. Auth: owner or admin.
|
||||||
|
|
||||||
|
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: Owner or admin.
|
||||||
|
|
||||||
|
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: Owner or admin.
|
||||||
|
|
||||||
|
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: Owner or admin.
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -106,11 +106,11 @@ The page is one column of cards, top to bottom: Version, Updates, Media cleanup,
|
|||||||
|
|
||||||
| # | State | Trigger | Copy |
|
| # | 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…" |
|
| 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" |
|
| 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. | "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." |
|
| 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. | "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…" |
|
| 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…" |
|
| 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." |
|
| 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." |
|
| 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" |
|
| 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" |
|
| 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." |
|
| 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" |
|
| 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" |
|
| 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. | |
|
| 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. | |
|
| 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" |
|
| 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." |
|
| 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." |
|
| 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. | "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." |
|
| 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. | "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." |
|
| 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)
|
### 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. | |
|
| 4 | Shortening the period | Confirms before older events are removed. | |
|
||||||
| 5 | Forever | No cleanup scheduled. | |
|
| 5 | Forever | No cleanup scheduled. | |
|
||||||
| 6 | Save error | The previous setting is still in place. | |
|
| 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)").
|
||||||
@@ -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.
|
||||||
@@ -47,6 +47,6 @@ 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." |
|
| 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." |
|
| 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…" |
|
| 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." |
|
| 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." |
|
| 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." |
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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="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>
|
<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">
|
<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-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>
|
<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="{{ 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>
|
<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;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 }}">
|
<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 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>
|
<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>
|
</div>
|
||||||
<sc-if value="{{ s.list.empty }}">
|
<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>
|
<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="{{ 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 }}">
|
<sc-if value="{{ showTable }}">
|
||||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
|
<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">
|
<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">
|
<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><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.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 }}">
|
<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 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;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 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>
|
<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>
|
<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">
|
<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 }}">
|
<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.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;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 / contain no-repeat"></span>Edit<Line truncated
|
||||||
</div>
|
</div>
|
||||||
</sc-for>
|
</sc-for>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,8 +190,9 @@
|
|||||||
<sc-if value="{{ desk }}">
|
<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">
|
<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>
|
<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 }}">
|
<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: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: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>
|
<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,6 +256,7 @@
|
|||||||
<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>
|
<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>
|
||||||
<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>
|
<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
|
<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
|
<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>
|
</div>
|
||||||
@@ -277,6 +283,12 @@
|
|||||||
<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: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><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>
|
</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">Uses your display name, Marta Alves. You can change it afterwards.</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="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: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>
|
<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>
|
||||||
@@ -357,8 +369,10 @@
|
|||||||
</sc-if>
|
</sc-if>
|
||||||
|
|
||||||
{{ purgeDlg }}
|
{{ 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="{{ 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 }}
|
{{ slotOverlays }}
|
||||||
<sc-if value="{{ hasToasts }}">
|
<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 }}">
|
<div style="position:absolute;z-index:60;display:flex;flex-direction:column;gap:8px;left:{{ tL }};right:{{ tR }};bottom:{{ tB }};width:{{ tW }}">
|
||||||
@@ -375,14 +389,17 @@ class Component extends DCLogic {
|
|||||||
const s = this.props.s || {}
|
const s = this.props.s || {}
|
||||||
const m = !!this.props.mobile
|
const m = !!this.props.mobile
|
||||||
const k = s.kind || 'home'
|
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 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 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 L = s.list || {}
|
||||||
const counts = L.counts || [6, 4, 2]
|
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 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 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 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']
|
const sv = saveMap[e.save || 'saved']
|
||||||
@@ -522,15 +539,15 @@ class Component extends DCLogic {
|
|||||||
const edReady = k === 'editor' && !e.loading && !e.notfound
|
const edReady = k === 'editor' && !e.loading && !e.notfound
|
||||||
const edTitle = e.title || 'How we price a bathroom renovation'
|
const edTitle = e.title || 'How we price a bathroom renovation'
|
||||||
return {
|
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.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 } })(), 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 130px 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)',
|
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)',
|
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,
|
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,
|
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',
|
fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row',
|
||||||
mLead: s.mLead || 'menu',
|
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,
|
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))',
|
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',
|
upBannerAlign: m ? 'stretch' : 'center', upCmdDir: m ? 'column' : 'row', btnH: m ? '44px' : '32px', rowH: m ? '52px' : '44px', inF: m ? '16px' : '14px',
|
||||||
|
|||||||
@@ -0,0 +1,438 @@
|
|||||||
|
<!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="{"$preview":{"width":1440,"height":900},"s":{"editor":null,"tsType":"object"},"mobile":{"editor":null,"tsType":"boolean"}}">
|
||||||
|
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 || 'e.g. Renew', { val: !!f.sname, focus: p.focus === 'sname', help: 'Shown in titles through the template, and sent as the Open Graph site name.' }),
|
||||||
|
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 (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 = {}) => D('display:flex;align-items:flex-start;gap:8px', 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('x', 14)))
|
||||||
|
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 (!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 => SugRow(c, o)), 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 })), 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 }), 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)))
|
||||||
|
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:var(--ui-bg-elevated);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', 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) 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>
|
||||||
@@ -9,8 +9,9 @@
|
|||||||
<x-dc>
|
<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">
|
<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 }}">
|
<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">
|
<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;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-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-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>
|
<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>
|
</div></sc-if>
|
||||||
@@ -19,12 +20,15 @@
|
|||||||
<div style="position:relative;padding:{{ pad }};display:flex;gap:40px;align-items:flex-start">
|
<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="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">
|
<div style="display:flex;flex-direction:column;gap:6px">
|
||||||
<span style="color:var(--ui-text-muted)">Integration</span>
|
<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="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>
|
<span style="color:var(--ui-text-muted);font-size:14px;line-height:22px">Everything a developer needs to build on RenewCMS.</span>
|
||||||
</div>
|
</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">
|
<div style="display:flex;flex-direction:column;gap:6px">
|
||||||
<span style="font-weight:500;color:var(--ui-text-highlighted)">Base URL</span>
|
<span style="font-weight:500;color:var(--ui-text-highlighted)">Base URL</span>
|
||||||
@@ -48,7 +52,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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;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 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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -57,10 +61,15 @@
|
|||||||
<section data-anchor="{{ g.key }}" style="display:flex;flex-direction:column;gap:10px">
|
<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>
|
<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>
|
<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)">
|
<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">
|
<sc-for list="{{ g.eps }}" as="ep" hint-placeholder-count="6">
|
||||||
<div data-anchor="{{ ep.anchor }}" style="border-top:1px solid var(--ui-border)">
|
<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="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="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>
|
<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>
|
<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-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>
|
<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>
|
<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>
|
</div>
|
||||||
</sc-if>
|
</sc-if>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,7 +94,7 @@
|
|||||||
</sc-for>
|
</sc-for>
|
||||||
|
|
||||||
<section data-anchor="session" aria-labelledby="h-sess" style="display:flex;flex-direction:column;gap:10px">
|
<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="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>
|
<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">
|
<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>
|
<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="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>
|
<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:{{ errNoteCol }};color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty">{{ r.n }}</span></sc-if></div></sc-for>
|
||||||
</div>
|
</div>
|
||||||
<dc-import name="CodeSnippet" tabs="{{ errSnips }}" hint-size="100%,120px"></dc-import>
|
<dc-import name="CodeSnippet" tabs="{{ errSnips }}" hint-size="100%,120px"></dc-import>
|
||||||
</section>
|
</section>
|
||||||
@@ -130,11 +139,16 @@
|
|||||||
</x-dc>
|
</x-dc>
|
||||||
<script type="text/x-dc" data-dc-script>
|
<script type="text/x-dc" data-dc-script>
|
||||||
class Component extends DCLogic {
|
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()
|
rootRef = React.createRef()
|
||||||
BASE = 'https://cms.renew.example'
|
BASE = 'https://cms.renew.example'
|
||||||
KEY = 'rcms_Kf3x9QwL0aBc…'
|
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() {
|
componentDidMount() {
|
||||||
const i = (this.props.s && this.props.s.int) || {}
|
const i = (this.props.s && this.props.s.int) || {}
|
||||||
const go = () => { if (i.scroll) this.scrollTo(i.scroll, !!this.props.mobile) }
|
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) }
|
yOf(el, r) { const w = el.offsetParent; return el.offsetTop + (w && w !== r ? w.offsetTop : 0) }
|
||||||
onScroll = () => {
|
onScroll = () => {
|
||||||
const r = this.rootRef.current; if (!r) return
|
const r = this.rootRef.current; if (!r) return
|
||||||
let cur = this.IDX[0][0]
|
let cur = this.IDX[0][0], best = -1
|
||||||
for (const [k] of this.IDX) { const el = r.querySelector('[data-anchor="' + k + '"]'); if (el && this.yOf(el, r) <= r.scrollTop + 120) cur = k }
|
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 })
|
if (cur !== this.state.active) this.setState({ active: cur })
|
||||||
}
|
}
|
||||||
flash(k) {
|
flash(k) {
|
||||||
@@ -177,23 +191,63 @@ class Component extends DCLogic {
|
|||||||
const i404 = [404, 'Invite not found']
|
const i404 = [404, 'Invite not found']
|
||||||
const mm = 'The request cannot be processed due to a mismatch in the format of the body.'
|
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 j = o => JSON.stringify(o, null, 2)
|
||||||
|
const eAd = [403, 'Only owners and admins can do this.'], 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 = {
|
const G = {
|
||||||
articles: { title: 'Articles', desc: 'Read, write and publish articles. Titles can\'t be changed after creation.', eps: [
|
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 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-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-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-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-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-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' })
|
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. An owner or admin creates 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.', 'Owner or admin', 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.', 'Owner or admin', 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.', 'Owner or admin', 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. Any verified user can write entries.', 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.', 'Owner or admin', 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.', 'Owner or admin', 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: [
|
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, 'Invalid page or size.'], 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-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-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-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-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.', 'Owner or admin', 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.', 'Owner or admin', 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.', 'Owner or admin', 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.', 'Owner or admin', 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, 'Invalid page or size.'], 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: [
|
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' }]) }),
|
A('usr-list', 'GET', '/v1/user/', 'List users and invites.', 'Verified user', 1, [e401], [], { r: j([{ email: 'marta@renew.example', role: 'owner', status: 'accepted' }]) }),
|
||||||
@@ -207,35 +261,35 @@ class Component extends DCLogic {
|
|||||||
A('set-get', 'GET', '/v1/settings', 'Get settings.', 'Verified user', 1, [e401], [], { r: j({ version: 'v2.2.0-beta' }) })
|
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
|
const mob = !!this.props.mobile
|
||||||
return Object.keys(G).map(key => {
|
return Object.keys(G).map(key => {
|
||||||
const g = G[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 => {
|
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('{id}', '12')
|
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]
|
const hdr = e.pub ? [] : ['Authorization: Bearer ' + this.KEY]
|
||||||
let curl = 'curl ' + (e.m === 'GET' ? '' : '-X ' + e.m + ' ') + '"' + url + '"'
|
let curl = 'curl ' + (e.m === 'GET' ? '' : '-X ' + e.m + ' ') + '"' + url + '"'
|
||||||
hdr.forEach(h => curl += ' \\\n -H "' + h + '"')
|
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] + '"')
|
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 + "'"
|
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 = []
|
const opts = []
|
||||||
if (e.m !== 'GET') opts.push("method: '" + e.m + "'")
|
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 (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')
|
if (e.form) opts.push('body: form')
|
||||||
fe += opts.length ? ', {\n ' + opts.join(',\n ') + '\n})' : ')'
|
fe += opts.length ? ', {\n ' + opts.join(',\n ') + '\n})' : ')'
|
||||||
const code = tab === 'curl' ? curl : tab === 'fetch' ? fe : e.r
|
const code = tab === 'curl' ? curl : tab === 'fetch' ? fe : e.r
|
||||||
const lang = tab === 'curl' ? 'Shell' : tab === 'fetch' ? 'JavaScript' : 'JSON'
|
const lang = tab === 'curl' ? 'Shell' : tab === 'fetch' ? 'JavaScript' : 'JSON'
|
||||||
const cp = this.state.copied === e.id
|
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 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 })]
|
const chips = [chip(e.acc, e.acc === 'Owner only' || e.acc === 'Owner or admin' ? { 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,
|
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] } })),
|
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,
|
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 } })) } }),
|
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 }],
|
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 +305,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 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 sg = (t, rows) => ({ t, rows: rows.map(([m, p, d]) => ({ m, p, d })) })
|
||||||
const D = (a) => 'rowDisplay'
|
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 {
|
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,
|
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',
|
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)',
|
authCols: mob ? 'minmax(0,1fr)' : 'minmax(0,5fr) minmax(0,7fr)',
|
||||||
@@ -261,17 +318,18 @@ class Component extends DCLogic {
|
|||||||
groups: this.eps(),
|
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',
|
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',
|
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',
|
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: [
|
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: 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: 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: 403, m: 'Forbidden', n: 'Site SEO defaults are owner only. 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), "Only the owner can do this.", "Only owners and admins 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: 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`. 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`.' },
|
||||||
|
{ 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: 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"' }
|
{ 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) })),
|
||||||
errBody: 'HTTP/1.1 403 Forbidden\nContent-Type: text/plain\n\nThis action needs a real session.'
|
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="{"$preview":{"width":1440,"height":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, 'Only owners and admins can do this.'], 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, 'Invalid page or size.'], [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.', 'Owner or admin', ['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.', 'Owner or admin', ['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.', 'Owner or admin', [], [[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.', 'Owner or admin', [], [[401, L], eAd]),
|
||||||
|
E('bt-get', 'GET', '/v1/builtin-type/{key}', 'Get one built-in type. Keys are article, author, site, media and user.', 'Owner or admin', [], [[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, 'Invalid page or size.'], [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.', 'Owner or admin', [], [[401, L], eAd]),
|
||||||
|
E('med-purge-settings-patch', 'PATCH', '/v1/media/purge-settings', 'Update the settings for purging unused images.', 'Owner or admin', ['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.', 'Owner or admin', [], [[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.', 'Owner or admin', ['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, 'Invalid page or size.'], [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>
|
||||||
@@ -53,6 +53,8 @@ class Component extends DCLogic {
|
|||||||
]
|
]
|
||||||
const VC = { 1: 4, 2: 5, 3: 5, 4: 0, 5: 3, 6: 0, 7: 2, 8: 0, 9: 1, 10: 3, 11: 4, 12: 5 }
|
const VC = { 1: 4, 2: 5, 3: 5, 4: 0, 5: 3, 6: 0, 7: 2, 8: 0, 9: 1, 10: 3, 11: 4, 12: 5 }
|
||||||
IM.forEach(x => { x.vc = VC[x.id] || 0 })
|
IM.forEach(x => { x.vc = VC[x.id] || 0 })
|
||||||
|
const ENT = { 10: 3, 11: 5 }
|
||||||
|
IM.forEach(x => { x.ent = s.ent ? (ENT[x.id] || 0) : 0 })
|
||||||
const byId = id => IM.find(x => x.id === id)
|
const byId = id => IM.find(x => x.id === id)
|
||||||
const AR = [['How we price a bathroom renovation', 1], ['Winter checklist for timber windows', 0], ['Before and after: the Rue Verte kitchen', 1], ['Why we reuse original floorboards', 1], ['Choosing lime plaster for old stone walls', 0]]
|
const AR = [['How we price a bathroom renovation', 1], ['Winter checklist for timber windows', 0], ['Before and after: the Rue Verte kitchen', 1], ['Why we reuse original floorboards', 1], ['Choosing lime plaster for old stone walls', 0]]
|
||||||
const usedList = n => AR.slice(0, Math.min(n, 5))
|
const usedList = n => AR.slice(0, Math.min(n, 5))
|
||||||
@@ -60,8 +62,8 @@ class Component extends DCLogic {
|
|||||||
const imgs = ids.map(byId)
|
const imgs = ids.map(byId)
|
||||||
const sel = s.sel || []
|
const sel = s.sel || []
|
||||||
const pl = n => n + (n === 1 ? ' article' : ' articles')
|
const pl = n => n + (n === 1 ? ' article' : ' articles')
|
||||||
const label = im => im.n + ', ' + im.sz + ', ' + (im.used ? 'used in ' + pl(im.used) : 'unused')
|
const label = im => im.n + ', ' + im.sz + ', ' + (im.used + im.ent ? 'used in ' + [im.used && pl(im.used), im.ent && (im.ent + (im.ent === 1 ? ' entry' : ' entries'))].filter(Boolean).join(' and ') : 'unused')
|
||||||
const usedBadge = im => im.used ? Bd('Used in ' + im.used, 'used') : Bd('Unused', 'unused')
|
const usedBadge = im => { const n = im.used + im.ent, b = n ? Bd('Used in ' + n, 'used') : Bd('Unused', 'unused'); return s.tip === im.id ? S('position:relative;display:inline-flex', b, D('position:absolute;left:0;top:26px;z-index:10;padding:6px 8px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;white-space:nowrap;box-shadow:var(--rc-shadow-pop)', im.used > 0 && D('', 'Used in articles: ' + im.used), D('', 'Used in entries: ' + im.ent))) : b }
|
||||||
|
|
||||||
// ---------- Media page pieces
|
// ---------- Media page pieces
|
||||||
const Card = im => {
|
const Card = im => {
|
||||||
@@ -168,7 +170,7 @@ class Component extends DCLogic {
|
|||||||
out.push(D('display:flex;flex-direction:column;gap:12px;padding:4px 0 16px', D('display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:space-between', sum, Size()),
|
out.push(D('display:flex;flex-direction:column;gap:12px;padding:4px 0 16px', D('display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:space-between', sum, Size()),
|
||||||
g.pages > 1 && D('display:flex;align-items:center;gap:8px', 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:' + back + ';' + ring('prev'), I('chevron-left'), 'Previous'), S('padding:0 4px;color:var(--ui-text-highlighted);font-weight:500;white-space:nowrap', 'Page ' + g.page + ' of ' + g.pages), 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:' + fwd + ';' + ring('next'), 'Next', I('chevron-right')))))
|
g.pages > 1 && D('display:flex;align-items:center;gap:8px', 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:' + back + ';' + ring('prev'), I('chevron-left'), 'Previous'), S('padding:0 4px;color:var(--ui-text-highlighted);font-weight:500;white-space:nowrap', 'Page ' + g.page + ' of ' + g.pages), 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:' + fwd + ';' + ring('next'), 'Next', I('chevron-right')))))
|
||||||
} else {
|
} else {
|
||||||
out.push(D('display:flex;align-items:center;gap:16px', D('flex:1;display:flex;align-items:center;gap:16px', sum, Size()), g.pages > 1 && D('display:flex;align-items:center;gap:4px;justify-content:flex-end', PB('chevrons-left', back), PB('chevron-left', back, ring('prev')), ...nums, PB('chevron-right', fwd, ring('next')), PB('chevrons-right', fwd))))
|
out.push(D('display:flex;align-items:center;gap:16px', D('flex:1;display:flex', sum), Size(), g.pages > 1 && D('display:flex;align-items:center;gap:4px;justify-content:flex-end', PB('chevrons-left', back), PB('chevron-left', back, ring('prev')), ...nums, PB('chevron-right', fwd, ring('next')), PB('chevrons-right', fwd))))
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -196,6 +198,8 @@ class Component extends DCLogic {
|
|||||||
const BulkBar = () => D('position:absolute;z-index:30;bottom:' + (m ? 16 : 24) + 'px;' + (m ? 'left:16px;right:16px' : 'left:calc(224px + (100% - 224px) / 2);transform:translateX(-50%);width:400px') + ';display:flex;align-items:center;gap:8px;padding:8px 8px 8px 16px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);box-shadow:var(--rc-shadow-pop)', S('flex:1;font-weight:500', sel.length + ' selected'), B('Delete', { k: 'danger', icon: 'trash-2' }), B('Clear', { k: 'inv' }))
|
const BulkBar = () => D('position:absolute;z-index:30;bottom:' + (m ? 16 : 24) + 'px;' + (m ? 'left:16px;right:16px' : 'left:calc(224px + (100% - 224px) / 2);transform:translateX(-50%);width:400px') + ';display:flex;align-items:center;gap:8px;padding:8px 8px 8px 16px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);box-shadow:var(--rc-shadow-pop)', S('flex:1;font-weight:500', sel.length + ' selected'), B('Delete', { k: 'danger', icon: 'trash-2' }), B('Clear', { k: 'inv' }))
|
||||||
|
|
||||||
// ---------- detail panel
|
// ---------- detail panel
|
||||||
|
const ENTL = [['Events', 'Lime plaster workshop'], ['Events', 'Heritage stone walk'], ['Events', 'Winter open day']]
|
||||||
|
const EntBox = im => D('display:flex;flex-direction:column;gap:8px', S('font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Used in entries'), D('border:1px solid var(--ui-border)', ...ENTL.map(([ty, tt], i) => D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 48 : 36) + 'px;padding:0 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('width:52px;flex-shrink:0;font-size:12px;color:var(--ui-text-muted)', ty), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;text-decoration:underline;text-underline-offset:3px;color:var(--ui-text-highlighted)', tt), I('arrow-up-right', 14, 'color:var(--ui-text-muted)'))), im.ent > 3 && D('padding:8px 12px;border-top:1px solid var(--ui-border-muted);color:var(--ui-text-muted)', 'and ' + (im.ent - 3) + ' more')))
|
||||||
const Panel = () => {
|
const Panel = () => {
|
||||||
const im = byId(s.panel.id), list = usedList(im.used)
|
const im = byId(s.panel.id), list = usedList(im.used)
|
||||||
const meta = [['Type', im.ty], ['Size', im.sz], ['Dimensions', im.dim], ['Uploaded', im.d], ['Uploaded by', im.by]]
|
const meta = [['Type', im.ty], ['Size', im.sz], ['Dimensions', im.dim], ['Uploaded', im.d], ['Uploaded by', im.by]]
|
||||||
@@ -204,14 +208,14 @@ class Component extends DCLogic {
|
|||||||
D('width:' + w + ';height:100%;background:var(--ui-bg);display:flex;flex-direction:column;' + (m ? '' : 'box-shadow:var(--rc-shadow-modal)'),
|
D('width:' + w + ';height:100%;background:var(--ui-bg);display:flex;flex-direction:column;' + (m ? '' : 'box-shadow:var(--rc-shadow-modal)'),
|
||||||
D('height:' + (m ? 56 : 56) + 'px;flex-shrink:0;display:flex;align-items:center;gap:' + (m ? 4 : 12) + 'px;padding:0 ' + (m ? 6 : 20) + 'px;border-bottom:1px solid var(--ui-border)', m && S('width:44px;height:44px;display:flex;align-items:center;justify-content:center', I('arrow-left', 20)), 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', im.n), !m && S('width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
|
D('height:' + (m ? 56 : 56) + 'px;flex-shrink:0;display:flex;align-items:center;gap:' + (m ? 4 : 12) + 'px;padding:0 ' + (m ? 6 : 20) + 'px;border-bottom:1px solid var(--ui-border)', m && S('width:44px;height:44px;display:flex;align-items:center;justify-content:center', I('arrow-left', 20)), 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', im.n), !m && S('width:28px;height:28px;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:20px',
|
D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? 16 : 20) + 'px;display:flex;flex-direction:column;gap:20px',
|
||||||
Th(im, 'aspect-ratio:' + (m ? '4/3' : '16/10') + ';border:1px solid var(--ui-border);flex-shrink:0', 40),
|
Th(im, 'aspect-ratio:' + (im.ent ? (m ? '16/3.6' : '16/6') : m ? '4/3' : '16/10') + ';border:1px solid var(--ui-border);flex-shrink:0', 40),
|
||||||
D('display:flex;gap:8px;' + (m ? 'flex-wrap:wrap' : ''), B('Copy URL', { icon: 'link', full: m }), B('Rename', { icon: 'pencil', full: m }), B('Delete', { k: 'del', icon: 'trash-2', full: m })),
|
D('display:flex;gap:8px;' + (m ? 'flex-wrap:wrap' : ''), B('Copy URL', { icon: 'link', full: m }), B('Rename', { icon: 'pencil', full: m }), B('Delete', { k: 'del', icon: 'trash-2', full: m })),
|
||||||
im.dup && Alert('info', 'copy', 'Identical to 2 other images', 'floorboards-copy.jpg and IMG_2208.jpg have the same content.', 'Review duplicates', { icon: 'arrow-right' }),
|
im.dup && Alert('info', 'copy', 'Identical to 2 other images', 'floorboards-copy.jpg and IMG_2208.jpg have the same content.', 'Review duplicates', { icon: 'arrow-right' }),
|
||||||
D('display:grid;grid-template-columns:110px minmax(0,1fr);gap:8px 12px;color:var(--ui-text-toned)', ...meta.flatMap(([k, v]) => [S('color:var(--ui-text-muted)', k), S('color:var(--ui-text-highlighted)', v)]), S('color:var(--ui-text-muted)', 'URL'), S('font:400 12px/20px \'IBM Plex Mono\',monospace;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', 'cms.renew.example/media/' + im.n)),
|
D('display:grid;grid-template-columns:110px minmax(0,1fr);gap:8px 12px;color:var(--ui-text-toned)', ...meta.flatMap(([k, v]) => [S('color:var(--ui-text-muted)', k), S('color:var(--ui-text-highlighted)', v)]), S('color:var(--ui-text-muted)', 'URL'), S('font:400 12px/20px \'IBM Plex Mono\',monospace;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', 'cms.renew.example/media/' + im.n)),
|
||||||
D('display:flex;flex-direction:column;gap:8px', S('font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', im.used ? 'Used in ' + pl(im.used) : 'Not used in any article'),
|
D('display:flex;flex-direction:column;gap:8px', S('font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', im.used ? 'Used in ' + pl(im.used) : 'Not used in any article'),
|
||||||
im.used ? D('border:1px solid var(--ui-border)', ...list.map(([t, on], i) => D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 48 : 40) + 'px;padding:0 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;text-decoration:underline;text-underline-offset:3px;color:var(--ui-text-highlighted)', t), on ? Bd('Online', 'on', 22) : Bd('Draft', 'draft', 22), I('arrow-up-right', 14, 'color:var(--ui-text-muted)'))))
|
im.used ? D('border:1px solid var(--ui-border)', ...list.map(([t, on], i) => D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 48 : 40) + 'px;padding:0 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;text-decoration:underline;text-underline-offset:3px;color:var(--ui-text-highlighted)', t), on ? Bd('Online', 'on', 22) : Bd('Draft', 'draft', 22), I('arrow-up-right', 14, 'color:var(--ui-text-muted)'))))
|
||||||
: S('color:var(--ui-text-muted)', 'This image is safe to delete. No article shows it.'),
|
: S('color:var(--ui-text-muted)', im.ent ? 'No article shows it.' : 'This image is safe to delete. No article shows it.'),
|
||||||
im.used ? S('color:var(--ui-text-muted);font-size:12px', 'Counted from the text of your articles each time you open this page.') : null))))
|
im.used ? S('color:var(--ui-text-muted);font-size:12px', 'Counted from the text of your articles each time you open this page.') : null), im.ent > 0 && EntBox(im))))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- modal
|
// ---------- modal
|
||||||
@@ -221,6 +225,7 @@ class Component extends DCLogic {
|
|||||||
const md = s.modal, k = md.k
|
const md = s.modal, k = md.k
|
||||||
if (k === 'rename') return { title: 'Rename image', desc: 'Only the filename changes. The URL stays the same, so articles that use this image keep working.', body: [Field('Filename', md.err ? '' : md.val || 'north-wall', { val: !md.err, suffix: '.jpg', focus: !md.loading, err: md.err, dis: md.loading, help: md.err ? '' : 'Letters, numbers, dashes and underscores. The .jpg ending stays.' })], cancel: 'Cancel', confirm: md.loading ? 'Renaming…' : 'Rename' }
|
if (k === 'rename') return { title: 'Rename image', desc: 'Only the filename changes. The URL stays the same, so articles that use this image keep working.', body: [Field('Filename', md.err ? '' : md.val || 'north-wall', { val: !md.err, suffix: '.jpg', focus: !md.loading, err: md.err, dis: md.loading, help: md.err ? '' : 'Letters, numbers, dashes and underscores. The .jpg ending stays.' })], cancel: 'Cancel', confirm: md.loading ? 'Renaming…' : 'Rename' }
|
||||||
if (k === 'del') return { title: 'Delete this image?', desc: '"window-frame.jpg" will be deleted from your library. No article uses it. This can\'t be undone.', cancel: 'Cancel', confirm: md.loading ? 'Deleting…' : 'Delete image', tone: 'danger' }
|
if (k === 'del') return { title: 'Delete this image?', desc: '"window-frame.jpg" will be deleted from your library. No article uses it. This can\'t be undone.', cancel: 'Cancel', confirm: md.loading ? 'Deleting…' : 'Delete image', tone: 'danger' }
|
||||||
|
if (k === 'delEnt') return { title: 'Delete an image that\'s in use?', desc: '"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.', body: [EntBox(byId(11)), ArtBox(usedList(2))], cancel: 'Cancel', confirm: 'Delete image', tone: 'danger' }
|
||||||
if (k === 'delUsed') return { title: 'Delete an image that\'s in use?', desc: '"north-wall.jpg" is used in 2 articles. If you delete it, they will show a broken image. This can\'t be undone.', body: [ArtBox(usedList(2))], cancel: 'Cancel', confirm: md.loading ? 'Deleting…' : 'Delete image', tone: 'danger' }
|
if (k === 'delUsed') return { title: 'Delete an image that\'s in use?', desc: '"north-wall.jpg" is used in 2 articles. If you delete it, they will show a broken image. This can\'t be undone.', body: [ArtBox(usedList(2))], cancel: 'Cancel', confirm: md.loading ? 'Deleting…' : 'Delete image', tone: 'danger' }
|
||||||
if (k === 'bulk') return { title: 'Delete 3 images?', desc: '2 of them are used in 5 articles, which will show a broken image. The other one isn\'t used anywhere. This can\'t be undone.', body: [D('display:flex;flex-direction:column;border:1px solid var(--ui-border)', ...[['north-wall.jpg', 'Used in 2'], ['kitchen-after.webp', 'Used in 3'], ['window-frame.jpg', 'Unused']].map(([n, u], i) => D('display:flex;align-items:center;gap:8px;min-height:36px;padding:0 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('flex:1;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', n), Bd(u === 'Unused' ? 'Unused' : u, u === 'Unused' ? 'unused' : 'used'))))], cancel: 'Cancel', confirm: md.loading ? 'Deleting…' : 'Delete 3 images', tone: 'danger' }
|
if (k === 'bulk') return { title: 'Delete 3 images?', desc: '2 of them are used in 5 articles, which will show a broken image. The other one isn\'t used anywhere. This can\'t be undone.', body: [D('display:flex;flex-direction:column;border:1px solid var(--ui-border)', ...[['north-wall.jpg', 'Used in 2'], ['kitchen-after.webp', 'Used in 3'], ['window-frame.jpg', 'Unused']].map(([n, u], i) => D('display:flex;align-items:center;gap:8px;min-height:36px;padding:0 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('flex:1;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', n), Bd(u === 'Unused' ? 'Unused' : u, u === 'Unused' ? 'unused' : 'used'))))], cancel: 'Cancel', confirm: md.loading ? 'Deleting…' : 'Delete 3 images', tone: 'danger' }
|
||||||
if (k === 'merge') return { title: 'Merge into floorboards.jpg?', desc: 'Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can\'t be undone.', body: [D('display:flex;gap:12px;align-items:center;border:1px solid var(--ui-border);padding:10px 12px', Th(byId(12), 'width:56px;height:42px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('display:flex;flex-direction:column;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', 'floorboards.jpg'), S('font-size:12px;color:var(--ui-text-muted)', 'Kept · used in 4 articles now')))], cancel: 'Cancel', confirm: md.loading ? 'Merging…' : 'Merge 2 copies', tone: 'danger' }
|
if (k === 'merge') return { title: 'Merge into floorboards.jpg?', desc: 'Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can\'t be undone.', body: [D('display:flex;gap:12px;align-items:center;border:1px solid var(--ui-border);padding:10px 12px', Th(byId(12), 'width:56px;height:42px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('display:flex;flex-direction:column;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', 'floorboards.jpg'), S('font-size:12px;color:var(--ui-text-muted)', 'Kept · used in 4 articles now')))], cancel: 'Cancel', confirm: md.loading ? 'Merging…' : 'Merge 2 copies', tone: 'danger' }
|
||||||
@@ -245,6 +250,7 @@ class Component extends DCLogic {
|
|||||||
S('display:block', 'A bathroom is priced by what has to happen behind the walls before anything gets tiled.'),
|
S('display:block', 'A bathroom is priced by what has to happen behind the walls before anything gets tiled.'),
|
||||||
s.inArticle && D('position:relative;aspect-ratio:16/9;border:1px solid var(--ui-border);overflow:hidden', Th(byId(1), 'position:absolute;inset:0', 40), S('position:absolute;top:8px;right:8px;height:28px;padding:0 10px;display:flex;align-items:center;gap:6px;background:var(--ui-bg);border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font:500 12px \'IBM Plex Sans\',sans-serif', I('trash-2', 14), 'Remove image')),
|
s.inArticle && D('position:relative;aspect-ratio:16/9;border:1px solid var(--ui-border);overflow:hidden', Th(byId(1), 'position:absolute;inset:0', 40), S('position:absolute;top:8px;right:8px;height:28px;padding:0 10px;display:flex;align-items:center;gap:6px;background:var(--ui-bg);border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font:500 12px \'IBM Plex Sans\',sans-serif', I('trash-2', 14), 'Remove image')),
|
||||||
S('display:block', 'We don\'t add a margin on materials you choose yourself.'))))
|
S('display:block', 'We don\'t add a margin on materials you choose yourself.'))))
|
||||||
|
const FEditor = () => D('flex:1;min-height:0;overflow:hidden;background:var(--ui-bg-muted);display:flex;flex-direction:column', D('background:var(--ui-bg);border-bottom:1px solid var(--ui-border);padding:12px ' + (m ? 16 : 24) + 'px;display:flex;align-items:center;gap:12px', S('flex:1;font:600 ' + (m ? 16 : 20) + 'px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Lime plaster workshop'), !m && B('Cancel'), !m && B('Save', { k: 'primary' })), D('padding:' + (m ? 16 : 24) + 'px;display:flex;flex-direction:column;gap:16px;max-width:760px', D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:12px', S('font-weight:500', 'Title'), D('height:32px;border:1px solid var(--ui-border-accented);display:flex;align-items:center;padding:0 10px', 'Lime plaster workshop'), S('font-weight:500', 'Thumbnail'), D('height:120px;border:1px solid var(--ui-border-accented);background:var(--ui-bg)'))))
|
||||||
const PT = s.pk || {}
|
const PT = s.pk || {}
|
||||||
const pImgs = (PT.ids || [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]).map(byId)
|
const pImgs = (PT.ids || [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]).map(byId)
|
||||||
const inArt = [1, 4]
|
const inArt = [1, 4]
|
||||||
@@ -252,7 +258,7 @@ class Component extends DCLogic {
|
|||||||
const PTile = im => {
|
const PTile = im => {
|
||||||
const on = PT.sel === im.id
|
const on = PT.sel === im.id
|
||||||
return D('display:flex;flex-direction:column;gap:6px;min-width:0', D('position:relative;' + (on ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : ''), Th(im, 'aspect-ratio:1/1;border:1px solid var(--ui-border)', 20),
|
return D('display:flex;flex-direction:column;gap:6px;min-width:0', D('position:relative;' + (on ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : ''), Th(im, 'aspect-ratio:1/1;border:1px solid var(--ui-border)', 20),
|
||||||
inArt.includes(im.id) && S('position:absolute;left:4px;bottom:4px;height:18px;padding:0 5px;display:flex;align-items:center;background:var(--rc-sand);color:var(--rc-on-sand);font-size:11px;font-weight:500', 'In this article'),
|
(s.field ? im.id === PT.cur : inArt.includes(im.id)) && S('position:absolute;left:4px;bottom:4px;height:18px;padding:0 5px;display:flex;align-items:center;background:var(--rc-sand);color:var(--rc-on-sand);font-size:11px;font-weight:500', s.field ? 'Current' : 'In this article'),
|
||||||
on && 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))),
|
on && 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', im.n))
|
S('font-size:12px;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', im.n))
|
||||||
}
|
}
|
||||||
@@ -267,35 +273,36 @@ class Component extends DCLogic {
|
|||||||
}
|
}
|
||||||
const kids = []
|
const kids = []
|
||||||
kids.push(D('height:' + (m ? 44 : 32) + 'px;padding:0 4px 0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:' + (PT.q ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + (m ? ';font-size:16px' : ''), I('search', 14), S('flex:1', PT.q || 'Search by filename'), PT.q && S('width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 14))))
|
kids.push(D('height:' + (m ? 44 : 32) + 'px;padding:0 4px 0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:' + (PT.q ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + (m ? ';font-size:16px' : ''), I('search', 14), S('flex:1', PT.q || 'Search by filename'), PT.q && S('width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 14))))
|
||||||
|
if (s.field && !PT.req && !PT.error && !PT.empty && !PT.loading) { const on = PT.sel === 'none'; kids.push(D('display:flex;align-items:center;gap:10px;min-height:' + (m ? 48 : 40) + 'px;padding:0 12px;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)'), I('image-off', 16), S('font-weight:500', 'No image'), S('flex:1;color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-muted)'), 'Leave this field empty.'), on && I('check', 16))) }
|
||||||
if (PT.error) kids.push(Alert('error', 'circle-x', 'Couldn\'t load your library', 'Check your connection and try again.', 'Try again'))
|
if (PT.error) kids.push(Alert('error', 'circle-x', 'Couldn\'t load your library', 'Check your connection and try again.', 'Try again'))
|
||||||
else if (PT.empty === 'first') kids.push(Empty('images', 'Your library is empty', 'Images you upload appear here, ready to use in any article.', 'Upload an image', true, 32))
|
else if (PT.empty === 'first') kids.push(Empty('images', 'Your library is empty', s.field ? 'Images you upload appear here, ready to use in any article or entry.' : 'Images you upload appear here, ready to use in any article.', 'Upload an image', true, 32))
|
||||||
else if (PT.empty === 'search') kids.push(Empty('search', 'No images match "wall"', 'Check the spelling, or search for a shorter part of the filename.', 'Clear search', false, 32))
|
else if (PT.empty === 'search') kids.push(Empty('search', 'No images match "wall"', 'Check the spelling, or search for a shorter part of the filename.', 'Clear search', false, 32))
|
||||||
else if (PT.loading) kids.push(PSkel())
|
else if (PT.loading) kids.push(PSkel())
|
||||||
else {
|
else {
|
||||||
kids.push(S('color:var(--ui-text-muted);font-size:12px', 'Newest first · 48 images'), D('min-height:0;overflow:hidden;' + (PT.sel ? 'max-height:' + (m ? 120 : 190) + 'px' : 'max-height:' + (m ? 300 : 300) + 'px'), PGrid()))
|
kids.push(S('color:var(--ui-text-muted);font-size:12px', 'Newest first · 48 images'), D('min-height:0;overflow:hidden;' + (PT.sel ? 'max-height:' + (m ? 120 : 190) + 'px' : 'max-height:' + (m ? 300 : 300) + 'px'), PGrid()))
|
||||||
if (!PT.sel) kids.push(D('display:flex;justify-content:center', B(PT.more ? 'Loading…' : 'Load more', { icon: PT.more ? 'loader-circle' : null, dis: !!PT.more })))
|
if (!PT.sel) kids.push(D('display:flex;justify-content:center', B(PT.more ? 'Loading…' : 'Load more', { icon: PT.more ? 'loader-circle' : null, dis: !!PT.more })))
|
||||||
}
|
}
|
||||||
if (PT.sel) { const im = byId(PT.sel); kids.push(D('display:flex;flex-direction:column;gap:12px;padding-top:12px;border-top:1px solid var(--ui-border)', D('display:flex;gap:12px;align-items:center', Th(im, 'width:56px;height:56px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('display:flex;flex-direction:column;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', im.n), S('font-size:12px;color:var(--ui-text-muted)', im.dim + ' · ' + im.sz))), Field('Alt text (optional)', PT.alt || 'e.g. North wall of the kitchen before plastering', { val: !!PT.alt, focus: !!PT.alt, help: 'Describes the image for people who use a screen reader, and shows if it doesn\'t load. Leave it empty if the image is only decoration.' }))) }
|
if (PT.sel && PT.sel !== 'none') { const im = byId(PT.sel); kids.push(D('display:flex;flex-direction:column;gap:12px;padding-top:12px;border-top:1px solid var(--ui-border)', D('display:flex;gap:12px;align-items:center', Th(im, 'width:56px;height:56px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('display:flex;flex-direction:column;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', im.n), S('font-size:12px;color:var(--ui-text-muted)', im.dim + ' · ' + im.sz))), !s.field && Field('Alt text (optional)', PT.alt || 'e.g. North wall of the kitchen before plastering', { val: !!PT.alt, focus: !!PT.alt, help: 'Describes the image for people who use a screen reader, and shows if it doesn\'t load. Leave it empty if the image is only decoration.' }))) }
|
||||||
return D('display:flex;flex-direction:column;gap:12px', ...kids)
|
return D('display:flex;flex-direction:column;gap:12px', ...kids)
|
||||||
}
|
}
|
||||||
const Picker = () => {
|
const Picker = () => {
|
||||||
const ins = PT.tab === 'Library'
|
const ins = PT.tab === 'Library'
|
||||||
return Shell({ title: 'Add an image', close: true, w: 640, top: 40, cancel: 'Cancel', confirm: ins ? 'Insert image' : null, cdis: ins && !PT.sel }, D('display:flex;flex-direction:column;min-height:0;flex:1', PTabs(), D('padding:16px 24px 0;min-height:0;overflow:hidden;display:flex;flex-direction:column', PBody())))
|
return Shell({ title: s.field ? 'Choose an image' : 'Add an image', close: true, w: 640, top: 40, cancel: 'Cancel', confirm: ins ? (s.field ? (PT.sel === 'none' ? 'Remove image' : 'Use this image') : 'Insert image') : null, cdis: ins && !PT.sel }, D('display:flex;flex-direction:column;min-height:0;flex:1', PTabs(), D('padding:16px 24px 0;min-height:0;overflow:hidden;display:flex;flex-direction:column', PBody())))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- shell
|
// ---------- shell
|
||||||
const isPicker = s.kind === 'picker'
|
const isPicker = s.kind === 'picker'
|
||||||
const navKey = isPicker ? 'articles' : 'media'
|
const navKey = isPicker ? (s.field ? 'content' : 'articles') : 'media'
|
||||||
const crumbs = isPicker ? ['Articles', 'How we price a bathroom renovation'] : ['Media']
|
const crumbs = isPicker ? (s.field ? ['Content', 'Events', 'Lime plaster workshop'] : ['Articles', 'How we price a bathroom renovation']) : ['Media']
|
||||||
const offline = s.offline && D('flex-shrink:0;display:flex;align-items:center;gap:10px;padding:10px 16px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted)', I('wifi-off'), S('flex:1', 'You\'re offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection.'))
|
const offline = s.offline && D('flex-shrink:0;display:flex;align-items:center;gap:10px;padding:10px 16px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted)', I('wifi-off'), S('flex:1', 'You\'re offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection.'))
|
||||||
const pad = m ? 16 : 24
|
const pad = m ? 16 : 24
|
||||||
const content = isPicker ? Editor() : D('flex:1;min-height:0;overflow:hidden;padding:' + pad + 'px;display:flex;flex-direction:column;gap:16px;position:relative', Header(), ...pageBody())
|
const content = isPicker ? (s.field ? FEditor() : Editor()) : D('flex:1;min-height:0;overflow:hidden;padding:' + pad + 'px;display:flex;flex-direction:column;gap:16px;position:relative', Header(), ...pageBody())
|
||||||
const dragOv = s.drag && D('position:absolute;z-index:35;inset:' + (m ? 8 : 12) + 'px;top:' + (m ? 64 : 60) + 'px;border:2px dashed var(--ui-primary);background:var(--rc-sand);color:var(--rc-on-sand);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;text-align:center', I('upload', 28), S('font-weight:600;font-size:16px', 'Drop images to upload'), S('color:var(--rc-on-sand)', 'PNG, JPEG, WEBP, SVG · max 10 MB each'))
|
const dragOv = s.drag && D('position:absolute;z-index:35;inset:' + (m ? 8 : 12) + 'px;top:' + (m ? 64 : 60) + 'px;border:2px dashed var(--ui-primary);background:var(--rc-sand);color:var(--rc-on-sand);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;text-align:center', I('upload', 28), S('font-weight:600;font-size:16px', 'Drop images to upload'), S('color:var(--rc-on-sand)', 'PNG, JPEG, WEBP, SVG · max 10 MB each'))
|
||||||
const fab = m && !isPicker && !(sel.length) && !s.panel && !s.modal && !s.error && s.empty !== 'first' && !s.drag && 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);opacity:' + (s.limited || s.offline ? 0.4 : 1), I(s.limited ? 'timer' : 'upload', 18), s.limited ? 'Try again in 0:42' : 'Upload')
|
const fab = m && !isPicker && !(sel.length) && !s.panel && !s.modal && !s.error && s.empty !== 'first' && !s.drag && 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);opacity:' + (s.limited || s.offline ? 0.4 : 1), I(s.limited ? 'timer' : 'upload', 18), s.limited ? 'Try again in 0:42' : 'Upload')
|
||||||
const ann = s.ann && D('position:absolute;z-index:20;' + (m ? 'left:16px;bottom:24px' : 'left:248px;bottom:20px'), Note(s.ann))
|
const ann = s.ann && D('position:absolute;z-index:20;' + (m ? 'left:16px;bottom:24px' : 'left:248px;bottom:20px'), Note(s.ann))
|
||||||
const main = D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column', offline, content)
|
const main = D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column', offline, content)
|
||||||
const ov = D('display:contents', dragOv, fab, ann, sel.length > 0 && !s.panel && !s.modal && BulkBar(), s.panel && Panel(), isPicker && s.picker && Picker(), s.modal && Modal(), s.toasts && Toasts())
|
const ov = D('display:contents', dragOv, fab, ann, sel.length > 0 && !s.panel && !s.modal && BulkBar(), s.panel && Panel(), isPicker && s.picker && Picker(), s.modal && Modal(), s.toasts && Toasts())
|
||||||
const shell = { kind: 'slot', display: 'Marta Alves', nav: navKey, crumbs, mTitle: isPicker ? 'Edit article' : 'Media', mLead: isPicker ? 'arrow-left' : undefined, mAction: isPicker ? 'ellipsis' : undefined }
|
const shell = { kind: 'slot', display: 'Marta Alves', nav: navKey, crumbs, mTitle: isPicker ? (s.field ? 'Lime plaster workshop' : 'Edit article') : 'Media', mLead: isPicker ? 'arrow-left' : undefined, mAction: isPicker ? 'ellipsis' : undefined, ...(s.field ? { content: [{ key: 'events', name: 'Events', icon: 'files' }, { key: 'recipes', name: 'Recipes', icon: 'files' }, { key: 'press-mentions', name: 'Press mentions', icon: 'files' }], contentOn: 'events' } : {}) }
|
||||||
return { shell, mob: m, main, ov }
|
return { shell, mob: m, main, ov }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,10 +37,10 @@
|
|||||||
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start">
|
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start">
|
||||||
<sc-for list="{{ g.states }}" as="st" hint-placeholder-count="4">
|
<sc-for list="{{ g.states }}" as="st" hint-placeholder-count="4">
|
||||||
<div style="display:flex;flex-direction:column;gap:12px">
|
<div style="display:flex;flex-direction:column;gap:12px">
|
||||||
<div style="display:flex;flex-direction:column;gap:2px;max-width:1040px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}</span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
|
<div style="display:flex;flex-direction:column;gap:2px;max-width:1040px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}<sc-if value="{{ st.clock }}"><span style="margin-left:10px;padding:1px 6px;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px/16px 'IBM Plex Mono',monospace;vertical-align:middle">Clock-sensitive</span></sc-if></span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
|
||||||
<div style="display:flex;gap:24px;align-items:flex-start">
|
<div style="display:flex;gap:24px;align-items:flex-start">
|
||||||
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isAct }}"><dc-import name="ActivityFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isVar }}"><dc-import name="VariantsFrame" s="{{ st.vs }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isImg }}"><div style="width:1440px;height:900px;overflow:hidden;box-sizing:border-box;background:var(--ui-bg-muted);padding:40px 280px"><dc-import name="IntegrationImages" s="{{ st.img }}" mobile="{{ false }}" hint-size="880px,820px"></dc-import></div></sc-if></div></div>
|
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isAct }}"><dc-import name="ActivityFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isDir }}"><dc-import name="DirectoryFrame" s="{{ st.m }}" mobile="{{ false }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isVar }}"><dc-import name="VariantsFrame" s="{{ st.vs }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isImg }}"><div style="width:1440px;height:900px;overflow:hidden;box-sizing:border-box;background:var(--ui-bg-muted);padding:40px 280px"><dc-import name="IntegrationImages" s="{{ st.img }}" mobile="{{ false }}" hint-size="880px,820px"></dc-import></div></sc-if></div></div>
|
||||||
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isAct }}"><dc-import name="ActivityFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isVar }}"><dc-import name="VariantsFrame" s="{{ st.vm }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isImg }}"><div style="width:390px;height:844px;overflow:hidden;box-sizing:border-box;background:var(--ui-bg-muted);padding:16px"><dc-import name="IntegrationImages" s="{{ st.img }}" mobile="{{ true }}" hint-size="358px,800px"></dc-import></div></sc-if></div></div>
|
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isAct }}"><dc-import name="ActivityFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isDir }}"><dc-import name="DirectoryFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isVar }}"><dc-import name="VariantsFrame" s="{{ st.vm }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isImg }}"><div style="width:390px;height:844px;overflow:hidden;box-sizing:border-box;background:var(--ui-bg-muted);padding:16px"><dc-import name="IntegrationImages" s="{{ st.img }}" mobile="{{ true }}" hint-size="358px,800px"></dc-import></div></sc-if></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</sc-for>
|
</sc-for>
|
||||||
@@ -53,6 +53,7 @@
|
|||||||
</sc-for>
|
</sc-for>
|
||||||
|
|
||||||
<section data-screen-label="States matrix: controls" style="display:flex;flex-direction:column;gap:20px"><div style="display:flex;flex-direction:column;gap:6px;max-width:1100px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">States matrix: controls</h2><p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">Hover, focus-visible, active and disabled for every interactive element, once. Focus-visible is a 2 px --ui-primary outline with a 2 px offset (inset for tabs, segmented controls and rows). Disabled is 40% opacity (rows 55%). The mobile column set has no hover and uses 44 px targets. The theme switch above applies to both.</p></div><div style="display:flex;gap:40px;align-items:flex-start;flex-wrap:wrap"><dc-import name="StatesMatrix" mobile="{{ false }}" hint-size="1240px,2400px"></dc-import><dc-import name="StatesMatrix" mobile="{{ true }}" hint-size="390px,2400px"></dc-import></div></section>
|
<section data-screen-label="States matrix: controls" style="display:flex;flex-direction:column;gap:20px"><div style="display:flex;flex-direction:column;gap:6px;max-width:1100px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">States matrix: controls</h2><p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">Hover, focus-visible, active and disabled for every interactive element, once. Focus-visible is a 2 px --ui-primary outline with a 2 px offset (inset for tabs, segmented controls and rows). Disabled is 40% opacity (rows 55%). The mobile column set has no hover and uses 44 px targets. The theme switch above applies to both.</p></div><div style="display:flex;gap:40px;align-items:flex-start;flex-wrap:wrap"><dc-import name="StatesMatrix" mobile="{{ false }}" hint-size="1240px,2400px"></dc-import><dc-import name="StatesMatrix" mobile="{{ true }}" hint-size="390px,2400px"></dc-import></div></section>
|
||||||
|
<section data-screen-label="Sites and authors: copy" style="display:flex;flex-direction:column;gap:20px;max-width:1240px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Sites and authors: copy per state</h2><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ dirCopy }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:300px minmax(0,1fr);gap:16px;padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for></div></section>
|
||||||
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Activity: copy per state</h2><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ spCopy }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for></div></section>
|
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Activity: copy per state</h2><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ spCopy }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for></div></section>
|
||||||
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Activity: component inventory (Nuxt UI v4)</h2><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ spComps }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for></div></section>
|
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Activity: component inventory (Nuxt UI v4)</h2><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ spComps }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for></div></section>
|
||||||
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Activity: API calls</h2><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ spApi }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for></div></section>
|
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Activity: API calls</h2><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ spApi }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for></div></section>
|
||||||
@@ -94,18 +95,18 @@ class Component extends DCLogic {
|
|||||||
}
|
}
|
||||||
boGroups() {
|
boGroups() {
|
||||||
const base = [
|
const base = [
|
||||||
{ t: 'How we price a bathroom renovation', on: true, c: '12 Mar 2026', u: '2 hours ago' },
|
{ t: 'How we price a bathroom renovation', au: ['Inès Duarte'], on: true, c: '12 Mar 2026', u: '2 hours ago' },
|
||||||
{ t: 'Choosing lime plaster for old stone walls', on: false, c: '9 Sep 2026', u: 'Yesterday' },
|
{ t: 'Choosing lime plaster for old stone walls', au: ['Inès Duarte', 'Camille Roche'], on: false, c: '9 Sep 2026', u: 'Yesterday', seo: 2 },
|
||||||
{ t: 'Before and after: the Rue Verte kitchen', on: true, c: '28 Aug 2026', u: '3 days ago' },
|
{ t: 'Before and after: the Rue Verte kitchen', au: ['Jonas Pereira'], on: true, c: '28 Aug 2026', u: '3 days ago', seo: 'hidden' },
|
||||||
{ t: 'Winter checklist for timber windows', on: false, c: '2 Sep 2026', u: '5 days ago' },
|
{ t: 'Winter checklist for timber windows', au: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche', 'Paul Huber', 'Léa Fontaine'], on: false, c: '2 Sep 2026', u: '5 days ago', seo: 3 },
|
||||||
{ t: 'Why we reuse original floorboards', on: true, c: '14 Jun 2026', u: '18 Aug 2026' },
|
{ t: 'Why we reuse original floorboards', au: ['Paul Huber'], on: true, c: '14 Jun 2026', u: '18 Aug 2026' },
|
||||||
{ t: 'Our first passive-house retrofit', on: true, c: '3 Apr 2026', u: '2 Jul 2026' }
|
{ t: 'Our first passive-house retrofit', au: [], on: true, c: '3 Apr 2026', u: '2 Jul 2026' }
|
||||||
]
|
]
|
||||||
const more = [
|
const more = [
|
||||||
{ t: 'Repointing a gable wall in lime mortar', on: true, c: '21 May 2026', u: '4 Jun 2026' },
|
{ t: 'Repointing a gable wall in lime mortar', au: ['Camille Roche'], on: true, c: '21 May 2026', u: '4 Jun 2026' },
|
||||||
{ t: 'What a heat pump survey includes', on: false, c: '30 Jul 2026', u: '1 Aug 2026' },
|
{ t: 'What a heat pump survey includes', au: ['Jonas Pereira'], on: false, c: '30 Jul 2026', u: '1 Aug 2026' },
|
||||||
{ t: 'Choosing a damp-proof course', on: true, c: '7 Feb 2026', u: '19 Apr 2026' },
|
{ t: 'Choosing a damp-proof course', au: ['Inès Duarte'], on: true, c: '7 Feb 2026', u: '19 Apr 2026' },
|
||||||
{ t: 'Reglazing sash windows without replacing them', on: true, c: '16 Jan 2026', u: '2 Mar 2026' }
|
{ t: 'Reglazing sash windows without replacing them', au: ['Inès Duarte', 'Paul Huber'], on: true, c: '16 Jan 2026', u: '2 Mar 2026' }
|
||||||
]
|
]
|
||||||
const pgRows = n => [...base, ...more].slice(0, n).map(r => ({ ...r }))
|
const pgRows = n => [...base, ...more].slice(0, n).map(r => ({ ...r }))
|
||||||
const PG = (g, o = {}) => { const rn = o.rows || 10; const { rows: _r, ...rest } = o; const { list: xl, ...ro } = rest; return L({ ...ro, list: { rows: pgRows(rn), pg: { per: 25, total: 312, pages: 13, ...g }, ...(xl || {}) } }) }
|
const PG = (g, o = {}) => { const rn = o.rows || 10; const { rows: _r, ...rest } = o; const { list: xl, ...ro } = rest; return L({ ...ro, list: { rows: pgRows(rn), pg: { per: 25, total: 312, pages: 13, ...g }, ...(xl || {}) } }) }
|
||||||
@@ -197,7 +198,7 @@ class Component extends DCLogic {
|
|||||||
withM({ ...L({ list: { q: 'tiles', empty: { icon: 'search', title: 'No articles match "tiles"', text: 'Check the spelling, or search for a shorter part of the title.', action: 'Clear search' } } }), name: 'No results', trigger: 'Search matched nothing. The tab counts still show the full list.' }),
|
withM({ ...L({ list: { q: 'tiles', empty: { icon: 'search', title: 'No articles match "tiles"', text: 'Check the spelling, or search for a shorter part of the title.', action: 'Clear search' } } }), name: 'No results', trigger: 'Search matched nothing. The tab counts still show the full list.' }),
|
||||||
withM({ ...L({ list: { tab: 2, counts: [4, 4, 0], empty: { icon: 'pencil-line', title: 'No drafts', text: 'Everything you\'ve written is online.', action: 'New article' } } }), name: 'No drafts', trigger: 'Drafts tab with nothing in it. The "Online" tab has an equivalent "Nothing is online yet" state.' }),
|
withM({ ...L({ list: { tab: 2, counts: [4, 4, 0], empty: { icon: 'pencil-line', title: 'No drafts', text: 'Everything you\'ve written is online.', action: 'New article' } } }), name: 'No drafts', trigger: 'Drafts tab with nothing in it. The "Online" tab has an equivalent "Nothing is online yet" state.' }),
|
||||||
withM({ ...L({ list: { rows: rows({ 3: { busy: 'Publishing…' } }) } }), name: 'Action in progress', trigger: 'Publish chosen from the row menu. That row is disabled.' }),
|
withM({ ...L({ list: { rows: rows({ 3: { busy: 'Publishing…' } }) } }), name: 'Action in progress', trigger: 'Publish chosen from the row menu. That row is disabled.' }),
|
||||||
withM({ ...L({ list: { rows: rows({ 3: { on: true, u: 'Just now' } }) }, toasts: [T('success', 'circle-check', 'Article published', '"Winter checklist for timber windows" is now online.')] }), name: 'Published', trigger: '200: the row updates in place and a toast confirms.' }),
|
withM({ ...L({ list: { rows: rows({ 3: { on: true, u: 'Just now' } }) }, toasts: [T('success', 'circle-check', 'Article published', '"Winter checklist for timber windows" is now online.')] }), clock: true, name: 'Published', trigger: '200: the row updates in place and a toast confirms.' }),
|
||||||
withM({ ...L({ toasts: [T('error', 'circle-x', 'Couldn\'t unpublish', 'The server didn\'t respond. Nothing changed.', 'Retry')] }), name: 'Action failed', trigger: 'A 500 or network error. The row stays as it was, and the toast stays until retried or dismissed.' }),
|
withM({ ...L({ toasts: [T('error', 'circle-x', 'Couldn\'t unpublish', 'The server didn\'t respond. Nothing changed.', 'Retry')] }), name: 'Action failed', trigger: 'A 500 or network error. The row stays as it was, and the toast stays until retried or dismissed.' }),
|
||||||
withM({ ...L({ list: { counts: [5, 4, 1], rows: rows().filter((r, i) => i !== 1) }, toasts: [T('info', 'info', 'That article no longer exists', 'It was deleted somewhere else, so we removed it from the list.')] }), name: 'Row vanished (404)', trigger: 'Any row action gets a 404.' }),
|
withM({ ...L({ list: { counts: [5, 4, 1], rows: rows().filter((r, i) => i !== 1) }, toasts: [T('info', 'info', 'That article no longer exists', 'It was deleted somewhere else, so we removed it from the list.')] }), name: 'Row vanished (404)', trigger: 'Any row action gets a 404.' }),
|
||||||
withM({ ...L({ modal: del(false) }), name: 'Delete: confirm', trigger: 'Delete… from the row menu.' }),
|
withM({ ...L({ modal: del(false) }), name: 'Delete: confirm', trigger: 'Delete… from the row menu.' }),
|
||||||
@@ -309,7 +310,7 @@ class Component extends DCLogic {
|
|||||||
Mp({ upload: upT([rowOk('kitchen-roof.jpg', '2.2 MB', 2), { n: 'porch-steps.jpg', sz: '2.6 MB', st: 'prog', p: '1.1 of 2.6 MB', pct: '42%', im: 11 }, rowDup, rowRej, rowFail, { n: 'attic.jpg', sz: '1.9 MB', st: 'wait', im: 3 }]), name: 'Upload: several files, mixed', trigger: '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.' }),
|
Mp({ upload: upT([rowOk('kitchen-roof.jpg', '2.2 MB', 2), { n: 'porch-steps.jpg', sz: '2.6 MB', st: 'prog', p: '1.1 of 2.6 MB', pct: '42%', im: 11 }, rowDup, rowRej, rowFail, { n: 'attic.jpg', sz: '1.9 MB', st: 'wait', im: 3 }]), name: 'Upload: several files, mixed', trigger: '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.' }),
|
||||||
Mp({ upload: upT([rowOk('kitchen-roof.jpg', '2.2 MB', 2), rowOk('porch-steps.jpg', '2.6 MB', 11), rowDup, rowRej, rowFail], { title: 'Upload finished', sub: '5 images', done: true, summary: '2 uploaded · 1 already in your library · 2 need attention' }), name: 'Upload: summary', trigger: '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.' }),
|
Mp({ upload: upT([rowOk('kitchen-roof.jpg', '2.2 MB', 2), rowOk('porch-steps.jpg', '2.6 MB', 11), rowDup, rowRej, rowFail], { title: 'Upload finished', sub: '5 images', done: true, summary: '2 uploaded · 1 already in your library · 2 need attention' }), name: 'Upload: summary', trigger: '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.' }),
|
||||||
Mp({ upload: upT([rowRej, { n: 'notes.pdf', sz: '210 KB', st: 'rej', title: 'notes.pdf isn\'t an image', msg: 'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.' }, { n: 'broken.png', sz: '3.0 MB', st: 'rej', title: 'broken.png couldn\'t be read', msg: 'The server couldn\'t read this image. Save it again as a PNG or JPEG, then try again.' }], { title: 'Nothing was uploaded', sub: '3 files were rejected', done: true }), name: 'Upload: rejected files (413, 400)', trigger: '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.' }),
|
Mp({ upload: upT([rowRej, { n: 'notes.pdf', sz: '210 KB', st: 'rej', title: 'notes.pdf isn\'t an image', msg: 'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.' }, { n: 'broken.png', sz: '3.0 MB', st: 'rej', title: 'broken.png couldn\'t be read', msg: 'The server couldn\'t read this image. Save it again as a PNG or JPEG, then try again.' }], { title: 'Nothing was uploaded', sub: '3 files were rejected', done: true }), name: 'Upload: rejected files (413, 400)', trigger: '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.' }),
|
||||||
Mp({ limited: true, name: 'Upload: too many requests (429)', trigger: 'Rate limited. Retry-After is counted down in the button. It re-enables at zero. Already-added files are kept.' }),
|
Mp({ limited: true, clock: true, name: 'Upload: too many requests (429)', trigger: 'Rate limited. Retry-After is counted down in the button. It re-enables at zero. Already-added files are kept.' }),
|
||||||
Mp({ tab: 'dups', name: 'Duplicates: groups', trigger: '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.' }),
|
Mp({ tab: 'dups', name: 'Duplicates: groups', trigger: '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.' }),
|
||||||
Mp({ tab: 'dups', modal: { k: 'purge' }, name: 'Purge duplicates: confirm', trigger: '"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.' }),
|
Mp({ tab: 'dups', modal: { k: 'purge' }, name: 'Purge duplicates: confirm', trigger: '"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.' }),
|
||||||
Mp({ tab: 'dups', member: true, name: 'Duplicates: no purge button (member)', trigger: 'Verified users who are not owner or admin. The "Purge duplicates" button is hidden. "Merge into this one" is unchanged.' }),
|
Mp({ tab: 'dups', member: true, name: 'Duplicates: no purge button (member)', trigger: 'Verified users who are not owner or admin. The "Purge duplicates" button is hidden. "Merge into this one" is unchanged.' }),
|
||||||
@@ -362,8 +363,28 @@ class Component extends DCLogic {
|
|||||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "3b. Copied: CodeBlock", trigger: "The Copy button in the API key example after a click. Back to \"Copy\" after 2 s.", int: {"scroll":"auth","copied":"auth"} }, { int: {"scroll":"auth","copied":"auth"} }),
|
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "3b. Copied: CodeBlock", trigger: "The Copy button in the API key example after a click. Back to \"Copy\" after 2 s.", int: {"scroll":"auth","copied":"auth"} }, { int: {"scroll":"auth","copied":"auth"} }),
|
||||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "4. Authentication", trigger: "The API key card with its example, the \"Manage API keys\" link (goes to the API keys board) and the two notes.", int: {"scroll":"auth"} }, { int: {"scroll":"auth"} }),
|
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "4. Authentication", trigger: "The API key card with its example, the \"Manage API keys\" link (goes to the API keys board) and the two notes.", int: {"scroll":"auth"} }, { int: {"scroll":"auth"} }),
|
||||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "5. Session only", trigger: "Routes API keys cannot call. Dashed, muted and locked, so they read as unavailable and not as an error.", int: {"scroll":"session"} }, { int: {"scroll":"session"} }),
|
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "5. Session only", trigger: "Routes API keys cannot call. Dashed, muted and locked, so they read as unavailable and not as an error.", int: {"scroll":"session"} }, { int: {"scroll":"session"} }),
|
||||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "6. Errors", trigger: "Status table and the plain-text error response (status line plus a one-sentence body). Key-specific causes are in the 401, 403 and 429 rows.", int: {"scroll":"errors"} }, { int: {"scroll":"errors"} }),
|
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "6. Errors", trigger: "Status table and the plain-text error response (status line plus a one-sentence body). Key-specific causes are in the 401, 403 and 429 rows. The 403 row carries a muted note line (12/18, --ui-text-muted, aligned with the cause column, 2 px row gap above it): \"Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.\" The 404 and 429 rows and everything below move down by the height of the wrapped note: two 18 px lines on desktop, three on mobile, plus the 2 px gap.", int: {"scroll":"errors"} }, { int: {"scroll":"errors"} }),
|
||||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "7. Navigation", trigger: "Desktop: the sticky index with the active item (Media). Mobile: the \"Jump to\" select open. Keyboard: the index buttons and the select are in the tab order, Enter jumps, Escape closes the list.", int: {"scroll":"media"} }, { int: {"scroll":"media","jump":true} })
|
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "7. Navigation", trigger: "Desktop: the sticky index with the active item (Media). Mobile: the \"Jump to\" select open. Keyboard: the index buttons and the select are in the tab order, Enter jumps, Escape closes the list.", int: {"scroll":"media"} }, { int: {"scroll":"media","jump":true} }),
|
||||||
|
...((B, X) => [
|
||||||
|
X({ name: "8a. Search: trigger focused", trigger: "Closed. Tab or the \"/\" key puts focus on the search field-style button in the page header (2 px --ui-primary ring, 2 px offset). On mobile the icon button sits right of \"Jump to\".", int: { trig: 'focus' } }, { int: {} }),
|
||||||
|
X({ name: "8b. Search: open, empty", trigger: "Ctrl or Cmd+K, \"/\" or a click. Focus is in the input. Five suggested items, no query. Footer count: \"5 suggestions\".", search: { q: '', sel: 0 } }),
|
||||||
|
X({ name: "8c. Search: typing, results", trigger: "\"media\". Endpoints first, then Sections. Matched characters use --rc-sand with --rc-on-sand. First result is selected. Announced: \"17 results, showing 7\".", search: { q: 'media', sel: 0 } }),
|
||||||
|
X({ name: "8d. Search: keyboard-selected, status code", trigger: "\"404\" matches error codes. Down arrow moved the selection to the third row (--ui-bg-elevated plus a 2 px --ui-primary bar). Rows matched outside the path show the matching error under the description. Only 6 endpoints are listed; the rest are counted. Announced: \"22 results, showing 7\".", search: { q: '404', sel: 2 } }),
|
||||||
|
X({ name: "8e. Search: body field match", trigger: "\"keep_id\" is a request body field of POST /v1/media/merge. The row adds a \"Body field\" line with the match highlighted. Announced: \"1 result\".", search: { q: 'keep_id', sel: 0 } }),
|
||||||
|
X({ name: "8f. Search: session-only results", trigger: "\"login\". Session-only routes keep the card look: muted text, dashed method badge, dashed \"Session only\" chip with the lock icon. Announced: \"2 results\".", search: { q: 'login', sel: 0 } }),
|
||||||
|
X({ name: "8g. Search: no results", trigger: "\"xyz\". No option is selectable. Announced: \"No results\". Footer count: \"0 results\".", search: { q: 'xyz', sel: 0 } }),
|
||||||
|
X({ name: "8h. Search: after selecting an endpoint", trigger: "Enter on POST /v1/media/merge. The palette closes, the page scrolls to the card, the card expands and takes focus, and the row shows a highlight (--rc-sand fill, 2 px --ui-primary ring) for 1.5 s, then fades to the open-card fill. The section index (desktop) and \"Jump to\" (mobile) now read Media.", int: { scroll: 'ep-med-merge', open: 'med-merge', tab: 'fetch', flash: 'med-merge' } }),
|
||||||
|
X({ name: "9a. Content types: create, curl", trigger: "Content types group. The Field types table and the sample field object sit above the routes. POST /v1/content-type/ is open on the curl tab with its long body table (key, name, icon, fields). On mobile the view starts at the card.", int: { scroll: 'ct-fields', open: 'ct-create', tab: 'curl' } }, { int: { scroll: 'ep-ct-create', open: 'ct-create', tab: 'curl' } }),
|
||||||
|
X({ name: "9b. Entries: list, Response tab", trigger: "GET /v1/content-type/{key}/entry open on the Response tab. The response is an object with items, total, page and size. The Query parameters table (page, size, sort) is above the example.", int: { scroll: 'ep-en-list', open: 'en-list', tab: 'response' } }),
|
||||||
|
X({ name: "9c. Entries: replace, 422 and 409", trigger: "PUT /v1/content-type/{key}/entry/{id} open. Errors list the 409 as a sentence and the 422 as a code-style caption, \"invalid_entry: capacity must be at most 5000\".", int: { scroll: 'ep-en-update', open: 'en-update', tab: 'fetch' } }),
|
||||||
|
X({ name: "9d. Errors: 404, 409 and 422 rows", trigger: "The table now has real 409 and 422 rows and the two new 404 codes. Codes are the literal body, so they use code style. The 403 row keeps its muted note.", int: { scroll: 'errors' } }),
|
||||||
|
X({ name: "9e. Session only: 6 sub-groups", trigger: "35 routes in six sub-groups: System, Sign in and account, API key management, Sites and authors writes, Image variants and Activity.", int: { scroll: 'session' } }),
|
||||||
|
X({ name: "9f. Search: \"entry\"", trigger: "Endpoints are capped at 6 and the rest are counted (\"N more endpoints\"). \"entries\" in a description or error also matches. Announced: \"8 results, showing 7\".", search: { q: 'entry', sel: 0 } }),
|
||||||
|
X({ name: "9g. Search: \"capacity\" (body field)", trigger: "Matches the capacity key in the data body of POST and PUT entry. The row adds a \"Body field\" line. Announced: \"2 results\".", search: { q: 'capacity', sel: 0 } }),
|
||||||
|
X({ name: "9h. Search: \"schema_incompatible\" (error)", trigger: "Matches the 409 of PUT /v1/content-type/{key}. The row adds an \"Error\" line. Announced: \"1 result\".", search: { q: 'schema_incompatible', sel: 0 } }),
|
||||||
|
X({ name: "9i. Search: \"content type\" (sections)", trigger: "Endpoints capped at 6, then a Sections group with Content types and Entries. Announced: \"12 results, showing 8\".", search: { q: 'content type', sel: 0 } }),
|
||||||
|
X({ name: "9j. Navigation: scroll-spy on Content types", trigger: "Desktop: the sticky index lists every group and marks Content types. Mobile: the Jump to list is open with all groups and a check on Content types.", int: { scroll: 'ctypes' } }, { int: { scroll: 'ctypes', jump: true } })
|
||||||
|
])({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration' }, (st, mo) => withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', int: {}, ...st }, mo || {}))
|
||||||
] },
|
] },
|
||||||
{ label: 'Activity', title: 'Activity, owner and admin', route: '/activity', api: 'GET /v1/activity · GET /v1/activity/summary', note: 'New route in the sidebar after Users. Owner and admin see everything; regular users get "My activity". Copy, component mapping and API calls are at the bottom of this board.', states: [
|
{ label: 'Activity', title: 'Activity, owner and admin', route: '/activity', api: 'GET /v1/activity · GET /v1/activity/summary', note: 'New route in the sidebar after Users. Owner and admin see everything; regular users get "My activity". Copy, component mapping and API calls are at the bottom of this board.', states: [
|
||||||
Act('Default, large dataset', 'Newest first, day separators, a security row, 1,284 events across 52 pages. Hover on the first row shows the exact time.', { tip: 1 }),
|
Act('Default, large dataset', 'Newest first, day separators, a security row, 1,284 events across 52 pages. Hover on the first row shows the exact time.', { tip: 1 }),
|
||||||
@@ -433,16 +454,16 @@ class Component extends DCLogic {
|
|||||||
withM({ ...U({ modal: { title: 'Cancel this invite?', desc: 'The link sent to sam.oliveira@renew.example will stop working. You can invite the address again later.', cancel: 'Keep invite', confirm: 'Cancel invite', tone: 'danger', notice: 'Couldn\'t cancel the invite. Try again in a moment.' } }), name: 'Cancel invite: failed', trigger: '500 or network failure. The dialog stays open.' }),
|
withM({ ...U({ modal: { title: 'Cancel this invite?', desc: 'The link sent to sam.oliveira@renew.example will stop working. You can invite the address again later.', cancel: 'Keep invite', confirm: 'Cancel invite', tone: 'danger', notice: 'Couldn\'t cancel the invite. Try again in a moment.' } }), name: 'Cancel invite: failed', trigger: '500 or network failure. The dialog stays open.' }),
|
||||||
withM({ ...U({ users: { rows: people.slice(1) }, toasts: [T('success', 'circle-check', 'Invite cancelled', 'sam.oliveira@renew.example can no longer use the link.')] }), name: 'Cancel invite: done', trigger: '204: the row disappears and a toast confirms.' }),
|
withM({ ...U({ users: { rows: people.slice(1) }, toasts: [T('success', 'circle-check', 'Invite cancelled', 'sam.oliveira@renew.example can no longer use the link.')] }), name: 'Cancel invite: done', trigger: '204: the row disappears and a toast confirms.' }),
|
||||||
withM({ ...U({ users: { rows: people.map(p => p.st === 'expired' ? { ...p, busy: 'Renewing…' } : p) } }), name: 'Renew invite: sending', trigger: '"Renew invite" on an Expired row. No confirmation, since it only sends an email. The button shows progress and is disabled.' }),
|
withM({ ...U({ users: { rows: people.map(p => p.st === 'expired' ? { ...p, busy: 'Renewing…' } : p) } }), name: 'Renew invite: sending', trigger: '"Renew invite" on an Expired row. No confirmation, since it only sends an email. The button shows progress and is disabled.' }),
|
||||||
withM({ ...U({ users: { rows: [{ ...people[1], st: 'pending', c: 'Today', hl: true }, ...people.filter((p, i) => i !== 1)] }, toasts: [T('success', 'circle-check', 'Invite renewed', 'p.huber@gmail.com has 3 days to accept the new link.')] }), name: 'Renew invite: done', trigger: '200: the row becomes Pending with today\'s date, moves to the top, and the old link stops working.' }),
|
withM({ ...U({ users: { rows: [{ ...people[1], st: 'pending', c: 'Today', hl: true }, ...people.filter((p, i) => i !== 1)] }, toasts: [T('success', 'circle-check', 'Invite renewed', 'p.huber@gmail.com has 3 days to accept the new link.')] }), clock: true, name: 'Renew invite: done', trigger: '200: the row becomes Pending with today\'s date, moves to the top, and the old link stops working.' }),
|
||||||
withM({ ...U({ toasts: [T('error', 'circle-x', 'Couldn\'t renew the invite', 'Try again in a moment.')] }), name: 'Renew invite: failed', trigger: '500 or network failure. The row stays Expired and the button can be pressed again.' }),
|
withM({ ...U({ toasts: [T('error', 'circle-x', 'Couldn\'t renew the invite', 'Try again in a moment.')] }), name: 'Renew invite: failed', trigger: '500 or network failure. The row stays Expired and the button can be pressed again.' }),
|
||||||
withM({ ...U({ users: { rows: [{ email: 'lea.martin@renew.example', role: 'admin', st: 'pending', c: 'Today', hl: true }, ...people] } , toasts: [T('success', 'circle-check', 'Invite sent', 'lea.martin@renew.example has 3 days to accept.')] }), name: 'Invite sent', trigger: '201: the dialog closes, the new invite appears at the top as Pending, and a toast confirms.' })
|
withM({ ...U({ users: { rows: [{ email: 'lea.martin@renew.example', role: 'admin', st: 'pending', c: 'Today', hl: true }, ...people] } , toasts: [T('success', 'circle-check', 'Invite sent', 'lea.martin@renew.example has 3 days to accept.')] }), name: 'Invite sent', trigger: '201: the dialog closes, the new invite appears at the top as Pending, and a toast confirms.' })
|
||||||
] },
|
] },
|
||||||
{ label: 'Settings', title: 'Settings: Updates', route: '/settings', api: 'GET /v1/system/update · POST …/check · POST …/download · POST …/update/restart (all pending)', note: 'The page is one column of cards, top to bottom: Version, Updates, Media cleanup, Retention (owner only). The screens below are scrolled to the card they show. New Updates card under Version, owner only. Update downloads and checks the new version and changes nothing. Restart installs it and restarts the site (about 10 seconds). "Restart later" is simply leaving the card or the dialog. A plain Restart shows only when no update is waiting. Admins see the version and a note. When the server isn\'t prepared, the card shows the notice and a "How to update" link with no buttons. Progress is a progressbar with a value, announced at 25% steps and when each step changes. The card is a polite live region. After the restart the page reloads, focus lands on the "Updates" heading, and a toast confirms. Buttons are disabled while any step runs, and any owner on any device sees the same state from the server. Cue elsewhere: see Home. Out of scope: choosing versions, downgrades, automatic updates, update history.', states: [
|
{ label: 'Settings', title: 'Settings: Updates', route: '/settings', api: 'GET /v1/system/update · POST …/check · POST …/download · POST …/update/restart (all pending)', note: 'The page is one column of cards, top to bottom: Version, Updates, Media cleanup, Retention (owner only). The screens below are scrolled to the card they show. New Updates card under Version, owner only. Update downloads and checks the new version and changes nothing. Restart installs it and restarts the site (about 10 seconds). "Restart later" is simply leaving the card or the dialog. A plain Restart shows only when no update is waiting. Admins see the version and a note. When the server isn\'t prepared, the card shows the notice and a "How to update" link with no buttons. Progress is a progressbar with a value, announced at 25% steps and when each step changes. The card is a polite live region. After the restart the page reloads, focus lands on the "Updates" heading, and a toast confirms. Buttons are disabled while any step runs, and any owner on any device sees the same state from the server. Cue elsewhere: see Home. Out of scope: choosing versions, downgrades, automatic updates, update history.', states: [
|
||||||
SU({ icon: 'circle-check', tone: 'success', title: 'You\'re up to date', text: 'You have the latest version of RenewCMS.', meta: LAST, btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { name: 'Up to date', trigger: 'Opened with no newer version. Restart is available but not emphasised.' }),
|
SU({ icon: 'circle-check', tone: 'success', title: 'You\'re up to date', text: 'You have the latest version of RenewCMS.', meta: LAST, btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { clock: true, name: 'Up to date', trigger: 'Opened with no newer version. Restart is available but not emphasised.' }),
|
||||||
SU({ icon: 'loader-circle', title: 'Checking for updates…', text: 'This usually takes a few seconds.', btns: [B('Checking…', 'sec', 'loader-circle', true)] }, { name: 'Checking', trigger: 'Check for updates pressed, or the first open after 24 hours. Announced as "Checking for updates". Buttons are disabled.' }),
|
SU({ icon: 'loader-circle', title: 'Checking for updates…', text: 'This usually takes a few seconds.', btns: [B('Checking…', 'sec', 'loader-circle', true)] }, { name: 'Checking', trigger: 'Check for updates pressed, or the first open after 24 hours. Announced as "Checking for updates". Buttons are disabled.' }),
|
||||||
SU({ icon: 'wifi-off', tone: 'warning', title: 'Couldn\'t check for updates', text: 'RenewCMS couldn\'t reach the update server. Check your connection and try again. Your site isn\'t affected.', meta: 'Last checked yesterday at 18:12', btns: [B('Try again', 'primary', 'rotate-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { name: 'Check failed', trigger: 'Offline, or the update server didn\'t answer (500, network). The last known result is kept.' }),
|
SU({ icon: 'wifi-off', tone: 'warning', title: 'Couldn\'t check for updates', text: 'RenewCMS couldn\'t reach the update server. Check your connection and try again. Your site isn\'t affected.', meta: 'Last checked yesterday at 18:12', btns: [B('Try again', 'primary', 'rotate-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { clock: true, name: 'Check failed', trigger: 'Offline, or the update server didn\'t answer (500, network). The last known result is kept.' }),
|
||||||
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart.', meta: LAST, notes: NOTES(false), btns: [B('Update', 'primary', 'download')], hint: 'Takes about 2 minutes. Your site stays online.' }, { name: 'Update available', trigger: 'A newer version exists. Release notes are collapsed. Update is the one primary button.', navBadge: true }),
|
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart.', meta: LAST, notes: NOTES(false), btns: [B('Update', 'primary', 'download')], hint: 'Takes about 2 minutes. Your site stays online.' }, { clock: true, name: 'Update available', trigger: 'A newer version exists. Release notes are collapsed. Update is the one primary button.', navBadge: true }),
|
||||||
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart.', meta: LAST, notes: NOTES(true), btns: [B('Update', 'primary', 'download')], hint: 'Takes about 2 minutes. Your site stays online.' }, { name: 'Update available, notes open', trigger: '"What\'s new in 2.3.0" expanded. It is a button with aria-expanded, and the list is plain text.', navBadge: true }),
|
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart.', meta: LAST, notes: NOTES(true), btns: [B('Update', 'primary', 'download')], hint: 'Takes about 2 minutes. Your site stays online.' }, { clock: true, name: 'Update available, notes open', trigger: '"What\'s new in 2.3.0" expanded. It is a button with aria-expanded, and the list is plain text.', navBadge: true }),
|
||||||
SU({ icon: 'download', tone: 'info', title: 'Downloading version 2.3.0…', text: 'You can leave this page. The download continues and your site stays online.', pct: 42, barLabel: 'Downloading version 2.3.0', barText: '42% · 11 of 26 MB', btns: [B('Updating…', 'sec', 'loader-circle', true)] }, { name: 'Downloading', trigger: 'Update pressed (202). Progress polls every second. Announced as "Downloading, 25 percent" and so on, never every tick.', navBadge: true }),
|
SU({ icon: 'download', tone: 'info', title: 'Downloading version 2.3.0…', text: 'You can leave this page. The download continues and your site stays online.', pct: 42, barLabel: 'Downloading version 2.3.0', barText: '42% · 11 of 26 MB', btns: [B('Updating…', 'sec', 'loader-circle', true)] }, { name: 'Downloading', trigger: 'Update pressed (202). Progress polls every second. Announced as "Downloading, 25 percent" and so on, never every tick.', navBadge: true }),
|
||||||
SU({ icon: 'shield-check', tone: 'info', title: 'Checking the download…', text: 'Making sure the file is complete and safe to install. This takes a few seconds.', indet: true, barLabel: 'Checking the download', barText: 'Almost there', btns: [B('Updating…', 'sec', 'loader-circle', true)] }, { name: 'Verifying', trigger: 'The download finished. No percentage, so the bar is indeterminate. Announced as "Checking the download".', navBadge: true }),
|
SU({ icon: 'shield-check', tone: 'info', title: 'Checking the download…', text: 'Making sure the file is complete and safe to install. This takes a few seconds.', indet: true, barLabel: 'Checking the download', barText: 'Almost there', btns: [B('Updating…', 'sec', 'loader-circle', true)] }, { name: 'Verifying', trigger: 'The download finished. No percentage, so the bar is indeterminate. Announced as "Checking the download".', navBadge: true }),
|
||||||
SU({ icon: 'circle-check', tone: 'success', title: 'Version 2.3.0 is ready to install', text: 'The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds.', meta: 'Nothing has changed yet.', notes: NOTES(false), btns: [B('Restart', 'primary', 'power')], hint: 'Not now? Leave this page. The update waits here, and the banner on Home reminds you.' }, { name: 'Ready to install', trigger: 'The file passed its checks. Restart becomes the primary action. "Restart later" is closing the dialog or leaving the page.', navBadge: true }),
|
SU({ icon: 'circle-check', tone: 'success', title: 'Version 2.3.0 is ready to install', text: 'The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds.', meta: 'Nothing has changed yet.', notes: NOTES(false), btns: [B('Restart', 'primary', 'power')], hint: 'Not now? Leave this page. The update waits here, and the banner on Home reminds you.' }, { name: 'Ready to install', trigger: 'The file passed its checks. Restart becomes the primary action. "Restart later" is closing the dialog or leaving the page.', navBadge: true }),
|
||||||
@@ -450,19 +471,19 @@ class Component extends DCLogic {
|
|||||||
SU({ icon: 'circle-check', tone: 'success', title: 'Version 2.3.0 is ready to install', text: 'The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds.', meta: 'Nothing has changed yet.', btns: [B('Restart', 'primary', 'power')] }, { name: 'Restart: request failed', trigger: '500 or network failure before the restart began. The site is still running.', navBadge: true, modal: installModal({ notice: 'Couldn\'t start the restart. Nothing was changed and your site is still running. Try again in a moment.' }) }),
|
SU({ icon: 'circle-check', tone: 'success', title: 'Version 2.3.0 is ready to install', text: 'The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds.', meta: 'Nothing has changed yet.', btns: [B('Restart', 'primary', 'power')] }, { name: 'Restart: request failed', trigger: '500 or network failure before the restart began. The site is still running.', navBadge: true, modal: installModal({ notice: 'Couldn\'t start the restart. Nothing was changed and your site is still running. Try again in a moment.' }) }),
|
||||||
SU({ icon: 'circle-check', tone: 'success', title: 'Version 2.3.0 is ready to install', text: '', btns: [] }, { name: 'Restarting to install', trigger: '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."', overlay: { title: 'Installing version 2.3.0', text: 'Your site is restarting with the new version. This takes about 10 seconds. This page reloads on its own when the site is back.', meta: 'Started at 09:51', barL: '30%' } }),
|
SU({ icon: 'circle-check', tone: 'success', title: 'Version 2.3.0 is ready to install', text: '', btns: [] }, { name: 'Restarting to install', trigger: '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."', overlay: { title: 'Installing version 2.3.0', text: 'Your site is restarting with the new version. This takes about 10 seconds. This page reloads on its own when the site is back.', meta: 'Started at 09:51', barL: '30%' } }),
|
||||||
SU({ icon: 'circle-check', tone: 'success', title: '', text: '', btns: [] }, { name: 'Restarting, taking longer', trigger: 'No answer after 60 seconds. The copy changes and a manual reload appears. The overlay stays until the site is back.', overlay: { title: 'Still restarting', text: '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.', meta: 'Started at 09:51 · 1 minute ago', btn: 'Reload page', barL: '65%' } }),
|
SU({ icon: 'circle-check', tone: 'success', title: '', text: '', btns: [] }, { name: 'Restarting, taking longer', trigger: 'No answer after 60 seconds. The copy changes and a manual reload appears. The overlay stays until the site is back.', overlay: { title: 'Still restarting', text: '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.', meta: 'Started at 09:51 · 1 minute ago', btn: 'Reload page', barL: '65%' } }),
|
||||||
SU({ icon: 'circle-check', tone: 'success', title: 'You\'re on version 2.3.0', text: 'The update is installed and your site is running again.', meta: 'Updated today at 09:52', btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT, link: 'Read the release notes', v: 'v2.3.0', focus: '2px solid var(--ui-primary)', toasts: undefined }, { name: 'Update done', trigger: 'The page reloaded on the new version. Focus lands on the "Updates" heading (ring shown), the toast is announced, and the version updates.', toasts: [T('success', 'circle-check', 'Update installed', 'RenewCMS is now on version 2.3.0.')] }),
|
SU({ icon: 'circle-check', tone: 'success', title: 'You\'re on version 2.3.0', text: 'The update is installed and your site is running again.', meta: 'Updated today at 09:52', btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT, link: 'Read the release notes', v: 'v2.3.0', focus: '2px solid var(--ui-primary)', toasts: undefined }, { clock: true, name: 'Update done', trigger: 'The page reloaded on the new version. Focus lands on the "Updates" heading (ring shown), the toast is announced, and the version updates.', toasts: [T('success', 'circle-check', 'Update installed', 'RenewCMS is now on version 2.3.0.')] }),
|
||||||
SU({ icon: 'triangle-alert', tone: 'warning', title: 'The update didn\'t install', text: 'RenewCMS put the previous version back, so your site is running as before. Nothing was lost.', meta: 'What to do next: try again in a few minutes. If it fails again, ask whoever manages your server to check the update.', btns: [B('Try again', 'primary', 'rotate-cw')] }, { name: 'Update failed, rolled back', trigger: 'The site came back on v2.2.0-beta. The error toast stays until dismissed and the card keeps the explanation.', toasts: [T('error', 'circle-x', 'The update didn\'t install', 'Your site is back on v2.2.0-beta.')] }),
|
SU({ icon: 'triangle-alert', tone: 'warning', title: 'The update didn\'t install', text: 'RenewCMS put the previous version back, so your site is running as before. Nothing was lost.', meta: 'What to do next: try again in a few minutes. If it fails again, ask whoever manages your server to check the update.', btns: [B('Try again', 'primary', 'rotate-cw')] }, { name: 'Update failed, rolled back', trigger: 'The site came back on v2.2.0-beta. The error toast stays until dismissed and the card keeps the explanation.', toasts: [T('error', 'circle-x', 'The update didn\'t install', 'Your site is back on v2.2.0-beta.')] }),
|
||||||
SU({ icon: 'circle-x', tone: 'error', title: 'The download didn\'t finish', text: 'The connection dropped before the file was complete. Nothing was changed and your site is running as before.', btns: [B('Try again', 'primary', 'rotate-cw')] }, { name: 'Download failed', trigger: 'A 500, or the connection dropped. Focus moves to the card heading. Try again restarts the download.', navBadge: true }),
|
SU({ icon: 'circle-x', tone: 'error', title: 'The download didn\'t finish', text: 'The connection dropped before the file was complete. Nothing was changed and your site is running as before.', btns: [B('Try again', 'primary', 'rotate-cw')] }, { name: 'Download failed', trigger: 'A 500, or the connection dropped. Focus moves to the card heading. Try again restarts the download.', navBadge: true }),
|
||||||
SU({ icon: 'triangle-alert', tone: 'warning', title: 'We didn\'t use this update', text: '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.', btns: [B('Try again', 'primary', 'rotate-cw')] }, { name: 'Verification failed', trigger: '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.', navBadge: true }),
|
SU({ icon: 'triangle-alert', tone: 'warning', title: 'We didn\'t use this update', text: '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.', btns: [B('Try again', 'primary', 'rotate-cw')] }, { name: 'Verification failed', trigger: '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.', navBadge: true }),
|
||||||
SU({ icon: 'info', tone: 'info', title: 'Too many tries', text: 'Wait a moment before trying again.', btns: [B('Try again in 0:42', 'sec', 'clock', true)] }, { name: 'Too many requests (429)', trigger: 'Retry-After counted down in the button. It re-enables at zero.' }),
|
SU({ icon: 'info', tone: 'info', title: 'Too many tries', text: 'Wait a moment before trying again.', btns: [B('Try again in 0:42', 'sec', 'clock', true)] }, { clock: true, name: 'Too many requests (429)', trigger: 'Retry-After counted down in the button. It re-enables at zero.' }),
|
||||||
SU(notPrep(false), { name: 'Not prepared', trigger: '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.', navBadge: true }),
|
SU(notPrep(false), { name: 'Not prepared', trigger: '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.', navBadge: true }),
|
||||||
SU(notPrep(true), { name: 'Not prepared: command copied', trigger: 'Copy pressed. The button reads "Copied" for 2 s and it is announced.', navBadge: true }),
|
SU(notPrep(true), { name: 'Not prepared: command copied', trigger: 'Copy pressed. The button reads "Copied" for 2 s and it is announced.', navBadge: true }),
|
||||||
SU({ icon: 'circle-check', tone: 'success', title: 'You\'re up to date', text: 'You have the latest version of RenewCMS.', meta: LAST, btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { name: 'Plain restart: confirm', trigger: 'Restart with no update waiting. Nothing is installed.', modal: restartModal() }),
|
SU({ icon: 'circle-check', tone: 'success', title: 'You\'re up to date', text: 'You have the latest version of RenewCMS.', meta: LAST, btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { clock: true, name: 'Plain restart: confirm', trigger: 'Restart with no update waiting. Nothing is installed.', modal: restartModal() }),
|
||||||
SU({ icon: 'circle-check', tone: 'success', title: '', text: '', btns: [] }, { name: 'Plain restart: restarting', trigger: 'Same overlay as the update restart with shorter copy.', overlay: { title: 'Restarting your site', text: 'This takes about 10 seconds. This page reloads on its own when the site is back.', meta: 'Started at 09:51', barL: '30%' } }),
|
SU({ icon: 'circle-check', tone: 'success', title: '', text: '', btns: [] }, { name: 'Plain restart: restarting', trigger: 'Same overlay as the update restart with shorter copy.', overlay: { title: 'Restarting your site', text: 'This takes about 10 seconds. This page reloads on its own when the site is back.', meta: 'Started at 09:51', barL: '30%' } }),
|
||||||
SU({ icon: 'circle-check', tone: 'success', title: 'Your site is back', text: '', btns: [] }, { name: 'Plain restart: done', trigger: 'The page reloaded. Focus lands on the "Updates" heading.', toasts: [T('success', 'circle-check', 'Site restarted', 'Your site is running again.')] }),
|
SU({ icon: 'circle-check', tone: 'success', title: 'Your site is back', text: '', btns: [] }, { name: 'Plain restart: done', trigger: 'The page reloaded. Focus lands on the "Updates" heading.', toasts: [T('success', 'circle-check', 'Site restarted', 'Your site is running again.')] }),
|
||||||
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.', meta: LAST }, { noRet: true, name: 'Admin view', trigger: '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.' }),
|
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.', meta: LAST }, { noRet: true, clock: true, name: 'Admin view', trigger: '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.' }),
|
||||||
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.', meta: LAST }, { noRet: true, name: 'Role changed (403)', trigger: 'An owner was changed to admin while the page was open and pressed a button. The card reloads in the admin view.', toasts: [T('error', 'circle-x', 'Only the owner can do this', 'Ask the owner of this site to update RenewCMS.')] }),
|
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.', meta: LAST }, { noRet: true, clock: true, name: 'Role changed (403)', trigger: 'An owner was changed to admin while the page was open and pressed a button. The card reloads in the admin view.', toasts: [T('error', 'circle-x', 'Only the owner can do this', 'Ask the owner of this site to update RenewCMS.')] }),
|
||||||
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart.', meta: LAST, btns: [B('Update', 'primary', 'download', true)] }, { name: 'Update already running (409)', trigger: 'Another owner started it. The card switches to that run\'s progress.', navBadge: true, toasts: [T('info', 'info', 'An update is already running', 'Another owner started it. Progress shows here.')] })
|
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart.', meta: LAST, btns: [B('Update', 'primary', 'download', true)] }, { clock: true, name: 'Update already running (409)', trigger: 'Another owner started it. The card switches to that run\'s progress.', navBadge: true, toasts: [T('info', 'info', 'An update is already running', 'Another owner started it. Progress shows here.')] })
|
||||||
], tableTitle: 'Status to copy', table: [
|
], tableTitle: 'Status to copy', table: [
|
||||||
{ a: '200', b: 'Check, up to date', c: '"You\'re up to date" / "You have the latest version of RenewCMS." / "Last checked today at 09:41"' },
|
{ a: '200', b: 'Check, up to date', c: '"You\'re up to date" / "You have the latest version of RenewCMS." / "Last checked today at 09:41"' },
|
||||||
{ a: '200', b: 'Check, newer version', c: '"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."' },
|
{ a: '200', b: 'Check, newer version', c: '"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."' },
|
||||||
@@ -580,7 +601,7 @@ class Component extends DCLogic {
|
|||||||
im('I8', 'Snippet copied', 'Copy pressed on the snippet. The label reads Copied for 2 s and is announced politely.', { v: 'many', tab: 'js', copied: true }),
|
im('I8', 'Snippet copied', 'Copy pressed on the snippet. The label reads Copied for 2 s and is announced politely.', { v: 'many', tab: 'js', copied: true }),
|
||||||
im('I9', 'URL builder copied', 'Copy pressed on the built URL.', { v: 'many', variant: 'card', urlCopied: true })
|
im('I9', 'URL builder copied', 'Copy pressed on the built URL.', { v: 'many', variant: 'card', urlCopied: true })
|
||||||
] }
|
] }
|
||||||
return [...bo, imgG, navG, ...vg.map(adapt)]
|
return [...bo, ...this.dirGroups(bo), imgG, navG, ...vg.map(adapt)]
|
||||||
}
|
}
|
||||||
cell(t, i, o = {}) { return { t, fg: i === 0 && !o.plain ? 'var(--ui-text-highlighted)' : o.muted ? 'var(--ui-text-muted)' : 'var(--ui-text-toned)', fw: i === 0 && !o.plain ? 500 : 400, ff: o.mono ? "'IBM Plex Mono',monospace" : "'IBM Plex Sans',sans-serif", fs: o.mono ? '12px' : '13px' } }
|
cell(t, i, o = {}) { return { t, fg: i === 0 && !o.plain ? 'var(--ui-text-highlighted)' : o.muted ? 'var(--ui-text-muted)' : 'var(--ui-text-toned)', fw: i === 0 && !o.plain ? 500 : 400, ff: o.mono ? "'IBM Plex Mono',monospace" : "'IBM Plex Sans',sans-serif", fs: o.mono ? '12px' : '13px' } }
|
||||||
tbl(title, cols, tpl, rows, o = {}) { return { title, cols, tpl, rows: rows.map(r => r.map((t, i) => this.cell(t, i, { mono: o.mono && o.mono.includes(i), muted: t === '–' }))) } }
|
tbl(title, cols, tpl, rows, o = {}) { return { title, cols, tpl, rows: rows.map(r => r.map((t, i) => this.cell(t, i, { mono: o.mono && o.mono.includes(i), muted: t === '–' }))) } }
|
||||||
@@ -831,6 +852,281 @@ class Component extends DCLogic {
|
|||||||
{ label: 'Vocabulary and accessibility', title: 'Vocabulary and accessibility', note: 'Use "variant", "generated" and "purge" everywhere. Never "thumbnail preset", "version" or "rendition". Delete removes a variant definition; purge removes only its generated files. Every status is an icon plus text: Generated (circle-check), Not generated (circle-dashed), Generating (loader), Failed (circle-x), Enabled (circle-check), Disabled (circle-off). The comparison slider has a visible focus ring and works from the keyboard. Dialogs trap focus and return it to the opener. Progress is announced with aria-live="polite". Touch targets on mobile are at least 44 px. The only token not already in the system is --rc-on-sand (dark ink on the sand selection color), used for the active nav item in both themes.', tables: [] }
|
{ label: 'Vocabulary and accessibility', title: 'Vocabulary and accessibility', note: 'Use "variant", "generated" and "purge" everywhere. Never "thumbnail preset", "version" or "rendition". Delete removes a variant definition; purge removes only its generated files. Every status is an icon plus text: Generated (circle-check), Not generated (circle-dashed), Generating (loader), Failed (circle-x), Enabled (circle-check), Disabled (circle-off). The comparison slider has a visible focus ring and works from the keyboard. Dialogs trap focus and return it to the opener. Progress is announced with aria-live="polite". Touch targets on mobile are at least 44 px. The only token not already in the system is --rc-on-sand (dark ink on the sand selection color), used for the active nav item in both themes.', tables: [] }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
// DIR-START
|
||||||
|
dirDoc() {
|
||||||
|
const S = (name, trigger, copy, o, mo) => ({ name, trigger, copy, o, mo })
|
||||||
|
const G = (label, title, route, api, note, states) => ({ label, title, route, api, note, states })
|
||||||
|
const edT = 'How we price a bathroom renovation'
|
||||||
|
const fmtP = n => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||||
|
const PGS = (v, cfg) => {
|
||||||
|
const { path, noun, N, mid, qN, qText, big, bigPage, single, empty } = cfg, pages = Math.ceil(N / 25)
|
||||||
|
const o = (pg, x = {}) => v === 'sites' ? { v, sites: { state: 'ok' }, pg, ...x } : { v, pg, ...x }
|
||||||
|
const rng = p => 'Showing ' + ((p - 1) * 25 + 1) + '–' + Math.min(p * 25, N) + ' of ' + fmtP(N)
|
||||||
|
const bp = Math.ceil(big / 25), bm = bigPage
|
||||||
|
return [
|
||||||
|
S('First page', path + '. Previous and First are disabled. Mobile: Previous is disabled.', rng(1) + ' · 25 per page · Page 1 of ' + pages + ' · Previous · Next', o({ page: 1, per: 25, total: N })),
|
||||||
|
S('Middle page', path + '?page=' + mid + '. Current page uses --rc-sand and --rc-on-sand.', rng(mid) + ' · Page ' + mid + ' of ' + pages, o({ page: mid, per: 25, total: N })),
|
||||||
|
S('Last page', path + '?page=' + pages + '. Next and Last are disabled.', rng(pages) + ' · Page ' + pages + ' of ' + pages, o({ page: pages, per: 25, total: N })),
|
||||||
|
S('Single page', 'Everything fits on one page. The pager and the page-size select are hidden; only the count remains.', single + ' ' + noun + (single === 1 ? '' : 's'), o({ page: 1, per: 25, total: single })),
|
||||||
|
S('Page size menu', 'The select opens upward: 5, 25, 50 or 100 per page. 25 is the default. Choosing another size sets ?size= and returns to page 1.', '5 per page · 25 per page · 50 per page · 100 per page', o({ page: mid, per: 25, total: N, sizeOpen: true })),
|
||||||
|
S('Loading a page', 'Next was pressed. The rows are replaced by skeleton rows; the pager stays, disabled, and the summary reads "Loading page ' + (mid + 1) + '…".', 'Loading page ' + (mid + 1) + '…', o({ page: mid, per: 25, total: N, loading: mid + 1 })),
|
||||||
|
S('Error loading a page', '500 or network failure on a page change. The current rows and the current page number stay; Retry sits in the notice.', 'Couldn\'t load page ' + (mid + 1) + '. Showing page ' + mid + '. · Check your connection and try again. · Retry', o({ page: mid, per: 25, total: N, err: 'Couldn\'t load page ' + (mid + 1) + '. Showing page ' + mid + '.' })),
|
||||||
|
S('Empty after search', 'A search matched nothing. The empty state replaces the list and the pager is hidden.', 'No ' + noun + 's match "' + empty + '" · Check the spelling, or search for a shorter part of the name. · Clear search', o({ empty: true, total: 0 }, { q: empty })),
|
||||||
|
S('Search resets to page 1', 'Searching "' + qText + '" from page ' + (mid + 2) + ' goes to ' + path + '?q=' + qText + ' (page omitted). Changing the page size does the same.', rng(1).replace(' of ' + fmtP(N), ' of ' + qN), o({ page: 1, per: 25, total: qN }, { q: qText })),
|
||||||
|
S('Long labels on mobile', 'Large totals on a 390 px screen. The summary and the page-size select wrap onto two lines if needed; Previous, "Page ' + bm + ' of ' + bp + '" and Next keep one row.', 'Showing ' + ((bm - 1) * 25 + 1) + '–' + (bm * 25) + ' of ' + fmtP(big) + ' · Page ' + bm + ' of ' + bp, o({ page: bm, per: 25, total: big }))
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const sitesNote = 'Settings, Publishing group. Compact list; actions in the row menu. Default site: no delete action.'
|
||||||
|
return { groups: [
|
||||||
|
G('Sites', 'Sites', '/settings/sites', 'GET /v1/site/ · PATCH /v1/site/{id}/default · DELETE /v1/site/{id}?reassign_to={id}', '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. Choosing a site for an article is not site management: anyone who can edit the article can set its site in the editor and read the site 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.', [
|
||||||
|
S('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', { v: 'sites', sites: { state: 'ok' } }),
|
||||||
|
S('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.', { v: 'sites', sites: { state: 'ok', only: true } }),
|
||||||
|
S('Loading', 'The first load, or a retry. Skeleton rows appear after 150 ms.', '', { v: 'sites', sites: { state: 'loading' } }),
|
||||||
|
S('Error', 'A 500 or network failure on the list request.', 'Couldn\'t load sites · Check your connection and try again. · Try again', { v: 'sites', sites: { state: 'error' } }),
|
||||||
|
S('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…', { v: 'sites', sites: { state: 'ok', menu: 1 } }),
|
||||||
|
S('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.', { v: 'sites', sites: { state: 'ok', menu: 0 }, tip: true }),
|
||||||
|
S('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.', { v: 'sites', sites: { state: 'ok' }, modal: { k: 'setdef', i: 1 } }),
|
||||||
|
S('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', { v: 'adminSettings' }),
|
||||||
|
S('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', { v: 'sites', forbidden: true, role: 'admin', fab: false })
|
||||||
|
]),
|
||||||
|
G('Sites: form', 'Sites: add and edit', 'slide-over (desktop) · 92% bottom sheet (mobile)', 'POST /v1/site/ · PATCH /v1/site/{id}', '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."', [
|
||||||
|
S('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', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'add', focus: 'name' } }),
|
||||||
|
S('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.', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'add', focus: 'url', f: { name: 'Journal', key: 'journal', keyMode: 'auto' } } }),
|
||||||
|
S('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.', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'add', f: { name: 'Journal', key: 'journal', keyMode: 'auto', url: 'http://journal.renew.example' }, err: { url: 'Use https. For example https://blog.example.com.' } } }),
|
||||||
|
S('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', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'add', f: { name: 'Journal', key: 'journal', keyMode: 'manual', url: 'https://journal2.renew.example' }, err: { key: 'A site with the key "journal" already exists. Choose another key.', keySuggest: 'journal-2' } } }),
|
||||||
|
S('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.', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'add', notice: 'Couldn\'t save the site. Your changes are still here. Try again in a moment.', f: { name: 'Journal', key: 'journal', keyMode: 'auto', url: 'https://journal.renew.example' } } }),
|
||||||
|
S('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', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'edit', i: 0, f: { name: 'Renew', key: 'renew', keyMode: 'saved', url: 'https://renew.example', img: 'north-wall.jpg', loc: 'en' } } }),
|
||||||
|
S('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', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'edit', i: 2, f: { name: 'Projects', key: 'projects', keyMode: 'saved', url: 'https://projects.renew.example', img: 'kitchen-after.webp', robots: 'Noindex', loc: 'fr' } } })
|
||||||
|
]),
|
||||||
|
G('Sites: delete', 'Sites: delete', 'dialog (desktop) · bottom sheet (mobile)', 'DELETE /v1/site/{id}?reassign_to={id}', '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.', [
|
||||||
|
S('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', { v: 'sites', sites: { state: 'ok' }, modal: { k: 'del', i: 1 } }),
|
||||||
|
S('Choose destination', 'The select is open. The site being deleted is not listed.', 'Journal is not offered. Options show name and host.', { v: 'sites', sites: { state: 'ok' }, modal: { k: 'del', i: 1, open: true } }),
|
||||||
|
S('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', { v: 'sites', sites: { state: 'ok' }, modal: { k: 'del0', i: 3 } })
|
||||||
|
]),
|
||||||
|
G('Sites: pagination', 'Sites: pagination', '/settings/sites?page=2&size=50', 'GET /v1/site/?q=&page=&size= (total in the X-Total-Count header; array body unchanged)', 'Same Paginator as Articles and Media (components.md). Sizes 5, 25, 50, 100, default 25: the existing pattern, unchanged. Page and size in the URL as page and size; defaults omitted; a page past the end falls back to the last page. Search resets to page 1. The count line "N sites" replaces the pager on a single page. Sites has no search box today: it is added with the pager (same field as Authors, "Search by name or key") and is a proposal to confirm. The list is drawn with generated rows; 61 sites give 3 pages.', PGS('sites', { path: '/settings/sites', noun: 'site', N: 61, mid: 2, qN: 27, qText: 'renew', big: 312, bigPage: 6, single: 4, empty: 'blog' })),
|
||||||
|
G('Authors', 'Authors', '/authors', 'GET /v1/author/?q=&page=&size= · DELETE /v1/author/{id}?reassign_to={id|none}', '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 (not redrawn). List, panel and dialogs come from DirectoryFrame and are shared with Sites.', [
|
||||||
|
S('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', { v: 'authors' }),
|
||||||
|
S('Loading', 'The first load, or a retry.', '', { v: 'authors', list: 'loading' }),
|
||||||
|
S('Error', '500 or network failure.', 'Couldn\'t load authors · Check your connection and try again. · Try again', { v: 'authors', list: 'error' }),
|
||||||
|
S('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', { v: 'authors', list: 'empty' }),
|
||||||
|
S('No results', 'Search matched nothing.', 'No authors match "zoe" · Check the spelling, or search for a shorter part of the name. · Clear search', { v: 'authors', q: 'zoe' }),
|
||||||
|
S('Row menu', 'Owner or admin. The ellipsis button. Mobile: bottom sheet.', 'Edit · Delete…', { v: 'authors', menu: 2 }),
|
||||||
|
S('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', { v: 'authors', ro: true, role: 'user' }),
|
||||||
|
S('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', { v: 'authors', ro: true, role: 'user', panel: { k: 'authorView', i: 0 } })
|
||||||
|
]),
|
||||||
|
G('Authors: form', 'Authors: add and edit', 'slide-over (desktop) · 92% bottom sheet (mobile)', 'POST /v1/author/ · PATCH /v1/author/{id}', '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.', [
|
||||||
|
S('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', { v: 'authors', panel: { k: 'author', mode: 'add', focus: 'name' } }),
|
||||||
|
S('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.', { v: 'authors', panel: { k: 'author', mode: 'add', focus: 'bio', f: { name: 'Camille Roche', slug: 'camille-roche', slugMode: 'auto', bio: 'Freelance writer on lime and stone.' } } }),
|
||||||
|
S('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', { v: 'authors', panel: { k: 'author', mode: 'add', f: { name: 'Camille Roche', slug: 'camille-roche', slugMode: 'manual' }, err: { slug: 'The slug "camille-roche" is already used by another author.', suggest: 'camille-roche-2' } } }),
|
||||||
|
S('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://.', { v: 'authors', panel: { k: 'author', mode: 'add', f: { name: '', bio: 'x'.repeat(291), web: 'example', slugMode: 'empty' }, err: { name: 'Enter a name.', bio: 'Shorten the bio to 280 characters.', web: 'Enter a full URL starting with https://.' } } }),
|
||||||
|
S('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.', { v: 'authors', panel: { k: 'author', mode: 'edit', i: 2, f: { name: 'Camille Roche', slug: 'camille-roche', slugMode: 'saved', bio: 'Freelance writer on lime and stone.', links: [{ t: 'Mastodon', v: 'https://mastodon.social/@camille' }, { t: 'LinkedIn', v: 'linkedin.com/in/camille', err: 'Enter a full URL starting with https://.' }, { t: 'Custom', v: '', focus: true }] } } }),
|
||||||
|
S('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', { v: 'authors', panel: { k: 'author', mode: 'edit', i: 2, f: { name: 'Camille Roche', slug: 'camille-roche', slugMode: 'saved', uopen: true } } }),
|
||||||
|
S('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', { v: 'authors', panel: { k: 'author', mode: 'edit', i: 0, f: { av: 'ines.jpg', name: 'Inès Duarte', slug: 'ines-duarte', slugMode: 'saved', bio: 'Site manager and restoration carpenter. Writes about timber and joinery.', web: 'https://ines-duarte.example', links: [{ t: 'LinkedIn', v: 'https://www.linkedin.com/in/ines-duarte' }], user: 'ines.duarte@renew.example' } } }),
|
||||||
|
S('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', { v: 'authors', panel: { k: 'author', mode: 'edit', i: 2, f: { name: 'Camille Roche', slug: 'camille-roche', slugMode: 'saved' } }, modal: { k: 'pick', n: 'Camille Roche', sel: 1 } }),
|
||||||
|
S('Save failed', '500 or network failure.', 'Couldn\'t save the author. Your changes are still here. Try again in a moment.', { v: 'authors', panel: { k: 'author', mode: 'edit', i: 2, notice: 'Couldn\'t save the author. Your changes are still here. Try again in a moment.', f: { name: 'Camille Roche', slug: 'camille-roche', slugMode: 'saved', bio: 'Freelance writer on lime and stone.' } } }),
|
||||||
|
S('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', { v: 'authors', panel: { k: 'author', mode: 'edit', i: 0, f: { av: 'ines.jpg', name: 'Inès Duarte', slug: 'ines-duarte', slugMode: 'saved', user: 'ines.duarte@renew.example' } }, modal: { k: 'unlink', i: 0 } }),
|
||||||
|
S('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', { v: 'authors', modal: { k: 'relink', i: 2 } })
|
||||||
|
]),
|
||||||
|
G('Authors: delete', 'Authors: delete', 'dialog (desktop) · bottom sheet (mobile)', 'DELETE /v1/author/{id}?reassign_to={id|none}', '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.', [
|
||||||
|
S('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', { v: 'authors', modal: { k: 'del', i: 2 } }),
|
||||||
|
S('Choose who', 'The select is open. The deleted author is not listed.', 'Nobody · Remove the byline', { v: 'authors', modal: { k: 'del', i: 2, open: true } }),
|
||||||
|
S('Nobody', '"Nobody" chosen.', 'The articles keep any other authors. Articles with only this author will have no byline.', { v: 'authors', modal: { k: 'del', i: 2, none: true } }),
|
||||||
|
S('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.', { v: 'authors', modal: { k: 'del', i: 2, notice: true } }),
|
||||||
|
S('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', { v: 'authors', modal: { k: 'blocked', i: 2 } }),
|
||||||
|
S('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', { v: 'authors', modal: { k: 'del0', i: 4 } })
|
||||||
|
]),
|
||||||
|
G('Authors: pagination', 'Authors: pagination', '/authors?page=3&size=50', 'GET /v1/author/?q=&page=&size= (total in the X-Total-Count header; array body unchanged)', 'Same Paginator as Articles and Media (components.md). Sizes 5, 25, 50, 100, default 25: the existing pattern, unchanged. Page and size in the URL as page and size; defaults omitted; a page past the end falls back to the last page. Search resets to page 1. The count line "N authors" replaces the pager on a single page. The list is drawn with generated rows; 128 authors give 6 pages.', PGS('authors', { path: '/authors', noun: 'author', N: 128, mid: 3, qN: 31, qText: 'mar', big: 1284, bigPage: 12, single: 6, empty: 'zoe' })),
|
||||||
|
G('Editor: site and authors', 'Editor: site and authors', '/article/:id/edit', '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.', [
|
||||||
|
S('Open: one site, one author', 'The section was opened. No Site select.', 'Authors · Add an author', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'] } }),
|
||||||
|
S('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', { v: 'editor', ed: { metaSummary: 'Journal · Inès Duarte' }, metaOpen: true, meta: { sites: true, site: 'Journal', authors: ['Inès Duarte'] } }),
|
||||||
|
S('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', { v: 'editor', metaOpen: true, meta: { sites: true, site: 'Journal', open: 'site', authors: ['Inès Duarte'] } }),
|
||||||
|
S('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.', { v: 'editor', metaOpen: true, meta: { hint: true, authors: [] } }),
|
||||||
|
S('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', { v: 'editor', ed: { metaSummary: 'Inès Duarte +3' }, metaOpen: true, meta: { authors: ['Inès Duarte', 'Jonas Pereira', 'Dr. Alexandra Konstantinopoulou-Whitfield', 'Paul Huber'] } }),
|
||||||
|
S('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…', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'], open: 'authors' } }),
|
||||||
|
S('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"…', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'], open: 'authors', q: 'Zoe Lambert' } }),
|
||||||
|
S('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', { v: 'editor', role: 'user', metaOpen: true, meta: { authors: ['Inès Duarte'], open: 'authors', noCreate: true } }),
|
||||||
|
S('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".', { v: 'editor', role: 'user', metaOpen: true, meta: { authors: ['Inès Duarte'], open: 'authors', q: 'Zoe Lambert', noCreate: true } }),
|
||||||
|
S('Add author: error', 'GET /v1/author/ failed. The article keeps its authors.', 'Couldn\'t load authors · Check your connection and try again. · Try again', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'], open: 'authors', state: 'error' } }),
|
||||||
|
S('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.', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche'], grab: 1 } }),
|
||||||
|
S('Reorder: pointer', 'Dragging a row. The row lifts and a line shows the drop position.', '', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche'], drag: 2, drop: 0 } }),
|
||||||
|
S('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', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'] }, modal: { name: 'Zoe Lambert', slug: 'zoe-lambert' } }),
|
||||||
|
S('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.', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'], hint: true }, modal: { name: 'Marta Alves', slug: 'marta-alves', me: true } }),
|
||||||
|
S('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', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'] }, modal: { name: 'Zoe Lambert', slug: 'zoe-lambert', err: true, slugErr: 'The slug "zoe-lambert" is already used by another author.', suggest: 'zoe-lambert-2' } }),
|
||||||
|
S('Create author: failed', '500 or network failure.', 'Couldn\'t create the author · The article is unchanged. Try again in a moment.', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'] }, modal: { name: 'Zoe Lambert', slug: 'zoe-lambert', notice: true } })
|
||||||
|
]),
|
||||||
|
G('Profile: author profile', 'Profile: author profile', '/profile', '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.', [
|
||||||
|
S('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.', { v: 'profile', pa: { k: 'none' } }),
|
||||||
|
S('Error', '500 or network failure.', 'Couldn\'t create your author profile · Try again in a moment.', { v: 'profile', pa: { k: 'none', err: true } }),
|
||||||
|
S('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.', { v: 'profile', pa: { k: 'has' }, toasts: [['success', 'circle-check', 'Author profile created', 'Marta Alves is now your byline.']] }),
|
||||||
|
S('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.', { v: 'profile', pa: { k: 'has', slug: 'marta-alves-2' } }),
|
||||||
|
S('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', { v: 'profile', role: 'user', pa: { k: 'has' }, panel: { k: 'author', mode: 'edit', own: true, f: { name: 'Marta Alves', slug: 'marta-alves', slugMode: 'saved', bio: 'Project coordinator. Writes about permits and planning.', user: 'marta@renew.example' } } })
|
||||||
|
]),
|
||||||
|
G('Articles: sites and authors', 'Articles: sites and authors', '/articles?site=journal', '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.', [
|
||||||
|
S('Several sites', 'More than one site exists.', 'Authors · Site · All sites · Renew · Journal · Projects', { v: 'app', list: { multi: true } }),
|
||||||
|
S('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', { v: 'app', list: { multi: true, siteMenu: true } }),
|
||||||
|
S('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', { v: 'app', list: { multi: true, site: 'Archive', counts: [0, 0, 0], empty: { icon: 'file-text', title: 'No articles on "Archive"', text: 'Move articles here from the editor, or choose another site.', action: 'Show all sites' } } })
|
||||||
|
]),
|
||||||
|
G('Articles: search status', 'Articles: search status', '/articles?seo=suggestions', '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.', [
|
||||||
|
S('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)', { v: 'app', list: {} }),
|
||||||
|
S('Needs attention on', 'The toggle is pressed. The list shows only articles with suggestions. Hidden-only articles are not included.', 'Needs attention · 3', { v: 'app', list: { needs: true } }),
|
||||||
|
S('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', { v: 'app', list: { needs: true, needsN: 0, counts: [0, 0, 0], empty: { icon: 'circle-check', title: 'Nothing needs attention', text: 'Every article has a clean check. Suggestions are optional, so this is just a bonus.', action: 'Show all articles' } } })
|
||||||
|
]),
|
||||||
|
G('Editor: search and sharing', 'Editor: search and sharing', '/article/:id/edit', '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.', [
|
||||||
|
S('Collapsed: ready', 'The default. Nothing to suggest.', 'Search and sharing · Ready', { v: 'editor', ed: { seoSummary: 'Ready' } }),
|
||||||
|
S('Collapsed: suggestions', 'Summary in the warning colour. No count badge elsewhere.', 'Search and sharing · 2 suggestions', { v: 'editor', ed: { seoSummary: '2 suggestions' } }),
|
||||||
|
S('Collapsed: hidden', 'noindex is on.', 'Search and sharing · Hidden from search', { v: 'editor', ed: { seoSummary: 'Hidden from search' } }),
|
||||||
|
S('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', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] } } }),
|
||||||
|
S('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', { v: 'editor', ed: { seoSummary: '4 suggestions' }, seo: { side: { codes: ['title_long', 'cover_alt', 'no_h', 'short'] } } }),
|
||||||
|
S('Open: checking', 'While the checks run after the editor loads. The rest of the section stays usable.', 'Checking this article…', { v: 'editor', ed: { seoSummary: 'Checking…' }, seo: { side: { state: 'loading' } } }),
|
||||||
|
S('Open: hidden', 'A line explains the flag.', 'Hidden from search engines. This only sets a flag in the API.', { v: 'editor', ed: { seoSummary: 'Hidden from search' }, seo: { side: { codes: [], hidden: true } } }),
|
||||||
|
S('Open: other site', 'The address uses the article\'s site.', 'journal.renew.example/how-we-price-a-bathroom-renovation', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [], site: 1 } } }),
|
||||||
|
S('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', { v: 'editor', ed: { seoSummary: '1 suggestion' }, seo: { panel: { codes: ['cover_none'] } } }),
|
||||||
|
S('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.', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { mt: 'What a bathroom renovation costs', md: 'A bathroom renovation is priced by room size, plumbing changes and finish level. Here is how we build a quote.', ex: 'How we price a bathroom renovation, from the survey to the final invoice.', cover: { alt: 'A tiled bathroom wall during renovation' }, codes: [] } } }),
|
||||||
|
S('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.', { v: 'editor', ed: { seoSummary: '2 suggestions' }, seo: { panel: { mt: 'How we price a bathroom renovation: surveys, plumbing, tiling and finishes', md: 'What a bathroom renovation costs.', codes: ['title_long', 'desc_short'] } } }),
|
||||||
|
S('Panel: slug edited', 'The Auto badge is gone. "Reset to match the title" brings it back.', 'Reset to match the title', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { slug: 'bathroom-costs', slugMode: 'manual', codes: [] } } }),
|
||||||
|
S('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', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { slug: 'bathroom-costs', slugMode: 'taken', codes: [] } } }),
|
||||||
|
S('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.', { v: 'editor', ed: { seoSummary: '1 suggestion' }, seo: { panel: { cover: { alt: '' }, codes: ['cover_alt'] } } }),
|
||||||
|
S('Panel: choose cover', 'The shared Media picker.', 'Choose a cover image · The cover for this article. · Use image', { v: 'editor', ed: { seoSummary: '1 suggestion' }, seo: { panel: { codes: ['cover_none'] }, pick: true } }),
|
||||||
|
S('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.', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { social: true, cover: { alt: 'A tiled bathroom wall during renovation' }, codes: [] } } }, { seo: { panel: { social: true, scrollY: 560, cover: { alt: 'A tiled bathroom wall during renovation' }, codes: [] } } }),
|
||||||
|
S('Panel: social customised', 'The card shows the overrides, so the Using chips disappear.', 'Customised', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { social: true, soc: { t: 'Bathroom costs, explained', d: 'A plain guide to what drives the price.', img: 'kitchen-after.webp' }, codes: [] } } }, { seo: { panel: { social: true, scrollY: 560, soc: { t: 'Bathroom costs, explained', d: 'A plain guide to what drives the price.', img: 'kitchen-after.webp' }, codes: [] } } }),
|
||||||
|
S('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://', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { adv: true, canon: 'renew.example/costs', err: { canon: 'Enter a full address starting with https://' }, focus: 'canon', codes: [] } } }, { seo: { panel: { adv: true, scrollY: 640, canon: 'renew.example/costs', err: { canon: 'Enter a full address starting with https://' }, focus: 'canon', codes: [] } } }),
|
||||||
|
S('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.', { v: 'editor', ed: { seoSummary: 'Hidden from search' }, seo: { panel: { noindex: true, codes: [] } } }, { seo: { panel: { noindex: true, scrollY: 700, codes: [] } } }),
|
||||||
|
S('Panel: mobile search preview', 'The Desktop and Mobile toggle changes the width and where the title is cut.', 'Search result · Desktop · Mobile', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { dev: 'mobile', mt: 'How we price a bathroom renovation: surveys, plumbing, tiling and finishes', codes: ['title_long'] } } }),
|
||||||
|
S('Panel: checking', 'Checks run on open and after each pause in typing.', 'Checking this article…', { v: 'editor', ed: { seoSummary: 'Checking…' }, seo: { panel: { sugState: 'loading' } } }),
|
||||||
|
S('Panel: saving', 'Fields are disabled while the request runs.', 'Saving…', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { state: 'saving', mt: 'What a bathroom renovation costs', codes: [] } } }),
|
||||||
|
S('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.', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { notice: 'Couldn\'t save search and sharing. Your changes are still here. Try again in a moment.', mt: 'What a bathroom renovation costs', codes: [] } } }),
|
||||||
|
S('Saved', 'The panel closes. The summary updates.', 'Search and sharing saved · Your search and sharing settings are up to date.', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] } }, toasts: [['success', 'circle-check', 'Search and sharing saved', 'Your search and sharing settings are up to date.']] })
|
||||||
|
]),
|
||||||
|
G('Editor: what the frontend gets', 'Editor: what the frontend gets', '/article/:id/edit', '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.', [
|
||||||
|
S('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', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: {} } }),
|
||||||
|
S('Raw JSON', 'The JSON as the API returns it.', 'Raw JSON', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { view: 'raw' } } }),
|
||||||
|
S('Copied', 'The button reads Copied for 2 s and this is announced.', 'Copied', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { view: 'raw', copied: true } } }),
|
||||||
|
S('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.', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { draft: true } } }),
|
||||||
|
S('Loading', 'Copy is disabled.', '', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { state: 'loading' } } }),
|
||||||
|
S('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', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { state: 'error' } } })
|
||||||
|
]),
|
||||||
|
G('Sites: SEO defaults', 'Sites: SEO defaults', '/settings/sites', '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).', [
|
||||||
|
S('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.', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'edit', i: 0, f: { name: 'Renew', key: 'renew', keyMode: 'saved', url: 'https://renew.example', img: 'north-wall.jpg', loc: 'en', ddesc: 'Practical advice on renovating old buildings, from the people who do the work.' } } }),
|
||||||
|
S('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.', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'add', f: { name: 'Journal', key: 'journal', keyMode: 'auto', url: 'https://journal.renew.example' } } }),
|
||||||
|
S('Template: custom', 'The example updates as the owner types.', 'Example · How we price a bathroom renovation – Journal', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'edit', i: 1, focus: 'tpl', f: { name: 'Journal', key: 'journal', keyMode: 'saved', url: 'https://journal.renew.example', sname: 'Journal', tpl: '%title% – %site%' } } }),
|
||||||
|
S('Template: not valid', 'The template needs %title%. Nothing is sent.', 'The template must contain %title%.', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'edit', i: 1, focus: 'tpl', err: { tpl: 'The template must contain %title%.' }, f: { name: 'Journal', key: 'journal', keyMode: 'saved', url: 'https://journal.renew.example', sname: 'Journal', tpl: '%site%' } } }),
|
||||||
|
S('Save failed', '500 or network failure.', 'Couldn\'t save the site. Your changes are still here. Try again in a moment.', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'edit', i: 0, notice: 'Couldn\'t save the site. Your changes are still here. Try again in a moment.', f: { name: 'Renew', key: 'renew', keyMode: 'saved', url: 'https://renew.example', img: 'north-wall.jpg', loc: 'en' } } })
|
||||||
|
])
|
||||||
|
],
|
||||||
|
specs: [
|
||||||
|
{ label: 'Sites and authors: decisions', title: 'Sites and authors: decisions to confirm', note: 'Only choices that change behaviour or the API. Rows marked Decided were answered in the revision brief and are no longer questions.', tables: [{ cols: ['#', 'Decision', 'Status', 'To confirm'], tpl: '40px minmax(0,1.8fr) 90px minmax(0,1.6fr)', rows: [
|
||||||
|
['1', 'Sites: only the owner can create, edit, set the default or delete. Other roles don\'t see the Sites entry; a direct link shows a 403 page. Anyone who can edit an article can still set its site and read the site list.', 'Decided', 'Roles, from the brief.'],
|
||||||
|
['2', 'Authors: owner and admin manage all authors. Other roles see the list and details read-only, pick authors in the editor, and can create and edit their own author profile.', 'Decided', 'Roles, from the brief.'],
|
||||||
|
['3', 'A site key is set when the site is created and can\'t be edited. The edit form shows it read-only.', 'Decided', 'Used in API paths. PATCH rejects a changed key.'],
|
||||||
|
['4', 'An article may have zero authors. Nothing blocks publishing.', 'Decided', 'Zero authors allowed.'],
|
||||||
|
['5', 'Deleting an author asks who gets the articles, with "Nobody" as the last option.', 'Decided', 'Reassign on delete.'],
|
||||||
|
['6', 'With one site, the editor hides the Site select and the list hides the Site column and filter.', 'Open', 'Hide in the editor too, or only in the list.'],
|
||||||
|
['7', 'Deleting a site with articles requires a destination, preselected as the default site. The default site can\'t be deleted.', 'Open', 'API: DELETE needs reassign_to when article_count is above 0.'],
|
||||||
|
['8', 'A user has at most one author and an author at most one user. Only accepted users without an author are listed. Unlinking keeps the author and its articles.', 'Open', 'API: unique link. Unlink is PATCH with user_id null.'],
|
||||||
|
['9', '"Create my author profile" never fails on a taken slug. The server adds a number and the card says so.', 'Open', 'API: the POST /v1/author/me response carries the final slug.'],
|
||||||
|
['10', 'Article counts on sites and authors include drafts.', 'Open', 'API: counts guard deletion, so they must be complete.'],
|
||||||
|
['11', 'Moving articles between sites changes their canonical host at once. No redirects are created.', 'Open', 'Behaviour. Redirects belong to a later slice.'],
|
||||||
|
['12', 'Limits: bio 280 characters, 5 social links, name 100 characters, locale as a language tag.', 'Open', 'API validation.'],
|
||||||
|
['13', 'The first site is seeded from PUBLIC_URL and named from its host.', 'Open', 'Migration: the seed name.']
|
||||||
|
] }] },
|
||||||
|
{ label: 'Sites and authors: deviations', title: 'Sites and authors: deviations from existing patterns', note: 'Where the design departs from a pattern already on the board, and why.', tables: [{ cols: ['Pattern', 'Existing', 'Here', 'Why'], tpl: 'minmax(0,1fr) minmax(0,1.2fr) minmax(0,1.4fr) minmax(0,1.4fr)', rows: [
|
||||||
|
['Settings children', 'One group, "Media", with Variants and Cache', 'A second group, "Publishing", with Sites', 'Sites is not a media setting'],
|
||||||
|
['Slide-over', 'Variant form and record details only', 'Site and author forms', 'Both have a live preview'],
|
||||||
|
['Delete dialog', 'Text and two buttons', 'A select for the destination, plus a count in the button', 'Articles must not be orphaned'],
|
||||||
|
['Media picker', 'Title "Add an image", inserts into text, has an alt text step', 'Title "Choose an image", button "Use image", no alt text', 'The image is a field value, not content'],
|
||||||
|
['Row menu on a disabled action', 'Actions are hidden when not allowed', 'Delete stays visible, aria-disabled, with an explanation', 'The brief asks for an explanatory tooltip. Touch shows the text'],
|
||||||
|
['Chips', 'No existing chip control', 'A vertical list of rows, not wrapped inline chips', 'Reordering needs one axis and 44 px targets on touch'],
|
||||||
|
['Editor sidebar', 'Static Details list', 'A collapsible section above Details', 'Holds the only new editor fields'],
|
||||||
|
['Role-restricted entries', 'Pages are shown or hidden by role', 'The Sites entry is removed for non-owners, and /settings/sites shows a 403 page', 'No dead entries, and a direct link still gets an answer'],
|
||||||
|
['Counter', 'Title field shows "n / 200"', 'Bio shows "n / 280", red above the limit', 'Same counter, same colours']
|
||||||
|
] }] },
|
||||||
|
{ label: 'Sites and authors: API and errors', title: 'Sites and authors: API and HTTP statuses', note: 'Rows for states.md. Inline errors sit under the named field; dialogs show the notice inside.', tables: [{ cols: ['Status', 'Code', 'Copy', 'Where'], tpl: '70px minmax(0,1fr) minmax(0,2fr) minmax(0,1fr)', rows: [
|
||||||
|
['400', 'invalid_site_url', '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.', 'Inline under Base URL'],
|
||||||
|
['409', 'site_key_taken', 'A site with the key "journal" already exists. Choose another key.', 'Inline under Key, with a suggestion'],
|
||||||
|
['409', 'site_has_articles', 'Choose where the articles go. This site has 38 articles.', 'Inside the delete dialog'],
|
||||||
|
['403', 'default_site', 'The default site can\'t be deleted. Make another site the default first.', 'Toast if the menu was stale'],
|
||||||
|
['403', 'owner_only', 'You don\'t have access to Sites · Only the owner can manage sites. Ask the owner of this site if you need a change.', '403 page on /settings/sites'],
|
||||||
|
['403', 'owner_only (action)', 'Only the owner can manage sites · Ask the owner of this site.', 'Error toast if the role changed while the page was open'],
|
||||||
|
['403', 'author_rights', 'Only an owner or admin can change authors · You can still edit your own author profile.', 'Error toast if the role changed while the page was open'],
|
||||||
|
['400', 'key_immutable', 'Used in API paths, so it can\'t change.', 'Not shown: the edit form has no editable key'],
|
||||||
|
['400', 'invalid_locale', 'Use a language tag like en or fr-CH.', 'Inline under Locale'],
|
||||||
|
['404', 'site_not_found', 'That site no longer exists · It was deleted somewhere else, so we removed it from the list.', 'Info toast'],
|
||||||
|
['409', 'author_slug_taken', 'The slug "camille-roche" is already used by another author.', 'Inline under Slug, with a suggestion'],
|
||||||
|
['409', 'author_has_articles', '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.', 'Blocked dialog'],
|
||||||
|
['409', 'user_already_linked', 'That user already has an author. Choose another user.', 'Inline under Linked user'],
|
||||||
|
['422', 'bio_too_long', 'Shorten the bio to 280 characters.', 'Inline under Bio'],
|
||||||
|
['422', 'invalid_url', 'Enter a full URL starting with https://.', 'Inline under Website and each social link'],
|
||||||
|
['404', 'author_not_found', 'That author no longer exists · It was deleted somewhere else, so we removed it from the list.', 'Info toast'],
|
||||||
|
['500', '–', 'Couldn\'t save the site. Your changes are still here. Try again in a moment. · Couldn\'t save the author. Your changes are still here. Try again in a moment. · Couldn\'t create the author · The article is unchanged. Try again in a moment.', 'Notice inside the panel or dialog']
|
||||||
|
] }, { cols: ['Method and path', 'Used by', 'Notes'], tpl: 'minmax(0,1.3fr) minmax(0,1fr) minmax(0,1.6fr)', rows: [
|
||||||
|
['GET /v1/site/', 'Sites, Site label, filter', 'Any role that edits articles. Returns article_count per site'],
|
||||||
|
['POST /v1/site/', 'Add site', 'Owner only. name, key, base_url, social_image_id, robots, locale, is_default'],
|
||||||
|
['PATCH /v1/site/{id}', 'Edit site, set as default', 'Owner only. The key can\'t be changed. is_default true moves the flag'],
|
||||||
|
['DELETE /v1/site/{id}?reassign_to={id}', 'Delete site', 'Owner only. reassign_to required when article_count > 0'],
|
||||||
|
['GET /v1/author/?q=&page=&size=', 'Authors, Add author combobox', 'Any role. Total in X-Total-Count, as for articles'],
|
||||||
|
['POST /v1/author/ · PATCH · DELETE ?reassign_to={id|none}', 'Authors, editor dialog', 'Owner and admin. none removes the byline'],
|
||||||
|
['POST /v1/author/me', 'Create my author profile', 'Any role. Name from the display name, linked to the user. A user can also PATCH their own linked author'],
|
||||||
|
['PATCH /v1/article/{id}', 'Editor', 'site_id and author_ids[] in order, any role that edits the article; is_online is untouched']
|
||||||
|
] }] },
|
||||||
|
{ label: 'Sites and authors: components', title: 'Sites and authors: components (Nuxt UI v4)', note: 'New components use existing tokens only.', tables: [{ cols: ['Component', 'Status', 'Nuxt UI', 'Notes'], tpl: 'minmax(0,1fr) 80px minmax(0,1.4fr) minmax(0,1.8fr)', rows: [
|
||||||
|
['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'],
|
||||||
|
['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 with a lock 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'],
|
||||||
|
['AuthorProfileCard', 'New', 'UCard + UButton + UAlert', 'Profile screen'],
|
||||||
|
['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']
|
||||||
|
] }] },
|
||||||
|
{ label: 'Sites and authors: accessibility', title: 'Sites and authors: keyboard and screen reader', note: 'Applies to the reorderable author list and to the media picker used by both forms.', tables: [{ cols: ['Control', 'Keyboard', 'Screen reader'], tpl: 'minmax(0,0.8fr) minmax(0,1.6fr) minmax(0,1.8fr)', rows: [
|
||||||
|
['Author list, handle', 'Tab reaches each handle. Space or Enter grabs. Up and Down move one place; Home and End move to the first and last place. Space or Enter drops. Escape cancels and restores the order.', 'The handle is a button: "Reorder Inès Duarte, position 1 of 3". The list has aria-describedby text "Drag to reorder. The first author is the primary byline." A polite live region announces: "Jonas Pereira grabbed. Position 2 of 3. Use the up and down arrow keys to move, Space to drop, Escape to cancel." Then "Jonas Pereira moved to position 1 of 3.", "Jonas Pereira dropped at position 1 of 3." or "Reorder cancelled. Jonas Pereira is back at position 2 of 3."'],
|
||||||
|
['Author list, remove', 'Tab, then Enter or Space. Focus moves to the next row\'s handle, or to the Add an author field after the last row.', 'Button "Remove Camille Roche from this article". Live region: "Camille Roche removed. 2 authors."'],
|
||||||
|
['Author list, pointer and touch', 'Pointer: drag the handle after 4 px. Touch: press and hold the 44 px handle for 250 ms, then drag.', 'Same live region messages as the keyboard.'],
|
||||||
|
['Add an author', 'Down arrow or typing opens the list. Up and Down move, Enter adds or removes, Escape closes and returns focus to the field. "Create author…" is the last option.', 'role=combobox, aria-expanded, aria-controls, aria-multiselectable. Selected options have aria-selected. Live region: "Inès Duarte added. 2 authors."'],
|
||||||
|
['Media picker', 'Focus moves into the dialog and is trapped. Tabs use arrow keys. Tiles form one radio group: arrow keys move, Space selects. "Use image" is aria-disabled until a tile is selected. Escape closes and focus returns to "Choose image" or "Change image".', 'Each tile reads "north-wall.jpg, 3.9 MB". After "Use image" a polite message reads "Image selected: north-wall.jpg", and focus lands on "Change image".'],
|
||||||
|
['Slug field', '"Edit slug" moves focus into the input. "Reset to match the name" keeps focus on itself.', 'The input has aria-invalid and aria-describedby for the error. Reset announces "Slug reset to camille-roche".'],
|
||||||
|
['Section header', 'Enter or Space toggles.', 'Button with aria-expanded and aria-controls.'],
|
||||||
|
['Disabled delete', 'Stays in the tab order of the menu.', 'aria-disabled, with aria-describedby pointing to the explanation.']
|
||||||
|
] }] }
|
||||||
|
] }
|
||||||
|
}
|
||||||
|
dirGroups(bo) {
|
||||||
|
const doc = this.dirDoc()
|
||||||
|
const rowsA = ['How we price a bathroom renovation', 'Choosing lime plaster for old stone walls', 'Before and after: the Rue Verte kitchen', 'Winter checklist for timber windows', 'Why we reuse original floorboards', 'Our first passive-house retrofit']
|
||||||
|
const meta = [[true, '12 Mar 2026', '2 hours ago', ['Inès Duarte'], 'Journal'], [false, '9 Sep 2026', 'Yesterday', ['Inès Duarte', 'Camille Roche'], 'Renew'], [true, '28 Aug 2026', '3 days ago', ['Jonas Pereira'], 'Projects'], [false, '2 Sep 2026', '5 days ago', ['Inès Duarte', 'Jonas Pereira', 'Camille Roche', 'Paul Huber', 'Léa Fontaine'], 'Journal'], [true, '14 Jun 2026', '18 Aug 2026', ['Paul Huber'], 'Renew'], [true, '3 Apr 2026', '2 Jul 2026', [], 'Journal']]
|
||||||
|
const rows = meta.map((x, i) => ({ t: rowsA[i], on: x[0], c: x[1], u: x[2], au: x[3], site: x[4], seo: [0, 2, 'hidden', 3, 1, 0][i] }))
|
||||||
|
const edShell = ed => ({ kind: 'editor', nav: 'articles', crumbs: ['Articles', 'How we price a bathroom renovation'], mTitle: 'Edit article', mLead: 'arrow-left', mAction: 'ellipsis', ed: { status: 'draft', save: 'saved', sheet: true, ...ed } })
|
||||||
|
const pfShell = { kind: 'profile', nav: 'profile', crumbs: ['Profile'], mTitle: 'Profile', pf: {} }
|
||||||
|
return doc.groups.map(g => ({ label: g.label, title: g.title, route: g.route, api: g.api, note: g.note, states: g.states.map(st => {
|
||||||
|
const o = st.o
|
||||||
|
if (o.v === 'adminSettings') { const src = bo.flatMap(x => x.states).find(x => x.name === 'Admin view'); const ch = { name: st.name, trigger: st.trigger, role: 'admin', sideOpen: true }; return { ...src, ...ch, m: { ...src.m, ...ch } } }
|
||||||
|
if (o.v === 'app') {
|
||||||
|
const L = { ...o.list }
|
||||||
|
if (!L.empty && !L.loading) L.rows = L.only ? rows.filter(r => r.site === L.only) : rows
|
||||||
|
if (L.needs && !L.empty) L.rows = rows.filter(r => r.seo > 0)
|
||||||
|
const base = { kind: 'articles', nav: 'articles', crumbs: ['Articles'], mTitle: 'Articles', list: L }
|
||||||
|
return { notMedia: true, name: st.name, trigger: st.trigger, ...base, m: { ...base } }
|
||||||
|
}
|
||||||
|
let s = { ...o }
|
||||||
|
if (o.v === 'editor') s.shell = { ...edShell(o.ed), role: o.role }
|
||||||
|
if (o.v === 'profile') s.shell = { ...pfShell, pa: o.pa, role: o.role }
|
||||||
|
const x = { isDir: true, notMedia: false, name: st.name, trigger: st.trigger, ...s }
|
||||||
|
return { ...x, m: { ...x, ...(st.mo || {}) } }
|
||||||
|
}) }))
|
||||||
|
}
|
||||||
|
dirSpecs() { return this.dirDoc().specs.map(sp => ({ label: sp.label, title: sp.title, note: sp.note, tables: sp.tables.map(t => ({ cols: t.cols, tpl: t.tpl, rows: t.rows.map(r => r.map((x, i) => this.cell(x, i, { muted: x === '–' }))) })) })) }
|
||||||
|
dirCopy() { return this.dirDoc().groups.flatMap(g => g.states.filter(s => s.copy).map(s => [g.title + ': ' + s.name, s.copy])) }
|
||||||
|
// DIR-END
|
||||||
finish(gs) { return gs.map(g => ({ ...g, states: g.states.map(st => ({ notMedia: !st.isMedia && !st.isAct, ...st, m: { ...st.m } })) })) }
|
finish(gs) { return gs.map(g => ({ ...g, states: g.states.map(st => ({ notMedia: !st.isMedia && !st.isAct, ...st, m: { ...st.m } })) })) }
|
||||||
renderVals() {
|
renderVals() {
|
||||||
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => {
|
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => {
|
||||||
@@ -838,7 +1134,7 @@ class Component extends DCLogic {
|
|||||||
return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) }
|
return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) }
|
||||||
})
|
})
|
||||||
const rows = a => a.map(r => ({ a: r[0], b: r[1] }))
|
const rows = a => a.map(r => ({ a: r[0], b: r[1] }))
|
||||||
return { rootRef: this.rootRef, modes, groups: this.groups(), specs: this.varSpecs(), spCopy: rows(this.state.spec.copy), spComps: rows(this.state.spec.comps), spApi: rows(this.state.spec.api) }
|
return { rootRef: this.rootRef, modes, groups: this.groups(), specs: [...this.varSpecs(), ...this.dirSpecs()], dirCopy: this.dirCopy().map(r => ({ a: r[0], b: r[1] })), spCopy: rows(this.state.spec.copy), spComps: rows(this.state.spec.comps), spApi: rows(this.state.spec.api) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<!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>
|
||||||
|
<meta name="design_doc_mode" content="canvas">
|
||||||
|
<link rel="icon" href="brand/favicon.svg">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<style>body{margin:0;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px}*{box-sizing:border-box}a{color:inherit;text-underline-offset:3px}a:hover{opacity:.8}code{font-family:'IBM Plex Mono',monospace;font-size:12px}</style>
|
||||||
|
</helmet>
|
||||||
|
<div ref="{{ rootRef }}" data-screen-label="Roles screens" style="background:var(--rc-canvas);color:var(--ui-text);padding:64px;display:flex;flex-direction:column;gap:72px;width:max-content;min-width:100%">
|
||||||
|
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
|
||||||
|
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
|
||||||
|
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screens: roles and permissions</h1>
|
||||||
|
<p style="margin:0;max-width:900px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">Roles list, role editor with the content grid and plain-language preview, delete role, Users changes and ownership transfer, the role-aware shell, 403 copy, activity events, profile, API keys, and a six-step flow for creating a role. Every state at 1440 px (shown at 50%) and 390 px (shown at 75%); the switch shows dark. Specs in screens/roles.md. Same shell and tokens as <a href="RenewCMS Screens Back-office.dc.html">the back-office board</a>.</p>
|
||||||
|
</div>
|
||||||
|
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
|
||||||
|
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
|
||||||
|
<button role="radio" aria-checked="{{ m.on }}" onClick="{{ m.pick }}" style="all:unset;cursor:pointer;height:36px;padding:0 14px;display:flex;align-items:center;font-weight:500;font-size:14px;background:{{ m.bg }};color:{{ m.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">{{ m.label }}</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="9">
|
||||||
|
<section data-screen-label="{{ g.label }}" style="display:flex;flex-direction:column;gap:28px">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;max-width:1100px">
|
||||||
|
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2>
|
||||||
|
<div style="display:flex;gap:16px;flex-wrap:wrap;font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><span>{{ g.route }}</span><span style="color:var(--ui-text-muted)">{{ g.api }}</span><span style="color:var(--ui-text-muted)">Auth: {{ g.auth }}</span></div>
|
||||||
|
<p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">{{ g.note }}</p>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:48px 40px;align-items:flex-start">
|
||||||
|
<sc-for list="{{ g.states }}" as="st" hint-placeholder-count="4">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px;width:1037px">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:2px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}<span style="margin-left:10px;padding:1px 6px;border:{{ st.chipBd }};color:var(--ui-text-toned);font:500 11px/16px 'IBM Plex Mono',monospace;vertical-align:middle">{{ st.scopeLabel }}</span></span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
|
||||||
|
<div style="display:flex;gap:24px;align-items:flex-start">
|
||||||
|
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><sc-if value="{{ st.isSchema }}"><dc-import name="RolesFrame" s="{{ st.s }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if></div></div>
|
||||||
|
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><sc-if value="{{ st.isSchema }}"><dc-import name="RolesFrame" s="{{ st.s }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if></div></div>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0;font-size:12px;line-height:18px;color:var(--ui-text-muted);text-wrap:pretty"><b style="color:var(--ui-text-toned)">Copy:</b> {{ st.copy }}</p>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</sc-for>
|
||||||
|
<sc-for list="{{ tables }}" as="t" hint-placeholder-count="3">
|
||||||
|
<section style="display:flex;flex-direction:column;gap:10px;max-width:1240px">
|
||||||
|
<h2 style="margin:0;font:600 22px/30px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ t.title }}</h2>
|
||||||
|
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
|
||||||
|
<div style="display:grid;grid-template-columns:{{ t.tpl }};gap:16px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><sc-for list="{{ t.cols }}" as="c" hint-placeholder-count="3"><span>{{ c }}</span></sc-for></div>
|
||||||
|
<sc-for list="{{ t.rows }}" as="r" hint-placeholder-count="4"><div style="display:grid;grid-template-columns:{{ t.tpl }};gap:16px;padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><sc-for list="{{ r.cells }}" as="c" hint-placeholder-count="3"><span style="color:var(--ui-text-highlighted);text-wrap:pretty;overflow-wrap:anywhere">{{ c }}</span></sc-for></div></sc-for>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"theme":{"editor":"enum","default":"light","options":["system","light","dark"],"tsType":"string"}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
state = { mode: this.props.theme ?? 'light', spec: { groups: [], tables: [] } }
|
||||||
|
rootRef = React.createRef()
|
||||||
|
light = { '--ui-bg': '#ffffff', '--ui-bg-muted': '#fafafa', '--ui-bg-elevated': '#f3f2f0', '--ui-bg-accented': '#e6e4e0', '--ui-bg-inverted': '#111111', '--ui-text-dimmed': '#999999', '--ui-text-muted': '#666666', '--ui-text-toned': '#444444', '--ui-text': '#222222', '--ui-text-highlighted': '#111111', '--ui-text-inverted': '#ffffff', '--ui-border': '#e6e4e0', '--ui-border-muted': '#f3f2f0', '--ui-border-accented': '#8a8783', '--ui-primary': '#111111', '--ui-secondary': '#635643', '--ui-success': '#315b33', '--ui-info': '#34536d', '--ui-warning': '#6c4700', '--ui-error': '#882c21', '--rc-sand': '#e8dfd1', '--rc-sel': '#e8dfd1', '--rc-on-sel': '#111111', '--rc-on-sand': '#111111', '--rc-success-soft': '#eaefea', '--rc-info-soft': '#ebeef0', '--rc-warning-soft': '#f0ede6', '--rc-error-soft': '#f3eae9', '--rc-overlay': 'rgb(10 10 10 / 0.4)', '--rc-shadow-pop': '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)', '--rc-shadow-modal': '0 16px 48px rgb(17 17 17 / 0.18)', '--rc-canvas': '#ecebe8' }
|
||||||
|
dark = { '--ui-bg': '#111111', '--ui-bg-muted': '#0a0a0a', '--ui-bg-elevated': '#1a1a1a', '--ui-bg-accented': '#222222', '--ui-bg-inverted': '#fafafa', '--ui-text-dimmed': '#666666', '--ui-text-muted': '#999999', '--ui-text-toned': '#cfccc7', '--ui-text': '#e6e4e0', '--ui-text-highlighted': '#fafafa', '--ui-text-inverted': '#111111', '--ui-border': '#333333', '--ui-border-muted': '#222222', '--ui-border-accented': '#666666', '--ui-primary': '#fafafa', '--ui-secondary': '#d4c5b0', '--ui-success': '#a3caa3', '--ui-info': '#a5c2db', '--ui-warning': '#dfb67d', '--ui-error': '#fba394', '--rc-sand': '#d4c5b0', '--rc-sel': '#222222', '--rc-on-sel': '#fafafa', '--rc-on-sand': '#111111', '--rc-success-soft': '#1f241f', '--rc-info-soft': '#202428', '--rc-warning-soft': '#2a251e', '--rc-error-soft': '#2c201f', '--rc-overlay': 'rgb(0 0 0 / 0.6)', '--rc-shadow-pop': '0 4px 16px rgb(0 0 0 / 0.5)', '--rc-shadow-modal': '0 16px 48px rgb(0 0 0 / 0.6)', '--rc-canvas': '#1c1c1c' }
|
||||||
|
resolved() { if (this.state.mode === 'system') return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; return this.state.mode }
|
||||||
|
apply() { const el = this.rootRef.current; if (!el) return; const vars = this.resolved() === 'dark' ? this.dark : this.light; for (const k in vars) el.style.setProperty(k, vars[k]); document.body.style.background = vars['--rc-canvas'] }
|
||||||
|
componentDidMount() { this.apply(); import('./roles-spec.js').then(x => this.setState({ spec: { groups: x.groups, tables: x.tables } })).catch(() => {}) }
|
||||||
|
componentDidUpdate(p) { if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme }); this.apply() }
|
||||||
|
renderVals() {
|
||||||
|
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => { const on = this.state.mode === k; return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) } })
|
||||||
|
const groups = this.state.spec.groups.map(g => ({ ...g, states: g.states.map(s => ({ ...s, isMedia: !!(s.s && s.s.frame === 'media'), isSchema: !(s.s && s.s.frame === 'media'), scopeLabel: s.scope === 'later' ? 'Later' : 'Available now', chipBd: s.scope === 'later' ? '1px dashed var(--ui-border-accented)' : '1px solid var(--ui-border-accented)' })) }))
|
||||||
|
const tables = this.state.spec.tables.map(t => ({ ...t, rows: t.rows.map(r => ({ cells: r })) }))
|
||||||
|
return { rootRef: this.rootRef, modes, groups, tables }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<!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>
|
||||||
|
<meta name="design_doc_mode" content="canvas">
|
||||||
|
<link rel="icon" href="brand/favicon.svg">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<style>body{margin:0;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px}*{box-sizing:border-box}a{color:inherit;text-underline-offset:3px}a:hover{opacity:.8}code{font-family:'IBM Plex Mono',monospace;font-size:12px}</style>
|
||||||
|
</helmet>
|
||||||
|
<div ref="{{ rootRef }}" data-screen-label="Schema screens" style="background:var(--rc-canvas);color:var(--ui-text);padding:64px;display:flex;flex-direction:column;gap:72px;width:max-content;min-width:100%">
|
||||||
|
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
|
||||||
|
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
|
||||||
|
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screens: custom content types</h1>
|
||||||
|
<p style="margin:0;max-width:900px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">Schema list, builder, change review, field-type gallery, content navigation, entry list and entry editor, plus the later screens: relations, permissions, export and import. Every state at 1440 px (shown at 50%) and 390 px (shown at 75%); the switch shows dark. Each state is tagged <b>Available now</b> or <b>Later</b>. Spec tables at the end: scope, routes, components with their Nuxt UI mapping, errors, needs backend, decisions, accessibility. Same shell, tokens and copy tone as <a href="RenewCMS Screens Back-office.dc.html">the back-office board</a>. New field type Media (one library image) is drawn inside groups 1 to 6, plus groups 10 (picker) and 11 (media library usage), and the tables "Media field: …" at the end. Changed in AppFrame: a Schema item and a Content group in the sidebar and the mobile drawer.</p>
|
||||||
|
</div>
|
||||||
|
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
|
||||||
|
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
|
||||||
|
<button role="radio" aria-checked="{{ m.on }}" onClick="{{ m.pick }}" style="all:unset;cursor:pointer;height:36px;padding:0 14px;display:flex;align-items:center;font-weight:500;font-size:14px;background:{{ m.bg }};color:{{ m.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">{{ m.label }}</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="9">
|
||||||
|
<section data-screen-label="{{ g.label }}" style="display:flex;flex-direction:column;gap:28px">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;max-width:1100px">
|
||||||
|
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2>
|
||||||
|
<div style="display:flex;gap:16px;flex-wrap:wrap;font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><span>{{ g.route }}</span><span style="color:var(--ui-text-muted)">{{ g.api }}</span><span style="color:var(--ui-text-muted)">Auth: {{ g.auth }}</span></div>
|
||||||
|
<p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">{{ g.note }}</p>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:48px 40px;align-items:flex-start">
|
||||||
|
<sc-for list="{{ g.states }}" as="st" hint-placeholder-count="4">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px;width:1037px">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:2px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}<span style="margin-left:10px;padding:1px 6px;border:{{ st.chipBd }};color:var(--ui-text-toned);font:500 11px/16px 'IBM Plex Mono',monospace;vertical-align:middle">{{ st.scopeLabel }}</span></span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
|
||||||
|
<div style="display:flex;gap:24px;align-items:flex-start">
|
||||||
|
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><sc-if value="{{ st.isSchema }}"><dc-import name="SchemaFrame" s="{{ st.s }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st.s }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if></div></div>
|
||||||
|
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><sc-if value="{{ st.isSchema }}"><dc-import name="SchemaFrame" s="{{ st.s }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st.s }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if></div></div>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0;font-size:12px;line-height:18px;color:var(--ui-text-muted);text-wrap:pretty"><b style="color:var(--ui-text-toned)">Copy:</b> {{ st.copy }}</p>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</sc-for>
|
||||||
|
<sc-for list="{{ tables }}" as="t" hint-placeholder-count="3">
|
||||||
|
<section style="display:flex;flex-direction:column;gap:10px;max-width:1240px">
|
||||||
|
<h2 style="margin:0;font:600 22px/30px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ t.title }}</h2>
|
||||||
|
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
|
||||||
|
<div style="display:grid;grid-template-columns:{{ t.tpl }};gap:16px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><sc-for list="{{ t.cols }}" as="c" hint-placeholder-count="3"><span>{{ c }}</span></sc-for></div>
|
||||||
|
<sc-for list="{{ t.rows }}" as="r" hint-placeholder-count="4"><div style="display:grid;grid-template-columns:{{ t.tpl }};gap:16px;padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><sc-for list="{{ r.cells }}" as="c" hint-placeholder-count="3"><span style="color:var(--ui-text-highlighted);text-wrap:pretty;overflow-wrap:anywhere">{{ c }}</span></sc-for></div></sc-for>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"theme":{"editor":"enum","default":"light","options":["system","light","dark"],"tsType":"string"}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
state = { mode: this.props.theme ?? 'light', spec: { groups: [], tables: [] } }
|
||||||
|
rootRef = React.createRef()
|
||||||
|
light = { '--ui-bg': '#ffffff', '--ui-bg-muted': '#fafafa', '--ui-bg-elevated': '#f3f2f0', '--ui-bg-accented': '#e6e4e0', '--ui-bg-inverted': '#111111', '--ui-text-dimmed': '#999999', '--ui-text-muted': '#666666', '--ui-text-toned': '#444444', '--ui-text': '#222222', '--ui-text-highlighted': '#111111', '--ui-text-inverted': '#ffffff', '--ui-border': '#e6e4e0', '--ui-border-muted': '#f3f2f0', '--ui-border-accented': '#8a8783', '--ui-primary': '#111111', '--ui-secondary': '#635643', '--ui-success': '#315b33', '--ui-info': '#34536d', '--ui-warning': '#6c4700', '--ui-error': '#882c21', '--rc-sand': '#e8dfd1', '--rc-sel': '#e8dfd1', '--rc-on-sel': '#111111', '--rc-on-sand': '#111111', '--rc-success-soft': '#eaefea', '--rc-info-soft': '#ebeef0', '--rc-warning-soft': '#f0ede6', '--rc-error-soft': '#f3eae9', '--rc-overlay': 'rgb(10 10 10 / 0.4)', '--rc-shadow-pop': '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)', '--rc-shadow-modal': '0 16px 48px rgb(17 17 17 / 0.18)', '--rc-canvas': '#ecebe8' }
|
||||||
|
dark = { '--ui-bg': '#111111', '--ui-bg-muted': '#0a0a0a', '--ui-bg-elevated': '#1a1a1a', '--ui-bg-accented': '#222222', '--ui-bg-inverted': '#fafafa', '--ui-text-dimmed': '#666666', '--ui-text-muted': '#999999', '--ui-text-toned': '#cfccc7', '--ui-text': '#e6e4e0', '--ui-text-highlighted': '#fafafa', '--ui-text-inverted': '#111111', '--ui-border': '#333333', '--ui-border-muted': '#222222', '--ui-border-accented': '#666666', '--ui-primary': '#fafafa', '--ui-secondary': '#d4c5b0', '--ui-success': '#a3caa3', '--ui-info': '#a5c2db', '--ui-warning': '#dfb67d', '--ui-error': '#fba394', '--rc-sand': '#d4c5b0', '--rc-sel': '#222222', '--rc-on-sel': '#fafafa', '--rc-on-sand': '#111111', '--rc-success-soft': '#1f241f', '--rc-info-soft': '#202428', '--rc-warning-soft': '#2a251e', '--rc-error-soft': '#2c201f', '--rc-overlay': 'rgb(0 0 0 / 0.6)', '--rc-shadow-pop': '0 4px 16px rgb(0 0 0 / 0.5)', '--rc-shadow-modal': '0 16px 48px rgb(0 0 0 / 0.6)', '--rc-canvas': '#1c1c1c' }
|
||||||
|
resolved() { if (this.state.mode === 'system') return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; return this.state.mode }
|
||||||
|
apply() { const el = this.rootRef.current; if (!el) return; const vars = this.resolved() === 'dark' ? this.dark : this.light; for (const k in vars) el.style.setProperty(k, vars[k]); document.body.style.background = vars['--rc-canvas'] }
|
||||||
|
componentDidMount() { this.apply(); import('./schema-spec.js').then(x => this.setState({ spec: { groups: x.groups, tables: x.tables } })).catch(() => {}) }
|
||||||
|
componentDidUpdate(p) { if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme }); this.apply() }
|
||||||
|
renderVals() {
|
||||||
|
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => { const on = this.state.mode === k; return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) } })
|
||||||
|
const groups = this.state.spec.groups.map(g => ({ ...g, states: g.states.map(s => ({ ...s, isMedia: !!(s.s && s.s.frame === 'media'), isSchema: !(s.s && s.s.frame === 'media'), scopeLabel: s.scope === 'later' ? 'Later' : 'Available now', chipBd: s.scope === 'later' ? '1px dashed var(--ui-border-accented)' : '1px solid var(--ui-border-accented)' })) }))
|
||||||
|
const tables = this.state.spec.tables.map(t => ({ ...t, rows: t.rows.map(r => ({ cells: r })) }))
|
||||||
|
return { rootRef: this.rootRef, modes, groups, tables }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
<!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>
|
||||||
|
<sc-if value="{{ framed }}" hint-placeholder-val="{{ true }}"><dc-import name="AppFrame" s="{{ shell }}" mobile="{{ mob }}" slot-body="{{ main }}" slot-overlays="{{ ov }}" hint-size="100%,100%"></dc-import></sc-if>
|
||||||
|
<sc-if value="{{ bare }}"><div style="width:{{ bw }};height:{{ bh }};overflow:hidden;position:relative;background:var(--ui-bg-muted);color:var(--ui-text);font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px">{{ main }}</div></sc-if>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1440,"height":900},"s":{"editor":null,"tsType":"object"},"mobile":{"editor":null,"tsType":"boolean"}}">
|
||||||
|
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 mono = 'font-family:\'IBM Plex Mono\',monospace;'
|
||||||
|
const bh = m ? 44 : 32, ih = m ? 44 : 32, 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)' : k === 'ghost' ? 'transparent' : '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 : k === 'ghost' ? 'transparent' : 'var(--ui-border-accented)'
|
||||||
|
return S('height:' + (o.h || bh) + 'px;' + (o.w ? 'width:' + o.w + '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;flex-shrink:0;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 IB = (ic, o = {}) => B(null, { k: 'ghost', icon: ic, w: o.w || (m ? 44 : 28), h: o.h || (m ? 44 : 28), px: 0, dis: o.dis, ring: o.ring, z: o.z || 16 })
|
||||||
|
const Alert = (tone, icon, title, text, ...actions) => 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-wrap:pretty', text)), actions.length > 0 && D('display:flex;gap:8px;flex-wrap:wrap', ...actions.map((a, i) => B(a, { icon: i === 0 && /again|Retry|Reload/.test(a) ? 'rotate-cw' : null, z: 14, h: m ? 44 : 28, fs: 12, px: 10, k: 'outline' })))))
|
||||||
|
const Later = (t = 'Later') => S('align-self:flex-start;height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);white-space:nowrap;flex-shrink:0;font:500 11px/16px \'IBM Plex Mono\',monospace', t)
|
||||||
|
const BDG = { req: 'border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', uniq: 'border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', neu: 'background:var(--rc-sand);color:var(--rc-on-sand)', chg: 'background:var(--rc-info-soft);color:var(--ui-info)', err: 'background:var(--rc-error-soft);color:var(--ui-error)', warn: 'background:var(--rc-warning-soft);color:var(--ui-warning)', built: 'border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', ok: 'background:var(--rc-success-soft);color:var(--ui-success)' }
|
||||||
|
const Bd = (t, k, ic) => S('height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:500;white-space:nowrap;flex-shrink:0;' + BDG[k], ic && I(ic, 12), t)
|
||||||
|
const Seg = (opts, on, w) => D('display:flex;border:1px solid var(--ui-border-accented);width:' + (w || '100%'), ...opts.map(([k, t]) => S('flex:1;height:' + bh + 'px;padding:0 10px;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:' + fs + 'px', t)))
|
||||||
|
const SwR = (on, lab, help, dis) => D('display:flex;gap:16px;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--ui-border);opacity:' + (dis ? 0.6 : 1), D('display:flex;flex-direction:column;gap:2px;min-width:0', 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)), S('width:32px;height:18px;padding:2px;display:flex;flex-shrink:0;justify-content:' + (on ? 'flex-end' : 'flex-start') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg-accented)') + ';' + (on ? '' : 'outline:1px solid var(--ui-border-accented);outline-offset:-1px'), S('width:14px;height:14px;background:var(--ui-bg)')))
|
||||||
|
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)') + ';' + (on ? '' : 'outline:1px solid var(--ui-border-accented);outline-offset:-1px'), S('width:14px;height:14px;background:var(--ui-bg)')), D('display:flex;flex-direction:column;gap:2px', lab && 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 Chk = on => S('width:16px;height:16px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg)') + ';color:var(--ui-bg)', on && I('check', 12))
|
||||||
|
const Skel = (w, hh = 14) => S('display:block;height:' + hh + 'px;width:' + w + ';background:var(--ui-bg-accented)')
|
||||||
|
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.req && ' *', o.opt && S('font-weight:400;color:var(--ui-text-muted)', ' (optional)')), o.count && S(mono + 'font-size:12px;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 : 88) : 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 || o.lock ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';font-size:' + fs + 'px;opacity:' + (o.dis ? 0.6 : 1), o.pre, S('flex:1;min-width:0;padding:' + (o.area ? '8px 10px' : '0 10px') + ';color:' + (o.ph || val === '' ? '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.phText ? o.phText : val), o.auto && Bd('Auto', 'neu'), o.auto && I('pencil', 14, 'margin:0 10px 0 8px;color:var(--ui-text-muted)'), o.lock && I('lock', 14, 'margin-right:10px;color:var(--ui-text-muted)'), o.sel && I('chevron-down', 14, 'margin-right:10px;color:var(--ui-text-muted)'), o.suffix && S('padding-right:10px;color:var(--ui-text-muted);font-size:12px', o.suffix), o.ico && I(o.ico, 14, 'margin-right:10px;color:var(--ui-text-muted)')),
|
||||||
|
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 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.later && Later(), 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 Sheet = (o, body, foot) => D('position:absolute;inset:0;z-index:' + (o.z || 40) + ';display:flex;align-items:flex-end;background:var(--rc-overlay)',
|
||||||
|
D('width:100%;height:92%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column', 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:4px 6px 4px 16px;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), o.later && Later(), S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
|
||||||
|
D('flex:1;min-height:0;overflow:hidden;padding:16px;display:flex;flex-direction:column;gap:16px', body), foot && D('flex-shrink:0;padding:12px 16px;border-top:1px solid var(--ui-border);display:flex;gap:8px', foot)))
|
||||||
|
const Toast = (tone, icon, title, text, action) => D('position:absolute;z-index:60;display:flex;gap:10px;padding:12px 14px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);' + (m ? 'left:16px;right:16px;bottom:' + (s.toastB || 16) + 'px' : 'right:24px;bottom:24px;width:356px'), S('margin-top:2px;display:flex;color:var(--ui-' + tone + ')', I(icon)), 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)', title), S('color:var(--ui-text-muted)', text)), action && B(action, { icon: 'rotate-cw', z: 14, h: m ? 44 : 28, fs: 12, px: 10 })), I('x'))
|
||||||
|
const Offline = () => D('flex-shrink:0;display:flex;align-items:center;gap:10px;padding:10px 16px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted)', I('wifi-off'), S('flex:1', 'You\'re offline. Changes will be saved when you reconnect.'))
|
||||||
|
const Tip = t => D('position:absolute;z-index:30;width:260px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', t)
|
||||||
|
const Page = (pad, ...c) => D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;position:relative;overflow:hidden;padding:' + pad + ';gap:16px', ...c)
|
||||||
|
const Title = (t, sub, ...acts) => D('display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap;flex-shrink:0', D('flex:1;min-width:200px;display:flex;flex-direction:column;gap:2px', !m && S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', t), sub && S('color:var(--ui-text-muted)', sub)), ...acts)
|
||||||
|
const pad = m ? '16px' : '24px'
|
||||||
|
const Fab = l => m && D('position:absolute;left:16px;right:16px;bottom:16px;height:48px;z-index:5;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), l)
|
||||||
|
const Center = (icon, title, text, ...acts) => D('flex:1;display:flex;align-items:center;justify-content:center;padding:24px', D('max-width:420px;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:600 24px/32px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted);text-wrap:pretty', text), D('display:flex;gap:8px', ...acts)))
|
||||||
|
const Empty = (icon, title, text, action) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:40px 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:360px;text-wrap:pretty', text), action && B(action, { k: 'primary', icon: 'plus', px: 16 }))
|
||||||
|
|
||||||
|
// ---------- roles data
|
||||||
|
BDG.lock = 'background:var(--ui-bg-elevated);color:var(--ui-text-toned)'; BDG.owner = 'background:var(--rc-sand);color:var(--rc-on-sand)'; BDG.admin = 'background:var(--rc-info-soft);color:var(--ui-info)'; BDG.own = 'border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned)'
|
||||||
|
const TY = ['Articles', 'Events', 'Case studies'], AL = ['read', 'create', 'update', 'delete', 'publish'], ALL = { read: 'Read', create: 'Create', update: 'Update', delete: 'Delete', publish: 'Publish' }, SC = { n: 'None', o: 'Own', a: 'All' }
|
||||||
|
const GEN = [
|
||||||
|
{ id: 'schema', name: 'Schema', items: [['View content types', 'See content types and their fields.', 'see content types and fields', 'b'], ['Create and edit content types', 'Add types and change their fields and rules.', 'create and edit content types', 'b'], ['Delete content types', 'Remove a type. Its entries go with it.', 'delete content types', 'b']] },
|
||||||
|
{ id: 'media', name: 'Media', items: [['View the media library', 'Browse images and files.', 'browse the media library', 'b'], ['Upload files', 'Add images and files to the library.', 'upload files', 'b'], ['Delete files', 'Remove files from the library.', 'delete files', 's']] },
|
||||||
|
{ id: 'authors', name: 'Authors', items: [['View authors', 'See author profiles.', 'see authors', 'b'], ['Create authors', 'Add an author profile.', 'create authors', 'b'], ['Edit authors', 'Change an author profile.', 'edit authors', 's'], ['Delete authors', 'Remove an author profile.', 'delete authors', 'b']] },
|
||||||
|
{ id: 'activity', name: 'Activity', items: [['See activity', 'Read the log of who did what.', 'read the activity log', 's'], ['Export activity', 'Download the log as a CSV file.', 'export the activity log', 'b']] },
|
||||||
|
{ id: 'users', name: 'Users and invites', items: [['View users', 'See members and pending invites.', 'see users and invites', 'b'], ['Invite users', 'Send an invitation with a role.', 'invite users', 'b'], ['Change a user\'s role', 'Move a user to another role.', 'change a user\'s role', 'b'], ['Remove users', 'Take a user off the site.', 'remove users', 'b']] },
|
||||||
|
{ id: 'settings', name: 'Settings', items: [['View settings', 'Open Settings pages, read-only.', 'read settings', 'b'], ['Edit general settings', 'Site name, language and defaults.', 'edit general settings', 'b'], ['Manage sites', 'Create, edit and set the default site.', 'manage sites', 'b'], ['See API keys', 'List the keys on this site.', 'see API keys', 's'], ['Create API keys', 'A key follows its creator\'s role.', 'create API keys', 'b'], ['Revoke API keys', 'Stop a key from working.', 'revoke API keys', 'b'], ['Delete a site', 'Remove a site and its content.', 'delete a site', 'o']] },
|
||||||
|
{ id: 'roles', name: 'Roles', items: [['View roles', 'See roles and what they allow.', 'see roles', 'b'], ['Manage roles', 'Create, edit, duplicate and delete roles.', 'manage roles', 'b'], ['Edit roles with owner-only power', 'Change a role that carries owner-only abilities.', 'edit roles with owner-only power', 'o']] },
|
||||||
|
{ id: 'system', name: 'System', items: [['Apply software updates', 'Install a new version of RenewCMS.', 'apply software updates', 'o'], ['Edit activity retention', 'How long the activity log is kept.', 'edit activity retention', 'o'], ['Transfer ownership', 'Make another member the owner.', 'transfer ownership', 'o']] }
|
||||||
|
]
|
||||||
|
const fullG = own => Object.fromEntries(GEN.map(g => [g.id, g.items.map(it => it[3] === 's' ? 'a' : it[3] === 'o' ? (own ? '1' : '0') : '1').join('')]))
|
||||||
|
const mkT = (a, ov) => Object.fromEntries(TY.map(t => [t, (ov && ov[t]) || a]))
|
||||||
|
const ROLES = {
|
||||||
|
owner: { name: 'Owner', desc: 'Everything on the site. One per site.', built: 1, lock: 1, members: 1, g: fullG(true), all: 'a1aaa', t: mkT('a1aaa') },
|
||||||
|
admin: { name: 'Admin', desc: 'Everything except owner-only settings.', built: 1, members: 2, g: fullG(false), all: 'a1aaa', t: mkT('a1aaa') },
|
||||||
|
writer: { name: 'Writer', desc: 'Reads everything and writes content. Can\'t delete or publish.', built: 1, members: 5, g: { schema: '100', media: '11n', authors: '10o0', activity: 'o0', users: '0000', settings: '000n000', roles: '000', system: '000' }, all: 'a1ann', t: mkT('a1ann') },
|
||||||
|
contrib: { name: 'Contributor', desc: 'Creates content and edits only their own.', members: 4, g: { schema: '100', media: '11n', authors: '10o0', activity: 'n0', users: '0000', settings: '000n000', roles: '000', system: '000' }, all: 'a1onn', t: mkT('a1onn', { 'Case studies': 'a0nnn' }) },
|
||||||
|
lead: { name: 'Content lead', desc: 'Edits and publishes all content.', members: 1, g: { schema: '100', media: '11a', authors: '10a0', activity: 'a1', users: '1000', settings: '100n000', roles: '100', system: '000' }, all: 'a1aaa', t: mkT('a1aaa') },
|
||||||
|
blank: { name: '', desc: '', members: 0, g: Object.fromEntries(GEN.map(g => [g.id, g.items.map(it => it[3] === 's' ? 'n' : '0').join('')])), all: 'n0nnn', t: mkT('n0nnn') }
|
||||||
|
}
|
||||||
|
const ON = c => c !== '0' && c !== 'n'
|
||||||
|
const cnt = (R, g) => R.g[g.id].split('').filter(ON).length
|
||||||
|
const j = a => a.length < 2 ? a.join('') : a.slice(0, -1).join(', ') + ' and ' + a[a.length - 1]
|
||||||
|
const phr = c => { const f = (ch, vb) => ch === 'a' ? vb + ' any entry' : ch === 'o' ? vb + ' only their own' : null; const can = [f(c[0], 'read'), c[1] === '1' ? 'create entries' : null, f(c[2], 'edit'), f(c[3], 'delete'), f(c[4], 'publish')]; const cl = can.filter(Boolean), no = ['read', 'create', 'edit', 'delete', 'publish'].filter((x, i) => !can[i]); return cl.length ? 'Can ' + j(cl) + '.' + (no.length ? ' Can\'t ' + j(no) + '.' : '') : 'No access to entries.' }
|
||||||
|
const pv = R => {
|
||||||
|
const groups = {}; TY.forEach(t => { const p = phr(R.t[t]); (groups[p] = groups[p] || []).push(t) })
|
||||||
|
const content = Object.entries(groups).map(([p, ts]) => ({ pre: ts.length === TY.length ? 'All content types' : j(ts), txt: p }))
|
||||||
|
const other = [], none = []
|
||||||
|
GEN.forEach(g => { const say = []; g.items.forEach((it, i) => { const ch = R.g[g.id][i]; if (it[3] === 'o' || !ON(ch)) return; say.push(it[3] === 's' ? (ch === 'o' ? it[2] + ', only their own' : it[2]) : it[2]) }); if (say.length) other.push({ pre: g.name, txt: 'Can ' + j(say) + '.' }); else if (g.id !== 'system') none.push(g.name) })
|
||||||
|
return { content, other, none }
|
||||||
|
}
|
||||||
|
const RB = n => n === 'Owner' ? Bd('Owner', 'owner', 'crown') : n === 'Admin' ? Bd('Admin', 'admin', 'shield') : n === 'Writer' ? Bd('Writer', 'req', 'pencil') : Bd(n, 'own', 'user-round')
|
||||||
|
const IC = (ic, t) => S('display:inline-flex;margin-top:2px;color:' + (t || 'var(--ui-text-toned)'), I(ic, 16))
|
||||||
|
// ---------- shared pieces
|
||||||
|
const Tri = (v, o = {}) => D('display:flex;border:1px solid var(--ui-border-accented);flex-shrink:0;opacity:' + (o.dis ? 0.55 : 1) + ';' + (o.fill ? 'flex:1;' : '') + (o.ring ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : ''),
|
||||||
|
...['n', 'o', 'a'].map(c => { const on = v === c, bl = (o.block || '').includes(c); return S('flex:1;min-width:' + (m ? 0 : 34) + 'px;height:' + (m ? 44 : 26) + 'px;padding:0 6px;display:flex;align-items:center;justify-content:center;gap:3px;font-size:' + (m ? 14 : 12) + 'px;font-weight:' + (on ? 600 : 400) + ';background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)') + ';box-shadow:' + (on ? 'inset 0 -2px 0 var(--ui-primary)' : 'none') + ';opacity:' + (bl ? 0.45 : 1), bl && I('lock', 10), SC[c]) }))
|
||||||
|
const OnOff = (on, o = {}) => D('display:flex;align-items:center;gap:8px;opacity:' + (o.dis ? 0.55 : 1), S('width:32px;height:18px;padding:2px;display:flex;flex-shrink:0;justify-content:' + (on ? 'flex-end' : 'flex-start') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg-accented)') + ';' + (on ? '' : 'outline:1px solid var(--ui-border-accented);outline-offset:-1px'), S('width:14px;height:14px;background:var(--ui-bg)')), S('font-size:12px;min-width:20px;color:var(--ui-text-toned)', on ? 'On' : 'Off'))
|
||||||
|
const Card = (title, desc, right, ...c) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:12px;flex-shrink:0;min-width:0', D('display:flex;gap:12px;align-items:flex-start', D('flex:1;display:flex;flex-direction:column;gap:2px', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', title), desc && S('color:var(--ui-text-muted);text-wrap:pretty', desc)), right), ...c)
|
||||||
|
const Menu = (right, top, items) => D('position:absolute;z-index:30;right:' + right + 'px;top:' + top + 'px;width:224px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px', ...items.map(([ic, t, dis, hot, danger]) => D('min-height:' + (m ? 44 : 32) + 'px;padding:6px 8px;display:flex;align-items:center;gap:8px;opacity:' + (dis ? 0.5 : 1) + ';background:' + (hot ? 'var(--ui-bg-elevated)' : 'transparent') + ';color:' + (danger ? 'var(--ui-error)' : 'var(--ui-text-highlighted)'), I(ic, 16), S('flex:1', t))))
|
||||||
|
const Banner = (tone, icon, title, text, ...acts) => Alert(tone, icon, title, text, ...acts)
|
||||||
|
const Link = t => S('text-decoration:underline;text-underline-offset:3px;font-weight:500;color:var(--ui-text-highlighted)', t)
|
||||||
|
const Pill = (t, on) => S('height:' + (m ? 44 : 28) + 'px;padding:0 10px;display:inline-flex;align-items:center;gap:6px;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';font-weight:' + (on ? 600 : 500) + ';font-size:' + (m ? 14 : 12) + 'px;white-space:nowrap', on && I('check', 12), t)
|
||||||
|
const crumbs = (...c) => { shell.crumbs = c; shell.mTitle = c[c.length - 1] }
|
||||||
|
// ---------- preview panel
|
||||||
|
const PvBody = (R, o = {}) => { const p = pv(R); const L = (ic, pre, txt, chg) => D('display:flex;gap:10px', IC(ic), D('flex:1;display:flex;flex-direction:column;gap:1px;min-width:0', S('font-size:12px;line-height:18px;font-weight:500;color:var(--ui-text-muted)', pre, chg && ' '), S('color:var(--ui-text-highlighted);text-wrap:pretty', txt)), chg && Bd('Changed', 'chg')); return D('display:flex;flex-direction:column;gap:12px',
|
||||||
|
D('display:flex;flex-direction:column;gap:10px', S('font-weight:600;color:var(--ui-text-highlighted)', 'Content'), ...p.content.map((x, i) => L(R.all === 'n0nnn' && !R.t.Articles.includes('a') && x.txt.startsWith('No') ? 'ban' : 'file-text', x.pre, x.txt, o.chg && i === 0))),
|
||||||
|
p.other.length > 0 && D('display:flex;flex-direction:column;gap:10px;padding-top:12px;border-top:1px solid var(--ui-border)', S('font-weight:600;color:var(--ui-text-highlighted)', 'Everything else'), ...p.other.map(x => L('check', x.pre, x.txt))),
|
||||||
|
p.none.length > 0 && D('display:flex;gap:10px;padding-top:12px;border-top:1px solid var(--ui-border)', IC('ban'), D('display:flex;flex-direction:column;gap:1px', S('font-size:12px;line-height:18px;font-weight:500;color:var(--ui-text-muted)', 'No access to'), S('color:var(--ui-text-highlighted)', j(p.none) + '.'))),
|
||||||
|
R.members > 0 && S('padding-top:12px;border-top:1px solid var(--ui-border);color:var(--ui-text-muted);font-size:12px;line-height:18px', 'Applies to ' + R.members + (R.members === 1 ? ' member' : ' members') + ', and to the API keys they created.')) }
|
||||||
|
const PvPanel = (R, o = {}) => D('width:320px;flex-shrink:0;align-self:flex-start;max-height:100%;overflow:hidden;background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:12px;' + (o.ro ? 'opacity:1;' : ''), D('display:flex;flex-direction:column;gap:2px', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'What this role can do'), S('color:var(--ui-text-muted)', o.ro ? 'A summary of what this role allows.' : 'Updates as you edit.')), PvBody(R, o))
|
||||||
|
const PvSheet = (R, o = {}) => Sheet({ title: o.title || 'What this role can do', z: 45 }, [S('color:var(--ui-text-muted)', o.sub || 'Updates as you edit.'), PvBody(R, o)], o.foot)
|
||||||
|
// ---------- content grid
|
||||||
|
const CG = (R, o = {}) => {
|
||||||
|
const chg = o.chg || [], esc = o.esc, base = R.all
|
||||||
|
const uni = i => TY.every(t => R.t[t][i] === R.t[TY[0]][i]) ? R.t[TY[0]][i] : null
|
||||||
|
const cell = (t, i, v) => { const a = AL[i], key = t + '.' + a, bl = esc && a === 'publish' ? 'a' : ''; const ctl = i === 1 ? OnOff(v === '1', { dis: o.ro }) : Tri(v, { dis: o.ro, block: bl, ring: o.ring === key }); return D('position:relative;display:flex;align-items:center', chg.includes(key) && S('position:absolute;left:-8px;top:50%;margin-top:-3px;width:6px;height:6px;background:var(--ui-info)'), ctl) }
|
||||||
|
const tcol = '118px 84px 118px 118px 118px'
|
||||||
|
const nameCell = (t, custom) => D('display:flex;flex-direction:column;gap:2px;min-width:0', D('display:flex;align-items:center;gap:6px;flex-wrap:wrap', S('font-weight:500;color:var(--ui-text-highlighted)', t), t === 'Articles' && Bd('Built-in', 'built', 'lock'), custom && Bd('Custom', 'warn'), custom && Link('Reset')), t === 'Articles' && S('font-size:12px;line-height:18px;color:var(--ui-text-muted)', 'Schema locked'))
|
||||||
|
const presets = D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', S('color:var(--ui-text-muted);margin-right:4px', 'Quick presets'), Pill('Read only', base === 'a0nnn'), Pill('Write own', base === 'a1onn'), Pill('Full content', base === 'a1aaa'))
|
||||||
|
if (m) {
|
||||||
|
const card = (t, v, custom, all) => D('border:1px solid var(--ui-border);padding:12px;display:flex;flex-direction:column;gap:10px;background:' + (all ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)'), all ? S('font-weight:600;color:var(--ui-text-highlighted)', 'All content types') : nameCell(t, custom), ...AL.map((a, i) => D('display:flex;align-items:center;gap:10px', S('width:64px;color:var(--ui-text-toned);flex-shrink:0', ALL[a]), i === 1 ? OnOff(v[i] === '1', { dis: o.ro }) : Tri(v[i], { fill: true, dis: o.ro, block: esc && a === 'publish' ? 'a' : '' }))))
|
||||||
|
return D('display:flex;flex-direction:column;gap:10px', presets, card('', base, false, true), ...TY.map(t => card(t, R.t[t], R.t[t] !== base)))
|
||||||
|
}
|
||||||
|
const hd = D('display:grid;grid-template-columns:minmax(120px,1fr) ' + tcol + ';gap:8px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500;align-items:center', S('', 'Content type'), ...AL.map(a => S('display:flex;align-items:center;gap:4px', ALL[a], !o.ro && I('chevron-down', 12))))
|
||||||
|
const row = (t, v, custom, all) => D('display:grid;grid-template-columns:minmax(120px,1fr) ' + tcol + ';gap:8px;padding:10px 12px;border-top:1px solid var(--ui-border);align-items:center;background:' + (all ? 'var(--ui-bg-muted)' : 'var(--ui-bg)'), all ? D('display:flex;flex-direction:column', S('font-weight:600;color:var(--ui-text-highlighted)', 'All content types'), S('font-size:12px;line-height:18px;color:var(--ui-text-muted)', 'Sets every row')) : nameCell(t, custom), ...AL.map((a, i) => v[i] === null ? D('display:flex;align-items:center;gap:6px', Tri(null, { dis: o.ro }), Bd('Mixed', 'warn')) : cell(t, i, v[i])))
|
||||||
|
const allv = AL.map((a, i) => uni(i))
|
||||||
|
return D('display:flex;flex-direction:column;gap:12px', !o.ro && presets, D('border:1px solid var(--ui-border);position:relative', hd, row('', allv, false, true), ...TY.map(t => row(t, R.t[t], R.t[t] !== base, false)), o.tip && Tip2(o.tip)))
|
||||||
|
}
|
||||||
|
const Tip2 = t => D('position:absolute;z-index:30;right:8px;top:78px;width:272px;padding:10px 12px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', t)
|
||||||
|
// ---------- general permissions
|
||||||
|
const GP = (R, o = {}) => { const open = o.open || ['schema']; return D('display:flex;flex-direction:column', ...GEN.map(g => { const isO = open.includes(g.id), c = cnt(R, g), tot = g.items.length - g.items.filter(it => it[3] === 'o').length || g.items.length; return D('border-top:1px solid var(--ui-border)', D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 48 : 40) + 'px;padding:4px 0', I(isO ? 'chevron-down' : 'chevron-right', 16, 'color:var(--ui-text-muted)'), S('font-weight:600;color:var(--ui-text-highlighted);flex:1', g.name), g.id === 'system' && Bd('Owner only', 'lock', 'lock'), S('font-size:12px;color:var(--ui-text-muted)', g.id === 'system' ? '0 of 3 enabled' : c + ' of ' + tot + ' enabled')), isO && D('display:flex;flex-direction:column;padding:0 0 4px 24px', ...g.items.map((it, i) => { const ch = R.g[g.id][i], lockd = it[3] === 'o', ctl = lockd ? D('display:flex;align-items:center;gap:8px', Bd('Owner only', 'lock', 'lock'), OnOff(ch === '1', { dis: true })) : it[3] === 's' ? Tri(ch, { dis: o.ro, fill: m }) : OnOff(ON(ch), { dis: o.ro }); return D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';gap:' + (m ? 8 : 16) + 'px;align-items:' + (m ? 'stretch' : 'center') + ';justify-content:space-between;padding:10px 0;border-top:1px solid var(--ui-border-muted)', D('display:flex;flex-direction:column;gap:2px;min-width:0;opacity:' + (lockd ? 0.7 : 1), S('font-weight:500;color:var(--ui-text-highlighted)', it[0]), S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', it[1])), ctl) }))) })) }
|
||||||
|
// ---------- editor
|
||||||
|
const Editor = (R, o = {}) => {
|
||||||
|
const ro = !!o.ro, isNew = !!o.isNew
|
||||||
|
const head = D('display:flex;flex-direction:column;gap:12px;flex-shrink:0',
|
||||||
|
D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', D('display:flex;align-items:center;gap:6px;color:var(--ui-text-toned);font-weight:500', I('arrow-left', 16), 'Roles'), D('flex:1'), !m && Title0(R, o)),
|
||||||
|
m && Title0(R, o),
|
||||||
|
o.alerts && D('display:flex;flex-direction:column;gap:8px', ...o.alerts),
|
||||||
|
D('display:grid;grid-template-columns:' + (m ? '1fr' : 'minmax(0,280px) minmax(0,1fr)') + ';gap:12px', F('Name', R.name, { req: !ro, lock: ro, err: o.nameErr, focus: o.focus === 'name', phText: 'For example, Contributor', help: o.nameHelp }), F('Description', R.desc, { lock: ro, opt: !ro, phText: 'What this role is for', focus: o.focus === 'desc' })))
|
||||||
|
const sections = D('display:flex;flex-direction:column;gap:16px;opacity:' + (o.saving ? 0.6 : 1), o.showHead !== false && head,
|
||||||
|
!o.onlyGeneral && Card('Content permissions', 'What this role can do with entries of each content type. Own means entries the person created.', null, CG(R, o)),
|
||||||
|
Card('General permissions', 'Everything outside entries.', !m && !ro ? S('color:var(--ui-text-toned);font-weight:500', 'Expand all') : null, GP(R, { ro, open: o.open })))
|
||||||
|
const scroll = o.scroll ? D('margin-top:-' + o.scroll + 'px;display:flex;flex-direction:column;gap:16px', sections) : sections
|
||||||
|
const col = D('flex:1;min-width:0;min-height:0;overflow:hidden;display:flex;flex-direction:column;padding-bottom:' + (o.bar ? 64 : 0) + 'px', scroll)
|
||||||
|
const bar = o.bar && D('position:absolute;left:0;right:0;bottom:0;z-index:20;display:flex;align-items:center;gap:' + (m ? 8 : 12) + 'px;padding:' + (m ? '8px 16px' : '10px 24px') + ';background:var(--ui-bg);border-top:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop)', ...o.bar)
|
||||||
|
const body = m ? col : D('flex:1;min-height:0;display:flex;gap:16px;align-items:flex-start', col, o.noPv ? null : PvPanel(R, { chg: o.pvChg, ro }))
|
||||||
|
return [Page(m ? '16px' : '24px', body, bar), o.over]
|
||||||
|
}
|
||||||
|
const Title0 = (R, o) => D('display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', o.isNew ? 'New role' : R.name), !o.isNew && (R.built ? Bd('Built-in', 'built') : Bd('Custom', 'own')), R.lock && Bd('Locked', 'lock', 'lock'), !o.isNew && S('color:var(--ui-text-muted)', R.members + (R.members === 1 ? ' member' : ' members')))
|
||||||
|
const SaveBar = (n, o = {}) => [m ? B('What this role can do', { icon: 'list-checks', full: true, h: 44 }) : D('display:flex;align-items:center;gap:8px;flex:1', S('width:6px;height:6px;background:var(--ui-info)'), S('font-weight:500;color:var(--ui-text-highlighted)', o.msg || (n + (n === 1 ? ' unsaved change' : ' unsaved changes')))), !m && S('flex:0'), B('Discard', { dis: o.saving }), S('height:' + bh + 'px;padding:0 14px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;opacity:' + (o.saving || o.sdis ? 0.4 : 1), o.saving && I('loader-circle'), o.saving ? 'Saving…' : 'Save changes')]
|
||||||
|
// ---------- tables used by several screens
|
||||||
|
const Th = (cols, ...c) => D('display:grid;grid-template-columns:' + cols + ';gap:16px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', ...c.map(x => S('', x)))
|
||||||
|
const Tr = (cols, o, ...c) => D('display:grid;grid-template-columns:' + cols + ';gap:16px;padding:0 12px;min-height:48px;align-items:center;border-top:1px solid var(--ui-border);position:relative;background:' + (o.sel ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (o.sel ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';opacity:' + (o.dim ? 0.55 : 1), ...c)
|
||||||
|
const Dots = ring => IB('ellipsis', { ring })
|
||||||
|
const Cd = (o, ...c) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:12px;display:flex;flex-direction:column;gap:6px;opacity:' + (o.dim ? 0.55 : 1), ...c)
|
||||||
|
const Mu = t => S('color:var(--ui-text-muted)', t)
|
||||||
|
const Hi = t => S('font-weight:500;color:var(--ui-text-highlighted)', t)
|
||||||
|
const Add = (label, o = {}) => B(label, { k: 'primary', icon: 'plus', px: 14, dis: o.dis })
|
||||||
|
// ---------- data for the screens
|
||||||
|
const shell = { kind: 'slot', display: 'Marta Alves', role: s.role || 'owner', content: [{ key: 'events', name: 'Events', icon: 'files' }, { key: 'case-studies', name: 'Case studies', icon: 'files' }], contentOn: null, navBadge: false, nav: 'settings', sideOpen: true }
|
||||||
|
let main = null; const ov = []; let bare = false
|
||||||
|
const RL = ['owner', 'admin', 'writer', 'contrib', 'lead']
|
||||||
|
const k = s.k, v = s.v
|
||||||
|
if (v === 'list') {
|
||||||
|
crumbs('Settings', 'Roles'); const cols = '200px minmax(0,1fr) 90px 100px 40px', noEdit = k === 'noedit', rows = k === 'empty' ? RL.slice(0, 3) : RL
|
||||||
|
const sk = (w) => Skel(w, 14)
|
||||||
|
const tbl = D('background:var(--ui-bg);border:1px solid var(--ui-border);position:relative', Th(cols, 'Role', 'Description', 'Members', 'Type', ''),
|
||||||
|
...(k === 'loading' ? [1, 2, 3, 4, 5].map(() => Tr(cols, {}, sk('120px'), sk('70%'), sk('24px'), sk('64px'), sk('16px'))) : rows.map((r, i) => { const R = ROLES[r]; return Tr(cols, { sel: k === 'menu' && i === 3 }, D('display:flex;align-items:center;gap:8px', Hi(R.name), R.lock && Bd('Locked', 'lock', 'lock')), Mu(R.desc), S('', R.members), R.built ? Bd('Built-in', 'built') : Bd('Custom', 'own'), Dots(k === 'menu' && i === 3 || k === 'menub' && i === 2)) })),
|
||||||
|
k === 'menu' && Menu(24, 200, [['pencil', 'Edit', false, true], ['copy', 'Duplicate'], ['trash-2', 'Delete', false, false, true]]),
|
||||||
|
k === 'menub' && Menu(24, 152, [['pencil', 'Edit', false, true], ['copy', 'Duplicate'], ['trash-2', 'Delete', true]]), k === 'menub' && D('position:absolute;z-index:31;right:260px;top:196px;width:240px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', 'Built-in roles can\'t be deleted. Duplicate it to make a custom role.'),
|
||||||
|
noEdit && k === 'noedit' && D('position:absolute;z-index:31;right:24px;top:-40px;width:260px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', 'You can see roles, but only people who manage roles can create or change them.'))
|
||||||
|
const cards = D('display:flex;flex-direction:column;gap:8px', ...(k === 'loading' ? [1, 2, 3].map(() => Cd({}, sk('120px'), sk('90%'), sk('60px'))) : rows.map((r, i) => { const R = ROLES[r]; return Cd({}, D('display:flex;align-items:center;gap:8px', Hi(R.name), R.lock && Bd('Locked', 'lock', 'lock'), D('flex:1'), R.built ? Bd('Built-in', 'built') : Bd('Custom', 'own'), IB('ellipsis')), Mu(R.desc), S('font-size:12px;color:var(--ui-text-muted)', R.members + (R.members === 1 ? ' member' : ' members'))) })))
|
||||||
|
const list = k === 'error' ? Alert('error', 'circle-x', 'Couldn\'t load roles', 'GET /v1/role failed. Check your connection and try again.', 'Try again') : m ? cards : tbl
|
||||||
|
main = Page(m ? '16px 16px 88px' : '24px', Title('Roles', 'Who can do what on this site. Each user has one role.', B('New role', { k: 'primary', icon: 'plus', px: 14, dis: k === 'loading' || noEdit })),
|
||||||
|
noEdit && Banner('info', 'lock', 'You can see roles but not change them', 'Ask an owner or admin if a role needs to change.'),
|
||||||
|
k === 'empty' && Banner('info', 'info', 'No custom roles yet', 'The three built-in roles cover most sites. Create a role when someone needs something in between.', 'New role'),
|
||||||
|
list, m && Fab('New role'))
|
||||||
|
} else if (v === 'editor') {
|
||||||
|
const flow = s.flow
|
||||||
|
let R = ROLES.contrib, o = {}
|
||||||
|
crumbs('Settings', 'Roles', k === 'new' ? 'New role' : R.name)
|
||||||
|
if (k === 'new') { R = ROLES.blank; o = { isNew: true, focus: 'name', bar: SaveBar(0, { sdis: true, msg: 'Name your role to save it' }) }; shell.mTitle = 'New role' }
|
||||||
|
else if (k === 'custom') { o = { open: ['schema', 'media'] } }
|
||||||
|
else if (k === 'owner-edit') { R = ROLES.writer; o = { open: [], alerts: [Banner('warning', 'triangle-alert', '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.')], bar: SaveBar(2) }; shell.mTitle = 'Writer'; R = { ...R, t: { ...R.t, Events: 'a1aan' } } }
|
||||||
|
else if (k === 'admin-ro') { R = ROLES.writer; shell.role = 'admin'; shell.display = 'Jonas Weber'; o = { ro: true, open: [], alerts: [Banner('info', 'lock', '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')] } }
|
||||||
|
else if (k === 'locked') { R = ROLES.owner; o = { ro: true, open: [], alerts: [Banner('info', 'lock', 'The Owner role is locked', 'It always has every permission. To hand it to someone else, transfer ownership from Users.', 'Go to Users')] } }
|
||||||
|
else if (k === 'dirty') { R = { ...R, t: { ...R.t, Events: 'a1aan' }, g: { ...R.g, media: '11o' } }; o = { open: ['media'], chg: ['Events.update', 'Events.delete'], pvChg: true, bar: SaveBar(3) } }
|
||||||
|
else if (k === 'saving') { R = { ...R, t: { ...R.t, Events: 'a1aan' } }; o = { saving: true, open: [], bar: SaveBar(3, { saving: true }) } }
|
||||||
|
else if (k === 'saved') { o = { open: [], over: Toast('success', 'circle-check', 'Role saved', 'Contributor is updated for 4 members.') } }
|
||||||
|
else if (k === 'req') { R = { ...ROLES.blank, name: '' }; o = { isNew: true, nameErr: 'Give the role a name.', bar: SaveBar(0, { sdis: true, msg: '1 problem to fix' }) }; shell.mTitle = 'New role' }
|
||||||
|
else if (k === 'taken') { R = { ...ROLES.blank, name: 'Writer', desc: 'Reads and writes' }; o = { isNew: true, nameErr: 'A role named "Writer" already exists. Choose a different name.', bar: SaveBar(0, { sdis: true, msg: '1 problem to fix' }) }; shell.mTitle = 'New role' }
|
||||||
|
else if (k === 'esc') { shell.display = 'Elena Rossi'; shell.role = 'admin'; R = ROLES.contrib; R = { ...R, name: 'Contributor', t: { ...R.t, Articles: 'a1ono' } }; o = { open: [], esc: true, ring: 'Articles.publish', tip: 'You can only grant what your role has. Your role can publish only entries you created, so All isn\'t available.', bar: SaveBar(1) } }
|
||||||
|
else if (k === 'conflict') { o = { open: [], alerts: [Banner('warning', 'triangle-alert', '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')], bar: SaveBar(2, { sdis: true, msg: '2 unsaved changes. Save is off until you reload' }) } }
|
||||||
|
else if (k === 'lowered') { shell.display = 'Elena Rossi'; shell.role = 'admin'; o = { ro: true, open: [], alerts: [Banner('error', 'circle-x', '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')] } }
|
||||||
|
else if (k === 'general') { o = { onlyGeneral: false, scroll: 360, open: ['activity', 'users', 'settings'], showHead: false } ; R = ROLES.lead }
|
||||||
|
else if (k === 'owneronly') { shell.display = 'Jonas Weber'; shell.role = 'admin'; R = ROLES.admin; o = { scroll: 300, open: ['roles', 'system'], showHead: false, ro: false, alerts: null } }
|
||||||
|
else if (k === 'custom-row') { R = ROLES.contrib; o = { open: [], chg: ['Case studies.read'], pvChg: false } }
|
||||||
|
else if (flow) {
|
||||||
|
const st = flow
|
||||||
|
if (st === 2) { R = { ...ROLES.blank, name: 'Contributor', desc: 'Writes posts and edits only their own' }; o = { isNew: true, focus: 'desc', open: [], bar: SaveBar(2) }; shell.mTitle = 'New role' }
|
||||||
|
if (st === 3) { R = { ...ROLES.blank, name: 'Contributor', desc: 'Writes posts and edits only their own', all: 'a1onn', t: mkT('a1onn') }; o = { isNew: true, open: [], bar: SaveBar(7), chg: ['Articles.read', 'Articles.create', 'Articles.update'], pvChg: true }; shell.mTitle = 'New role' }
|
||||||
|
if (st === 4) { R = { ...ROLES.contrib, g: { ...ROLES.contrib.g, schema: '100' } }; o = { isNew: true, open: ['schema', 'media'], scroll: 0, bar: SaveBar(9), noPv: false }; shell.mTitle = 'New role' }
|
||||||
|
if (st === 5) { R = ROLES.contrib; o = { isNew: true, open: [], bar: SaveBar(9), ring: null }; shell.mTitle = 'New role' }
|
||||||
|
if (st === 6) { o = { open: [], over: Toast('success', 'circle-check', 'Role created', 'Contributor is ready. Assign it from Users.') } }
|
||||||
|
}
|
||||||
|
const out = Editor(R, o)
|
||||||
|
main = out[0]; if (out[1]) ov.push(out[1])
|
||||||
|
if (s.sheet && m) ov.push(PvSheet(R, { chg: !!o.pvChg, foot: [B('Close', { full: true })] }))
|
||||||
|
} else if (v === 'pv') {
|
||||||
|
let R = ROLES.contrib; crumbs('Settings', 'Roles', 'Contributor')
|
||||||
|
if (k === 'empty') { R = { ...ROLES.blank, name: 'New role' } }
|
||||||
|
if (k === 'readonly') { R = ROLES.writer }
|
||||||
|
const o = { ro: k === 'readonly', noPv: false, open: [], pvChg: k === 'live', chg: k === 'live' ? ['Events.update'] : [] }
|
||||||
|
if (k === 'live') R = { ...R, t: { ...R.t, Events: 'a1aan' } }
|
||||||
|
if (k === 'empty') o.isNew = true
|
||||||
|
const out = Editor(R, o); main = out[0]
|
||||||
|
if (m) ov.push(PvSheet(R, { chg: k === 'live', ro: k === 'readonly', foot: [B('Close', { full: true })] }))
|
||||||
|
} else if (v === 'del') {
|
||||||
|
crumbs('Settings', 'Roles'); const cols = '200px minmax(0,1fr) 90px 100px 40px'
|
||||||
|
const rows = ['owner', 'admin', 'writer', 'contrib', 'lead']
|
||||||
|
main = Page(m ? '16px' : '24px', Title('Roles', 'Who can do what on this site. Each user has one role.', B('New role', { k: 'primary', icon: 'plus', px: 14 })), m ? D('display:flex;flex-direction:column;gap:8px', ...rows.slice(2).map(r => Cd({}, D('display:flex;gap:8px;align-items:center', Hi(ROLES[r].name), D('flex:1'), ROLES[r].built ? Bd('Built-in', 'built') : Bd('Custom', 'own')), Mu(ROLES[r].desc)))) : D('background:var(--ui-bg);border:1px solid var(--ui-border)', Th(cols, 'Role', 'Description', 'Members', 'Type', ''), ...rows.map(r => { const R = ROLES[r]; return Tr(cols, {}, Hi(R.name), Mu(R.desc), S('', R.members), R.built ? Bd('Built-in', 'built') : Bd('Custom', 'own'), Dots()) })))
|
||||||
|
const pick = (R, o = {}) => F('Move members to', o.val || '', { req: true, sel: true, phText: 'Choose a role', focus: o.open, help: 'They keep their API keys. Keys follow the new role.' })
|
||||||
|
const rp = (n, sum, dis, why) => D('padding:8px 10px;display:flex;flex-direction:column;gap:2px;opacity:' + (dis ? 0.5 : 1) + ';min-height:' + (m ? 52 : 44) + 'px;border-top:1px solid var(--ui-border-muted)', D('display:flex;gap:6px;align-items:center', Hi(n), dis && I('lock', 12)), S('font-size:12px;line-height:18px;color:var(--ui-text-muted)', dis ? why : sum))
|
||||||
|
if (k === 'blocked' || k === 'picker') ov.push(Shell({ title: 'Delete Contributor?', desc: '4 members have this role. Move them to another role first. Then Contributor can be deleted.', close: true, cancel: 'Cancel', confirm: 'Move 4 members and delete role', cdis: true, tone: 'danger', top: 140 }, [pick(null, { open: k === 'picker' }), k === 'picker' && D('border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);background:var(--ui-bg);margin-top:-8px', rp('Writer', 'Reads everything and writes content. Can\'t delete or publish.'), rp('Content lead', 'Edits and publishes all content.'), rp('Admin', 'Everything except owner-only settings.', true, 'Your role can\'t assign a role with more power than yours.'))]))
|
||||||
|
if (k === 'confirm') ov.push(Shell({ title: 'Delete Intern?', desc: 'No one has this role. This can\'t be undone.', close: true, cancel: 'Cancel', confirm: 'Delete role', tone: 'danger', top: 140 }, [Mu('Deleting a role doesn\'t affect content.')]))
|
||||||
|
if (k === 'builtin') ov.push(Shell({ title: 'Delete Writer?', desc: 'Built-in roles can\'t be deleted.', close: true, cancel: 'Close', confirm: 'Delete role', cdis: true, tone: 'danger', top: 140 }, [Banner('info', 'lock', 'Writer is built in', 'Duplicate it to make a custom role you can change or delete.', 'Duplicate role')]))
|
||||||
|
} else if (v === 'users') {
|
||||||
|
shell.nav = 'users'; shell.sideOpen = false; crumbs('Users'); const cols = 'minmax(0,1fr) minmax(0,1.2fr) 150px 110px 40px'
|
||||||
|
const U = [['Marta Alves', 'marta@renewbuild.example', 'Owner', 'Active'], ['Jonas Weber', 'jonas@renewbuild.example', 'Admin', 'Active'], ['Ines Duarte', 'ines@renewbuild.example', 'Writer', 'Active'], ['Pedro Lima', 'pedro@renewbuild.example', 'Contributor', 'Active'], ['sofia@renewbuild.example', 'Invited', 'Content lead', 'Pending']]
|
||||||
|
const rows = U.map((u, i) => i === 4 ? [u[0], '', u[2], 'Invite pending'] : u)
|
||||||
|
const tbl = D('background:var(--ui-bg);border:1px solid var(--ui-border);position:relative', Th(cols, 'Name', 'Email', 'Role', 'Status', ''), ...rows.map((u, i) => Tr(cols, { sel: k === 'menu' && i === 2 }, Hi(u[0]), Mu(u[1]), D('display:flex;align-items:center;gap:6px', RB(u[2]), i === 0 && I('lock', 14, 'color:var(--ui-text-muted)')), Mu(u[3]), Dots(k === 'menu' && i === 2 || k === 'ownermenu' && i === 0))),
|
||||||
|
k === 'menu' && Menu(24, 190, [['user-cog', 'Change role', false, true], ['mail', 'Send password reset'], ['user-x', 'Remove user', false, false, true]]),
|
||||||
|
k === 'ownermenu' && Menu(24, 96, [['user-cog', 'Change role', true], ['crown', 'Transfer ownership', false, true], ['user-x', 'Remove user', true]]), k === 'ownermenu' && D('position:absolute;z-index:31;right:260px;top:140px;width:250px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', 'The owner can\'t be changed or removed. Transfer ownership first.'))
|
||||||
|
const cards = D('display:flex;flex-direction:column;gap:8px', ...rows.map(u => Cd({}, D('display:flex;gap:8px;align-items:center', Hi(u[0]), D('flex:1'), IB('ellipsis')), u[1] && Mu(u[1]), D('display:flex;gap:8px;align-items:center', RB(u[2]), Mu(u[3])))))
|
||||||
|
main = Page(m ? '16px 16px 88px' : '24px', Title('Users', '5 members and invites', B('Invite user', { k: 'primary', icon: 'user-plus', px: 14 })), m ? cards : tbl, m && Fab('Invite user'))
|
||||||
|
const radio = (n, sum, on, dis, why) => D('display:flex;gap:10px;padding:10px 12px;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border)') + ';background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';opacity:' + (dis ? 0.5 : 1) + ';min-height:' + (m ? 56 : 52) + 'px', S('margin-top:2px;width:16px;height:16px;flex-shrink:0;border:1px solid var(--ui-border-accented);border-radius:50%;display:flex;align-items:center;justify-content:center;background:var(--ui-bg)', on && S('width:8px;height:8px;border-radius:50%;background:var(--ui-primary)')), D('flex:1;display:flex;flex-direction:column;gap:1px', D('display:flex;gap:6px;align-items:center', S('font-weight:600', n), dis && I('lock', 12), on && S('font-size:12px;font-weight:500', '· Current')), S('font-size:12px;line-height:18px;color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-muted)'), dis ? why : sum)))
|
||||||
|
const SUM = { Owner: ROLES.owner.desc, Admin: ROLES.admin.desc, Writer: ROLES.writer.desc, Contributor: ROLES.contrib.desc, 'Content lead': ROLES.lead.desc }
|
||||||
|
if (k === 'change') ov.push(Shell({ title: 'Change role for Ines Duarte', desc: 'Her API keys follow the new role.', close: true, cancel: 'Cancel', confirm: 'Change role', cdis: false, top: 100 }, D('display:flex;flex-direction:column;gap:8px', radio('Admin', SUM.Admin), radio('Writer', SUM.Writer, true), radio('Contributor', SUM.Contributor), radio('Content lead', SUM['Content lead']))))
|
||||||
|
if (k === 'changeblock') { shell.role = 'admin'; shell.display = 'Jonas Weber'; ov.push(Shell({ title: 'Change role for Ines Duarte', desc: 'Her API keys follow the new role.', close: true, cancel: 'Cancel', confirm: 'Change role', cdis: true, top: 100 }, D('display:flex;flex-direction:column;gap:8px', radio('Admin', SUM.Admin, false, false), radio('Owner', SUM.Owner, false, true, 'There is one owner. Transfer ownership to change who it is.'), radio('Writer', SUM.Writer, true), radio('Content lead', SUM['Content lead'], false, true, 'It can publish. Your role can\'t grant that.')))) }
|
||||||
|
if (k === 'invite') ov.push(Shell({ title: 'Invite user', desc: 'They get an email with a link to set a password.', close: true, cancel: 'Cancel', confirm: 'Send invite', top: 120 }, [F('Email', 'sofia@renewbuild.example', {}), F('Role', 'Writer', { sel: true, help: SUM.Writer }), Mu('Writer is the default. You can change a role later.')]))
|
||||||
|
if (k === 'pending') { shell.display = 'Marta Alves'; ov.push(D('display:contents')) }
|
||||||
|
if (k === 'tr1' || k === 'tr2' || k === 'tr3') {
|
||||||
|
const tit = 'Transfer ownership'
|
||||||
|
if (k === 'tr1') ov.push(Shell({ title: tit, desc: 'Choose who becomes the owner. Only members can be chosen.', close: true, cancel: 'Cancel', confirm: 'Continue', cdis: false, top: 100 }, D('display:flex;flex-direction:column;gap:8px', radio('Jonas Weber', 'jonas@renewbuild.example · Admin', true), radio('Ines Duarte', 'ines@renewbuild.example · Writer'), radio('Pedro Lima', 'pedro@renewbuild.example · Contributor'))))
|
||||||
|
if (k === 'tr2') ov.push(Shell({ title: tit + ' to Jonas Weber', desc: 'This takes effect straight away.', close: true, cancel: 'Back', confirm: 'Transfer ownership', cdis: true, tone: 'danger', top: 80 }, [D('display:flex;flex-direction:column;gap:6px', ...['Jonas Weber becomes the owner.', 'You become an Admin. You can\'t take ownership back unless he transfers it.', 'Only the owner can apply updates, delete a site and change activity retention.', 'Your API keys follow your new role.'].map(t => D('display:flex;gap:8px', IC('arrow-right'), S('text-wrap:pretty;color:var(--ui-text-highlighted)', t)))), F('Type jonas@renewbuild.example to confirm', 'jonas@renewbuild', { focus: true, help: 'The email must match exactly.' })]))
|
||||||
|
if (k === 'tr3') { main = Page(m ? '16px 16px 88px' : '24px', Title('Users', '5 members and invites', B('Invite user', { k: 'primary', icon: 'user-plus', px: 14, dis: false })), m ? cards : D('background:var(--ui-bg);border:1px solid var(--ui-border)', Th(cols, 'Name', 'Email', 'Role', 'Status', ''), ...[['Jonas Weber', 'jonas@renewbuild.example', 'Owner', 'Active'], ['Marta Alves', 'marta@renewbuild.example', 'Admin', 'Active'], ['Ines Duarte', 'ines@renewbuild.example', 'Writer', 'Active']].map(u => Tr(cols, {}, Hi(u[0]), Mu(u[1]), RB(u[2]), Mu(u[3]), Dots())))); shell.role = 'admin'; ov.push(Toast('success', 'circle-check', 'Ownership transferred', 'Jonas Weber is now the owner. You are an Admin.')) }
|
||||||
|
}
|
||||||
|
} else if (v === 'shell') {
|
||||||
|
shell.role = 'user'; shell.display = 'Pedro Lima'; shell.nav = k.startsWith('art') ? 'articles' : 'content'; shell.sideOpen = false
|
||||||
|
const cols = 'minmax(0,1fr) 140px 110px 120px 40px'
|
||||||
|
const lk = (t) => S('display:inline-flex;align-items:center;gap:4px;color:var(--ui-text-muted);font-size:12px', I('lock', 12), t)
|
||||||
|
const entry = (t, by, st, own, o2 = {}) => Tr(cols, { dim: false, sel: o2.sel }, D('display:flex;align-items:center;gap:8px;min-width:0', Hi(t), !own && lk('View only')), Mu(by), Bd(st, st === 'Published' ? 'ok' : 'req'), Mu('Oct 14'), Dots(o2.menu))
|
||||||
|
const cardE = (t, by, st, own) => Cd({}, D('display:flex;gap:8px;align-items:center', Hi(t), D('flex:1'), IB('ellipsis')), D('display:flex;gap:8px;align-items:center', Bd(st, st === 'Published' ? 'ok' : 'req'), Mu(by), !own && lk('View only')))
|
||||||
|
const A = [['How we price a bathroom renovation', 'Ines Duarte', 'Published', 1], ['Choosing lime plaster', 'Pedro Lima', 'Draft', 1], ['Retrofit grants in 2027', 'Jonas Weber', 'Published', 1], ['Site safety checklist', 'Pedro Lima', 'Draft', 1]]
|
||||||
|
const own = k === 'own' || k === 'ownmenu'
|
||||||
|
const E = [['Lime plaster workshop', 'Pedro Lima', 'Draft', 1], ['Open site day', 'Ines Duarte', 'Published', 0], ['Retrofit talk', 'Jonas Weber', 'Published', 0], ['Tool library evening', 'Pedro Lima', 'Draft', 1]]
|
||||||
|
const data = own ? E : A
|
||||||
|
const tbl = D('background:var(--ui-bg);border:1px solid var(--ui-border);position:relative', Th(cols, 'Title', 'Created by', 'Status', 'Updated', ''), ...data.map((r, i) => entry(r[0], r[1], r[2], r[3], { menu: (k === 'artmenu' && i === 1) || (k === 'ownmenu' && i === 1) })),
|
||||||
|
k === 'artmenu' && Menu(24, 140, [['pencil', 'Edit', false, true], ['copy', 'Duplicate'], ['trash-2', 'Delete', true, false, true]]),
|
||||||
|
k === 'artmenu' && D('position:absolute;z-index:31;right:260px;top:196px;width:230px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', 'Your role can\'t delete articles.'),
|
||||||
|
k === 'ownmenu' && Menu(24, 140, [['pencil', 'Edit', true, true], ['copy', 'Duplicate'], ['trash-2', 'Delete', true, false, true]]),
|
||||||
|
k === 'ownmenu' && D('position:absolute;z-index:31;right:260px;top:150px;width:240px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', 'You can only edit content you created.'))
|
||||||
|
if (k === 'art' || k === 'artmenu' || k === 'own' || k === 'ownmenu') {
|
||||||
|
const ttl = own ? 'Events' : 'Articles'; crumbs(own ? 'Content' : 'Articles', ...(own ? ['Events'] : [])); if (own) shell.contentOn = 'events'
|
||||||
|
main = Page(m ? '16px 16px 88px' : '24px', Title(ttl, own ? 'You can create entries and edit your own.' : 'You can write and edit all articles. Publishing is done by an editor.', B(own ? 'New entry' : 'New article', { k: 'primary', icon: 'plus', px: 14 })), own && Banner('info', 'info', 'Entries by others are view only', 'Your role lets you edit entries you created.'), m ? D('display:flex;flex-direction:column;gap:8px', ...data.map(r => cardE(r[0], r[1], r[2], r[3]))) : tbl, m && Fab(own ? 'New entry' : 'New article'))
|
||||||
|
}
|
||||||
|
if (k === 'entry') {
|
||||||
|
crumbs('Articles', 'Choosing lime plaster'); shell.mLead = 'arrow-left'
|
||||||
|
const side = D('width:' + (m ? 'auto' : '280px') + ';flex-shrink:0;display:flex;flex-direction:column;gap:12px;background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Status'), D('display:flex;align-items:center;gap:8px', Bd('Draft', 'req'), Mu('Saved at 11:02')), D('display:flex;gap:6px;color:var(--ui-text-muted);font-size:12px;line-height:18px', I('info', 14, 'margin-top:2px'), 'Ask an editor to publish.'))
|
||||||
|
const ed = D('flex:1;min-width:0;display:flex;flex-direction:column;gap:12px', F('Title', 'Choosing lime plaster', { req: true }), D('border:1px solid var(--ui-border-accented);background:var(--ui-bg);min-height:' + (m ? 220 : 380) + 'px;padding:12px;color:var(--ui-text-toned);text-wrap:pretty', 'Lime plaster lets a wall move moisture in and out. Before you pick one, check what the wall is made of.'))
|
||||||
|
main = Page(m ? '16px 16px 88px' : '24px', Title('Choosing lime plaster', null, B('Save draft', { k: 'primary', px: 14 })), D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';gap:16px;align-items:flex-start;flex:1;min-height:0', ed, side))
|
||||||
|
}
|
||||||
|
if (k === 'rule') {
|
||||||
|
shell.role = 'user'; crumbs('Home'); shell.nav = 'home'
|
||||||
|
const col = (t, sub, ...c) => D('flex:1;background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:10px;min-width:0', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', t), Mu(sub), ...c)
|
||||||
|
const r = (ic, t, ex) => D('display:flex;gap:8px;align-items:flex-start', IC(ic), D('display:flex;flex-direction:column;gap:2px', Hi(t), Mu(ex)))
|
||||||
|
main = Page(m ? '16px' : '24px', Title('Home', 'Pedro Lima · Contributor'), D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';gap:16px', col('Hidden', 'The person can never use it.', r('eye-off', 'Sections of the sidebar', 'Schema, Users, Integration and Settings are not listed.'), r('eye-off', 'Buttons for another area', 'No Publish button when the role can\'t publish.')), col('Disabled, with a reason', 'The person sees the thing but can\'t act on it.', r('ban', 'Delete on an entry they can see', 'Your role can\'t delete articles.'), r('ban', 'Edit on someone else\'s entry', 'You can only edit content you created.')), col('Read-only', 'The person may look but not change.', r('lock', 'Settings pages for an admin', 'A banner says who can edit.'))))
|
||||||
|
}
|
||||||
|
} else if (v === 'admin') {
|
||||||
|
shell.role = 'admin'; shell.display = 'Jonas Weber'; shell.nav = 'settings'; shell.sideOpen = true
|
||||||
|
if (k === 'ro') { crumbs('Settings', 'General'); main = Page(m ? '16px 16px 88px' : '24px', Title('General', 'Site name, language and defaults.'), Banner('info', 'lock', 'You can read these settings but not change them', 'Ask the owner to change them, or ask for a role that can edit settings.'), Card('Site', null, null, F('Site name', 'Renew Build', { lock: true }), F('Default language', 'English', { lock: true, sel: false })), D('display:flex;gap:8px;opacity:0.4', B('Save changes', { k: 'primary' }))) }
|
||||||
|
if (k === 'owneronly') { crumbs('Settings', 'Software'); main = Page(m ? '16px' : '24px', Title('Software', 'Version 2.8.0'), Card('Update available', 'Version 2.9.0 is ready to install.', Bd('Owner only', 'lock', 'lock'), D('display:flex;align-items:center;gap:12px;flex-wrap:wrap', B('Apply update', { k: 'primary', icon: 'download', dis: true }), S('color:var(--ui-text-muted)', 'Only the owner can apply updates.'))), Card('Activity retention', 'How long the activity log is kept.', Bd('Owner only', 'lock', 'lock'), F('Keep activity for', '12 months', { lock: true, help: 'Admins can read the log. Only the owner can change how long it is kept.' }))) }
|
||||||
|
} else if (v === 'forbid') {
|
||||||
|
shell.role = 'user'; shell.display = 'Pedro Lima'; shell.sideOpen = false; shell.nav = 'home'; crumbs('Roles')
|
||||||
|
if (k === 'page') main = Center('lock', 'You don\'t have access to this page', 'Your role, Contributor, doesn\'t include it. Ask an owner or admin if you need it.', B('Go to Articles', { k: 'primary', px: 16 }), B('See what I can do'))
|
||||||
|
if (k === 'missing') { crumbs('Articles', 'Choosing lime plaster'); shell.nav = 'articles'; main = Page(m ? '16px' : '24px', Title('Choosing lime plaster'), F('Title', 'Choosing lime plaster', {}), D('background:var(--ui-bg);border:1px solid var(--ui-border);min-height:180px;padding:12px;color:var(--ui-text-toned)', 'Lime plaster lets a wall move moisture in and out.')); ov.push(Toast('error', 'circle-x', 'You don\'t have permission to do that', 'Your role doesn\'t include Delete entries. Ask an owner or admin.')) }
|
||||||
|
if (k === 'locked') { shell.role = 'admin'; shell.display = 'Jonas Weber'; crumbs('Settings', 'Roles', 'Owner'); main = Page(m ? '16px' : '24px', Title('Owner'), F('Name', 'Owner', { lock: true })); ov.push(Toast('error', 'lock', 'This role is locked', 'The Owner role can\'t be changed or deleted. Transfer ownership from Users instead.')) }
|
||||||
|
} else if (v === 'act') {
|
||||||
|
shell.nav = 'activity'; shell.sideOpen = false; crumbs('Activity'); const cols = '28px minmax(0,1fr) 140px 120px 40px'
|
||||||
|
const E = [['shield', 'Role Contributor created', 'Marta Alves', 'Oct 14, 10:41'], ['pencil', 'Role Contributor edited', 'Marta Alves', 'Oct 14, 10:52'], ['user-cog', 'Ines Duarte\'s role changed from Writer to Content lead', 'Marta Alves', 'Oct 14, 11:02'], ['crown', 'Ownership transferred from Marta Alves to Jonas Weber', 'Marta Alves', 'Oct 15, 09:10'], ['trash-2', 'Role Intern deleted', 'Marta Alves', 'Oct 15, 09:30']]
|
||||||
|
const tbl = D('background:var(--ui-bg);border:1px solid var(--ui-border)', Th(cols, '', 'Event', 'By', 'When', ''), ...E.map((e, i) => Tr(cols, { sel: k === 'detail' && i === 1 }, I(e[0], 16, 'color:var(--ui-text-toned)'), Hi(e[1]), Mu(e[2]), Mu(e[3]), I('chevron-right', 16, 'color:var(--ui-text-muted)'))))
|
||||||
|
const cards = D('display:flex;flex-direction:column;gap:8px', ...E.map(e => Cd({}, D('display:flex;gap:8px', IC(e[0]), Hi(e[1])), Mu(e[2] + ' · ' + e[3]))))
|
||||||
|
main = Page(m ? '16px' : '24px', Title('Activity', 'Everything that happened on this site.'), m ? cards : tbl)
|
||||||
|
if (k === 'detail') { const body = D('display:flex;flex-direction:column;gap:12px', D('display:flex;flex-direction:column;gap:2px', Hi('Role Contributor edited'), Mu('Marta Alves · Oct 14, 10:52')), D('display:flex;flex-direction:column;gap:6px;padding:12px;background:var(--ui-bg-elevated)', S('font-weight:600;color:var(--ui-text-highlighted)', 'What changed'), D('display:flex;gap:8px', Bd('Added', 'ok', 'plus'), S('color:var(--ui-text-highlighted)', 'Delete entries (Own)')), D('display:flex;gap:8px', Bd('Removed', 'err', 'minus'), S('color:var(--ui-text-highlighted)', 'Publish'))), Mu('4 members have this role.')); ov.push(m ? Sheet({ title: 'Activity details' }, body, [B('Close', { full: true })]) : D('position:absolute;inset:0;z-index:40;background:var(--rc-overlay);display:flex;justify-content:flex-end', D('width:440px;height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column', D('padding:20px 24px 0;display:flex;align-items:center', S('flex:1;font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Activity details'), IB('x')), D('padding:20px 24px', body)))) }
|
||||||
|
} else if (v === 'prof') {
|
||||||
|
shell.nav = 'profile'; shell.role = 'user'; shell.display = 'Pedro Lima'; shell.sideOpen = false; crumbs('Profile')
|
||||||
|
const R = ROLES.contrib
|
||||||
|
main = Page(m ? '16px' : '24px', Title('Profile', 'Your account.'), Card('Account', null, null, F('Name', 'Pedro Lima', {}), F('Email', 'pedro@renewbuild.example', { lock: true })), Card('Your role', null, null, D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', RB('Contributor'), Mu('Assigned by Marta Alves')), Mu(R.desc), D('display:flex;align-items:center;gap:6px;color:var(--ui-text-highlighted);font-weight:500', I('list-checks', 16), Link('See what I can do')), Mu('Your API keys follow this role. If your role changes, they change too.')))
|
||||||
|
if (k === 'sheet') { const body = [PvBody(R, { ro: true })]; ov.push(m ? PvSheet(R, { ro: true, title: 'What you can do', sub: 'This is a summary of your role.', foot: [B('Close', { full: true })] }) : Shell({ title: 'What you can do', desc: 'A summary of your role, Contributor. Read-only.', close: true, cancel: 'Close', top: 60 }, body)) }
|
||||||
|
} else if (v === 'keys') {
|
||||||
|
shell.nav = 'settings'; shell.sideOpen = true; crumbs('Settings', 'API keys'); const cols = 'minmax(0,1fr) 140px 130px 110px 40px'
|
||||||
|
main = Page(m ? '16px' : '24px', Title('API keys', 'Keys for apps that read or write your content.', B('New key', { k: 'primary', icon: 'plus', px: 14 })), Banner('info', 'info', '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.'), m ? D('display:flex;flex-direction:column;gap:8px', Cd({}, Hi('Website build'), Mu('Marta Alves · Owner')), Cd({}, Hi('Newsletter sync'), Mu('Pedro Lima · Contributor'))) : D('background:var(--ui-bg);border:1px solid var(--ui-border)', Th(cols, 'Name', 'Created by', 'Follows role', 'Created', ''), Tr(cols, {}, Hi('Website build'), Mu('Marta Alves'), RB('Owner'), Mu('Sep 2'), Dots()), Tr(cols, {}, Hi('Newsletter sync'), Mu('Pedro Lima'), RB('Contributor'), Mu('Oct 3'), Dots())))
|
||||||
|
}
|
||||||
|
if (m && shell.drawer === undefined && s.drawer) shell.drawer = true
|
||||||
|
const framed = true
|
||||||
|
return { framed, bare: false, shell, mob: m, main, ov: D('display:contents', ...ov), bw: '1440px', bh: '900px' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
<!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>
|
||||||
|
<sc-if value="{{ framed }}" hint-placeholder-val="{{ true }}"><dc-import name="AppFrame" s="{{ shell }}" mobile="{{ mob }}" slot-body="{{ main }}" slot-overlays="{{ ov }}" hint-size="100%,100%"></dc-import></sc-if>
|
||||||
|
<sc-if value="{{ bare }}"><div style="width:{{ bw }};height:{{ bh }};overflow:hidden;position:relative;background:var(--ui-bg-muted);color:var(--ui-text);font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px">{{ main }}</div></sc-if>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1440,"height":900},"s":{"editor":null,"tsType":"object"},"mobile":{"editor":null,"tsType":"boolean"}}">
|
||||||
|
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 mono = 'font-family:\'IBM Plex Mono\',monospace;'
|
||||||
|
const bh = m ? 44 : 32, ih = m ? 44 : 32, 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)' : k === 'ghost' ? 'transparent' : '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 : k === 'ghost' ? 'transparent' : 'var(--ui-border-accented)'
|
||||||
|
return S('height:' + (o.h || bh) + 'px;' + (o.w ? 'width:' + o.w + '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;flex-shrink:0;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 IB = (ic, o = {}) => B(null, { k: 'ghost', icon: ic, w: o.w || (m ? 44 : 28), h: o.h || (m ? 44 : 28), px: 0, dis: o.dis, ring: o.ring, z: o.z || 16 })
|
||||||
|
const Alert = (tone, icon, title, text, ...actions) => 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-wrap:pretty', text)), actions.length > 0 && D('display:flex;gap:8px;flex-wrap:wrap', ...actions.map((a, i) => B(a, { icon: i === 0 && /again|Retry|Reload/.test(a) ? 'rotate-cw' : null, z: 14, h: m ? 44 : 28, fs: 12, px: 10, k: 'outline' })))))
|
||||||
|
const Later = (t = 'Later') => S('align-self:flex-start;height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);white-space:nowrap;flex-shrink:0;font:500 11px/16px \'IBM Plex Mono\',monospace', t)
|
||||||
|
const BDG = { req: 'border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', uniq: 'border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', neu: 'background:var(--rc-sand);color:var(--rc-on-sand)', chg: 'background:var(--rc-info-soft);color:var(--ui-info)', err: 'background:var(--rc-error-soft);color:var(--ui-error)', warn: 'background:var(--rc-warning-soft);color:var(--ui-warning)', built: 'border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', ok: 'background:var(--rc-success-soft);color:var(--ui-success)' }
|
||||||
|
const Bd = (t, k, ic) => S('height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:500;white-space:nowrap;flex-shrink:0;' + BDG[k], ic && I(ic, 12), t)
|
||||||
|
const Seg = (opts, on, w) => D('display:flex;border:1px solid var(--ui-border-accented);width:' + (w || '100%'), ...opts.map(([k, t]) => S('flex:1;height:' + bh + 'px;padding:0 10px;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:' + fs + 'px', t)))
|
||||||
|
const SwR = (on, lab, help, dis) => D('display:flex;gap:16px;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--ui-border);opacity:' + (dis ? 0.6 : 1), D('display:flex;flex-direction:column;gap:2px;min-width:0', 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)), S('width:32px;height:18px;padding:2px;display:flex;flex-shrink:0;justify-content:' + (on ? 'flex-end' : 'flex-start') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg-accented)') + ';' + (on ? '' : 'outline:1px solid var(--ui-border-accented);outline-offset:-1px'), S('width:14px;height:14px;background:var(--ui-bg)')))
|
||||||
|
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)') + ';' + (on ? '' : 'outline:1px solid var(--ui-border-accented);outline-offset:-1px'), S('width:14px;height:14px;background:var(--ui-bg)')), D('display:flex;flex-direction:column;gap:2px', lab && 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 Chk = on => S('width:16px;height:16px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg)') + ';color:var(--ui-bg)', on && I('check', 12))
|
||||||
|
const Skel = (w, hh = 14) => S('display:block;height:' + hh + 'px;width:' + w + ';background:var(--ui-bg-accented)')
|
||||||
|
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.req && ' *', o.opt && S('font-weight:400;color:var(--ui-text-muted)', ' (optional)')), o.count && S(mono + 'font-size:12px;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 : 88) : 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 || o.lock ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';font-size:' + fs + 'px;opacity:' + (o.dis ? 0.6 : 1), o.pre, S('flex:1;min-width:0;padding:' + (o.area ? '8px 10px' : '0 10px') + ';color:' + (o.ph || val === '' ? '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.phText ? o.phText : val), o.auto && Bd('Auto', 'neu'), o.auto && I('pencil', 14, 'margin:0 10px 0 8px;color:var(--ui-text-muted)'), o.lock && I('lock', 14, 'margin-right:10px;color:var(--ui-text-muted)'), o.sel && I('chevron-down', 14, 'margin-right:10px;color:var(--ui-text-muted)'), o.suffix && S('padding-right:10px;color:var(--ui-text-muted);font-size:12px', o.suffix), o.ico && I(o.ico, 14, 'margin-right:10px;color:var(--ui-text-muted)')),
|
||||||
|
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 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.later && Later(), 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 Sheet = (o, body, foot) => D('position:absolute;inset:0;z-index:' + (o.z || 40) + ';display:flex;align-items:flex-end;background:var(--rc-overlay)',
|
||||||
|
D('width:100%;height:92%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column', 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:4px 6px 4px 16px;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), o.later && Later(), S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
|
||||||
|
D('flex:1;min-height:0;overflow:hidden;padding:16px;display:flex;flex-direction:column;gap:16px', body), foot && D('flex-shrink:0;padding:12px 16px;border-top:1px solid var(--ui-border);display:flex;gap:8px', foot)))
|
||||||
|
const Toast = (tone, icon, title, text, action) => D('position:absolute;z-index:60;display:flex;gap:10px;padding:12px 14px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);' + (m ? 'left:16px;right:16px;bottom:' + (s.toastB || 16) + 'px' : 'right:24px;bottom:24px;width:356px'), S('margin-top:2px;display:flex;color:var(--ui-' + tone + ')', I(icon)), 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)', title), S('color:var(--ui-text-muted)', text)), action && B(action, { icon: 'rotate-cw', z: 14, h: m ? 44 : 28, fs: 12, px: 10 })), I('x'))
|
||||||
|
const Offline = () => D('flex-shrink:0;display:flex;align-items:center;gap:10px;padding:10px 16px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted)', I('wifi-off'), S('flex:1', 'You\'re offline. Changes will be saved when you reconnect.'))
|
||||||
|
const Tip = t => D('position:absolute;z-index:30;width:260px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', t)
|
||||||
|
const Page = (pad, ...c) => D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;position:relative;overflow:hidden;padding:' + pad + ';gap:16px', ...c)
|
||||||
|
const Title = (t, sub, ...acts) => D('display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap;flex-shrink:0', D('flex:1;min-width:200px;display:flex;flex-direction:column;gap:2px', !m && S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', t), sub && S('color:var(--ui-text-muted)', sub)), ...acts)
|
||||||
|
const pad = m ? '16px' : '24px'
|
||||||
|
const Fab = l => m && D('position:absolute;left:16px;right:16px;bottom:16px;height:48px;z-index:5;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), l)
|
||||||
|
const Center = (icon, title, text, ...acts) => D('flex:1;display:flex;align-items:center;justify-content:center;padding:24px', D('max-width:420px;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:600 24px/32px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted);text-wrap:pretty', text), D('display:flex;gap:8px', ...acts)))
|
||||||
|
const Empty = (icon, title, text, action) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:40px 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:360px;text-wrap:pretty', text), action && B(action, { k: 'primary', icon: 'plus', px: 16 }))
|
||||||
|
|
||||||
|
// ---------- data
|
||||||
|
const FT = { text: ['type', 'Text', 'Short text, like a title or a name'], richtext: ['pilcrow', 'Rich text', 'Long text with formatting, written in Markdown'], number: ['hash', 'Number', 'Whole or decimal numbers'], boolean: ['toggle-left', 'Yes or no', 'An on or off switch'], date: ['calendar', 'Date', 'A calendar day, without a time'], select: ['list', 'Choice', 'One option from a list you define'], media: ['image', 'Media', 'One image from your media library'] }
|
||||||
|
const EV = [
|
||||||
|
{ key: 'title', label: 'Title', type: 'text', req: true, uniq: true, max: 80 }, { key: 'starts_on', label: 'Starts on', type: 'date', req: true }, { key: 'ends_on', label: 'Ends on', type: 'date' },
|
||||||
|
{ key: 'online', label: 'Online', type: 'boolean', def: false }, { key: 'capacity', label: 'Capacity', type: 'number', min: 1, max: 5000 }, { key: 'category', label: 'Category', type: 'select', opts: ['Talk', 'Workshop', 'Meetup'] }, { key: 'description', label: 'Description', type: 'richtext' }].map(f => ({ ...f, saved: true }))
|
||||||
|
const RC = [{ key: 'title', label: 'Title', type: 'text', req: true }, { key: 'servings', label: 'Servings', type: 'number', min: 1 }, { key: 'vegetarian', label: 'Vegetarian', type: 'boolean' }, { key: 'method', label: 'Method', type: 'richtext' }].map(f => ({ ...f, saved: true }))
|
||||||
|
const SPK = { key: 'speaker', label: 'Speaker', type: 'text', isNew: true }, ROOM = { key: 'room', label: 'Room', type: 'select', req: true, def: 'Main hall', opts: ['Main hall', 'Studio'], isNew: true }
|
||||||
|
const CAPC = { ...EV[4], max: 2000, changed: true }
|
||||||
|
const DIRTY = [EV[0], EV[1], EV[2], EV[3], CAPC, EV[5], SPK, ROOM]
|
||||||
|
const CT = n => ['Events', 'Recipes', 'Press mentions', 'Frequently asked questions and answers', 'Job openings', 'Testimonials', 'Suppliers', 'Certifications', 'Case studies', 'Glossary terms', 'Partners', 'Downloads', 'Locations', 'Awards'].slice(0, n).map(x => ({ key: x === 'Events' ? 'events' : x.toLowerCase().replace(/[^a-z0-9]+/g, '-'), name: x, icon: 'files' }))
|
||||||
|
const shell = { kind: 'slot', display: 'Marta Alves', role: s.role || 'owner', content: CT(s.many ? 14 : 3), contentOn: 'events', navBadge: false }
|
||||||
|
const BUILTIN = [['Articles', 'articles', 5], ['Authors', 'authors', 5], ['Sites', 'sites', 8], ['Media', 'media', 6], ['Users', 'users', 6]]
|
||||||
|
const CUSTOM = [['Events', 'events', 7, '15 Oct 2026'], ['Recipes', 'recipes', 4, '2 Oct 2026'], ['Press mentions', 'press-mentions', 5, '18 Sep 2026']]
|
||||||
|
const ROWS = [
|
||||||
|
{ id: 42, title: 'Lime plaster workshop', starts_on: '21 Nov 2026', ends_on: '22 Nov 2026', online: false, capacity: 18, category: 'Workshop', description: 'Two days of hands-on work with lime putty and old stone.', c: '12 Oct 2026', u: '15 Oct 2026, 11:02', by: 'Marta Alves' },
|
||||||
|
{ id: 41, title: 'Heat pumps: what the numbers say', starts_on: '3 Nov 2026', ends_on: '', online: true, capacity: 500, category: 'Talk', description: 'A look at running costs after two winters.', c: '8 Oct 2026', u: '14 Oct 2026, 16:40', by: 'Jonas Pereira' },
|
||||||
|
{ id: 40, title: 'Open day at the Rue Verte site', starts_on: '12 Nov 2026', ends_on: '12 Nov 2026', online: false, capacity: 120, category: 'Meetup', description: '', c: '2 Oct 2026', u: '12 Oct 2026, 09:15', by: 'Inès Duarte' },
|
||||||
|
{ id: 39, title: 'Winter windows clinic', starts_on: '5 Dec 2026', ends_on: '', online: false, capacity: null, category: 'Workshop', description: 'Bring a window, leave with a plan.', c: '30 Sep 2026', u: '9 Oct 2026, 14:22', by: 'Marta Alves' },
|
||||||
|
{ id: 38, title: 'Timber joinery for beginners', starts_on: '14 Jan 2027', ends_on: '15 Jan 2027', online: false, capacity: 12, category: 'Workshop', description: 'Mortise, tenon and a lot of patience.', c: '28 Sep 2026', u: '7 Oct 2026, 10:05', by: 'Inès Duarte' },
|
||||||
|
{ id: 37, title: 'Q&A with the site managers', starts_on: '28 Oct 2026', ends_on: '', online: true, capacity: null, category: 'Meetup', description: '', c: '21 Sep 2026', u: '2 Oct 2026, 17:48', by: 'Jonas Pereira' },
|
||||||
|
{ id: 36, title: 'Reclaimed floorboards talk', starts_on: '17 Oct 2026', ends_on: '', online: false, capacity: 60, category: 'Talk', description: 'Where to find them and how to lay them.', c: '14 Sep 2026', u: '29 Sep 2026, 08:30', by: 'Marta Alves' }]
|
||||||
|
ROWS.forEach((r, i) => { r.thumbnail = ['ok', 'ok', '', 'miss', 'ok', '', 'ok'][i] })
|
||||||
|
const mdSample = 'Two days of hands-on work with **lime putty** and old stone.\n\n- Bring work clothes\n- Lunch is included'
|
||||||
|
const sampleVal = f => f.type === 'text' ? (f.def ?? 'Example text') : f.type === 'richtext' ? (f.def ?? 'Markdown text') : f.type === 'number' ? (f.def ?? f.min ?? 0) : f.type === 'boolean' ? (f.def ?? false) : f.type === 'date' ? (f.def ?? '2026-11-21') : (f.def ?? (f.opts && f.opts[0]) ?? null)
|
||||||
|
const apiJson = (fs_, key) => JSON.stringify({ id: 42, content_type: key, data: Object.fromEntries(fs_.map(f => [f.key, sampleVal(f)])), created_by: 3, updated_by: 3, created_at: '2026-10-15T11:02:00Z', updated_at: '2026-10-15T11:02:00Z' }, null, 2)
|
||||||
|
const MM = t => t === 'number' ? ['Lowest value', 'Highest value', ''] : t === 'date' ? ['Earliest date', 'Latest date', ''] : ['Shortest', 'Longest', 'characters']
|
||||||
|
const dot = (t, ic) => S('display:inline-flex;align-items:center;gap:6px', I(ic, 14), t)
|
||||||
|
|
||||||
|
// ---------- inspector
|
||||||
|
const stripe = 'repeating-linear-gradient(135deg,var(--ui-bg-elevated) 0 6px,var(--ui-bg-accented) 6px 7px)'
|
||||||
|
const THUMB = { key: 'thumbnail', label: 'Thumbnail', type: 'media', saved: true }
|
||||||
|
const Ph = (w, hh, ex, ...c) => D('width:' + w + ';' + (hh ? 'height:' + hh + ';' : '') + 'flex-shrink:0;box-sizing:border-box;display:flex;align-items:center;justify-content:center;background:' + stripe + ';color:var(--ui-text-toned);' + mono + 'font-size:11px;line-height:16px;' + (ex || ''), ...c)
|
||||||
|
const Miss = (w, hh, ic = 'image-off', z = 20, ex = '') => h('div', { role: 'img', 'aria-label': 'Image no longer in the library', style: P('width:' + w + ';' + (hh ? 'height:' + hh + ';' : '') + 'flex-shrink:0;display:flex;align-items:center;justify-content:center;border:1px dashed var(--ui-border-accented);color:var(--ui-text-muted);box-sizing:border-box;' + ex) }, I(ic, z))
|
||||||
|
const cellM = (st, z) => st === 'ok' ? Ph(z + 'px', z + 'px', '') : st === 'miss' ? Miss(z + 'px', z + 'px', 'unlink', z > 32 ? 14 : 12) : S('color:var(--ui-text-muted);width:' + z + 'px;text-align:center;display:inline-block', '–')
|
||||||
|
const MThumb = o => D('position:relative;flex-shrink:0;box-sizing:border-box;overflow:hidden;width:' + (m ? '100%' : '160px') + ';' + (m ? 'aspect-ratio:16/9;' : 'height:120px;') + 'border:1px solid ' + (o.hover && !m ? 'var(--ui-text-muted)' : 'var(--ui-border-accented)') + ';' + (o.focus ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : '') + 'opacity:' + (o.dis ? 0.6 : 1), Ph('100%', '100%', '', '1600 × 900'), o.hover && !m && D('position:absolute;left:0;right:0;bottom:0;height:28px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;font-weight:500', I('image', 14), 'Change image'))
|
||||||
|
const MCtl = (f, o = {}) => {
|
||||||
|
const dis = !!o.dis, st = o.st || 'empty'
|
||||||
|
const bd = o.err ? 'border:1px solid var(--ui-error);box-shadow:inset 0 0 0 1px var(--ui-error);' : st === 'empty' ? 'border:1px dashed var(--ui-border-accented);' : 'border:1px solid var(--ui-border-accented);'
|
||||||
|
const row = (...c) => D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';gap:' + (m ? 12 : 16) + 'px;padding:12px;align-items:' + (m ? 'stretch' : 'flex-start'), ...c)
|
||||||
|
const col = (...c) => D('flex:1;min-width:0;display:flex;flex-direction:column;gap:12px;' + (m ? '' : 'justify-content:space-between;min-height:120px'), ...c)
|
||||||
|
let inner
|
||||||
|
if (st === 'empty') inner = D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';align-items:' + (m ? 'stretch' : 'center') + ';gap:12px;padding:12px', D('display:flex;align-items:center;gap:12px;flex:1;min-width:0', S('width:40px;height:40px;display:flex;align-items:center;justify-content:center;background:var(--ui-bg-elevated);color:var(--ui-text-toned);flex-shrink:0', I('image', 20)), S('color:var(--ui-text-muted)', 'No image selected.')), B('Pick an image', { icon: 'image-plus', dis, ring: o.ringBtn === 'pick' }))
|
||||||
|
else if (st === 'filled') inner = row(MThumb(o), col(D('display:flex;flex-direction:column;gap:2px;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', 'lime-plaster-workshop.jpg'), S('color:var(--ui-text-muted);font-size:12px;line-height:18px', '1600 × 900 · 240 KB')), D('display:flex;gap:8px', B('Change image', { icon: 'image', dis, full: m }), B('Remove', { k: 'ghost', icon: 'x', dis, full: m }))))
|
||||||
|
else inner = row(Miss(m ? '100%' : '160px', m ? null : '120px', 'image-off', 24, (m ? 'aspect-ratio:16/9;' : '') + (dis ? 'opacity:0.6;' : '')), col(D('display:flex;flex-direction:column;gap:4px', S('font-weight:500;color:var(--ui-text-highlighted)', 'This image is no longer in the library'), S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', f.req ? 'Thumbnail is required. Choose another image to save.' : 'You can still save. The entry keeps the link until you change it.')), D('display:flex;gap:8px;flex-wrap:wrap', B('Choose another image', { icon: 'image', dis, ring: o.ringBtn === 'choose', full: m }), B('Clear', { k: 'ghost', dis, full: m }))))
|
||||||
|
return D('display:flex;flex-direction:column;gap:6px;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', f.label, f.req && ' *'), D(bd + 'background:var(--ui-bg);box-sizing:border-box', inner), 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) : st === 'missing' ? null : S('color:var(--ui-text-muted);font-size:12px;line-height:18px', 'One image from your library.'))
|
||||||
|
}
|
||||||
|
const Insp = (f, o = {}) => {
|
||||||
|
const t = f.type, e = f.err || {}, one = m || o.one, locked = !!f.saved
|
||||||
|
const [mnL, mxL, suf] = MM(t), canU = ['text', 'number', 'date', 'select'].includes(t), hasMM = ['text', 'richtext', 'number', 'date'].includes(t)
|
||||||
|
const g2 = one ? '1fr' : '1fr 1fr'
|
||||||
|
const tile = k => { const on = k === t, dim = locked && !on; return D('display:flex;gap:10px;align-items:flex-start;padding:10px;min-height:56px;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';opacity:' + (dim ? 0.4 : 1), I(FT[k][0], 16, 'margin-top:2px'), D('display:flex;flex-direction:column;min-width:0', S('font-weight:500', FT[k][1]), S('font-size:12px;line-height:16px;color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-muted)') + ';text-wrap:pretty', FT[k][2]))) }
|
||||||
|
const mmErr = e.min || ''
|
||||||
|
const minV = f.min != null ? String(f.min) : '', maxV = f.max != null ? String(f.max) : ''
|
||||||
|
const defCtl = t === 'boolean' ? null : F('Default value', f.def != null ? String(f.def) : '', { opt: true, ph: f.def == null, phText: t === 'select' ? 'None' : t === 'date' ? 'No default' : 'No default', sel: t === 'select', ico: t === 'date' ? 'calendar' : null, area: t === 'richtext', err: e.def, dis: !!f.uniq, help: f.uniq ? 'A field with unique values can\'t have a default.' : e.def ? '' : 'Used when an entry is created and this field is left out.' })
|
||||||
|
const left = D('display:flex;flex-direction:column;min-width:0;border-top:1px solid var(--ui-border)', SwR(!!f.req, 'Required', 'An entry can\'t be saved with this field empty.'), t !== 'media' && SwR(!!f.uniq, 'Unique values', canU ? 'Each entry needs a different value.' : t === 'richtext' ? 'Not available for rich text.' : 'Not available for yes or no fields.', !canU), t === 'boolean' && SwR(!!f.def, 'On by default', 'New entries start with this switch ' + (f.def ? 'on' : 'off') + ' when the field is left out.'))
|
||||||
|
const right = t === 'media' ? [] : hasMM ? [F(mnL, minV, { suffix: suf, ph: minV === '', phText: 'No minimum', err: mmErr, help: mmErr ? '' : null }), F(mxL, maxV, { suffix: suf, ph: maxV === '', phText: 'No maximum', help: mmErr ? '' : t === 'text' || t === 'richtext' ? 'Counted in characters. Entries show a counter.' : t === 'number' ? 'Entries outside the range are refused.' : 'Entries outside the range are refused.' }), defCtl] : [defCtl, D('display:flex;flex-direction:column;gap:6px;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', 'Limits'), S('color:var(--ui-text-muted);font-size:12px;line-height:18px', t === 'boolean' ? 'Yes or no fields have no limits.' : 'Limits come from the choices below.'))]
|
||||||
|
const ch = t === 'select' ? Choices(f) : t === 'media' ? D('display:flex;gap:8px;padding:10px 12px;background:var(--rc-info-soft);color:var(--ui-info)', S('padding-top:2px;display:flex', I('info', 16)), S('color:var(--ui-text-toned);text-wrap:pretty', 'Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version.')) : null
|
||||||
|
return D('display:flex;flex-direction:column;gap:' + (m ? 16 : 14) + 'px;min-width:0',
|
||||||
|
D('display:grid;grid-template-columns:' + g2 + ';gap:14px', F('Label', f.label, { req: true, err: e.label, focus: o.focus === 'label', ph: !f.label, phText: 'For example, Title' }),
|
||||||
|
D('display:flex;flex-direction:column;gap:6px;min-width:0', F('Key', f.key || '', { mono: true, auto: !locked && !e.key && !!f.key, lock: locked, err: e.key, ph: !f.key, phText: 'title', dis: false, help: locked ? 'Used in the API, so it can\'t change.' : e.key ? '' : 'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.' }))),
|
||||||
|
D('display:flex;flex-direction:column;gap:6px', D('display:flex;justify-content:space-between;gap:8px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Type'), locked && S('color:var(--ui-text-muted);font-size:12px;display:flex;gap:4px;align-items:center', I('lock', 12), 'Can\'t change after saving')), D('display:grid;grid-template-columns:repeat(' + (one ? 2 : 3) + ',minmax(0,1fr));gap:8px', ...Object.keys(FT).map(tile))),
|
||||||
|
left,
|
||||||
|
right.length > 0 && D('display:grid;grid-template-columns:' + g2 + ';gap:14px;align-items:start', ...right.filter(Boolean)),
|
||||||
|
ch)
|
||||||
|
}
|
||||||
|
const Choices = f => {
|
||||||
|
const e = f.err || {}, ops = f.opts || [], bad = e.choices || {}
|
||||||
|
return D('display:flex;flex-direction:column;gap:8px', D('display:flex;justify-content:space-between', S('font-weight:500;color:var(--ui-text-highlighted)', 'Choices', ' *'), S(mono + 'font-size:12px;color:var(--ui-text-muted)', ops.length + ' of 50')),
|
||||||
|
...ops.map((o_, i) => D('display:flex;flex-direction:column;gap:4px', D('display:flex;align-items:center;gap:4px', 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('grip-vertical', 16)), D('flex:1;height:' + ih + 'px;display:flex;align-items:center;padding:0 10px;font-size:' + fs + 'px;border:1px solid ' + (bad[i] ? 'var(--ui-error)' : 'var(--ui-border-accented)') + ';background:var(--ui-bg);color:' + (o_ ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)'), o_ || 'Choice'), IB('x')), bad[i] && S('margin-left:' + (m ? 48 : 32) + 'px;display:flex;gap:6px;color:var(--ui-error);font-size:12px;line-height:18px', I('circle-alert', 14, 'margin-top:2px'), bad[i]))),
|
||||||
|
D('display:flex;gap:8px;padding-left:' + (m ? 48 : 32) + 'px', D('flex:1;height:' + ih + 'px;display:flex;align-items:center;padding:0 10px;border:1px dashed var(--ui-border-accented);color:var(--ui-text-muted);font-size:' + fs + 'px', 'Add a choice'), B('Add', { icon: 'plus' })),
|
||||||
|
S('color:var(--ui-text-muted);font-size:12px;line-height:18px', 'Add 1 to 50 choices. Each one needs a different name. Removing a choice that entries use will be refused when you save.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- field list
|
||||||
|
const FRow = (f, i, o = {}) => {
|
||||||
|
const on = o.sel === i, err = !!f.err, kb = o.kb === i
|
||||||
|
const rm = on && D('display:flex;gap:2px;margin-left:auto', IB('chevron-up', { dis: i === 0 }), IB('chevron-down'), IB('trash-2'))
|
||||||
|
return D('display:flex;align-items:center;gap:' + (m ? 4 : 2) + 'px;min-height:' + (m ? 64 : 56) + 'px;padding:6px 8px 6px 4px;border:1px solid ' + (err ? 'var(--ui-error)' : kb ? 'var(--ui-primary)' : 'var(--ui-border)') + ';' + (kb ? 'outline:2px solid var(--ui-primary);outline-offset:-1px;box-shadow:var(--rc-shadow-pop);' : '') + 'background:' + (on ? 'var(--rc-sel)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sel)' : 'var(--ui-text-highlighted)') + ';opacity:' + (o.dim ? 0.6 : 1),
|
||||||
|
S('width:' + (m ? 36 : 24) + 'px;height:32px;display:flex;align-items:center;justify-content:center;flex-shrink:0;color:' + (on ? 'var(--rc-on-sel)' : 'var(--ui-text-muted)'), I('grip-vertical', 16)),
|
||||||
|
S('width:28px;height:28px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:' + (on ? 'var(--ui-bg)' : 'var(--ui-bg-elevated)') + ';color:var(--ui-text-highlighted)', I(FT[f.type][0], 16)),
|
||||||
|
D('flex:1;min-width:0;display:flex;flex-direction:column;margin-left:8px', S('font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', f.label || 'Untitled field'), D('display:flex;align-items:center;gap:6px;min-width:0', S(mono + 'font-size:12px;line-height:16px;color:' + (on ? 'var(--rc-on-sel)' : 'var(--ui-text-muted)') + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto', f.key || '–'), f.isNew && Bd('New', 'neu'), f.changed && Bd('Changed', 'chg'), !on && o.cause && f.changed && Bd('Possible cause', 'warn', 'triangle-alert'), f.req && h('span', { title: 'Required', 'aria-label': 'Required', style: { display: 'flex', flexShrink: 0 } }, Bd('R', 'req')), f.uniq && h('span', { title: 'Unique', 'aria-label': 'Unique', style: { display: 'flex', flexShrink: 0 } }, Bd('U', 'uniq')))),
|
||||||
|
err && !on && I('circle-alert', 16, 'color:var(--ui-error);flex-shrink:0;margin:0 4px'), on && !m && rm)
|
||||||
|
}
|
||||||
|
const ApiPanel = (fl, key, open) => {
|
||||||
|
const routes = [['GET', '/v1/content-type/' + key + '/entry'], ['POST', '/v1/content-type/' + key + '/entry'], ['GET', '/v1/content-type/' + key + '/entry/{id}'], ['PUT', '/v1/content-type/' + key + '/entry/{id}'], ['DELETE', '/v1/content-type/' + key + '/entry/{id}']]
|
||||||
|
const head = D('height:44px;flex-shrink:0;display:flex;align-items:center;gap:10px;padding:0 ' + (m ? 12 : 24) + 'px;border-top:1px solid var(--ui-border);background:var(--ui-bg);color:var(--ui-text-highlighted)', I(open ? 'chevron-down' : 'chevron-right', 14), S('font-weight:600', 'API preview'), !m && S(mono + 'font-size:12px;color:var(--ui-text-muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', 'GET /v1/content-type/' + key + '/entry'), m && S('flex:1'), B('Copy JSON', { icon: 'copy', h: m ? 44 : 28, fs: 12, px: 10 }))
|
||||||
|
if (!open) return head
|
||||||
|
return D('flex-shrink:0;display:flex;flex-direction:column', head, D('display:grid;grid-template-columns:1fr 1.4fr;gap:16px;height:248px;padding:0 24px 16px;background:var(--ui-bg)', D('display:flex;flex-direction:column;gap:6px;min-width:0', S('color:var(--ui-text-muted);font-size:12px', 'Where entries of this type live'), D('display:flex;flex-direction:column;gap:2px;padding:10px 12px;background:var(--ui-bg-elevated);border:1px solid var(--ui-border)', ...routes.map(r => S(mono + 'font-size:12px;line-height:19px;color:var(--ui-text-highlighted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis', S('display:inline-block;width:56px;color:var(--ui-text-muted)', r[0]), r[1]))), S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', 'Updates as you edit. Fields you add or remove here appear after you save.')), D('display:flex;flex-direction:column;gap:6px;min-width:0', S('color:var(--ui-text-muted);font-size:12px', 'A sample entry. Values come from each field\'s type and default.'), S('display:block;flex:1;overflow:hidden;padding:10px 12px;background:var(--ui-bg-elevated);border:1px solid var(--ui-border);white-space:pre;' + mono + 'font-size:12px;line-height:19px;color:var(--ui-text-highlighted)', apiJson(fl, key)))))
|
||||||
|
}
|
||||||
|
const stChip = (kind, n) => { const M = { 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)'], fail: ['circle-alert', 'Save failed', 'var(--ui-error)'], offline: ['wifi-off', 'Waiting for connection', 'var(--ui-text-muted)'], errs: ['circle-alert', n + (n === 1 ? ' problem' : ' problems') + ' to fix', 'var(--ui-error)'] }[kind]; return S('display:flex;align-items:center;gap:6px;white-space:nowrap;font-size:12px;color:' + M[2], I(M[0], 14), M[1]) }
|
||||||
|
|
||||||
|
// ---------- entry controls
|
||||||
|
const hint = f => { const t = f.type; if (t === 'text' || t === 'richtext') return [f.min && 'At least ' + f.min + ' characters', f.max && 'Up to ' + f.max + ' characters', f.uniq && 'Each entry needs a different one'].filter(Boolean).join('. ') + (f.min || f.max || f.uniq ? '.' : ''); if (t === 'number') return f.min != null && f.max != null ? 'Between ' + f.min + ' and ' + f.max + '.' : f.min != null ? 'At least ' + f.min + '.' : f.max != null ? 'At most ' + f.max + '.' : ''; if (t === 'date') return [f.min && 'From ' + f.min, f.max && 'until ' + f.max].filter(Boolean).join(' ') ; return t === 'select' ? '' : '' }
|
||||||
|
const MdBox = (o, val) => D('display:flex;flex-direction:column;border:1px solid ' + (o.err ? 'var(--ui-error)' : o.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (o.dis ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + (o.err ? ';box-shadow:inset 0 0 0 1px var(--ui-error)' : ''),
|
||||||
|
D('display:flex;align-items:center;border-bottom:1px solid var(--ui-border);padding:0 6px;gap:2px;overflow:hidden', D('display:flex;gap:14px;padding:0 6px;margin-right:8px', ...[['write', 'Write'], ['preview', 'Preview']].map(([k, t]) => S('height:' + (m ? 44 : 32) + 'px;display:flex;align-items:center;font-weight:' + (o.tab === k || (!o.tab && k === 'write') ? 500 : 400) + ';color:' + (o.tab === k || (!o.tab && k === 'write') ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)') + ';border-bottom:2px solid ' + (o.tab === k || (!o.tab && k === 'write') ? 'var(--ui-primary)' : 'transparent'), t))), ...(o.tab === 'preview' ? [] : ['bold', 'italic', 'heading', 'link', 'list'].map(ic => S('width:' + (m ? 40 : 28) + 'px;height:' + (m ? 40 : 28) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-toned)', I(ic, 16))))),
|
||||||
|
o.tab === 'preview' ? D('padding:10px 12px;min-height:' + (m ? 120 : 96) + 'px;display:flex;flex-direction:column;gap:8px;font-size:' + fs + 'px', S('', 'Two days of hands-on work with ', S('font-weight:600', 'lime putty'), ' and old stone.'), D('display:flex;flex-direction:column;gap:2px;padding-left:18px', S('display:list-item;list-style:square', 'Bring work clothes'), S('display:list-item;list-style:square', 'Lunch is included'))) : S('display:block;white-space:pre-wrap;padding:8px 10px;min-height:' + (m ? 120 : 96) + 'px;font-size:' + (m ? 14 : 12) + 'px;line-height:' + (m ? 22 : 19) + 'px;' + mono + 'color:' + (val ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)'), val || 'Write in Markdown'))
|
||||||
|
const Cal = () => { const cells = [...Array(6).fill(''), ...Array.from({ length: 30 }, (_, i) => i + 1)]; return D('width:' + (m ? 280 : 252) + 'px;padding:8px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop)', D('display:flex;align-items:center;justify-content:space-between;height:32px', IB('chevron-left'), S('font-weight:600', 'November 2026'), IB('chevron-right')), D('display:grid;grid-template-columns:repeat(7,1fr)', ...['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map(d => S('height:28px;display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--ui-text-muted)', d)), ...cells.map(c => S('height:' + (m ? 34 : 30) + 'px;display:flex;align-items:center;justify-content:center;background:' + (c === 21 ? 'var(--ui-primary)' : 'transparent') + ';color:' + (c === 21 ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)'), c))) ) }
|
||||||
|
const Ctl = (f, o = {}) => {
|
||||||
|
const t = f.type, v = o.v == null ? '' : String(o.v)
|
||||||
|
if (t === 'media') return MCtl(f, o)
|
||||||
|
const lab = D('display:flex;justify-content:space-between;gap:8px', S('font-weight:500;color:var(--ui-text-highlighted)', f.label, f.req && ' *'), o.count && S(mono + 'font-size:12px;color:' + (o.countErr ? 'var(--ui-error)' : 'var(--ui-text-muted)'), o.count), o.def && Bd('Default', 'neu'))
|
||||||
|
const bd = o.err ? 'var(--ui-error)' : o.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)'
|
||||||
|
const box = (...inner) => D('display:flex;align-items:center;min-height:' + ih + 'px;border:1px solid ' + bd + ';background:' + (o.dis ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';font-size:' + fs + 'px;' + (o.err ? 'box-shadow:inset 0 0 0 1px var(--ui-error);' : o.focus ? 'box-shadow:inset 0 0 0 1px var(--ui-primary);' : ''), ...inner)
|
||||||
|
const txt = (val, ph) => S('flex:1;min-width:0;padding:0 10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:' + (val ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)'), val || ph || '')
|
||||||
|
let ctl
|
||||||
|
if (t === 'boolean') ctl = Sw(v === 'true', v === 'true' ? 'On' : 'Off', null, o.dis)
|
||||||
|
else if (t === 'richtext') ctl = MdBox(o, v)
|
||||||
|
else if (t === 'number') ctl = box(txt(v, 'Enter a number'), D('display:flex;border-left:1px solid var(--ui-border);height:' + ih + 'px;color:var(--ui-text-muted)', S('width:' + (m ? 40 : 28) + 'px;display:flex;align-items:center;justify-content:center', I('minus', 14)), S('width:' + (m ? 40 : 28) + 'px;display:flex;align-items:center;justify-content:center;border-left:1px solid var(--ui-border)', I('plus', 14))))
|
||||||
|
else if (t === 'date') ctl = box(txt(v, 'Pick a date'), I('calendar', 14, 'margin-right:10px;color:var(--ui-text-muted)'))
|
||||||
|
else if (t === 'select') ctl = box(txt(v, 'Choose an option'), I('chevron-down', 14, 'margin-right:10px;color:var(--ui-text-muted)'))
|
||||||
|
else ctl = box(txt(v, ''))
|
||||||
|
const hp = o.help === undefined ? hint(f) : o.help
|
||||||
|
return D('display:flex;flex-direction:column;gap:6px;min-width:0;position:relative', lab, ctl, 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.err && hp && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', hp), o.extra)
|
||||||
|
}
|
||||||
|
const Cell = (f, v) => {
|
||||||
|
if (v === undefined || v === null || v === '') return S('color:var(--ui-text-muted)', '–')
|
||||||
|
if (f.type === 'media') return cellM(v, 40)
|
||||||
|
if (f.type === 'boolean') return S('display:flex;align-items:center;gap:6px', I(v ? 'check' : 'minus', 14, 'color:var(--ui-text-muted)'), v ? 'Yes' : 'No')
|
||||||
|
if (f.type === 'select') return S('height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;justify-self:start', v)
|
||||||
|
if (f.type === 'number') return S('justify-self:end;font-variant-numeric:tabular-nums', v)
|
||||||
|
return S('overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' + (f.type === 'richtext' ? 'color:var(--ui-text-toned)' : ''), v)
|
||||||
|
}
|
||||||
|
const mt = (k) => k === 'true' || k === 'false'
|
||||||
|
|
||||||
|
// ---------- pages
|
||||||
|
let main = null; const ov = []; let bare = false, bw = '1440px', bhh = '900px'
|
||||||
|
const v = s.v, k = s.k
|
||||||
|
|
||||||
|
// 1 schema list
|
||||||
|
const typesTable = (custom) => {
|
||||||
|
const cols = '24px minmax(0,1.4fr) minmax(0,1fr) 90px 150px 24px'
|
||||||
|
const hdr = 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', S('', ''), S('', 'Name'), S('', 'Key'), S('', 'Fields'), S('', 'Last updated'), S('', ''))
|
||||||
|
const r = (name, key, n, up, built) => D('display:grid;grid-template-columns:' + cols + ';gap:12px;height:48px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border)', I(built ? 'lock' : 'file-stack', 16, 'color:var(--ui-text-muted)'), D('display:flex;align-items:center;gap:8px;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', name), built && Bd('Built-in', 'built', 'lock')), S(mono + 'font-size:12px;color:var(--ui-text-muted)', key), S('', String(n)), S('color:var(--ui-text-muted)', up || 'Managed by RenewCMS'), I('chevron-right', 14, 'color:var(--ui-text-muted)'))
|
||||||
|
const mr = (name, key, n, up, built) => D('display:flex;align-items:center;gap:12px;padding:12px 8px 12px 16px;border-top:1px solid var(--ui-border);min-height:68px', D('flex:1;min-width:0;display:flex;flex-direction:column;gap:4px', D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', S('font-weight:500;font-size:15px;line-height:21px;color:var(--ui-text-highlighted)', name), built && Bd('Built-in', 'built', 'lock')), S('font-size:12px;color:var(--ui-text-muted)', S(mono, key), ' · ' + n + ' fields' + (up ? ' · Updated ' + up : ''))), S('width:36px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('chevron-right', 18)))
|
||||||
|
const note = D('display:flex;align-items:center;gap:8px;padding:10px 12px;border-top:1px solid var(--ui-border);background:var(--ui-bg-muted);color:var(--ui-text-toned);font-size:12px;line-height:18px', I('lock', 14, 'flex-shrink:0'), S('', 'Built-in types are managed by RenewCMS. You can read their fields, but not change them.'))
|
||||||
|
const rr = m ? mr : r
|
||||||
|
return D('background:var(--ui-bg);border:1px solid var(--ui-border)', !m && hdr, ...(custom ? CUSTOM.map(x => rr(x[0], x[1], x[2], x[3])) : []), note, ...BUILTIN.map(x => rr(x[0], x[1], x[2], '', true)))
|
||||||
|
}
|
||||||
|
if (v === 'types') {
|
||||||
|
Object.assign(shell, { nav: 'schema', crumbs: ['Schema'], mTitle: 'Schema' })
|
||||||
|
const newBtn = B('New content type', { k: 'primary', icon: 'plus', px: 14, dis: k === 'loading' })
|
||||||
|
const impExp = s.later ? [B('Import', { icon: 'upload' }), B('Export', { icon: 'download' })] : []
|
||||||
|
if (k === 'forbidden') { shell.role = 'user'; shell.crumbs = ['Schema']; main = Center('lock', '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.', B('Go to Home', { k: 'primary', px: 16 })) }
|
||||||
|
else if (k === 'builtin' || k === 'customro') {
|
||||||
|
const cr = k === 'customro'
|
||||||
|
shell.crumbs = ['Schema', cr ? 'Events' : 'Articles']; shell.mTitle = cr ? 'Events' : 'Articles'; shell.mLead = 'arrow-left'
|
||||||
|
const AF = cr ? [['Title', 'title', 'text', 'Required · unique · up to 80 characters'], ['Starts on', 'starts_on', 'date', 'Required'], ['Category', 'category', 'select', 'Talk, Workshop, Meetup'], ['Description', 'description', 'richtext', ''], ['Thumbnail', 'thumbnail', 'media', 'One image from the library']] : [['Title', 'title', 'text', 'Required · up to 200 characters'], ['Slug', 'slug', 'text', 'Required · unique'], ['Body', 'body', 'richtext', 'Up to 10,000 characters'], ['Online', 'online', 'boolean', 'Off by default'], ['Published on', 'published_on', 'date', '']]
|
||||||
|
const cols = '28px minmax(0,1fr) minmax(0,1fr) 120px minmax(0,1.2fr)'
|
||||||
|
main = Page(pad, D('display:flex;align-items:center;gap:12px;flex-shrink:0', I('lock', 18, 'color:var(--ui-text-muted)'), S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', cr ? 'Events' : 'Articles'), cr ? Bd('Read only', 'built', 'lock') : Bd('Built-in', 'built', 'lock')),
|
||||||
|
cr ? Alert('info', 'info', 'You can read these fields here', 'Only an owner or admin can change a content type.') : Alert('info', 'info', '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.'),
|
||||||
|
m ? D('background:var(--ui-bg);border:1px solid var(--ui-border)', ...AF.map(f => D('display:flex;gap:12px;align-items:center;padding:12px 16px;border-top:1px solid var(--ui-border);min-height:64px', S('width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:var(--ui-bg-elevated)', I(FT[f[2]][0], 16)), D('flex:1;min-width:0;display:flex;flex-direction:column', S('font-weight:500', f[0]), S('font-size:12px;color:var(--ui-text-muted)', S(mono, f[1]), f[3] ? ' · ' + f[3] : ''))))) :
|
||||||
|
D('background:var(--ui-bg);border:1px solid var(--ui-border)', 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', S('', ''), S('', 'Label'), S('', 'Key'), S('', 'Type'), S('', 'Rules')), ...AF.map(f => D('display:grid;grid-template-columns:' + cols + ';gap:12px;height:48px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border)', I(FT[f[2]][0], 16, 'color:var(--ui-text-toned)'), S('font-weight:500', f[0]), S(mono + 'font-size:12px;color:var(--ui-text-muted)', f[1]), S('', FT[f[2]][1]), S('color:var(--ui-text-muted)', f[3] || '–')))), S('color:var(--ui-text-muted);font-size:12px', 'Entries of built-in types are managed from their own screens, like Articles and Authors.'))
|
||||||
|
} else {
|
||||||
|
const body = []
|
||||||
|
if (k === 'loading') body.push(D('background:var(--ui-bg);border:1px solid var(--ui-border)', ...Array.from({ length: m ? 6 : 8 }, (_, i) => D('height:' + (m ? 68 : 48) + 'px;display:flex;align-items:center;gap:16px;padding:0 16px;border-top:' + (i ? '1px solid var(--ui-border)' : 'none'), Skel('18px', 16), Skel(i % 2 ? '28%' : '20%'), Skel('14%', 12), !m && Skel('40px', 12), !m && Skel('90px', 12)))))
|
||||||
|
else if (k === 'error') body.push(Alert('error', 'circle-x', 'Couldn\'t load content types', 'GET /v1/content-type failed. Check your connection and try again.', 'Try again'))
|
||||||
|
else if (k === 'empty') body.push(Empty('shapes', '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'), typesTable(false))
|
||||||
|
else body.push(typesTable(true), S('color:var(--ui-text-muted)', CUSTOM.length + ' content types · ' + BUILTIN.length + ' built-in'))
|
||||||
|
main = Page(pad, Title('Schema', 'Content types and the fields they are made of.', ...impExp, s.later && Later(), newBtn), ...body)
|
||||||
|
if (m && k !== 'loading' && k !== 'error' && k !== 'empty') ov.push(Fab('New content type'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2 builder
|
||||||
|
const entN = v === 'review' && (k === 'mrefuse' || k === 'mclean') ? 12 : 37
|
||||||
|
const bld = () => {
|
||||||
|
let fl = EV, name = 'Events', key = 'events', sel = 0, st = 'clean', isNew = false, api = k === 'api', kb = -1, tErr = {}, banner = null, removed = null, cause = false, tip = false, toast = null, info = true, nErr = 0
|
||||||
|
if (v === 'review') { const rk = k; if (rk === 'confirm' || rk === 'fallback') { fl = RC; name = 'Recipes'; key = 'recipes'; info = false } else if (rk === 'blocked') { sel = 0 } else if (rk === 'mrefuse' || rk === 'mclean') { fl = [...EV, { key: 'thumbnail', label: 'Thumbnail', type: 'media', isNew: true, req: rk === 'mrefuse' }]; sel = 7; st = 'dirty' } else { fl = DIRTY; sel = 4; st = 'dirty'; removed = EV[6] } }
|
||||||
|
if (v === 'rel') { }
|
||||||
|
if (v === 'builder') {
|
||||||
|
if (k === 'locked') { sel = 1; tip = true }
|
||||||
|
if (k === 'media' || k === 'mreq') { fl = [...EV, k === 'mreq' ? { ...THUMB, req: true } : THUMB]; sel = 7 }
|
||||||
|
if (k === 'mnew') { fl = [...EV, { key: 'thumbnail', label: 'Thumbnail', type: 'media', isNew: true }]; sel = 7; st = 'dirty' }
|
||||||
|
if (['dirty', 'saving', 'saved', 'fail', 'incompat', 'offline'].includes(k)) { fl = DIRTY; sel = 4; st = k === 'incompat' ? 'dirty' : k; removed = EV[6] }
|
||||||
|
if (k === 'saved') { removed = null; st = 'saved'; fl = DIRTY.map(f => ({ ...f, isNew: false, changed: false, saved: true })); toast = Toast('success', 'circle-check', 'Content type saved', 'Events now has 8 fields.') }
|
||||||
|
if (k === 'fail') banner = Alert('error', 'circle-x', 'Couldn\'t save the content type', 'Something went wrong on our side. Your changes are still here. Try again in a moment.', 'Try again')
|
||||||
|
if (k === 'incompat') { banner = Alert('error', 'circle-x', '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'); cause = true }
|
||||||
|
if (k === 'offline') { }
|
||||||
|
if (k === 'ferr') { fl = EV.map((f, i) => i === 2 ? { ...f, label: '', err: { label: 'Give the field a label.' } } : i === 4 ? { ...f, min: 6000, err: { min: 'Lowest value can\'t be above the highest value.' } } : i === 5 ? { ...f, opts: ['Talk', 'Workshop', '', 'Talk'], err: { choices: { 2: 'Choices can\'t be blank.', 3: 'Two choices are the same.' } } } : f); sel = 4; st = 'errs'; nErr = 3 }
|
||||||
|
if (k === 'choices') { fl = EV.map((f, i) => i === 5 ? { ...f, opts: ['Talk', 'Workshop', 'Meetup', '', 'Talk'], err: { choices: { 3: 'Choices can\'t be blank.', 4: 'Two choices are the same.' } } } : f); sel = 5; st = 'errs'; nErr = 1 }
|
||||||
|
if (k === 'fkey') { fl = [...EV, { key: '2nd-date', label: 'Second date', type: 'date', isNew: true, err: { key: 'Start with a letter. Use lowercase letters, digits and underscores.' } }]; sel = 7; st = 'errs'; nErr = 1 }
|
||||||
|
if (k === 'kb') { fl = [EV[0], EV[2], EV[1], ...EV.slice(3)]; sel = 1; kb = 1; st = 'dirty' }
|
||||||
|
if (k === 'new') { fl = [{ key: 'title', label: 'Title', type: 'text', isNew: true }]; name = ''; key = ''; isNew = true; sel = 0; st = 'clean'; info = false }
|
||||||
|
if (k === 'terr') { fl = []; name = ''; key = 'e'; isNew = true; sel = -1; st = 'errs'; nErr = 3; info = false; tErr = { name: 'Give the content type a name.', key: 'Use 2 to 40 lowercase letters, digits or hyphens.', fields: 'A content type needs at least one field.' } }
|
||||||
|
if (k === 'taken') { fl = [{ key: 'title', label: 'Title', type: 'text', req: true, isNew: true }, { key: 'starts_on', label: 'Starts on', type: 'date', isNew: true }]; name = 'Events'; key = 'events'; isNew = true; sel = 0; st = 'errs'; nErr = 1; info = false; tErr = { key: 'The key "events" is already used by another content type. Choose a different key.' } }
|
||||||
|
}
|
||||||
|
const f = fl[sel], dim = st === 'saving'
|
||||||
|
const nameF = F('Name', name, { req: true, err: tErr.name, ph: !name, phText: 'For example, Events', dis: dim })
|
||||||
|
const keyF = F('Key', key, { mono: true, lock: !isNew, auto: isNew && !!key && !tErr.key, err: tErr.key, ph: !key, phText: 'events', dis: dim, help: tErr.key ? '' : isNew ? 'Used in URLs and the API. Lowercase letters, digits and hyphens. It can\'t change after you save.' : null })
|
||||||
|
const save = D('display:flex;align-items:center;gap:' + (m ? 8 : 12) + 'px;flex-shrink:0', !m && stChip(st, nErr), !m && B('Discard', { dis: st === 'clean' || st === 'saving' || st === 'saved' || (st === 'errs' && isNew && k === 'new') }), !m && B(st === 'saving' ? 'Saving…' : 'Save', { k: 'primary', icon: st === 'saving' ? 'loader-circle' : null, px: 16, dis: st === 'clean' || st === 'saved' || st === 'saving' || st === 'errs' || st === 'offline' }))
|
||||||
|
const top = m ? D('display:flex;flex-direction:column;gap:12px;padding:16px;flex-shrink:0;background:var(--ui-bg);border-bottom:1px solid var(--ui-border)', nameF, keyF) : D('display:flex;align-items:flex-start;gap:16px;padding:16px 24px;flex-shrink:0;background:var(--ui-bg);border-bottom:1px solid var(--ui-border);position:relative', D('width:320px', nameF), D('width:320px', keyF), D('flex:1'), D('display:flex;align-items:center;gap:12px;height:32px;margin-top:26px', save, IB('ellipsis')), tip && D('position:absolute;left:352px;top:84px;z-index:30', Tip('The key is used in URLs and the API, so it can\'t change after the type is created.')))
|
||||||
|
const infoBar = info && !isNew && D('flex-shrink:0;display:flex;align-items:center;gap:8px;padding:8px ' + (m ? 16 : 24) + 'px;background:var(--rc-info-soft);color:var(--ui-info);font-size:12px;line-height:18px', I('info', 14, 'flex-shrink:0'), S('color:var(--ui-text-toned)', entN + ' entries use this content type. Changes are checked against them when you save.'))
|
||||||
|
const listHdr = D('display:flex;align-items:center;justify-content:space-between;height:28px', S('font-weight:600;color:var(--ui-text-highlighted)', 'Fields'), S(mono + 'font-size:12px;color:var(--ui-text-muted)', fl.length + ' of 50'))
|
||||||
|
const live = kb >= 0 && D('padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px', S('font-weight:600', 'Moving Ends on. Position 2 of 7.'), ' Press Up or Down to move, Space to drop, Esc to cancel.')
|
||||||
|
const rows = fl.length ? fl.map((x, i) => FRow(x, i, { sel: m ? -1 : sel, kb, dim, cause })) : [D('display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;padding:32px 16px;border:1px dashed ' + (tErr.fields ? 'var(--ui-error)' : 'var(--ui-border-accented)'), I('rows-3', 20, 'color:var(--ui-text-muted)'), S('font-weight:600', 'No fields yet'), S('color:' + (tErr.fields ? 'var(--ui-error)' : 'var(--ui-text-muted)'), tErr.fields || 'Add the first field.'))]
|
||||||
|
const rem = removed && D('display:flex;align-items:center;gap:8px;min-height:44px;padding:0 12px;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned)', I('minus', 14), S('flex:1', removed.label + ' will be removed'), S('text-decoration:underline;text-underline-offset:3px;font-weight:500;color:var(--ui-text-highlighted)', 'Undo'))
|
||||||
|
const addB = B('Add field', { icon: 'plus', full: false, dis: dim })
|
||||||
|
const apiRow = m && D('display:flex;align-items:center;gap:10px;min-height:44px;padding:0 12px;border:1px solid var(--ui-border);background:var(--ui-bg);color:var(--ui-text-highlighted)', I('braces', 16), S('flex:1;font-weight:500', 'API preview'), I('chevron-right', 14, 'color:var(--ui-text-muted)'))
|
||||||
|
const list = D('display:flex;flex-direction:column;gap:8px;' + (m ? 'padding:16px;flex:1;min-height:0;overflow:hidden' : 'width:380px;flex-shrink:0;border-right:1px solid var(--ui-border);padding:16px;overflow:hidden;background:var(--ui-bg-muted)'), listHdr, live, ...rows, rem, addB, apiRow)
|
||||||
|
const inspPane = !m && D('flex:1;min-width:0;overflow:hidden;padding:16px 24px;background:var(--ui-bg);display:flex;flex-direction:column;gap:14px', f ? D('display:flex;align-items:center;gap:8px', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Field: ' + (f.label || 'Untitled field')), f.isNew && Bd('New', 'neu')) : S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Field settings'), f ? D('opacity:' + (dim ? 0.6 : 1), Insp(f)) : S('color:var(--ui-text-muted)', 'Add a field, then choose its type and rules here.'))
|
||||||
|
const bar = m && D('flex-shrink:0;display:flex;align-items:center;gap:8px;padding:12px 16px;border-top:1px solid var(--ui-border);background:var(--ui-bg)', stChip(st, nErr), D('flex:1'), B('Discard', { dis: st === 'clean' || st === 'saved' || st === 'saving' || k === 'new' }), B(st === 'saving' ? 'Saving…' : 'Save', { k: 'primary', icon: st === 'saving' ? 'loader-circle' : null, px: 16, dis: ['clean', 'saved', 'saving', 'errs', 'offline'].includes(st) }))
|
||||||
|
shell.nav = 'schema'; shell.crumbs = ['Schema', isNew ? 'New content type' : name]; shell.mTitle = isNew ? 'New content type' : name; shell.mLead = 'arrow-left'; shell.mAction = 'ellipsis'
|
||||||
|
shell.offline = false
|
||||||
|
main = D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;position:relative', st === 'offline' && Offline(), top, infoBar, banner && D('padding:12px ' + (m ? 16 : 24) + 'px 0', banner), D('flex:1;min-height:0;display:flex;' + (m ? 'flex-direction:column' : ''), list, inspPane), m ? bar : ApiPanel(fl, key || 'events', api))
|
||||||
|
if (m && f && (s.sheet || (v === 'builder' && ['edit-sheet', 'choices', 'media', 'mreq', 'mnew'].includes(k)))) ov.push(Sheet({ title: 'Field: ' + (f.label || 'Untitled field') }, Insp(f), [B('Done', { k: 'primary', full: true })]))
|
||||||
|
if (m && api) ov.push(Sheet({ title: 'API preview' }, [S('color:var(--ui-text-muted);font-size:12px', 'GET /v1/content-type/' + key + '/entry'), S('display:block;flex:1;overflow:hidden;padding:10px 12px;background:var(--ui-bg-elevated);border:1px solid var(--ui-border);white-space:pre;' + mono + 'font-size:12px;line-height:19px;color:var(--ui-text-highlighted)', apiJson(fl, key))], [B('Copy JSON', { k: 'primary', full: true, icon: 'copy' })]))
|
||||||
|
if (toast) ov.push(toast)
|
||||||
|
}
|
||||||
|
if (v === 'builder') { bld() }
|
||||||
|
|
||||||
|
// 3 review
|
||||||
|
const chg = (icon, tone, title, text, flag, extra) => D('display:flex;gap:10px;padding:10px 12px;border:1px solid var(--ui-border);background:' + (flag ? 'var(--rc-warning-soft)' : 'var(--ui-bg)'), S('margin-top:2px;display:flex;color:' + (flag ? 'var(--ui-warning)' : 'var(--ui-text-toned)'), I(icon)), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', S('font-weight:600;color:var(--ui-text-highlighted)', title), flag && Bd(flag, 'warn', 'triangle-alert'), extra), S('color:var(--ui-text-toned);text-wrap:pretty', text)))
|
||||||
|
if (v === 'review') {
|
||||||
|
bld()
|
||||||
|
const sum = D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;font-size:15px;color:var(--ui-text-highlighted)', 'Add 2 fields, change 1, remove 1'), S('color:var(--ui-text-muted)', 'Events has 37 entries. Each one is checked against the new fields when you apply.'))
|
||||||
|
const lat = t => k === 'later' ? Later(t) : null
|
||||||
|
const list = [chg('plus', 'n', 'Add Speaker', 'Text, optional.', null, lat('Affects 0 entries')), chg('plus', 'n', 'Add Room', 'Choice, required, default "Main hall". Existing entries get the default, so they still fit.', null, lat('Affects 37 entries')), chg('pencil', 'n', 'Change Capacity', 'Highest value goes from 5000 to 2000.', 'Entries may not fit', lat('Affects 3 entries')), chg('minus', 'n', 'Remove Description', 'Entries stop returning its values.', 'Removes data', lat('Affects 31 entries'))]
|
||||||
|
if (k === 'review' || k === 'later') ov.push(Shell({ title: 'Review changes to Events', desc: 'Apply them to save the content type.', w: k === 'later' ? 640 : 560, top: 60, cancel: 'Back to editing', confirm: 'Apply changes', later: false }, [sum, D('display:flex;flex-direction:column;gap:8px', ...list), k === 'later' ? D('display:flex;align-items:center;gap:8px', B('View full diff', { icon: 'git-compare', h: m ? 44 : 28, fs: 12, px: 10 }), Later('Per-change counts and diff come later')) : S('color:var(--ui-text-muted);font-size:12px;line-height:18px', 'Flagged changes can be refused if existing entries don\'t fit. If that happens, nothing is changed.')]))
|
||||||
|
if (k === 'applying') ov.push(Shell({ title: 'Review changes to Events', desc: 'Apply them to save the content type.', w: 560, top: 60, cancel: 'Back to editing', confirm: 'Applying…', loading: true, cdis: true }, [sum, D('display:flex;flex-direction:column;gap:8px', ...list)]))
|
||||||
|
if (k === 'refused') ov.push(Shell({ title: 'Nothing was changed', desc: 'RenewCMS refused the changes because some entries don\'t fit the new fields. It doesn\'t say which entries.', w: 560, top: 60, close: true, cancel: 'Discard changes', confirm: 'Back to editing' }, [Alert('error', 'circle-x', 'Your content type is as it was', 'No field was added, changed or removed.'), S('font-weight:600;color:var(--ui-text-highlighted)', 'The change that can cause this in your edit'), chg('pencil', 'n', 'Change Capacity', 'Highest value goes from 5000 to 2000. An entry may have a higher value.', 'Possible cause'), S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', '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.')]))
|
||||||
|
if (k === 'blocked') ov.push(Shell({ title: 'You can\'t delete Events yet', desc: 'A content type can only be deleted when it has no entries.', close: true, cancel: 'Close', confirm: 'View entries' }, [Alert('warning', 'triangle-alert', 'Events has 37 entries', 'Delete all of its entries first, then delete the content type.')]))
|
||||||
|
if (k === 'confirm') ov.push(Shell({ title: 'Delete Recipes?', desc: 'Recipes has no entries. Its fields and its API routes will be deleted. This can\'t be undone.', close: true, cancel: 'Cancel', confirm: 'Delete content type', tone: 'danger' }, [S('color:var(--ui-text-toned)', 'Key: ', S(mono + 'font-size:12px', 'recipes'))]))
|
||||||
|
if (k === 'mrefuse' || k === 'mclean') { const rf = k === 'mrefuse'; ov.push(Shell({ title: 'Review changes to Events', desc: 'Apply them to save the content type.', w: 560, top: 60, cancel: 'Back to editing', confirm: 'Apply changes', cdis: rf }, [D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;font-size:15px;color:var(--ui-text-highlighted)', 'Add 1 field'), S('color:var(--ui-text-muted)', 'Events has 12 entries. Each one is checked against the new fields when you apply.')), chg('plus', 'n', 'Add Thumbnail', rf ? '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.', rf ? 'Can\'t be applied' : null), rf && S('color:var(--ui-text-muted);font-size:12px;line-height:18px', 'Back to editing keeps your other changes.')])) }
|
||||||
|
if (k === 'fallback') ov.push(Shell({ title: 'Delete Recipes?', desc: 'Recipes has no entries. Its fields and its API routes will be deleted. This can\'t be undone.', close: true, cancel: 'Close', confirm: 'View entries' }, [Alert('error', 'circle-x', 'Recipes now has entries', 'Someone added an entry after this page loaded, so nothing was deleted. Delete the entries first, then try again.')]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4 gallery
|
||||||
|
const GF = { media: THUMB, text: EV[0], richtext: { key: 'description', label: 'Description', type: 'richtext', max: 5000 }, number: EV[4], boolean: EV[3], date: { key: 'starts_on', label: 'Starts on', type: 'date', req: true, max: '31 Dec 2027' }, select: { ...EV[5], req: false } }
|
||||||
|
if (v === 'gallery') {
|
||||||
|
bare = true; if (m) { bw = '390px'; bhh = '844px' }
|
||||||
|
const f = { ...GF[k], saved: false }, t = k, L = f.label
|
||||||
|
const ERR = { media: [['required', 422, 'Pick an image for ' + L + '.'], ['type', 422, L + ' must be an image from the library.'], ['min', '', 'Not used for media.'], ['max', '', 'Not used for media.'], ['options', '', 'Not used for media.'], ['unique', '', 'Not used for media.']], text: [['required', 422, 'Fill in ' + L + '.'], ['type', 422, L + ' must be text.'], ['min', 422, L + ' needs at least 3 characters.'], ['max', 422, L + ' can be up to 80 characters.'], ['options', '', 'Not used for text.'], ['unique', 409, 'Another entry already uses this ' + L + '. Enter a different one.']], richtext: [['required', 422, 'Fill in ' + L + '.'], ['type', 422, L + ' must be text.'], ['min', 422, L + ' needs at least 10 characters.'], ['max', 422, L + ' can be up to 5000 characters.'], ['options', '', 'Not used for rich text.'], ['unique', '', 'Not used for rich text.']], number: [['required', 422, 'Fill in ' + L + '.'], ['type', 422, L + ' must be a number.'], ['min', 422, L + ' must be at least 1.'], ['max', 422, L + ' must be at most 5000.'], ['options', '', 'Not used for numbers.'], ['unique', 409, 'Another entry already uses this ' + L + '.']], boolean: [['required', 422, 'Choose yes or no for ' + L + '.'], ['type', 422, L + ' must be yes or no.'], ['min', '', 'Not used for yes or no.'], ['max', '', 'Not used for yes or no.'], ['options', '', 'Not used for yes or no.'], ['unique', '', 'Not used for yes or no.']], date: [['required', 422, 'Pick a date for ' + L + '.'], ['type', 422, L + ' must be a date, like 2026-11-21.'], ['min', 422, L + ' can\'t be before 1 Jan 2026.'], ['max', 422, L + ' can\'t be after 31 Dec 2027.'], ['options', '', 'Not used for dates.'], ['unique', 409, 'Another entry already uses this date for ' + L + '.']], select: [['required', 422, 'Choose an option for ' + L + '.'], ['type', 422, L + ' must be one of the choices.'], ['min', '', 'Not used for choices.'], ['max', '', 'Not used for choices.'], ['options', 422, 'Choose one of: Talk, Workshop or Meetup.'], ['unique', 409, 'Another entry already uses this choice for ' + L + '.']] }[t]
|
||||||
|
const SAMPLE = { media: ['', ''], text: ['Lime plaster workshop', ''], richtext: [mdSample, ''], number: ['18', ''], boolean: ['true', 'false'], date: ['21 Nov 2026', ''], select: ['Workshop', ''] }[t]
|
||||||
|
const sec = (title, ...c) => D('display:flex;flex-direction:column;gap:10px;min-width:0', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', title), ...c)
|
||||||
|
const card = (...c) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:14px', ...c)
|
||||||
|
const specCtl = { media: Ctl(f, { st: 'filled', hover: true }), text: Ctl(f, { v: 'Lime plaster workshop, two days of work with the whole team on site', count: '74 / 80', focus: true }), richtext: Ctl({ ...f, max: 5000 }, { v: mdSample, tab: 'write', count: '86 / 5000', help: 'Markdown. Switch to Preview to check the result.' }), number: Ctl(f, { v: '18', focus: true }), boolean: Ctl(f, { v: 'true' }), date: Ctl(f, { v: '21 Nov 2026', focus: true, extra: D('position:absolute;left:0;top:68px;z-index:3', Cal()) }), select: Ctl(f, { v: 'Workshop', focus: true, extra: D('position:absolute;left:0;top:66px;right:0;z-index:3;border:1px solid var(--ui-border);background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);padding:4px', ...['Talk', 'Workshop', 'Meetup'].map(o_ => D('height:' + (m ? 44 : 32) + 'px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;background:' + (o_ === 'Workshop' ? 'var(--ui-bg-elevated)' : 'transparent'), o_, o_ === 'Workshop' && I('check', 14)))) }) }[t]
|
||||||
|
const spacer = { date: 'height:236px', select: 'height:120px', richtext: '', text: '', number: '', boolean: '' }[t]
|
||||||
|
const stateLbl = { media: 'Filled, pointer over the thumbnail', text: 'Typing, close to the limit', richtext: 'Write tab, with counter', number: 'Focused', boolean: 'On', date: 'Date picker open', select: 'Choices open' }[t]
|
||||||
|
const ctlCol = sec('Entry form control', card(S('font-size:12px;color:var(--ui-text-muted)', stateLbl), D('', specCtl, spacer && D(spacer)), D('border-top:1px solid var(--ui-border)'), S('font-size:12px;color:var(--ui-text-muted)', t === 'richtext' ? 'Preview tab' : t === 'boolean' ? 'Off, and not set' : 'Empty'), t === 'richtext' ? Ctl(f, { v: mdSample, tab: 'preview', help: '' }) : t === 'boolean' ? Ctl(f, { v: 'false' }) : Ctl(f, { v: '' })))
|
||||||
|
const cell = (val, lab) => D('display:grid;grid-template-columns:' + (m ? '90px' : '110px') + ' minmax(0,1fr);gap:12px;align-items:center;min-height:40px;padding:0 12px;border-top:1px solid var(--ui-border)', S('font-size:12px;color:var(--ui-text-muted)', lab), Cell(f, val))
|
||||||
|
const cellVals = { media: ['ok', ''], text: ['Lime plaster workshop', ''], richtext: ['Two days of hands-on work with lime putty and old stone.', ''], number: [18, null], boolean: [true, null], date: ['21 Nov 2026', ''], select: ['Workshop', ''] }[t]
|
||||||
|
const listCol = sec('List cell', D('background:var(--ui-bg);border:1px solid var(--ui-border)', D('display:grid;grid-template-columns:' + (m ? '90px' : '110px') + ' minmax(0,1fr);gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', S('', 'Value'), S('', L)), D('', cell(cellVals[0], 'Filled'), cell(cellVals[1], 'Empty'), t === 'media' && cell('miss', 'Image missing'))), S('font-size:12px;color:var(--ui-text-muted)', { text: 'Truncated on one line, full text on hover.', richtext: 'First line as plain text, truncated.', number: 'Right-aligned, tabular figures.', boolean: 'Yes or No with an icon. Empty only when the field has no value.', date: 'Day, month and year.', select: 'The chosen option as a small tag.', media: 'A 40 px square thumbnail, 32 px in cards. Not sortable. A dash when empty.' }[t]))
|
||||||
|
const errCol = sec('Errors by rule', D('background:var(--ui-bg);border:1px solid var(--ui-border)', D('display:grid;grid-template-columns:' + (m ? '64px 38px' : '72px 40px') + ' minmax(0,1fr);gap:10px;height:32px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', S('', 'Rule'), S('', 'HTTP'), S('', 'Message')), ...ERR.map(r => D('display:grid;grid-template-columns:' + (m ? '64px 38px' : '72px 40px') + ' minmax(0,1fr);gap:10px;padding:8px 12px;border-top:1px solid var(--ui-border);align-items:start;line-height:18px', S(mono + 'font-size:12px;color:var(--ui-text-highlighted)', r[0]), S(mono + 'font-size:12px;color:var(--ui-text-muted)', String(r[1] || '–')), S('color:' + (r[1] ? 'var(--ui-error)' : 'var(--ui-text-muted)') + ';text-wrap:pretty', r[2])))))
|
||||||
|
const FW = { media: [['has an image'], 'media'], text: [['contains', 'Any text'], 'text'], richtext: [['contains', 'Any text'], 'text'], number: [['between'], 'num'], boolean: [['is'], 'bool'], date: [['between'], 'date'], select: [['is one of'], 'sel'] }[t]
|
||||||
|
const fw = FW[1] === 'media' ? Seg([['any', 'Any'], ['yes', 'Has an image'], ['no', 'No image']], 'any') : FW[1] === 'bool' ? Seg([['any', 'Any'], ['yes', 'Yes'], ['no', 'No']], 'any') : FW[1] === 'sel' ? D('display:flex;gap:6px;flex-wrap:wrap', ...['Talk', 'Workshop', 'Meetup'].map((o_, i) => S('height:28px;padding:0 10px;display:flex;align-items:center;gap:6px;border:1px solid ' + (i === 1 ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (i === 1 ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (i === 1 ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)'), i === 1 && I('check', 12), o_))) : FW[1] === 'num' || FW[1] === 'date' ? D('display:flex;gap:8px;align-items:center', F('', '', { ph: true, phText: FW[1] === 'date' ? 'From' : 'Min', ico: FW[1] === 'date' ? 'calendar' : null }), S('color:var(--ui-text-muted)', 'to'), F('', '', { ph: true, phText: FW[1] === 'date' ? 'Until' : 'Max', ico: FW[1] === 'date' ? 'calendar' : null })) : F('', '', { ph: true, phText: 'Contains…', ico: 'search' })
|
||||||
|
const filtCol = sec('Filter in the entry list', D('border:1px dashed var(--ui-border-accented);padding:14px;display:flex;flex-direction:column;gap:10px', D('display:flex;align-items:center;gap:8px', S('font-weight:500;color:var(--ui-text-highlighted)', L + ': ' + FW[0][0]), Later('Later')), fw, S('font-size:12px;color:var(--ui-text-muted);line-height:18px', 'Filtering on custom fields isn\'t available yet. The list only sorts by ID, created and updated.')))
|
||||||
|
const head = D('display:flex;align-items:center;gap:14px;flex-shrink:0', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--rc-sand);color:var(--rc-on-sand)', I(FT[t][0], 22)), D('flex:1;display:flex;flex-direction:column', S('font:600 ' + (m ? 20 : 24) + 'px/32px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', FT[t][1]), S('color:var(--ui-text-muted)', FT[t][2])), !m && S(mono + 'font-size:12px;color:var(--ui-text-toned)', 'type: "' + t + '"'))
|
||||||
|
const inspCol = !m && sec('Inspector options', card(Insp(f, { one: true })))
|
||||||
|
main = D('position:absolute;inset:0;padding:' + (m ? '16px' : '32px') + ';display:flex;flex-direction:column;gap:' + (m ? 14 : 20) + 'px;overflow:hidden', head, m ? D('display:flex;flex-direction:column;gap:14px', ctlCol, listCol, errCol, filtCol) : D('display:grid;grid-template-columns:400px 440px minmax(0,1fr);gap:24px;align-items:start;flex:1;min-height:0;overflow:hidden', inspCol, D('display:flex;flex-direction:column;gap:20px;min-width:0', ctlCol, listCol), D('display:flex;flex-direction:column;gap:20px;min-width:0', errCol, filtCol)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5 entries
|
||||||
|
const visCols = ['thumb', 'thumbcols', 'thumbtip'].includes(k) ? [EV[0], THUMB, EV[1], EV[5]] : [EV[0], EV[1], EV[2], EV[3]]
|
||||||
|
if (v === 'entries') {
|
||||||
|
shell.nav = 'content'; shell.crumbs = ['Content', 'Events']; shell.mTitle = 'Events'; shell.contentOn = k === 'notfound' ? null : 'events'
|
||||||
|
if (s.many && m) { shell.drawer = true; shell.contentOpen = true }
|
||||||
|
if (k === 'forbidden') main = Center('lock', '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.', B('Go to Home', { k: 'primary', px: 16 }))
|
||||||
|
else if (k === 'notfound') { shell.crumbs = ['Content']; shell.mTitle = 'Content'; main = Center('file-x', 'Content type not found', 'It may have been deleted, or the address may be mistyped.', B('Go to Home', { k: 'primary', px: 16 })) }
|
||||||
|
else {
|
||||||
|
const paged = k === 'paged', total = paged ? 37 : 7, size = paged ? 5 : 25
|
||||||
|
const editB = B('Edit content type', { icon: 'settings-2', k: 'ghost' })
|
||||||
|
const sortB = D('display:flex;align-items:center;gap:8px;height:' + bh + 'px;padding:0 10px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);white-space:nowrap;' + (m ? 'flex:1' : 'width:220px;flex-shrink:0'), S('color:var(--ui-text-muted)', 'Sort by'), S('font-weight:500;flex:1', 'Last updated'), I('chevron-down', 14, 'color:var(--ui-text-muted)'))
|
||||||
|
const dirB = B(null, { icon: 'arrow-down-wide-narrow', w: bh, px: 0 }), colB = B(m ? null : 'Columns', { icon: 'columns-3', w: m ? 44 : null, px: m ? 0 : 12, ring: false })
|
||||||
|
const toolbar = D('display:flex;align-items:center;gap:8px;flex-shrink:0;position:relative', sortB, dirB, colB, !m && D('flex:1'), !m && editB)
|
||||||
|
let body
|
||||||
|
if (k === 'loading') body = D('background:var(--ui-bg);border:1px solid var(--ui-border);flex:1;min-height:0;overflow:hidden', ...Array.from({ length: m ? 6 : 8 }, (_, i) => D('height:' + (m ? 68 : 48) + 'px;display:flex;align-items:center;gap:20px;padding:0 16px;border-top:' + (i ? '1px solid var(--ui-border)' : 'none'), !m && Skel('32px', 12), Skel(i % 2 ? '30%' : '24%'), !m && Skel('90px', 12), !m && Skel('90px', 12), !m && Skel('50px', 12))))
|
||||||
|
else if (k === 'error') body = Alert('error', 'circle-x', 'Couldn\'t load entries', 'GET /v1/content-type/events/entry failed. Check your connection and try again.', 'Try again')
|
||||||
|
else if (k === 'empty') body = Empty('file-plus', 'No entries yet', 'Use New entry to add the first event. Each entry follows the fields of this content type.', 'New entry')
|
||||||
|
else {
|
||||||
|
const rows = ROWS.slice(0, size > 7 ? 7 : 5)
|
||||||
|
const gc = '48px ' + visCols.map((f, i) => i === 0 ? 'minmax(0,1.6fr)' : f.type === 'boolean' || f.type === 'number' || f.type === 'media' ? '80px' : '120px').join(' ') + ' 110px 150px 130px'
|
||||||
|
const hdrC = (t, o = {}) => S('display:flex;align-items:center;gap:4px;' + (o.r ? 'justify-content:flex-end;' : ''), t, o.sort && I('arrow-down', 12))
|
||||||
|
const tbl = D('background:var(--ui-bg);border:1px solid var(--ui-border)', D('display:grid;grid-template-columns:' + gc + ';gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', hdrC('ID'), ...visCols.map(f => hdrC(f.label, { r: f.type === 'number' })), hdrC('Created'), hdrC('Updated', { sort: true }), hdrC('Updated by')),
|
||||||
|
...rows.map((r, i) => D('display:grid;grid-template-columns:' + gc + ';gap:12px;height:48px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border);background:' + (i === 0 && false ? 'var(--ui-bg-elevated)' : 'transparent'), S(mono + 'font-size:12px;color:var(--ui-text-muted)', String(r.id)), ...visCols.map((f, j) => D('min-width:0;' + (j === 0 ? 'font-weight:500;color:var(--ui-text-highlighted);display:flex' : 'display:grid'), Cell(f, r[f.key]))), S('color:var(--ui-text-muted);white-space:nowrap', r.c), S('color:var(--ui-text-muted);white-space:nowrap', r.u.split(',')[0]), S('overflow:hidden;text-overflow:ellipsis;white-space:nowrap', r.by))))
|
||||||
|
const cards = D('background:var(--ui-bg);border:1px solid var(--ui-border)', ...ROWS.slice(0, 6).map((r, i) => D('display:flex;align-items:center;gap:8px;padding:12px 4px 12px 16px;border-top:' + (i ? '1px solid var(--ui-border)' : 'none') + ';min-height:68px', visCols.some(f => f.type === 'media') && cellM(r.thumbnail, 32), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:4px', S('font-weight:500;font-size:15px;line-height:21px;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', r.title), D('display:flex;align-items:center;gap:8px;font-size:12px;color:var(--ui-text-muted)', S('', r.starts_on), r.category && Cell(EV[5], r.category), S('overflow:hidden;text-overflow:ellipsis;white-space:nowrap', '· ' + r.by))), S('width:36px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('chevron-right', 18)))))
|
||||||
|
const pgB = (c, on, dis) => S('width:' + bh + 'px;height:' + bh + 'px;display:flex;align-items:center;justify-content:center;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';opacity:' + (dis ? 0.4 : 1) + ';font-weight:' + (on ? 500 : 400), typeof c === 'string' && c.length > 2 ? I(c, 14) : c)
|
||||||
|
const pager = !paged ? S('color:var(--ui-text-muted)', '7 entries') : m ? D('display:flex;align-items:center;justify-content:space-between;gap:8px', pgB('chevron-left', false, true), S('color:var(--ui-text-muted)', 'Page 1 of 8'), pgB('chevron-right')) : D('display:flex;align-items:center;gap:16px', S('flex:1;color:var(--ui-text-muted)', 'Showing 1–5 of 37'), D('display:flex;align-items:center;gap:8px;height:32px;padding:0 10px;border:1px solid var(--ui-border-accented);background:var(--ui-bg)', '5 per page', I('chevron-down', 14, 'color:var(--ui-text-muted)')), D('display:flex;gap:4px', pgB('chevrons-left', false, true), pgB('chevron-left', false, true), pgB('1', true), pgB('2'), pgB('3'), pgB('…'), pgB('8'), pgB('chevron-right'), pgB('chevrons-right')))
|
||||||
|
body = D('display:flex;flex-direction:column;gap:12px;min-height:0', m ? cards : tbl, pager)
|
||||||
|
}
|
||||||
|
main = Page(pad, Title('Events', 'events · 7 fields', !m && B('New entry', { k: 'primary', icon: 'plus', px: 14, dis: k === 'loading' })), ...(k === 'error' || k === 'empty' || k === 'loading' ? [k !== 'error' ? toolbar : null, body] : [toolbar, body]))
|
||||||
|
if (m && k !== 'error' && k !== 'loading') ov.push(Fab('New entry'))
|
||||||
|
if (k === 'thumbtip' && !m) ov.push(D('position:absolute;z-index:30;left:609px;top:416px;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);white-space:nowrap', 'Image no longer in the library'))
|
||||||
|
if (k === 'cols' || k === 'thumbcols') {
|
||||||
|
const items = k === 'thumbcols' ? [...EV, THUMB].map(f => [f, ['title', 'thumbnail', 'starts_on', 'category'].includes(f.key)]) : EV.map((f, i) => [f, i < 4])
|
||||||
|
const lst = D('display:flex;flex-direction:column', ...items.map(([f, on]) => D('display:flex;align-items:center;gap:10px;min-height:' + (m ? 48 : 36) + 'px;padding:0 8px', Chk(on), I(FT[f.type][0], 14, 'color:var(--ui-text-muted)'), S('flex:1', f.label), S(mono + 'font-size:12px;color:var(--ui-text-muted)', f.key))), D('display:flex;align-items:center;gap:10px;min-height:' + (m ? 48 : 36) + 'px;padding:0 8px;color:var(--ui-text-muted)', Chk(false), I('clock', 14), S('', 'Created and updated dates always show')))
|
||||||
|
if (m) ov.push(Sheet({ title: 'Columns', z: 45 }, [S('color:var(--ui-text-muted)', 'Choose up to 6 fields to show. 4 selected.'), lst], [B('Reset', { full: true }), B('Done', { k: 'primary', full: true })]))
|
||||||
|
else ov.push(D('position:absolute;z-index:40;left:' + (224 + 24 + 330) + 'px;top:' + (48 + 24 + 28 + 18 + 16 + 40) + 'px;width:300px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:8px;display:flex;flex-direction:column;gap:8px', D('display:flex;justify-content:space-between;padding:4px 8px', S('font-weight:600', 'Columns'), S('color:var(--ui-text-muted);font-size:12px', '4 of 6')), lst, D('display:flex;justify-content:flex-end;padding:4px 8px', B('Reset', { h: 28, fs: 12 }))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6 entry
|
||||||
|
if (v === 'entry') {
|
||||||
|
const mk = s.m || null, mErr = mk === 'err' || mk === 'missingreq'
|
||||||
|
shell.nav = 'content'; shell.contentOn = 'events'; shell.crumbs = ['Content', 'Events', k === 'new' || k === 'required' ? 'New entry' : 'Lime plaster workshop']; shell.mTitle = k === 'new' || k === 'required' ? 'New entry' : 'Lime plaster workshop'; shell.mLead = 'arrow-left'; shell.mAction = k === 'new' || k === 'required' ? null : 'trash-2'
|
||||||
|
if (k === 'notfound') main = Center('file-x', 'Entry not found', 'It may have been deleted by someone else.', B('Back to Events', { k: 'primary', px: 16 }))
|
||||||
|
else {
|
||||||
|
const isNew = k === 'new' || k === 'required'
|
||||||
|
const val = { title: isNew ? '' : 'Lime plaster workshop', starts_on: isNew ? '' : '21 Nov 2026', ends_on: isNew ? '' : '22 Nov 2026', online: 'false', capacity: isNew ? '' : '18', category: isNew ? '' : 'Workshop', description: isNew ? '' : mdSample }
|
||||||
|
const E = {}
|
||||||
|
if (k === 'dirty' || k === 'saving' || k === 'fail') val.title = 'Lime plaster workshop, two days'
|
||||||
|
if (k === 'required') { E.title = 'Fill in Title.'; E.starts_on = 'Pick a date for Starts on.' }
|
||||||
|
if (k === 'rules') { val.title = 'Lime plaster workshop, two days of hands-on work with the whole team on site and lunch'; E.title = 'Title can be up to 80 characters.'; val.ends_on = '31/11/2026'; E.ends_on = 'Ends on must be a date, like 2026-11-21.'; val.capacity = '6000'; E.capacity = 'Capacity must be at most 5000.'; val.category = 'Seminar'; E.category = 'Choose one of: Talk, Workshop or Meetup.' }
|
||||||
|
if (k === 'unique') { val.title = 'Heat pumps: what the numbers say'; E.title = 'Another entry already uses this Title. Enter a different one.' }
|
||||||
|
const dis = k === 'saving' || k === 'changed'
|
||||||
|
const sk = k === 'dirty' ? 'dirty' : k === 'saving' ? 'saving' : k === 'saved' ? 'saved' : k === 'fail' ? 'fail' : (k === 'required' || k === 'rules' || k === 'unique' || mErr) ? 'fail' : 'clean'
|
||||||
|
const chip = k === 'required' || k === 'rules' || k === 'unique' || mErr ? S('display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-error);white-space:nowrap', I('circle-alert', 14), 'Not saved') : isNew ? S('font-size:12px;color:var(--ui-text-muted);white-space:nowrap', 'New entry') : stChip(sk)
|
||||||
|
const canSave = k === 'dirty' || k === 'fail' || k === 'new' || k === 'required' || k === 'rules' || k === 'unique' || mErr
|
||||||
|
const acts = D('display:flex;align-items:center;gap:' + (m ? 8 : 12) + 'px', !m && chip, !m && !isNew && B('Delete', { k: 'del', icon: 'trash-2' }), !m && B('Cancel', { dis: dis }), !m && B(k === 'saving' ? 'Saving…' : 'Save', { k: 'primary', icon: k === 'saving' ? 'loader-circle' : null, px: 16, dis: !canSave && k !== 'saving' }))
|
||||||
|
const fieldsUI = EV.map(f => { const vv = val[f.key], e = E[f.key]; const o = { v: vv, err: e, dis, def: isNew && f.key === 'online', focus: k === 'dirty' && f.key === 'title' }; if (f.key === 'title') { const n = (vv || '').length; o.count = n + ' / 80'; o.countErr = n > 80; if (k === 'unique') o.count = n + ' / 80' } if (f.key === 'description') { o.tab = k === 'preview' ? 'preview' : 'write'; o.help = 'Markdown. Switch to Preview to check the result.' } if (f.key === 'capacity') o.help = 'Between 1 and 5000.'; if (f.key === 'title' && !e) o.help = 'Each entry needs a different title.'; return Ctl(f, o) })
|
||||||
|
const mo = { hover: { st: 'filled', hover: true }, focus: { st: 'filled', focus: true }, filled: { st: 'filled' }, err: { st: 'empty', err: 'Pick an image for Thumbnail.', ringBtn: 'pick' }, missing: { st: 'missing' }, missingreq: { st: 'missing', err: 'Pick an image for Thumbnail.', ringBtn: 'choose' } }[mk] || { st: 'empty' }
|
||||||
|
const mUI = mk && Ctl({ ...THUMB, req: ['emptyreq', 'err', 'missingreq'].includes(mk) }, { ...mo, dis })
|
||||||
|
const grid = (a, b) => D('display:grid;grid-template-columns:' + (m ? '1fr' : '1fr 1fr') + ';gap:16px', a, b)
|
||||||
|
const form = D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + (m ? 16 : 24) + 'px;display:flex;flex-direction:column;gap:20px;max-width:760px;opacity:' + (dis ? 0.7 : 1), fieldsUI[0], grid(fieldsUI[1], fieldsUI[2]), grid(fieldsUI[3], fieldsUI[4]), fieldsUI[5], fieldsUI[6], mUI, D('border-top:1px solid var(--ui-border);padding-top:16px;display:grid;grid-template-columns:' + (m ? '1fr' : 'auto 1fr') + ';gap:6px 16px;font-size:12px;line-height:18px', S('color:var(--ui-text-muted)', 'Entry'), S('', isNew ? 'Not saved yet' : S(mono, 'events / 42')), S('color:var(--ui-text-muted)', 'Created by'), S('', isNew ? '–' : 'Inès Duarte · 12 Oct 2026, 09:14'), S('color:var(--ui-text-muted)', 'Updated by'), S('', isNew ? '–' : k === 'saved' ? 'Marta Alves · 19 Oct 2026, 11:02' : 'Marta Alves · 15 Oct 2026, 11:02')))
|
||||||
|
const ban = k === 'fail' ? Alert('error', 'circle-x', 'Couldn\'t save the entry', 'Something went wrong on our side. Your changes are still here. Try again in a moment.', 'Try again') : k === 'changed' && mk ? Alert('warning', 'triangle-alert', 'The fields of Events changed', '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.', 'Reload form') : k === 'changed' ? Alert('warning', 'triangle-alert', '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') : k === 'unique' ? Alert('error', 'circle-x', 'Not saved', 'Another entry already uses this title. Change it and save again.') : null
|
||||||
|
const bar = m && D('flex-shrink:0;display:flex;align-items:center;gap:8px;padding:12px 16px;border-top:1px solid var(--ui-border);background:var(--ui-bg);position:relative;z-index:1', B('Cancel', { full: true, dis }), B(k === 'saving' ? 'Saving…' : 'Save', { k: 'primary', full: true, icon: k === 'saving' ? 'loader-circle' : null, dis: !canSave && k !== 'saving' }))
|
||||||
|
main = D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;position:relative', Page(pad, Title(isNew ? 'New entry' : 'Lime plaster workshop', isNew ? 'Events · Fields marked * are required.' : 'Events · Entry 42', acts), m && D('display:flex;align-items:center;justify-content:space-between', chip), ban, mk ? D('flex:1;min-height:0;overflow:hidden', D('margin-top:' + (m ? (k === 'changed' ? -740 : -560) : -380) + 'px', form)) : form), bar)
|
||||||
|
if (k === 'delete') ov.push(Shell({ title: 'Delete this entry?', desc: '"Lime plaster workshop" will be deleted from Events. This can\'t be undone.', close: true, cancel: 'Cancel', confirm: 'Delete entry', tone: 'danger' }, []))
|
||||||
|
if (k === 'saved') ov.push(Toast('success', 'circle-check', 'Entry saved', '"Lime plaster workshop" is up to date.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7 relation (later)
|
||||||
|
if (v === 'rel') {
|
||||||
|
shell.nav = 'schema'
|
||||||
|
if (k === 'builder') {
|
||||||
|
shell.crumbs = ['Schema', 'Events']; shell.mTitle = 'Events'; shell.mLead = 'arrow-left'
|
||||||
|
const shape = (on, a, b, diag) => D('display:flex;flex-direction:column;gap:6px;padding:10px;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)'), diag, S('font-weight:500', a), S('font-size:12px;line-height:16px;color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-muted)'), b))
|
||||||
|
const sq = (n, c) => D('display:flex;gap:3px', ...Array.from({ length: n }, () => S('width:10px;height:10px;background:' + c)))
|
||||||
|
const dg = (l, r) => D('display:flex;align-items:center;gap:6px', sq(l, 'currentColor'), S('width:20px;height:1px;background:currentColor'), sq(r, 'currentColor'))
|
||||||
|
const sel = (t) => S('display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 8px;margin:0 4px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-weight:500;color:var(--ui-text-highlighted)', t, I('chevron-down', 12))
|
||||||
|
const card_ = (t, ...c) => D('flex:1;min-width:0;padding:12px;background:var(--ui-bg);border:1px solid var(--ui-border);display:flex;flex-direction:column;gap:8px', S('font-weight:600;color:var(--ui-text-highlighted)', t), ...c)
|
||||||
|
const chipR = t => S('height:22px;padding:0 8px;display:inline-flex;align-items:center;gap:4px;background:var(--rc-sand);color:var(--rc-on-sand);font-size:12px;font-weight:500', t)
|
||||||
|
const insp = D('display:flex;flex-direction:column;gap:14px;min-width:0', D('display:flex;align-items:center;gap:8px', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Field: Venue'), Later()), D('display:grid;grid-template-columns:' + (m ? '1fr' : '1fr 1fr') + ';gap:14px', F('Label', 'Venue', { req: true }), F('Key', 'venue', { mono: true, auto: true })),
|
||||||
|
D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Type'), D('display:grid;grid-template-columns:repeat(' + (m ? 2 : 4) + ',minmax(0,1fr));gap:8px', ...['text', 'number', 'date', 'select'].map(t => D('display:flex;gap:8px;align-items:center;padding:8px 10px;border:1px solid var(--ui-border-accented);opacity:.6', I(FT[t][0], 16), FT[t][1])), D('display:flex;gap:8px;align-items:center;padding:8px 10px;border:1px solid var(--ui-primary);background:var(--rc-sand);color:var(--rc-on-sand)', I('link', 16), S('font-weight:500', 'Relation'), S('margin-left:auto;font-size:11px', 'Later')))),
|
||||||
|
F('Related content type', 'Venue', { sel: true, help: 'Choose the content type this field points to.' }),
|
||||||
|
D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'How are they connected?'), D('display:grid;grid-template-columns:' + (m ? '1fr' : 'repeat(3,minmax(0,1fr))') + ';gap:8px', shape(false, 'One to one', 'Each Event has one Venue, and each Venue has one Event.', dg(1, 1)), shape(true, 'One to many', 'Each Event has one Venue. A Venue has many Events.', dg(3, 1)), shape(false, 'Many to many', 'Each Event has many Venues, and each Venue has many Events.', dg(2, 2)))),
|
||||||
|
D('display:flex;flex-direction:column;gap:8px;padding:12px;background:var(--ui-bg-elevated)', S('', 'Each Event has', sel('one'), 'Venue'), S('', 'Each Venue has', sel('many'), 'Events', S('color:var(--ui-text-muted)', ' · shown on Venue as the field "Events"'))),
|
||||||
|
D('display:flex;gap:12px;flex-direction:' + (m ? 'column' : 'row'), card_('Event', S('color:var(--ui-text-muted);font-size:12px', 'Venue'), D('display:flex;gap:6px', chipR('Rue Verte hall'))), card_('Venue', S('color:var(--ui-text-muted);font-size:12px', 'Events'), D('display:flex;gap:6px;flex-wrap:wrap', chipR('Open day'), chipR('Lime plaster workshop'), S('font-size:12px;color:var(--ui-text-muted);align-self:center', '+1')))))
|
||||||
|
main = Page(pad, Title('Edit content type', 'Events · Relation field, shown as it will work later.'), D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;overflow:hidden', insp))
|
||||||
|
} else if (k === 'form') {
|
||||||
|
shell.nav = 'content'; shell.crumbs = ['Content', 'Events', 'Lime plaster workshop']; shell.mTitle = 'Lime plaster workshop'; shell.contentOn = 'events'
|
||||||
|
const res = D('position:absolute;left:0;right:0;top:64px;z-index:5;border:1px solid var(--ui-border);background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);padding:4px', ...[['Rue Verte hall', 'Lyon · 120 seats'], ['Rue Verte yard', 'Lyon · outdoor'], ['Rue du Port studio', 'Nantes · 24 seats']].map(([a, b], i) => D('min-height:' + (m ? 48 : 40) + 'px;padding:4px 8px;display:flex;flex-direction:column;justify-content:center;background:' + (i === 0 ? 'var(--ui-bg-elevated)' : 'transparent'), S('font-weight:500', a), S('font-size:12px;color:var(--ui-text-muted)', b))))
|
||||||
|
const chipX = t => S('height:' + (m ? 32 : 24) + 'px;padding:0 4px 0 8px;display:inline-flex;align-items:center;gap:6px;background:var(--rc-sand);color:var(--rc-on-sand);font-weight:500', t, I('x', 14))
|
||||||
|
main = Page(pad, Title('Lime plaster workshop', 'Events · Entry 42', Later('Relation field: later')), D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + (m ? 16 : 24) + 'px;display:flex;flex-direction:column;gap:' + (m ? 16 : 20) + 'px;max-width:760px;overflow:visible;flex:1;min-height:0', Ctl(EV[0], { v: 'Lime plaster workshop', count: '21 / 80' }),
|
||||||
|
D('display:flex;flex-direction:column;gap:6px;position:relative', S('font-weight:500;color:var(--ui-text-highlighted)', 'Venue'), F('', 'rue', { focus: true, ico: 'search' }), res, S('height:176px')),
|
||||||
|
D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Speakers'), D('display:flex;gap:6px;flex-wrap:wrap;align-items:center;min-height:' + ih + 'px;padding:4px 8px;border:1px solid var(--ui-border-accented)', chipX('Inès Duarte'), chipX('Jonas Pereira'), S('color:var(--ui-text-muted);margin-left:4px', 'Search to add')), S('color:var(--ui-text-muted);font-size:12px', 'An event can have many speakers.'))))
|
||||||
|
} else {
|
||||||
|
shell.nav = 'content'; shell.crumbs = ['Content', 'Events']; shell.mTitle = 'Events'; shell.contentOn = 'events'
|
||||||
|
const chipR = t => S('height:20px;padding:0 6px;display:inline-flex;align-items:center;background:var(--rc-sand);color:var(--rc-on-sand);font-size:12px;font-weight:500;white-space:nowrap', t)
|
||||||
|
const gc = '48px minmax(0,1.4fr) 110px 200px 200px'
|
||||||
|
const rr = [[42, 'Lime plaster workshop', '21 Nov 2026', [chipR('Rue Verte hall')], [chipR('Inès Duarte'), chipR('Jonas Pereira'), S('color:var(--ui-text-muted);font-size:12px', '+2')]], [41, 'Heat pumps: what the numbers say', '3 Nov 2026', [S('color:var(--ui-text-muted)', '–')], [chipR('Jonas Pereira')]], [40, 'Open day at the Rue Verte site', '12 Nov 2026', [chipR('Rue Verte yard')], [S('color:var(--ui-text-muted)', '–')]]]
|
||||||
|
main = Page(pad, Title('Events', 'events · 9 fields', Later('Relation columns: later')), m ? D('background:var(--ui-bg);border:1px solid var(--ui-border)', ...rr.map(r => D('padding:12px 16px;border-top:1px solid var(--ui-border);display:flex;flex-direction:column;gap:6px', S('font-weight:500;font-size:15px', r[1]), D('display:flex;gap:6px;align-items:center', S('font-size:12px;color:var(--ui-text-muted)', 'Venue'), ...r[3]), D('display:flex;gap:6px;align-items:center;flex-wrap:wrap', S('font-size:12px;color:var(--ui-text-muted)', 'Speakers'), ...r[4])))) : D('background:var(--ui-bg);border:1px solid var(--ui-border)', D('display:grid;grid-template-columns:' + gc + ';gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', S('', 'ID'), S('', 'Title'), S('', 'Starts on'), S('', 'Venue'), S('', 'Speakers')), ...rr.map(r => D('display:grid;grid-template-columns:' + gc + ';gap:12px;height:48px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border)', S(mono + 'font-size:12px;color:var(--ui-text-muted)', String(r[0])), S('font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', r[1]), S('', r[2]), D('display:flex;gap:4px;align-items:center', ...r[3]), D('display:flex;gap:4px;align-items:center', ...r[4])))), S('color:var(--ui-text-muted);font-size:12px;line-height:18px', 'One chip for a single relation. Up to two chips and a "+N" for many. Empty shows a dash.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8 permissions (later)
|
||||||
|
if (v === 'perms') {
|
||||||
|
shell.nav = 'settings'; shell.crumbs = ['Settings', 'Permissions']; shell.mTitle = 'Permissions'; shell.mLead = 'arrow-left'; shell.sideOpen = true
|
||||||
|
const ch = k === 'changed'
|
||||||
|
const rowsC = [['Events', 'events', [1, 1, 1, 1]], ['Recipes', 'recipes', ch ? [1, 1, 1, 0] : [1, 1, 1, 1]], ['Press mentions', 'press-mentions', ch ? [1, 0, 0, 0] : [1, 1, 1, 1]]]
|
||||||
|
const rowsB = [['Articles', [1, 1, 1, 1]], ['Authors', [1, 1, 1, 0]], ['Sites', [1, 0, 0, 0]], ['Media', [1, 1, 1, 1]], ['Users', [0, 0, 0, 0]]]
|
||||||
|
const A4 = ['Read', 'Create', 'Update', 'Delete']
|
||||||
|
const cols = 'minmax(0,1fr) 80px 80px 80px 80px'
|
||||||
|
const cellP = (on, lock, changed) => D('display:flex;align-items:center;justify-content:center;gap:4px;opacity:' + (lock ? 0.55 : 1), Chk(!!on), changed && S('width:6px;height:6px;background:var(--ui-info)'))
|
||||||
|
const desk = D('background:var(--ui-bg);border:1px solid var(--ui-border)', D('display:grid;grid-template-columns:' + cols + ';gap:8px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', S('', 'Content type'), ...A4.map(a => S('text-align:center', a))),
|
||||||
|
...rowsC.map(r => D('display:grid;grid-template-columns:' + cols + ';gap:8px;height:44px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border)', D('display:flex;align-items:center;gap:8px', S('font-weight:500', r[0]), S(mono + 'font-size:12px;color:var(--ui-text-muted)', r[1])), ...r[2].map((x, i) => cellP(x, false, ch && ((r[0] === 'Recipes' && i === 3) || (r[0] === 'Press mentions' && i > 0)))))),
|
||||||
|
D('display:flex;align-items:center;gap:8px;padding:8px 12px;border-top:1px solid var(--ui-border);background:var(--ui-bg-muted);color:var(--ui-text-toned);font-size:12px', I('lock', 14), 'Built-in types. RenewCMS sets these rules.'),
|
||||||
|
...rowsB.map(r => D('display:grid;grid-template-columns:' + cols + ';gap:8px;height:44px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border)', D('display:flex;align-items:center;gap:8px', I('lock', 14, 'color:var(--ui-text-muted)'), S('font-weight:500', r[0]), Bd('Built-in', 'built')), ...r[1].map(x => cellP(x, true)))))
|
||||||
|
const mob = D('display:flex;flex-direction:column;gap:12px', ...rowsC.map(r => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:12px 16px;display:flex;flex-direction:column;gap:2px', S('font-weight:600;font-size:15px;color:var(--ui-text-highlighted)', r[0]), ...r[2].map((x, i) => D('display:flex;align-items:center;gap:12px;min-height:44px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : 'none'), Chk(!!x), S('flex:1', A4[i]), ch && ((r[0] === 'Recipes' && i === 3) || (r[0] === 'Press mentions' && i > 0)) && Bd('Changed', 'chg'))))), D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:12px 16px;display:flex;align-items:center;gap:10px;min-height:48px', I('lock', 16, 'color:var(--ui-text-muted)'), S('flex:1', '5 built-in types'), S('color:var(--ui-text-muted);font-size:12px', 'Set by RenewCMS'), I('chevron-right', 14, 'color:var(--ui-text-muted)')))
|
||||||
|
const foot = D('flex-shrink:0;display:flex;align-items:center;gap:8px;padding:12px ' + (m ? 16 : 24) + 'px;border-top:1px solid var(--ui-border);background:var(--ui-bg)', S('flex:1;font-size:12px;color:' + (ch ? 'var(--ui-warning)' : 'var(--ui-text-muted)'), ch ? '4 changes not saved' : 'No changes'), B('Discard', { dis: !ch }), B('Save permissions', { k: 'primary', dis: !ch }))
|
||||||
|
main = D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column', Page(pad, Title('Permissions', 'What each role can do with each content type.', Later()), Alert('info', 'info', 'Today\'s rules are the starting point', 'Owners and admins edit the schema. Any verified user can read, add, edit and delete entries.'), Seg([['owner', 'Owner'], ['admin', 'Admin'], ['user', 'User']], 'user', m ? '100%' : '320px'), m ? mob : desk, D('display:flex;align-items:center;gap:8px;color:var(--ui-text-toned);font-size:12px;line-height:18px;background:var(--ui-bg-elevated);padding:10px 12px', I('lock', 14), S('', 'Schema (create, edit and delete content types): owners and admins only. Set by RenewCMS.'))), foot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9 export / import (later)
|
||||||
|
if (v === 'xport') {
|
||||||
|
Object.assign(shell, { nav: 'schema', crumbs: ['Schema'], mTitle: 'Schema' })
|
||||||
|
main = Page(pad, Title('Schema', 'Content types and the fields they are made of.', B('Import', { icon: 'upload' }), B('Export', { icon: 'download' }), Later(), B('New content type', { k: 'primary', icon: 'plus', px: 14 })), typesTable(true))
|
||||||
|
const rd = (on, t, sub) => D('display:flex;gap:10px;align-items:flex-start;padding:10px 12px;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)'), S('width:16px;height:16px;margin-top:2px;flex-shrink:0;border:1px solid currentColor;display:flex;align-items:center;justify-content:center', on && S('width:8px;height:8px;background:currentColor')), D('display:flex;flex-direction:column', S('font-weight:500', t), S('font-size:12px;color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-muted)'), sub)))
|
||||||
|
if (k === 'export') ov.push(Shell({ title: 'Export schema', desc: 'Download the fields of your content types as a file. Entries aren\'t included.', close: true, later: true, cancel: 'Cancel', confirm: 'Download file' }, [D('display:flex;flex-direction:column;gap:8px', rd(true, 'All content types', '3 content types'), rd(false, 'One content type', 'Choose which one')), F('File name', 'renewcms-schema.json', { mono: true, dis: true, help: 'Import this file on another RenewCMS site to create the same content types.' })]))
|
||||||
|
else {
|
||||||
|
const ref = k === 'refused'
|
||||||
|
const rowI = (ic, t, text, flag, tag) => D('display:flex;gap:10px;padding:10px 12px;border:1px solid var(--ui-border);background:' + (flag ? 'var(--rc-warning-soft)' : 'var(--ui-bg)'), S('margin-top:2px;display:flex;color:' + (flag ? 'var(--ui-warning)' : 'var(--ui-text-toned)'), I(ic)), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', S('font-weight:600;color:var(--ui-text-highlighted)', t), tag && Bd(tag, 'chg'), flag && Bd(flag, 'warn', 'triangle-alert')), S('color:var(--ui-text-toned);text-wrap:pretty', text)))
|
||||||
|
ov.push(Shell({ title: 'Import schema', desc: 'renewcms-schema.json · 3 content types', w: 600, top: 70, close: true, later: true, cancel: 'Cancel', confirm: ref ? 'Try again' : 'Import schema', cdis: false }, [ref ? Alert('error', 'circle-x', '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.') : S('font-weight:600;font-size:15px;color:var(--ui-text-highlighted)', 'Create 1, change 1, no change 1'), D('display:flex;flex-direction:column;gap:8px', rowI('plus', 'Recipes', 'New content type with 4 fields.', null, 'Create'), rowI('pencil', 'Events', 'Add 2 fields, remove Description. Events has 37 entries on this site.', 'Removes data', 'Change'), rowI('equal', 'Press mentions', 'Same as on this site.', null, null)), S('color:var(--ui-text-muted);font-size:12px;line-height:18px', 'Existing entries are checked against the changes when you import. If any entry doesn\'t fit, nothing is imported.')]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m && v !== 'gallery' && s.drawer) shell.drawer = true
|
||||||
|
const framed = !bare
|
||||||
|
return { framed, bare, shell, mob: m, main, ov: D('display:contents', ...ov), bw, bh: bhh }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
export const tables = [
|
||||||
|
{
|
||||||
|
"title": "Media field: scope",
|
||||||
|
"tpl": "260px 140px minmax(0,1fr)",
|
||||||
|
"cols": [
|
||||||
|
"Screen or state",
|
||||||
|
"Scope",
|
||||||
|
"Note"
|
||||||
|
],
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
"2 Builder: media field selected, new, required",
|
||||||
|
"Available now",
|
||||||
|
"Needs the media type in the schema API."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"3 Review: refused and clean",
|
||||||
|
"Available now",
|
||||||
|
"The refusal is known to the client before the server answers."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"4 Field types: Media frame",
|
||||||
|
"Available now",
|
||||||
|
"The filter widget is Later: filtering on custom fields isn't in the API."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"5 Entry list: Thumbnail column, Columns menu, tooltip",
|
||||||
|
"Available now",
|
||||||
|
"Needs image summaries in the entry list payload."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"6 Entry editor: Media control, 10 states",
|
||||||
|
"Available now",
|
||||||
|
""
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"10 Picker for fields, 8 states",
|
||||||
|
"Available now",
|
||||||
|
"Same endpoints as the article picker."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"11 Media library usage, 4 states",
|
||||||
|
"Available now",
|
||||||
|
"Needs entries in the usage endpoint."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"1 Schema list: custom type read-only view",
|
||||||
|
"Available now",
|
||||||
|
"Table only; the route is a decision, see conflicts."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Several images in one field",
|
||||||
|
"Not drawn",
|
||||||
|
"Out of scope."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Media field: exact copy",
|
||||||
|
"tpl": "200px 200px minmax(0,1fr)",
|
||||||
|
"cols": [
|
||||||
|
"Surface",
|
||||||
|
"Element",
|
||||||
|
"Copy"
|
||||||
|
],
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
"Inspector",
|
||||||
|
"Hint",
|
||||||
|
"Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Inspector",
|
||||||
|
"Locked type",
|
||||||
|
"Media · Can't change after saving."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Inspector",
|
||||||
|
"Required",
|
||||||
|
"Required · An entry can't be saved with this field empty."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Field row",
|
||||||
|
"Tag",
|
||||||
|
"Required"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Review",
|
||||||
|
"Refusal row",
|
||||||
|
"Add Thumbnail · 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."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Review",
|
||||||
|
"Clean row",
|
||||||
|
"Add Thumbnail · Media, optional. 12 entries are unaffected."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Control",
|
||||||
|
"Empty",
|
||||||
|
"No image selected. · Pick an image"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Control",
|
||||||
|
"Filled",
|
||||||
|
"lime-plaster-workshop.jpg · 1600 × 900 · 240 KB · Change image · Remove"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Control",
|
||||||
|
"Help",
|
||||||
|
"One image from your library."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Control",
|
||||||
|
"Error",
|
||||||
|
"Pick an image for Thumbnail."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Control",
|
||||||
|
"Missing",
|
||||||
|
"This image is no longer in the library · Choose another image · Clear"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Control",
|
||||||
|
"Missing help",
|
||||||
|
"Optional: You can still save. The entry keeps the link until you change it. Required: Thumbnail is required. Choose another image to save."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Control",
|
||||||
|
"Announcements (live, polite)",
|
||||||
|
"Image selected: lime-plaster-workshop.jpg · Image removed from Thumbnail. · Thumbnail cleared."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Read view",
|
||||||
|
"Rules cell",
|
||||||
|
"Optional · One image from the library"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Gallery",
|
||||||
|
"Name, description",
|
||||||
|
"Media · One image from your media library"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Picker",
|
||||||
|
"Title, search, row",
|
||||||
|
"Choose an image · Search by filename · No image · Leave this field empty."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Picker",
|
||||||
|
"Badge and buttons",
|
||||||
|
"Current · Load more · Cancel · Use this image · Remove image (when No image is chosen)"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Picker",
|
||||||
|
"States",
|
||||||
|
"Your library is empty · Images you upload appear here, ready to use in any article or entry. · Upload an image · Couldn't load your library · Check your connection and try again. · Try again"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Entry list",
|
||||||
|
"Header, empty, missing",
|
||||||
|
"Thumbnail · – · Image no longer in the library"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Media library",
|
||||||
|
"Badge and tooltip",
|
||||||
|
"Used in 3 · Used in entries: 3"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Media library",
|
||||||
|
"Panel",
|
||||||
|
"Used in entries · and 2 more"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Media library",
|
||||||
|
"Delete",
|
||||||
|
"Delete an image that's in use? · \"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. · Cancel · Delete image"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Media field: components, reused and new",
|
||||||
|
"tpl": "220px 140px minmax(0,1fr)",
|
||||||
|
"cols": [
|
||||||
|
"Component",
|
||||||
|
"Status",
|
||||||
|
"Notes"
|
||||||
|
],
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
"AppFrame, SchemaFrame builder rows, Insp, FRow, review dialog rows, entry form, entry list and Columns menu",
|
||||||
|
"Reused",
|
||||||
|
"The inspector hides Unique, default and limits for media, as it does for yes or no. One more type tile (seven)."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"MediaFrame: grid card, list row, detail panel, delete dialog, picker dialog",
|
||||||
|
"Reused",
|
||||||
|
"Picker gains a field mode (no alt text, no variant select, Current, No image). Panel gains a Used in entries list. Badge counts articles plus entries."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"MediaFieldControl",
|
||||||
|
"New (the only one)",
|
||||||
|
"Props: label, required, modelValue (image id or null), image (summary or missing), disabled, error. Emits update:modelValue. Opens the picker."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Nuxt UI mapping",
|
||||||
|
"Nuxt UI v4",
|
||||||
|
"UFormField (label, help, error, required) · UButton \"Pick an image\", \"Change image\", \"Choose another image\" (outline) · UButton \"Remove\", \"Clear\" (ghost) · UModal on desktop and a bottom UDrawer on mobile for the picker, as the article picker · UTooltip in the list and on the badge · UIcon lucide:image, lucide:image-off, lucide:unlink. The thumbnail is a plain button."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Picker and field failures: status to copy",
|
||||||
|
"tpl": "160px minmax(0,1fr)",
|
||||||
|
"cols": [
|
||||||
|
"Request and status",
|
||||||
|
"Copy"
|
||||||
|
],
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
"GET /v1/media/ 400",
|
||||||
|
"That request wasn't valid. Check the fields and try again. (search is trimmed first, so this is rare)"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"GET /v1/media/ 401, 403",
|
||||||
|
"Global redirect, no local copy."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"GET /v1/media/ 429",
|
||||||
|
"Too many requests. Try again in a few minutes. (countdown alert as on the Media page)"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"GET /v1/media/ 500, network",
|
||||||
|
"Couldn't load your library · Check your connection and try again. · Try again (500 body: Something went wrong on our side. Nothing was changed. Try again in a moment.)"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"POST /v1/media/ 413",
|
||||||
|
"That file is too large."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"POST /v1/media/ 400",
|
||||||
|
"That request wasn't valid. Check the fields and try again."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"POST /v1/media/ 429",
|
||||||
|
"Too many requests. Try again in a few minutes."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"POST /v1/media/ 500, network",
|
||||||
|
"Something went wrong on our side. Nothing was changed. Try again in a moment."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"PUT entry 422 invalid_entry thumbnail: required",
|
||||||
|
"Pick an image for Thumbnail."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"PUT entry 422 invalid_entry thumbnail: type",
|
||||||
|
"Thumbnail must be an image from the library. (the image was deleted between the pick and the save)"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"PUT entry 409",
|
||||||
|
"This changed somewhere else. Reload and try again."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"DELETE /v1/media/{id} 404",
|
||||||
|
"That item no longer exists."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"DELETE /v1/media/{id} 500, network",
|
||||||
|
"Something went wrong on our side. Nothing was changed. Try again in a moment."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Row height with media",
|
||||||
|
"tpl": "220px minmax(0,1fr)",
|
||||||
|
"cols": [
|
||||||
|
"Surface",
|
||||||
|
"Choice and reason"
|
||||||
|
],
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
"Desktop table",
|
||||||
|
"The design rule is 48 px rows (56 px for the media library). The brief mentions 32 px, which the design does not use. The thumbnail is 40 px, so a 48 px row keeps 4 px above and below and does not grow. No row exceeds 48 px."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Mobile card",
|
||||||
|
"Cards are 68 px minimum. The thumbnail is 32 px and leads the card. Cards do not grow."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Missing image",
|
||||||
|
"Dashed square with the same 40 px (32 px) footprint, so the columns stay aligned and the row does not move."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Accessibility",
|
||||||
|
"tpl": "220px minmax(0,1fr)",
|
||||||
|
"cols": [
|
||||||
|
"Topic",
|
||||||
|
"Notes"
|
||||||
|
],
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
"Group and label",
|
||||||
|
"MediaFieldControl is role=\"group\" labelled by the field label. A required field adds aria-required to the control button and the * is part of the label text."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Button names",
|
||||||
|
"Pick an image → \"Pick an image for Thumbnail\". Thumbnail button → \"Change image, lime-plaster-workshop.jpg, 1600 by 900, 240 KB\". Remove → \"Remove image from Thumbnail\". Clear → \"Clear Thumbnail\". Missing placeholder: role img, \"Image no longer in the library\". Each name starts with the visible label."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Focus order",
|
||||||
|
"Label, thumbnail, Change image, Remove, help or error. The thumbnail duplicates Change image; if reviewers prefer one stop, make it tabindex -1 and keep the pointer shortcut."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Announcements",
|
||||||
|
"One polite live region on the control: \"Image selected: lime-plaster-workshop.jpg\", \"Image removed from Thumbnail.\", \"Thumbnail cleared.\" No announcement when the picker opens."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Focus after actions",
|
||||||
|
"Picker closes with Use this image: focus goes to Change image. Remove or Clear: focus goes to Pick an image. Cancel or Esc: focus returns to the button that opened it. Save refused: focus goes to the first invalid control (Pick an image or Choose another image)."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Error",
|
||||||
|
"The error text has an id. The control button gets aria-invalid=\"true\" and aria-describedby set to the error id, then the help id. aria-invalid is not defined for group, so it sits on the focusable button."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Picker",
|
||||||
|
"Tabs follow the existing tablist pattern. Tiles are buttons with aria-pressed and names like \"stone-steps.jpg, 310 KB\" and \", Current\". No image is a button with aria-pressed. Use this image uses aria-disabled while nothing is chosen. Load more keeps focus until new tiles arrive."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Contrast and targets",
|
||||||
|
"Current badge is on-sand on sand (14.3). Text on selected tiles uses on-sand, never muted. The hover bar is inverted text on inverted ground (18.9). Dashed borders use border-accented (3.6). Mobile targets are 44 px."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Motion and touch",
|
||||||
|
"No hover on mobile. Hover and focus never carry information alone."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Needs backend",
|
||||||
|
"tpl": "260px minmax(0,1fr)",
|
||||||
|
"cols": [
|
||||||
|
"Item",
|
||||||
|
"What is needed"
|
||||||
|
],
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
"Media field type",
|
||||||
|
"Schema API accepts type \"media\" with label, key, required only. The value is an image id or null. Writes with a missing id on a changed value return 422 invalid_entry, type."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Image summary in entries",
|
||||||
|
"Entry and list responses include, per media field, filename, width, height, size and a small thumbnail URL, or a flag that the image is missing, so the editor and list need no extra requests."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Usage by entries",
|
||||||
|
"GET /v1/media/{id}/usage returns articles and entries (content type name, key, entry id, title), a total count and the first few items."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Counts for the badge and delete",
|
||||||
|
"GET /v1/media/ list items carry the combined use count. DELETE keeps working for used images; the entries keep their ids."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Required with entries",
|
||||||
|
"The server refuses a new required media field on a type with entries with schema_incompatible. The client shows the refusal first."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Media field: conflicts with the existing design, decide before coding",
|
||||||
|
"tpl": "40px 260px minmax(0,1fr)",
|
||||||
|
"cols": [
|
||||||
|
"#",
|
||||||
|
"Brief",
|
||||||
|
"Existing design and what the board does"
|
||||||
|
],
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
"1",
|
||||||
|
"Rows follow a 32 px density rule.",
|
||||||
|
"Tables use 48 px rows (56 px for media) and 68 px cards. Drawn at 48 and 68; the 40 px thumbnail fits without growing a row."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"2",
|
||||||
|
"Thumbnail sits between Description and Starts on.",
|
||||||
|
"In the existing editor Description is the last field and Starts on comes second. Drawn after Description, at the end of the form, which is schema order. Moving it needs a different form order."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"3",
|
||||||
|
"\"Used in 3\" counts entries.",
|
||||||
|
"Today \"Used in N\" counts articles. Drawn as articles plus entries; the tooltip and the panel split them."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"4",
|
||||||
|
"Delete title \"Delete this image?\".",
|
||||||
|
"The existing dialog for an image in use is \"Delete an image that's in use?\" with an articles list. Kept that title and added the entries list. \"Delete this image?\" stays the unused-image title."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"5",
|
||||||
|
"\"Clear\" in the missing state.",
|
||||||
|
"The glossary says Remove detaches and the filled state uses Remove. Kept \"Clear\" as written. Suggest Remove for both."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"6",
|
||||||
|
"\"1600 x 900 - 240 KB\".",
|
||||||
|
"The design uses × and the middle dot (\"1600 × 900 · 240 KB\")."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"7",
|
||||||
|
"Refuse the required media field in the review step.",
|
||||||
|
"The review is informational and Apply changes is never disabled; the server decides. This one case is certain, so Apply is off. Alternative: leave Apply on and show the 422 refusal dialog."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"8",
|
||||||
|
"Primary button \"Use this image\".",
|
||||||
|
"The article picker says \"Insert image\". When No image is chosen the button reads \"Remove image\" (my addition). The picker keeps its existing copy for empty and failed states."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"9",
|
||||||
|
"Pick an image on empty optional fields too.",
|
||||||
|
"The brief names the button for required fields only. One name covers both."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"10",
|
||||||
|
"Read view of a custom type.",
|
||||||
|
"Only owners and admins see Schema, and they edit. No read-only route exists for custom types. Table drawn as the built-in one; the route is a decision."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"11",
|
||||||
|
"Thumbnail focus ring.",
|
||||||
|
"The thumbnail and Change image open the same picker, so there are two tab stops. See accessibility."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"12",
|
||||||
|
"Disabled look.",
|
||||||
|
"Entry form fields dim to 60% and foundations.md says 40%. The control follows the form."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"13",
|
||||||
|
"Picker page behind the dialog.",
|
||||||
|
"The existing picker frame shows the article editor. The field picker shows a stand-in for the entry form."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
const N = 'available now', L = 'later'
|
||||||
|
const st = (name, scope, trigger, s, copy) => ({ name, scope, trigger, s, copy })
|
||||||
|
const E = (name, k, trigger, copy, extra = {}) => st(name, N, trigger, { v: 'editor', k, ...extra }, copy)
|
||||||
|
const CONTRIB = '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'
|
||||||
|
|
||||||
|
export const groups = [
|
||||||
|
{ label: '1 Roles list', title: '1 Roles list', route: '/settings/roles', api: 'GET /v1/role', auth: 'Roles: view', note: '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.', states: [
|
||||||
|
st('Default', N, 'Owner opens /settings/roles.', { v: 'list', k: 'ok' }, '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'),
|
||||||
|
st('Loading', N, 'First load or retry. Skeleton rows after 150 ms. New role disabled.', { v: 'list', k: 'loading' }, '(skeleton) · New role (disabled)'),
|
||||||
|
st('Only built-in roles', N, 'GET /v1/role returns the three built-ins and no custom role.', { v: 'list', k: 'empty' }, 'No custom roles yet · The three built-in roles cover most sites. Create a role when someone needs something in between. · New role'),
|
||||||
|
st('Row menu: custom role', N, 'Open the row menu on Contributor.', { v: 'list', k: 'menu' }, 'Edit · Duplicate · Delete'),
|
||||||
|
st('Row menu: built-in role', N, 'Open the row menu on Writer as owner. Delete is disabled and its tooltip shows on focus or hover.', { v: 'list', k: 'menub' }, 'Edit · Duplicate · Delete (disabled) · Built-in roles can\'t be deleted. Duplicate it to make a custom role.'),
|
||||||
|
st('No permission to edit', N, 'User has Roles: view but not Roles: manage. New role is disabled with a tooltip.', { v: 'list', k: 'noedit', role: 'admin' }, 'You can see roles but not change them · Ask an owner or admin if a role needs to change. · You can see roles, but only people who manage roles can create or change them.'),
|
||||||
|
st('Error', N, '500 or network failure.', { v: 'list', k: 'error' }, 'Couldn\'t load roles · GET /v1/role failed. Check your connection and try again. · Try again')
|
||||||
|
] },
|
||||||
|
{ label: '2 Role editor', title: '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)', note: '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).', states: [
|
||||||
|
E('New role (empty)', 'new', 'Owner or admin opens /settings/roles/new. Nothing is granted. Save is off until the role has a name.', 'New role · Name · For example, Contributor · Description (optional) · What this role is for · Name your role to save it · Discard · Save changes · ' + CONTRIB),
|
||||||
|
E('Editing a custom role', 'custom', 'Open Contributor. Two groups open. Case studies shows Custom with a Reset link.', CONTRIB + ' · Custom · Reset · 4 members · Custom'),
|
||||||
|
E('Built-in role, as owner', 'owner-edit', 'The owner opens Writer. A warning shows how many members are affected.', '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'),
|
||||||
|
E('Built-in role, as admin (read-only)', 'admin-ro', 'An admin opens Writer. All controls are disabled and the fields are locked.', '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'),
|
||||||
|
E('Owner role (locked)', 'locked', 'Anyone with Roles: view opens Owner.', 'The Owner role is locked · It always has every permission. To hand it to someone else, transfer ownership from Users. · Go to Users · Locked'),
|
||||||
|
E('General permissions', 'general', 'Scrolled to General permissions. Groups collapse; each shows "x of y enabled". Scoped rows use the same None / Own / All control.', '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'),
|
||||||
|
E('Owner-only items', 'owneronly', '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.', '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'),
|
||||||
|
E('Unsaved changes', 'dirty', 'Three edits. Changed cells carry a square marker and the preview tags the changed line.', '3 unsaved changes · Discard · Save changes · Changed'),
|
||||||
|
E('Saving', 'saving', 'PUT in flight. Fields dim; Save shows the loader.', 'Saving… · Discard (disabled)'),
|
||||||
|
E('Saved', 'saved', '200. The bar closes and a toast confirms.', 'Role saved · Contributor is updated for 4 members.'),
|
||||||
|
E('Name required', 'req', 'Save with an empty name.', 'Give the role a name. · 1 problem to fix'),
|
||||||
|
E('Name already taken', 'taken', '409 role_name_taken on POST or PUT.', 'A role named "Writer" already exists. Choose a different name. · 1 problem to fix'),
|
||||||
|
E('Escalation blocked', 'esc', 'The editor holds Roles: manage but can publish only their own entries, so Publish: All is disabled with a lock.', 'You can only grant what your role has. Your role can publish only entries you created, so All isn\'t available. · All (locked)'),
|
||||||
|
E('Conflict', 'conflict', '409 role_conflict on PUT (version mismatch).', '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'),
|
||||||
|
E('Permission lowered while open (403)', 'lowered', '403 missing_permission on PUT or on refresh. The page becomes read-only.', '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')
|
||||||
|
] },
|
||||||
|
{ label: '3 Plain-language preview', title: '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', note: '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.', states: [
|
||||||
|
st('Live panel', N, 'Editing Contributor. Events: Update changed from Own to All. The first line is tagged.', { v: 'pv', k: 'live' }, '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'),
|
||||||
|
st('Read-only summary', N, 'Opened from a built-in role in read-only mode. No "Updates as you edit".', { v: 'pv', k: 'readonly', role: 'admin' }, 'What this role can do · A summary of what this role allows.'),
|
||||||
|
st('Nothing granted', N, 'New role with every permission off.', { v: 'pv', k: 'empty' }, 'Content · All content types · No access to entries. · No access to Schema, Media, Authors, Activity, Users and invites, Settings, Roles.'),
|
||||||
|
st('Mobile sheet', N, 'Tap "What this role can do" in the sticky bar. 92% sheet with grabber and close.', { v: 'editor', k: 'dirty', sheet: true }, 'What this role can do · Updates as you edit. · Close')
|
||||||
|
] },
|
||||||
|
{ label: '4 Delete role', title: '4 Delete role', route: 'Dialog over /settings/roles', api: 'POST /v1/role/{id}/reassign, DELETE /v1/role/{id}', auth: 'Roles: manage', note: '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.', states: [
|
||||||
|
st('Blocked: has members', N, 'Delete Contributor, 4 members.', { v: 'del', k: 'blocked' }, '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)'),
|
||||||
|
st('Role picker open', N, 'The picker shows each role\'s summary. Admin is disabled for an admin.', { v: 'del', k: 'picker' }, '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.'),
|
||||||
|
st('Confirm: no members', N, 'Delete Intern, 0 members.', { v: 'del', k: 'confirm' }, 'Delete Intern? · No one has this role. This can\'t be undone. · Deleting a role doesn\'t affect content. · Cancel · Delete role'),
|
||||||
|
st('Built-in role', N, 'Delete is disabled in the menu. If the dialog is reached by link it shows this state.', { v: 'del', k: 'builtin' }, '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')
|
||||||
|
] },
|
||||||
|
{ label: '5 Users', title: '5 Users: roles and ownership', route: '/users', api: 'PUT /v1/user/{id}/role, POST /v1/invite, POST /v1/site/transfer-ownership', auth: 'Users: view; change role, invite, remove; owner for transfer', note: '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.', states: [
|
||||||
|
st('List with roles', N, 'Owner opens /users.', { v: 'users', k: 'list' }, 'Users · 5 members and invites · Invite user · Name · Email · Role · Status · Owner · Admin · Writer · Contributor · Content lead · Active · Invite pending'),
|
||||||
|
st('Row menu', N, 'Open the menu on Ines Duarte.', { v: 'users', k: 'menu' }, 'Change role · Send password reset · Remove user'),
|
||||||
|
st('Owner row menu', N, 'Owner row: Change role and Remove are disabled; Transfer ownership appears.', { v: 'users', k: 'ownermenu' }, 'Change role (disabled) · Transfer ownership · Remove user (disabled) · The owner can\'t be changed or removed. Transfer ownership first.'),
|
||||||
|
st('Change role dialog', N, 'Each option shows the role\'s one-line summary. The current role is marked.', { v: 'users', k: 'change' }, 'Change role for Ines Duarte · Her API keys follow the new role. · Admin · Writer · · Current · Contributor · Content lead · Cancel · Change role'),
|
||||||
|
st('Change role: options blocked', N, 'An admin opens the dialog. Roles with more power are disabled with a reason.', { v: 'users', k: 'changeblock' }, 'Owner · There is one owner. Transfer ownership to change who it is. · Content lead · It can publish. Your role can\'t grant that.'),
|
||||||
|
st('Invite dialog', N, 'Role picker defaults to Writer and shows its summary.', { v: 'users', k: 'invite' }, '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'),
|
||||||
|
st('Transfer ownership: pick member', N, 'Owner menu > Transfer ownership. Only members can be chosen.', { v: 'users', k: 'tr1' }, 'Transfer ownership · Choose who becomes the owner. Only members can be chosen. · Cancel · Continue'),
|
||||||
|
st('Transfer ownership: confirm', N, 'Consequences listed. The action stays off until the email is retyped exactly.', { v: 'users', k: 'tr2' }, '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 he transfers 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'),
|
||||||
|
st('Transfer ownership: done', N, 'The former owner is now Admin. The list shows the new owner.', { v: 'users', k: 'tr3' }, 'Ownership transferred · Jonas Weber is now the owner. You are an Admin.')
|
||||||
|
] },
|
||||||
|
{ label: '6 Role-aware shell', title: '6 Role-aware shell', route: 'Every page', api: 'GET /v1/me returns role and permission keys', auth: 'Any', note: '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.', states: [
|
||||||
|
st('The rule', N, 'Reference card used across the app.', { v: 'shell', k: 'rule' }, '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.'),
|
||||||
|
st('Writer: Articles list', N, 'Create and edit all, no delete, no publish. No Schema, Users, Integration or Settings in the sidebar.', { v: 'shell', k: 'art' }, 'Articles · You can write and edit all articles. Publishing is done by an editor. · New article · Created by · Status · Draft · Published'),
|
||||||
|
st('Writer: row menu', N, 'Delete is disabled with a reason.', { v: 'shell', k: 'artmenu' }, 'Edit · Duplicate · Delete (disabled) · Your role can\'t delete articles.'),
|
||||||
|
st('Writer: entry editor', N, 'Publish is hidden. Status reads Draft with a hint.', { v: 'shell', k: 'entry' }, 'Save draft · Status · Draft · Saved at 11:02 · Ask an editor to publish.'),
|
||||||
|
st('Own scope: entry list', N, 'Contributor can edit only entries they created. Others show a View only mark.', { v: 'shell', k: 'own' }, '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'),
|
||||||
|
st('Own scope: row menu', N, 'On an entry by someone else, Edit and Delete are disabled.', { v: 'shell', k: 'ownmenu' }, 'Edit (disabled) · Duplicate · Delete (disabled) · You can only edit content you created.'),
|
||||||
|
st('Admin: read-only settings', N, 'An admin without Edit general settings. Fields locked, Save off.', { v: 'admin', k: 'ro' }, '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)'),
|
||||||
|
st('Admin: owner-only settings', N, 'Software and Retention show an Owner only tag. The action stays visible and disabled.', { v: 'admin', k: 'owneronly' }, '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.')
|
||||||
|
] },
|
||||||
|
{ label: '7 No access', title: '7 No access: 403 copy', route: 'Any page', api: '403 missing_permission, 403 role_locked', auth: 'Any', note: '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.', states: [
|
||||||
|
st('Page: no access', N, 'Contributor opens /settings/roles by link.', { v: 'forbid', k: 'page' }, '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'),
|
||||||
|
st('Action: missing_permission', N, 'An action was refused with 403 missing_permission.', { v: 'forbid', k: 'missing' }, 'You don\'t have permission to do that · Your role doesn\'t include Delete entries. Ask an owner or admin.'),
|
||||||
|
st('Action: role_locked', N, 'An attempt to edit or delete the Owner role.', { v: 'forbid', k: 'locked' }, 'This role is locked · The Owner role can\'t be changed or deleted. Transfer ownership from Users instead.')
|
||||||
|
] },
|
||||||
|
{ label: '8 Activity', title: '8 Activity: role events', route: '/activity', api: 'GET /v1/activity', auth: 'Activity: own or all', note: 'New event types. The detail panel summarises a role diff as Added and Removed lines using the human permission names.', states: [
|
||||||
|
st('List with role events', N, 'Five new events.', { v: 'act', k: 'list' }, 'Role Contributor created · Role Contributor edited · Ines Duarte\'s role changed from Writer to Content lead · Ownership transferred from Marta Alves to Jonas Weber · Role Intern deleted'),
|
||||||
|
st('Detail: role diff', N, 'Open "Role Contributor edited".', { v: 'act', k: 'detail' }, 'Activity details · What changed · Added · Delete entries (Own) · Removed · Publish · 4 members have this role.')
|
||||||
|
] },
|
||||||
|
{ label: '9 Profile', title: '9 Profile: your role', route: '/profile', api: 'GET /v1/me', auth: 'Any', note: 'The person sees their own role and a read-only summary.', states: [
|
||||||
|
st('Role card', N, 'Any signed-in user.', { v: 'prof', k: 'card' }, '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. If your role changes, they change too.'),
|
||||||
|
st('See what I can do', N, 'Opens the summary. Dialog on desktop, sheet on mobile.', { v: 'prof', k: 'sheet' }, 'What you can do · A summary of your role, Contributor. Read-only. · Close')
|
||||||
|
] },
|
||||||
|
{ label: '10 API keys', title: '10 API keys: inherited role', route: '/settings/api-keys', api: 'GET /v1/api-key', auth: 'Settings: API keys', note: 'Keys follow their creator\'s role live. The fact is stated in an info alert and each row shows the creator\'s role.', states: [
|
||||||
|
st('Keys follow the role', N, 'Always shown on the API keys screen.', { v: 'keys', k: 'ok' }, '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')
|
||||||
|
] },
|
||||||
|
{ label: '11 Create a role in under a minute', title: '11 Flow: create "Contributor" in under a minute', route: '/settings/roles, /settings/roles/new', api: 'POST /v1/role', auth: 'Roles: manage', note: '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.', states: [
|
||||||
|
st('1 Roles list: New role', N, 'Click New role.', { v: 'list', k: 'ok' }, 'New role'),
|
||||||
|
E('2 Name it', 'flow', 'Type a name and a one-line description.', 'Name Contributor · Description Writes posts and edits only their own', { flow: 2 }),
|
||||||
|
E('3 Tap the preset: Write own', 'flow', 'The preset sets Read: All, Create: On, Update: Own, Delete and Publish: None for every type.', 'Write own · 7 unsaved changes', { flow: 3 }),
|
||||||
|
E('4 Check General permissions', 'flow', 'Optional. Schema view and Media view and upload are on by default for the preset. Check the preview.', 'What this role can do · Can read any entry, create entries and edit only their own. Can\'t delete and publish.', { flow: 4 }),
|
||||||
|
E('5 Save', 'flow', 'Save changes. The role appears in the list and in the invite picker.', 'Save changes', { flow: 5 }),
|
||||||
|
E('6 Done', 'flow', 'Toast, and the bar closes.', 'Role created · Contributor is ready. Assign it from Users.', { flow: 6 })
|
||||||
|
] }
|
||||||
|
]
|
||||||
|
|
||||||
|
const T = (title, tpl, cols, rows) => ({ title, tpl, cols, rows })
|
||||||
|
export const tables = [
|
||||||
|
T('Permission catalogue (names shown to people)', '170px 1fr 1fr', ['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']]),
|
||||||
|
T('Built-in roles', '140px 1fr 160px', ['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)']]),
|
||||||
|
T('No-escalation feedback', '220px 1fr 1fr', ['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.']]),
|
||||||
|
T('Hidden or disabled', '240px 1fr 1fr', ['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']]),
|
||||||
|
T('HTTP status to copy (additions)', '180px 120px 1fr 1fr', ['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.'],
|
||||||
|
['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.']]),
|
||||||
|
T('Components (new) and Nuxt UI v4 mapping', '200px 1fr 1fr', ['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']]),
|
||||||
|
T('Accessibility: tri-state cell', '220px 1fr', ['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.']]),
|
||||||
|
T('Needs backend', '240px 1fr', ['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.created, role.updated (with a diff of added and removed permissions), role.deleted, user.role_changed, site.ownership_transferred.']]),
|
||||||
|
T('Existing screens that change', '220px 1fr', ['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.']]),
|
||||||
|
T('Decisions to confirm', '60px 1fr 1fr', ['#', '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.']])
|
||||||
|
]
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { tables as mfTables } from './media-field-tables.js'
|
||||||
|
const N = 'available now', L = 'later'
|
||||||
|
const st = (name, scope, trigger, s, copy, o = {}) => ({ name, scope, trigger, s, copy, ...o })
|
||||||
|
|
||||||
|
export const groups = [
|
||||||
|
{ label: '1 Schema list', title: '1 Schema: list of types', route: '/schema, /schema/:key (built-in)', api: 'GET /v1/content-type', auth: 'Owner or admin', note: '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.', states: [
|
||||||
|
st('Populated', N, 'An owner or admin opens /schema.', { v: 'types', k: 'ok' }, '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'),
|
||||||
|
st('Loading', N, 'First load or retry. Skeleton rows appear after 150 ms. New content type is disabled.', { v: 'types', k: 'loading' }, '(skeleton) · New content type (disabled)'),
|
||||||
|
st('Empty: no custom type yet', N, 'GET /v1/content-type returns []. Built-ins are still listed below.', { v: 'types', k: 'empty' }, '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'),
|
||||||
|
st('Error', N, '500 or network failure on the list request.', { v: 'types', k: 'error' }, 'Couldn\'t load content types · GET /v1/content-type failed. Check your connection and try again. · Try again'),
|
||||||
|
st('Forbidden (403)', N, 'A user who is not owner or admin types /schema. There is no sidebar entry, so the page explains instead of redirecting.', { v: 'types', k: 'forbidden' }, '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'),
|
||||||
|
st('Built-in type: read-only view', N, 'Click Articles in the list. No edit, no delete, no add field.', { v: 'types', k: 'builtin' }, '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.'),
|
||||||
|
st("Custom type: read-only view", N, "The fields table of a custom type with a media field. Same table as the built-in view; the Media type shows the image icon and the name \"Media\".", {"v":"types","k":"customro"}, "Events · Read only · You can read these fields here · Only an owner or admin can change a content type. · Label · Key · Type · Rules · Thumbnail · thumbnail · Media · One image from the library")
|
||||||
|
] },
|
||||||
|
{ label: '2 Schema builder', title: '2 Schema builder', route: '/schema/new, /schema/:key', api: 'POST /v1/content-type, GET/PUT /v1/content-type/{key}', auth: 'Owner or admin', note: '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.', states: [
|
||||||
|
st('New type', N, '/schema/new. One starter field is added because a type needs at least one.', { v: 'builder', k: 'new' }, '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'),
|
||||||
|
st('Editing an existing type', N, '/schema/events with 37 entries. Key is locked, saved field keys and types are locked.', { v: 'builder', k: 'edit' }, '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'),
|
||||||
|
st('Locked key: tooltip', N, 'Hover or focus the locked key.', { v: 'builder', k: 'locked' }, 'The key is used in URLs and the API, so it can\'t change after the type is created.'),
|
||||||
|
st('Unsaved changes', N, 'Two fields added, Capacity changed, Description removed. Rows carry New and Changed badges; removal shows an undo row.', { v: 'builder', k: 'dirty' }, 'Unsaved changes · New · Changed · Description will be removed · Undo · Save · Discard'),
|
||||||
|
st('Field errors', N, '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.', { v: 'builder', k: 'ferr' }, '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.'),
|
||||||
|
st('Type-level errors', N, 'New type with no name, a one-letter key and no fields.', { v: 'builder', k: 'terr' }, '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'),
|
||||||
|
st('Field key error', N, '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.', { v: 'builder', k: 'fkey' }, 'Key · Start with a letter. Use lowercase letters, digits and underscores. · 1 problem to fix'),
|
||||||
|
st('Choice list editor', N, 'A Choice field is selected. Add, remove and reorder; blanks and duplicates are flagged per row.', { v: 'builder', k: 'choices', sheet: true }, '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.'),
|
||||||
|
st('Keyboard reorder', N, '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.', { v: 'builder', k: 'kb' }, 'Moving Ends on. Position 2 of 7. Press Up or Down to move, Space to drop, Esc to cancel. (live region, polite)'),
|
||||||
|
st('API preview open', N, 'The panel at the bottom. Updates as fields change. Copy JSON copies the sample entry.', { v: 'builder', k: 'api' }, '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'),
|
||||||
|
st('Saving', N, 'PUT in flight. Inputs dim, Save shows the loader, nothing can be dragged.', { v: 'builder', k: 'saving' }, 'Saving…'),
|
||||||
|
st('Saved', N, '200. A toast confirms, the badges clear.', { v: 'builder', k: 'saved' }, 'Saved at 11:02 · Content type saved · Events now has 8 fields.'),
|
||||||
|
st('Save failed', N, '500 or network failure. Nothing was sent as saved; the edits stay.', { v: 'builder', k: 'fail' }, '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'),
|
||||||
|
st('Key already taken', N, '409 content_type_key_taken on POST.', { v: 'builder', k: 'taken' }, 'The key "events" is already used by another content type. Choose a different key.'),
|
||||||
|
st('Schema incompatible', N, '422 schema_incompatible on PUT. The review dialog (group 3) closes into this state. Changes of the kinds that can cause it are marked.', { v: 'builder', k: 'incompat' }, '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'),
|
||||||
|
st('Offline', N, 'navigator.onLine is false. Edits are kept; Save is disabled.', { v: 'builder', k: 'offline' }, 'You\'re offline. Changes will be saved when you reconnect. · Waiting for connection'),
|
||||||
|
st("Media field selected", N, "Thumbnail was saved before. Key and type are locked. The inspector shows Label, Key, Type and Required only; the hint explains the field holds one image.", {"v":"builder","k":"media"}, "Thumbnail · thumbnail · Field: Thumbnail · Key · Used in the API, so it can't change. · Type · Can't change after saving · Media · One image from your media library · Required · An entry can't be saved with this field empty. · Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version."),
|
||||||
|
st("New media field, seven types", N, "Add field, then Media. The type tiles show all seven types; key and type can still change.", {"v":"builder","k":"mnew"}, "New · Type · Text · Rich text · Number · Yes or no · Date · Choice · Media · One image from your media library · Key · The name used in the API. Starts with a letter. Lowercase letters, digits and underscores."),
|
||||||
|
st("Media field, required", N, "A saved required media field. The row carries the Required marker.", {"v":"builder","k":"mreq"}, "Thumbnail · thumbnail · Required (marker) · Required · An entry can't be saved with this field empty.")
|
||||||
|
] },
|
||||||
|
{ label: '3 Review and delete', title: '3 Schema change review and delete type', route: '/schema/:key (dialogs)', api: 'PUT /v1/content-type/{key}, DELETE /v1/content-type/{key}', auth: 'Owner or admin', note: '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.', states: [
|
||||||
|
st('Review before saving', N, 'Save on a type that has entries.', { v: 'review', k: 'review' }, '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'),
|
||||||
|
st('Applying', N, 'PUT in flight. Dialog cannot be dismissed.', { v: 'review', k: 'applying' }, 'Applying…'),
|
||||||
|
st('Refused (schema_incompatible)', N, '422 schema_incompatible. States that nothing changed and names the kind of change that likely caused it, never an entry.', { v: 'review', k: 'refused' }, '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'),
|
||||||
|
st('Review with counts and diff', L, 'Per-change entry counts and a full diff need an API that does not exist yet.', { v: 'review', k: 'later' }, 'Affects 0 entries · Affects 37 entries · Affects 3 entries · Affects 31 entries · View full diff · Per-change counts and diff come later', { notNow: true }),
|
||||||
|
st('Delete type: blocked', N, 'Delete content type on a type with entries. No confirm button.', { v: 'review', k: 'blocked' }, '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'),
|
||||||
|
st('Delete type: confirm', N, 'Delete content type on an empty type.', { v: 'review', k: 'confirm' }, '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'),
|
||||||
|
st('Delete type: server fallback (409)', N, '409 content_type_has_entries. An entry was added after the page loaded.', { v: 'review', k: 'fallback' }, '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'),
|
||||||
|
st("Review: required media field refused", N, "Thumbnail is required and Events has 12 entries. A media field can't have a default, so Apply is off.", {"v":"review","k":"mrefuse"}, "Review changes to Events · Apply them to save the content type. · Add 1 field · Events has 12 entries. Each one is checked against the new fields when you apply. · Add Thumbnail · 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. · Back to editing keeps your other changes. · Back to editing · Apply changes (disabled)"),
|
||||||
|
st("Review: optional media field", N, "Thumbnail is optional.", {"v":"review","k":"mclean"}, "Review changes to Events · Add 1 field · Events has 12 entries. Each one is checked against the new fields when you apply. · Add Thumbnail · Media, optional. 12 entries are unaffected. · Back to editing · Apply changes")
|
||||||
|
] },
|
||||||
|
{ label: '4 Field types', title: '4 Field-type gallery', route: 'reference board', api: '', auth: 'n/a', note: '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).', states: ['text', 'richtext', 'number', 'boolean', 'date', 'select', 'media'].map((t, i) => st(['Text', 'Rich text', 'Number', 'Yes or no', 'Date', 'Choice', 'Media'][i], N, 'type: "' + t + '"', { v: 'gallery', k: t }, ({ 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.', 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)', 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.', 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.', 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.', media: 'Media · One image from your media library · Label · Key · Required · Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version. · Pick an image · No image selected. · One image from your library. · Change image · Remove · lime-plaster-workshop.jpg · 1600 × 900 · 240 KB · This image is no longer in the library · Choose another image · Clear · Pick an image for Thumbnail. · Thumbnail must be an image from the library. · Not used for media. · Thumbnail: has an image (later)', 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.' })[t])) },
|
||||||
|
{ label: '5 Content and entry list', title: '5 Content navigation and entry list', route: '/content/:key', api: 'GET /v1/content-type/{key}, GET /v1/content-type/{key}/entry', auth: 'Any verified user', note: '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.', states: [
|
||||||
|
st('Populated', N, '/content/events. 7 entries, one page: no pager, only the count.', { v: 'entries', k: 'ok' }, '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'),
|
||||||
|
st('Paginated, 5 per page', N, '/content/events?size=5. 37 entries.', { v: 'entries', k: 'paged' }, 'Showing 1–5 of 37 · 5 per page · Page 1 of 8 (mobile)'),
|
||||||
|
st('Columns menu', N, 'The Columns button. Popover on desktop, sheet on mobile. Created and updated always show.', { v: 'entries', k: 'cols' }, 'Columns · 4 of 6 · Choose up to 6 fields to show. 4 selected. · Created and updated dates always show · Reset · Done'),
|
||||||
|
st('Many types in the sidebar', N, '14 custom types. Long names truncate. Mobile shows the drawer with the group expanded.', { v: 'entries', k: 'ok', many: true }, 'Content · Frequently asked questions and answers (truncated) · Show all 14 · All content types 14 (mobile, collapsed)'),
|
||||||
|
st('Loading', N, 'First load or retry.', { v: 'entries', k: 'loading' }, '(skeleton)'),
|
||||||
|
st('Empty for this type', N, 'Total is 0. The hint points to New entry.', { v: 'entries', k: 'empty' }, 'No entries yet · Use New entry to add the first event. Each entry follows the fields of this content type. · New entry'),
|
||||||
|
st('Error', N, '500 or network failure.', { v: 'entries', k: 'error' }, 'Couldn\'t load entries · GET /v1/content-type/events/entry failed. Check your connection and try again. · Try again'),
|
||||||
|
st('Forbidden (403)', N, 'The API answers 403. Unverified accounts are redirected to /register/pending before this.', { v: 'entries', k: 'forbidden', role: 'user' }, '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'),
|
||||||
|
st('Type not found', N, '404 content_type_not_found on /content/unknown.', { v: 'entries', k: 'notfound' }, 'Content type not found · It may have been deleted, or the address may be mistyped. · Go to Home'),
|
||||||
|
st("Thumbnail column", N, "Events list with a Thumbnail column: 40 px square thumbnails, one \"–\", one missing image. Row height stays 48 px. Mobile: 32 px thumbnail leads the card. Not sortable.", {"v":"entries","k":"thumb"}, "Thumbnail · Lime plaster workshop · Winter open day · – · Limewash masterclass · Image no longer in the library (accessible name)"),
|
||||||
|
st("Columns menu with Thumbnail", N, "Columns. Thumbnail is listed with the image icon.", {"v":"entries","k":"thumbcols"}, "Columns · 4 of 6 · Title · Description · Thumbnail · Starts on · Ends on · Online · Capacity · Category · Reset · Choose up to 6 fields to show. 4 selected."),
|
||||||
|
st("Missing image: tooltip", N, "Hover or focus on the dashed square (desktop). Mobile has no tooltip.", {"v":"entries","k":"thumbtip"}, "Image no longer in the library")
|
||||||
|
] },
|
||||||
|
{ label: '6 Entry editor', title: '6 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', note: '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.', states: [
|
||||||
|
st('New entry', N, '/content/events/new. Online is pre-filled from its default.', { v: 'entry', k: 'new' }, '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'),
|
||||||
|
st('Editing', N, '/content/events/42. Save is disabled until something changes.', { v: 'entry', k: 'edit' }, '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'),
|
||||||
|
st('Unsaved changes', N, 'Any edit.', { v: 'entry', k: 'dirty' }, 'Unsaved changes · 32 / 80'),
|
||||||
|
st('Markdown preview', N, 'The Preview tab of a rich text field.', { v: 'entry', k: 'preview' }, 'Write · Preview'),
|
||||||
|
st('Saving', N, 'PUT or POST in flight. Form dims.', { v: 'entry', k: 'saving' }, 'Saving…'),
|
||||||
|
st('Saved', N, '200.', { v: 'entry', k: 'saved' }, 'Saved at 11:02 · Entry saved · "Lime plaster workshop" is up to date.'),
|
||||||
|
st('Save failed', N, '500 or network failure. Values stay.', { v: 'entry', k: 'fail' }, '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'),
|
||||||
|
st('Required fields empty', N, '422 invalid_entry: title: required, starts_on: required. The first field with an error takes focus.', { v: 'entry', k: 'required' }, 'Fill in Title. · Pick a date for Starts on. · Not saved'),
|
||||||
|
st('Rule errors', N, '422 invalid_entry for max, type, max and options. One rule per field is shown at a time.', { v: 'entry', k: 'rules' }, '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'),
|
||||||
|
st('Duplicate unique value (409)', N, '409 invalid_entry: title: unique.', { v: 'entry', k: 'unique' }, 'Not saved · Another entry already uses this title. Change it and save again. · Another entry already uses this Title. Enter a different one.'),
|
||||||
|
st('Entry not found', N, '404 entry_not_found, for example after a delete elsewhere.', { v: 'entry', k: 'notfound' }, 'Entry not found · It may have been deleted by someone else. · Back to Events'),
|
||||||
|
st('Schema changed since load', N, '422 invalid_entry: room: required, or unknown_field. The form is read-only until reloaded.', { v: 'entry', k: 'changed' }, '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'),
|
||||||
|
st('Delete entry: confirm', N, 'Delete.', { v: 'entry', k: 'delete' }, 'Delete this entry? · "Lime plaster workshop" will be deleted from Events. This can\'t be undone. · Cancel · Delete entry'),
|
||||||
|
st("Media control: empty, optional", N, "Thumbnail follows Description. Empty optional field.", {"v":"entry","k":"m","m":"empty"}, "Thumbnail · No image selected. · Pick an image · One image from your library."),
|
||||||
|
st("Media control: empty, required", N, "Field marked *.", {"v":"entry","k":"m","m":"emptyreq"}, "Thumbnail * · No image selected. · Pick an image · One image from your library."),
|
||||||
|
st("Media control: filled", N, "An image is picked.", {"v":"entry","k":"m","m":"filled"}, "Thumbnail · lime-plaster-workshop.jpg · 1600 × 900 · 240 KB · Change image · Remove · One image from your library."),
|
||||||
|
st("Media control: hover", N, "Pointer over the thumbnail (desktop). The border darkens and a bar reads Change image. Mobile shows the filled state.", {"v":"entry","k":"m","m":"hover"}, "Change image"),
|
||||||
|
st("Media control: focus", N, "Keyboard focus on the thumbnail: 2 px primary ring, 2 px offset.", {"v":"entry","k":"m","m":"focus"}, "Change image, lime-plaster-workshop.jpg (accessible name)"),
|
||||||
|
st("Media control: validation error", N, "Save with a required field empty (422 invalid_entry, required). Focus moves to Pick an image.", {"v":"entry","k":"m","m":"err"}, "Pick an image for Thumbnail. · Not saved"),
|
||||||
|
st("Media control: image no longer in the library", N, "The image was deleted. The entry keeps the link. Optional field: saving is allowed.", {"v":"entry","k":"m","m":"missing"}, "This image is no longer in the library · You can still save. The entry keeps the link until you change it. · Choose another image · Clear"),
|
||||||
|
st("Media control: missing image, required", N, "Same, on a required field. Save is refused.", {"v":"entry","k":"m","m":"missingreq"}, "This image is no longer in the library · Thumbnail is required. Choose another image to save. · Choose another image · Clear · Pick an image for Thumbnail. · Not saved"),
|
||||||
|
st("Media control: saving", N, "PUT in flight. The thumbnail and its buttons are disabled with the rest of the form.", {"v":"entry","k":"saving","m":"filled"}, "Saving…"),
|
||||||
|
st("Media control: read-only, type changed", N, "422 unknown_field. The form is read-only until reloaded.", {"v":"entry","k":"changed","m":"filled"}, "The fields of Events changed · 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. · Reload form")
|
||||||
|
] },
|
||||||
|
{ label: '7 Relations', title: '7 Relation field and picker', route: '/schema/:key, /content/:key/:id', api: 'not built', auth: 'Owner or admin (picker), any verified user (form)', note: '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.', states: [
|
||||||
|
st('Relation in the builder', L, 'A seventh type, Relation, in the type gallery.', { v: 'rel', k: 'builder' }, '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"'),
|
||||||
|
st('Relation in the entry form', L, 'Search and pick; chips for many.', { v: 'rel', k: 'form' }, 'Venue · Speakers · Search to add · An event can have many speakers.'),
|
||||||
|
st('Relation in the list', L, 'One chip, two chips and +N, or a dash.', { v: 'rel', k: 'list' }, 'One chip for a single relation. Up to two chips and a "+N" for many. Empty shows a dash.')
|
||||||
|
] },
|
||||||
|
{ label: '8 Permissions', title: '8 Permissions grid', route: '/settings/permissions', api: 'not built', auth: 'Owner or admin', note: '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.', states: [
|
||||||
|
st('Default: today\'s rules', L, 'Role User selected. Everything on, as today.', { v: 'perms', k: 'default' }, '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'),
|
||||||
|
st('Changed, not saved', L, 'Two types restricted for the User role.', { v: 'perms', k: 'changed' }, '4 changes not saved · Changed')
|
||||||
|
] },
|
||||||
|
{ label: '9 Export and import', title: '9 Schema export and import', route: '/schema (dialogs)', api: 'not built', auth: 'Owner or admin', note: 'All later. Dev-to-production flow: export a file, import it elsewhere, preview, same compatibility review as group 3, explicit confirm.', states: [
|
||||||
|
st('Export', L, 'Export on /schema.', { v: 'xport', k: 'export' }, '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'),
|
||||||
|
st('Import preview', L, 'A file was chosen.', { v: 'xport', k: 'import' }, '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'),
|
||||||
|
st('Import refused', L, 'The server answers schema_incompatible.', { v: 'xport', k: 'refused' }, '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')
|
||||||
|
] },
|
||||||
|
{ label: '10 Media picker for fields', title: '10 Image picker from the entry editor', route: '/content/events/:id (dialog)', api: 'GET /v1/media/?q=&limit=&cursor= · POST /v1/media/', auth: 'Any verified user', note: "The existing picker (Media picker and Editor picker groups of the back-office board) with Upload and Library tabs, single selection, search and Load more. Changes: the title is \"Choose an image\", the primary button reads \"Use this image\", there is no alt text and no variant select, the current image carries \"Current\" in place of \"In this article\", and \"No image\" tops the Library tab for optional fields only. Desktop dialog, mobile bottom sheet. The page behind is a stand-in for the entry editor.", states: [
|
||||||
|
st("Library: nothing chosen", N, "Change image on an optional field. Current marks the image in the field.", {"frame":"media","kind":"picker","picker":true,"field":true,"pk":{"tab":"Library","cur":1}}, "Choose an image · Upload · Library · Search by filename · No image · Leave this field empty. · Current · Newest first · 48 images · Load more · Cancel · Use this image (disabled)"),
|
||||||
|
st("Library: image chosen", N, "One tile selected (single selection). A strip shows filename, dimensions and size. No alt text field.", {"frame":"media","kind":"picker","picker":true,"field":true,"pk":{"tab":"Library","cur":1,"sel":4}}, "kitchen-before.webp · 2400 × 1600 · 1.1 MB · Cancel · Use this image"),
|
||||||
|
st("Library: No image chosen", N, "Optional fields only. The primary button changes.", {"frame":"media","kind":"picker","picker":true,"field":true,"pk":{"tab":"Library","cur":1,"sel":"none"}}, "No image · Leave this field empty. · Cancel · Remove image"),
|
||||||
|
st("Library: required field", N, "No \"No image\" row.", {"frame":"media","kind":"picker","picker":true,"field":true,"pk":{"tab":"Library","cur":1,"req":true}}, "Choose an image · Cancel · Use this image (disabled)"),
|
||||||
|
st("Library: empty", N, "Zero images. Upload an image switches to the Upload tab.", {"frame":"media","kind":"picker","picker":true,"field":true,"pk":{"tab":"Library","empty":"first"}}, "Your library is empty · Images you upload appear here, ready to use in any article or entry. · Upload an image"),
|
||||||
|
st("Library: failed to load", N, "500 or network failure. Other statuses: see the failures table.", {"frame":"media","kind":"picker","picker":true,"field":true,"pk":{"tab":"Library","error":true}}, "Couldn't load your library · Check your connection and try again. · Try again"),
|
||||||
|
st("Library: loading", N, "First open or a new search. Skeleton tiles after 150 ms.", {"frame":"media","kind":"picker","picker":true,"field":true,"pk":{"tab":"Library","loading":true}}, "(skeleton) · Use this image (disabled)"),
|
||||||
|
st("Upload tab", N, "The existing upload flow, including crop and resize. The new image lands in the library and is selected.", {"frame":"media","kind":"picker","picker":true,"field":true,"pk":{"tab":"Upload"}}, "Drop an image here, or choose a file · PNG, JPEG, WEBP, SVG · max 10 MB")
|
||||||
|
] },
|
||||||
|
{ label: '11 Media library: usage', title: '11 Media library: usage by entries', route: '/media', api: 'GET /v1/media/ · GET /v1/media/{id}/usage · DELETE /v1/media/{id}', auth: 'Verified users', note: "Same Media screens as the back-office board. Entries count as uses: the \"Used in N\" badge adds articles and entries, its tooltip splits them, and the detail panel gains a \"Used in entries\" list (up to 3 links, then \"and N more\"). Deleting an image that entries use says how many and what the fields will show.", states: [
|
||||||
|
st("Grid: usage badge tooltip", N, "Hover or focus the \"Used in 3\" badge on bathroom-tiles.png (3 entries, no article).", {"frame":"media","ent":true,"tip":10}, "Used in 3 · Used in entries: 3 · Unused"),
|
||||||
|
st("List view: usage badge", N, "List view; mobile uses the card rows.", {"frame":"media","ent":true,"view":"list"}, "Used in 3 · Used in 7 · Unused"),
|
||||||
|
st("Detail panel: used in entries", N, "Click lime-plaster.jpg (2 articles, 5 entries). Right-hand panel; full screen on mobile.", {"frame":"media","ent":true,"panel":{"id":11}}, "Used in 2 articles · Used in entries · Events · Lime plaster workshop · Events · Heritage stone walk · Events · Winter open day · and 2 more"),
|
||||||
|
st("Delete: image used by entries", N, "Delete in the panel.", {"frame":"media","ent":true,"panel":{"id":11},"modal":{"k":"delEnt"}}, "Delete an image that's in use? · \"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. · Cancel · Delete image")
|
||||||
|
] }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const tables = [
|
||||||
|
{ title: 'Scope: available now or later', tpl: '260px 110px minmax(0,1fr)', cols: ['Feature', 'Status', 'Notes'], rows: [
|
||||||
|
['1 Schema list, built-in rows, read-only view', 'Available now', 'Built-in list is static client data (see Needs backend)'],
|
||||||
|
['2 Schema builder, all states', 'Available now', ''],
|
||||||
|
['Live API preview', 'Available now', 'Built on the client from the field list'],
|
||||||
|
['Type-level and field-level validation', 'Available now', 'Same rules as the server, shown while typing'],
|
||||||
|
['3 Review step and Apply', 'Available now', 'Uses the total from the entry list response'],
|
||||||
|
['3 Refusal state, delete type, blocked delete', 'Available now', ''],
|
||||||
|
['3 Per-change entry counts, full diff', 'Later', 'The API does not return them'],
|
||||||
|
['4 Field-type gallery', 'Available now', 'Filter widgets inside it are Later'],
|
||||||
|
['5 Content group in the sidebar, entry list', 'Available now', 'No filter or search on custom fields'],
|
||||||
|
['6 Entry editor, all states', 'Available now', ''],
|
||||||
|
['7 Relation field and picker', 'Later', ''],
|
||||||
|
['8 Permissions grid', 'Later', ''],
|
||||||
|
['9 Schema export and import', 'Later', ''],
|
||||||
|
['Filters on custom fields', 'Later', ''],
|
||||||
|
['Publish and draft state for entries', 'Later', 'Not drawn'],
|
||||||
|
['Public (anonymous) read access', 'Later', 'Not drawn'],
|
||||||
|
['Activity log entries for types and entries', 'Later', 'Not drawn']] },
|
||||||
|
{ title: 'Routes', tpl: '190px 280px 200px minmax(0,1fr)', cols: ['Route', 'Screen', 'Auth', 'API'], rows: [
|
||||||
|
['/schema', 'Schema list', 'Owner or admin. Others get the 403 page', 'GET /v1/content-type'],
|
||||||
|
['/schema/new', 'Schema builder, new type', 'Owner or admin', 'POST /v1/content-type'],
|
||||||
|
['/schema/:key', 'Schema builder (custom type) or read-only view (built-in)', 'Owner or admin', 'GET, PUT, DELETE /v1/content-type/{key}'],
|
||||||
|
['/content/:key', 'Entry list', 'Any verified user', 'GET /v1/content-type/{key}, GET /v1/content-type/{key}/entry'],
|
||||||
|
['/content/:key/new', 'New entry', 'Any verified user', 'POST /v1/content-type/{key}/entry'],
|
||||||
|
['/content/:key/:id', 'Entry editor', 'Any verified user', 'GET, PUT, DELETE /v1/content-type/{key}/entry/{id}'],
|
||||||
|
['/settings/permissions (later)', 'Permissions grid', 'Owner or admin', 'Not built'],
|
||||||
|
['/content/:key query', 'page, size (5, 25, 50, 100; default 25), sort=<id|created_at|updated_at>:<asc|desc>, default updated_at:desc', 'Any verified user', 'Defaults omitted from the URL. Query: page, size, sort=<id|created_at|updated_at>:<asc|desc>, default updated_at:desc. The response carries total, page, size and sort']] },
|
||||||
|
{ title: 'New components and their Nuxt UI mapping', tpl: '210px 110px 330px minmax(0,1fr)', cols: ['Component', 'Status', 'Nuxt UI', 'Why an existing one does not fit'], rows: [
|
||||||
|
['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', '']] },
|
||||||
|
{ title: 'Errors to copy', tpl: '300px 70px minmax(0,1fr)', cols: ['Error', 'HTTP', 'Copy and place'], rows: [
|
||||||
|
['content_type_not_found', '404', 'Page: "Content type not found · It may have been deleted, or the address may be mistyped."'],
|
||||||
|
['entry_not_found', '404', 'Page: "Entry not found · It may have been deleted by someone else."'],
|
||||||
|
['content_type_key_taken', '409', 'Under the key: "The key "events" is already used by another content type. Choose a different key."'],
|
||||||
|
['content_type_has_entries', '409', 'Delete dialog: "Recipes now has entries · Someone added an entry after this page loaded, so nothing was deleted. Delete the entries first, then try again."'],
|
||||||
|
['invalid_content_type_key', '400', 'Under the key: "Use 2 to 40 lowercase letters, digits or hyphens."'],
|
||||||
|
['invalid_field', '400', 'On the marked field, per rule (label, key, limits, default, choices). If the client missed one: "Check the fields marked in the list."'],
|
||||||
|
['schema_incompatible', '422', 'Refusal dialog: "Nothing was changed · RenewCMS refused the changes because some entries don\'t fit the new fields. It doesn\'t say which entries."'],
|
||||||
|
['invalid_entry: key: required', '422', '"Fill in Title." (date: "Pick a date for Starts on.", choice: "Choose an option for Category.")'],
|
||||||
|
['invalid_entry: key: type', '422', '"Title must be text." / "must be a number." / "must be yes or no." / "must be a date, like 2026-11-21."'],
|
||||||
|
['invalid_entry: key: min, max', '422', 'Text: "needs at least N characters", "can be up to N characters". Number: "must be at least N", "must be at most N". Date: "can\'t be before D", "can\'t be after D"'],
|
||||||
|
['invalid_entry: key: options', '422', '"Choose one of: Talk, Workshop or Meetup."'],
|
||||||
|
['invalid_entry: key: unique', '409', '"Another entry already uses this Title. Enter a different one." plus the alert "Not saved"'],
|
||||||
|
['invalid_entry: key: unknown_field', '422', 'Alert "The fields of Events changed", button Reload form'],
|
||||||
|
['401', '401', 'Existing: redirect to /login?redirect=…&reason=expired'],
|
||||||
|
['403', '403', 'Unverified: redirect to /register/pending. Wrong role on /schema: "You don\'t have access to Schema"'],
|
||||||
|
['400, 500', '', 'Existing table. Saves: "Couldn\'t save the … Something went wrong on our side. Your changes are still here. Try again in a moment."']] },
|
||||||
|
{ title: 'Needs backend', tpl: '40px minmax(0,1fr)', cols: ['#', 'What the design assumes'], rows: [
|
||||||
|
['1', 'Built-in types and their fields. GET /v1/content-type returns custom types only, so the read-only views use static client data (sample counts on the board are illustrative). To be closed in a backend slice before the SPA work.'],
|
||||||
|
['2', 'Display names for created_by and updated_by. They are user ids and the user list is not open to every role. To be closed in the same slice.'],
|
||||||
|
['3', 'A per-type icon. It does not exist, so the sidebar uses one default icon. To be closed in the same slice.'],
|
||||||
|
['4', 'Later: per-change entry counts and diff (the API does not return them), relations, permissions, export and import, filters on custom fields, publish and draft, public read, activity log.']] },
|
||||||
|
{ title: 'Decisions to confirm', tpl: '40px minmax(0,1fr)', cols: ['#', 'Decision'], rows: [
|
||||||
|
['1', 'Confirmed: field keys and types lock per field once saved. Fields added since the last save can still change both. This is a client rule: a PUT with a changed key would remove one field and add another, and the server does not reject it.'],
|
||||||
|
['2', 'Confirmed: the review opens only when the type already has entries (the list response total tells the client). A type without entries saves directly.'],
|
||||||
|
['3', 'Confirmed: a new type starts with one field (Title), because a type needs at least one field.'],
|
||||||
|
['4', 'Confirmed: the type key is 2 to 40 lowercase letters, digits and hyphens, the same slug rule as Sites. A field key is different: lowercase letters, digits and underscores, starts with a letter, up to 40. The two inputs show their own rule.'],
|
||||||
|
['5', 'Yes or no is the label for the boolean type, Choice for select, Rich text for richtext.'],
|
||||||
|
['6', 'Required fields carry * and the line "Fields marked * are required." Optional fields carry nothing.'],
|
||||||
|
['7', 'A yes or no field with default false and one with no default look the same in the API sample. The inspector offers "On by default" only; no default means off. Confirm that no null default is needed.'],
|
||||||
|
['8', 'The client does not know when the schema changes while a form is open. The design relies on 422 invalid_entry (required, unknown_field) on save and then shows the reload alert.'],
|
||||||
|
['9', 'The Schema item sits after Users in the sidebar. The Content group sits under the main items.'],
|
||||||
|
['10', 'The list shows 4 field columns by default and at most 6.']] },
|
||||||
|
{ title: 'Accessibility', tpl: '220px minmax(0,1fr)', cols: ['Topic', 'Rule'], rows: [
|
||||||
|
['Reorder', 'The grip is a button with aria-label "Move Ends on". Space picks up, Up and Down move, Space drops, Esc cancels, with a polite live region ("Moving Ends on. Position 2 of 7."). Move up, Move down and Remove buttons exist on the selected row and in the mobile sheet.'],
|
||||||
|
['Focus order', 'Name, key, Save, Discard, field list (one tab stop, arrows move between rows), inspector top to bottom, API preview. Opening the inspector sheet on mobile moves focus to its title; closing returns it to the row.'],
|
||||||
|
['Focus', '2 px --ui-primary, offset 2 px, as everywhere.'],
|
||||||
|
['Labels and errors', 'Every control has a visible label. Error text is tied to its input with aria-describedby and aria-invalid. Rows with errors also show an icon, not only a red border.'],
|
||||||
|
['Type picker', 'A radio group. The locked state sets aria-disabled and the line "Can\'t change after saving" is read with the group.'],
|
||||||
|
['Counters', 'Character counters are polite live regions on pause, not on every key.'],
|
||||||
|
['Contrast', 'Tokens already computed in foundations.md. Muted text is never placed on sand; selected tiles use --rc-on-sand for their description.'],
|
||||||
|
['Touch', 'Controls are 44 px on mobile. The inspector, columns and API preview are bottom sheets.']] },
|
||||||
|
...mfTables
|
||||||
|
]
|
||||||
|
|
||||||
+95
-1
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
Every state in the three boards is drawn in light and dark (theme switch) at 1440×900 and 390×844. Counts are in README.md.
|
Every state in the three boards is drawn in light and dark (theme switch) at 1440×900 and 390×844. Counts are in README.md.
|
||||||
|
|
||||||
|
## Clock-sensitive states
|
||||||
|
Only individual states that render a relative time or a countdown are marked "Clock-sensitive" on the board: Articles "Published" (Updated reads "Just now"), Media "Upload: too many requests (429)" and Settings "Too many requests (429)" (countdowns), Users "Renew invite: done", and the Settings update states that show "Last checked today at 09:41" or "yesterday" (Up to date, Check failed, Update available, Update available with notes open, Update done, Plain restart: confirm, Admin view, Role changed, Update already running). Capture and test them with a frozen clock at 13 Oct 2026, 09:41. Frames that merely list dated rows are not marked.
|
||||||
|
|
||||||
|
## Sites and authors: roles
|
||||||
|
Sites: only the owner can create, edit, delete or set the default; other roles have no Sites entry and get a 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 see the list and detail read-only and can create and edit their own author only. The site key is immutable after creation.
|
||||||
|
|
||||||
## Copy glossary
|
## Copy glossary
|
||||||
| Use | Not |
|
| Use | Not |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -23,12 +29,45 @@ Glossary pass 2, changes made: activity log lines "Removed N expired invites" an
|
|||||||
| Status | Copy |
|
| Status | Copy |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 400 | "That request wasn't valid. Check the fields and try again." |
|
| 400 | "That request wasn't valid. Check the fields and try again." |
|
||||||
| 401, 403 | Global redirect, no local copy |
|
| 401, 403 | Global redirect, no local copy (403 from an unverified account). Role 403s have local copy: see the rows below |
|
||||||
| 404 | "That item no longer exists." |
|
| 404 | "That item no longer exists." |
|
||||||
| 409 | "This changed somewhere else. Reload and try again." |
|
| 409 | "This changed somewhere else. Reload and try again." |
|
||||||
| 413 | "That file is too large." |
|
| 413 | "That file is too large." |
|
||||||
| 429 | "Too many requests. Try again in a few minutes." |
|
| 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." |
|
| 500, network | "Something went wrong on our side. Nothing was changed. Try again in a moment." |
|
||||||
|
| 400 invalid_site_url | "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." (inline under base url) |
|
||||||
|
| 409 site_key_taken | "A site with the key "journal" already exists. Choose another key." (inline under key, with a suggestion) |
|
||||||
|
| 409 site_has_articles | "Choose where the articles go. This site has 38 articles." (inside the delete dialog) |
|
||||||
|
| 403 "Only the owner can do this." (page) | "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" (403 page on /settings/sites) |
|
||||||
|
| 403 "Only the owner can do this." (action) | "Only the owner can manage sites" / "Ask the owner of this site." (error toast, role changed while the page was open) |
|
||||||
|
| 403 author_rights | "Only an owner or admin can change authors" / "You can still edit your own author profile." (error toast, role changed while the page was open) |
|
||||||
|
| 400 key_immutable | "Used in API paths, so it can't change." (not shown: the edit form has no editable key) |
|
||||||
|
| 400 invalid_locale | "Use a language tag like en or fr-CH." (inline under locale) |
|
||||||
|
| 403 default_site | "The default site can't be deleted. Make another site the default first." (toast if the menu was stale) |
|
||||||
|
| 404 site_not_found | "That site no longer exists" / "It was deleted somewhere else, so we removed it from the list." (info toast) |
|
||||||
|
| 409 author_slug_taken | "The slug "camille-roche" is already used by another author." (inline under slug, with a suggestion) |
|
||||||
|
| 409 author_has_articles | "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." (server fallback dialog, drawn once) |
|
||||||
|
| 409 user_already_linked | "That user already has an author. Choose another user." (inline under linked user) |
|
||||||
|
| 422 bio_too_long | "Shorten the bio to 280 characters." (inline under bio) |
|
||||||
|
| 422 invalid_url | "Enter a full URL starting with https://." (inline under website and each social link) |
|
||||||
|
| 404 author_not_found | "That author no longer exists" / "It was deleted somewhere else, so we removed it from the list." (info toast) |
|
||||||
|
| 404 site_not_found (sitemap) | GET /v1/site/{key}/sitemap, unknown key. The body is the code `site_not_found`, shown on Integration in the code style (not rewritten as prose). |
|
||||||
|
| 400 (sitemap page or size) | "The request cannot be processed due to a mismatch in the format of the body." (same text as the other routes on Integration) |
|
||||||
|
| 403 owner_only vs article SEO | Integration note under the 403 row: "Site SEO defaults are owner only. Article SEO fields need only the right to edit the article." |
|
||||||
|
| 404 content_type_not_found | Page: "Content type not found · It may have been deleted, or the address may be mistyped." |
|
||||||
|
| 404 entry_not_found | Page: "Entry not found · It may have been deleted by someone else." |
|
||||||
|
| 409 content_type_key_taken | Under the key: "The key "events" is already used by another content type. Choose a different key." |
|
||||||
|
| 409 content_type_has_entries | Delete dialog: "Recipes now has entries · Someone added an entry after this page loaded, so nothing was deleted. Delete the entries first, then try again." |
|
||||||
|
| 400 invalid_content_type_key | Under the key: "Use 2 to 40 lowercase letters, digits or hyphens." |
|
||||||
|
| 400 invalid_field | On the marked field, per rule (label, key, limits, default, choices). If the client missed one: "Check the fields marked in the list." |
|
||||||
|
| 409 schema_incompatible | Refusal dialog: "Nothing was changed · RenewCMS refused the changes because some entries don't fit the new fields. It doesn't say which entries." |
|
||||||
|
| 422 invalid_entry: key: required | "Fill in Title." (date: "Pick a date for Starts on.", choice: "Choose an option for Category.") |
|
||||||
|
| 422 invalid_entry: key: type | "Title must be text." / "must be a number." / "must be yes or no." / "must be a date, like 2026-11-21." |
|
||||||
|
| 422 invalid_entry: key: min, max | Text: "needs at least N characters", "can be up to N characters". Number: "must be at least N", "must be at most N". Date: "can't be before D", "can't be after D" |
|
||||||
|
| 422 invalid_entry: key: options | "Choose one of: Talk, Workshop or Meetup." |
|
||||||
|
| 409 invalid_entry: key: unique | "Another entry already uses this Title. Enter a different one." plus the alert "Not saved" |
|
||||||
|
| 422 invalid_entry: key: unknown_field | Alert "The fields of Events changed", button Reload form |
|
||||||
|
| 500 – | "Couldn't save the site. Your changes are still here. Try again in a moment." / "Couldn't save the author. Your changes are still here. Try again in a moment." / "Couldn't create the author" / "The article is unchanged. Try again in a moment." (notice inside the panel or dialog) |
|
||||||
|
|
||||||
## Public image 400 bodies
|
## Public image 400 bodies
|
||||||
`{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "hero" }` and `{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" }`. The original is not served as a fallback.
|
`{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "hero" }` and `{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" }`. The original is not served as a fallback.
|
||||||
@@ -36,6 +75,9 @@ Glossary pass 2, changes made: activity log lines "Removed N expired invites" an
|
|||||||
## Articles and Media pagination
|
## Articles and Media pagination
|
||||||
Same states and copy on Articles and Media (Media draws them under the grid or list). Loading next page keeps row height, controls disabled, summary "Loading page 7…". Error "Couldn't load page 7. Showing page 6." with Retry. Out of range "That page doesn't exist. Showing the last page." Page size menu offers 5, 25, 50, 100. Invalid `page` or `size` in the URL (size=30, page=abc, page=0) is replaced by the default without a notice; the API 400 never reaches the user.
|
Same states and copy on Articles and Media (Media draws them under the grid or list). Loading next page keeps row height, controls disabled, summary "Loading page 7…". Error "Couldn't load page 7. Showing page 6." with Retry. Out of range "That page doesn't exist. Showing the last page." Page size menu offers 5, 25, 50, 100. Invalid `page` or `size` in the URL (size=30, page=abc, page=0) is replaced by the default without a notice; the API 400 never reaches the user.
|
||||||
|
|
||||||
|
## Sites and authors pagination
|
||||||
|
Groups "Sites: pagination" and "Authors: pagination", 10 states each, same pager as Articles and Media, sizes 5, 25, 50, 100 (default 25). First, middle and last page; single page (count line "N sites" or "N authors" only, no pager, no size select); page-size menu; loading ("Loading page N…", skeleton rows, pager kept and disabled); error ("Couldn't load page N. Showing page M." with Retry, rows and page number kept); empty after search (existing empty state, no pager); search resets to page 1; long labels on mobile. Out of range: ?page past the end is replaced by the last page, with "That page doesn't exist. Showing the last page." (not drawn; as Articles). Details in screens/sites.md and screens/authors.md.
|
||||||
|
|
||||||
## Editor
|
## Editor
|
||||||
Counter without limit ("1,204 words · 7,318 characters"; at 120,000 characters "19,845 words · 120,000 characters"), "Saving…", "Saved", "Couldn't save. Your text is kept. Retry". Link states and the toolbar overflow are drawn in the Editor group (11 states).
|
Counter without limit ("1,204 words · 7,318 characters"; at 120,000 characters "19,845 words · 120,000 characters"), "Saving…", "Saved", "Couldn't save. Your text is kept. Retry". Link states and the toolbar overflow are drawn in the Editor group (11 states).
|
||||||
|
|
||||||
@@ -47,3 +89,55 @@ As in screens/settings.md, screens/image-variants.md and screens/activity.md. Sy
|
|||||||
|
|
||||||
## Per-screen copy
|
## Per-screen copy
|
||||||
screens/<name>.md, section "Copy per state" (generated from the board source).
|
screens/<name>.md, section "Copy per state" (generated from the board source).
|
||||||
|
|
||||||
|
## SEO round
|
||||||
|
Frames: Editor: search and sharing (24), Editor: what the frontend gets (6), Sites: SEO defaults (5), Articles: search status (3). Each is drawn desktop and mobile; light and dark follow the theme switch. None is clock-sensitive: dates in the API preview are fixed sample values. Disabled state: all fields at 40% while saving. Validation: slug taken, canonical not absolute, title template without %title%, excerpt over 300. Suggestions never block saving or publishing. Copy per state: screens/editor.md, articles.md, sites.md.
|
||||||
|
|
||||||
|
|
||||||
|
## Custom content types
|
||||||
|
Groups on `RenewCMS Screens Schema.dc.html`: 1 Schema list (6), 2 Schema builder (16), 3 Review and delete (7), 4 Field types (6), 5 Content and entry list (9), 6 Entry editor (13), 7 Relations (3), 8 Permissions (2), 9 Export and import (3). 65 states, 56 available now and 9 later. Each has a 1440 and a 390 frame; light and dark follow the theme switch. Nothing is clock-sensitive. Tag per state: "Available now" or "Later". Copy per state: screens/schema.md, content.md, entry.md, schema-later.md.
|
||||||
|
|
||||||
|
Roles: owners and admins see Schema and edit types. Any verified user sees the Content group and reads and writes entries. A user who is not owner or admin and opens /schema gets "You don't have access to Schema".
|
||||||
|
|
||||||
|
Keys: a type key is 2 to 40 lowercase letters, digits and hyphens (chosen at creation, never changes). A field key is lowercase letters, digits and underscores, starts with a letter, up to 40 (editable until its field is first saved). The two inputs show their own rule.
|
||||||
|
|
||||||
|
Schema changes with entries: Save opens the review ("Add 2 fields, change 1, remove 1"); Apply sends the PUT; schema_incompatible shows "Nothing was changed" and names the kind of change that likely caused it, never an entry. A type with entries cannot be deleted ("You can't delete Events yet").
|
||||||
|
|
||||||
|
Entry rules, 422 except unique (409): required, type, min, max, options, unique, unknown_field. Messages are per type and drawn in the field-type gallery (group "Field types").
|
||||||
|
|
||||||
|
## Media field (pass 5)
|
||||||
|
Copy and states are in `media-field-changes.md` (sections 2 to 6). Entry rules for a media field: required 422 "Pick an image for Thumbnail."; type 422 "Thumbnail must be an image from the library."; min, max, options and unique do not apply. A required media field added to a type with entries is refused client side in the review ("Can't be applied") and by the server as 409 schema_incompatible. Image deleted while entries use it: the control shows "This image is no longer in the library"; saving is refused only when the field is required. HTTP statuses for the picker and the field use the table above (400, 401, 403, 404, 409, 413, 429, 500, network).
|
||||||
|
|
||||||
|
## Roles and permissions
|
||||||
|
|
||||||
|
61 states, all Available now, listed with trigger and copy in `screens/roles.md`. Groups: Roles list 7, Role editor 15, Plain-language preview 4, Delete role 4, Users 9, Role-aware shell 8, No access 3, Activity 2, Profile 2, API keys 1, Create-a-role flow 6. None is clock-sensitive (times are fixed strings).
|
||||||
|
|
||||||
|
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 |
|
||||||
|
|
||||||
|
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. |
|
||||||
|
|
||||||
|
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. |
|
||||||
|
| 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. |
|
||||||
@@ -72,6 +72,11 @@ task clean # remove bin/, tmp/ and the built SPA
|
|||||||
- All Go tests live in [`test/`](../test) (package `test`, black-box). They use in-memory fakes of the domain `Repository` interfaces, so they need no database, SMTP server or network.
|
- All Go tests live in [`test/`](../test) (package `test`, black-box). They use in-memory fakes of the domain `Repository` interfaces, so they need no database, SMTP server or network.
|
||||||
- HTTP-level tests use `net/http/httptest` against a chi router. Call `validator.InitValidator()` before invoking a handler.
|
- HTTP-level tests use `net/http/httptest` against a chi router. Call `validator.InitValidator()` before invoking a handler.
|
||||||
- SPA component tests use Vitest + Vue Test Utils (`task test:web`). `task lint:web` runs eslint and a type check.
|
- SPA component tests use Vitest + Vue Test Utils (`task test:web`). `task lint:web` runs eslint and a type check.
|
||||||
|
- A few tests open a real SQLite file in a temporary directory (the chunked id lookups, the slug backfill and the `?seo=` measurement). They never touch the real database.
|
||||||
|
- The tests that need a real MySQL, MariaDB or PostgreSQL server skip themselves when `RENEWCMS_TEST_MYSQL_DSN` or `RENEWCMS_TEST_POSTGRES_DSN` is unset. No CI exists, so run them by hand before a release that touches the repositories:
|
||||||
|
- `task test:mysql` runs `mysql:8.4` then `mariadb:11` in Docker, and `task test:postgres` runs `postgres:16` then `postgres:17`. Docker is required; the tasks start and remove the containers. Extra arguments go after `--`, for example `task test:postgres -- -run Postgres.*Chunked -v`.
|
||||||
|
- To use your own server on a scratch database: `RENEWCMS_TEST_MYSQL_DSN='user:pass@tcp(host:3306)/db' go test -tags nofrontend ./test/... -run MySQL -count=1`, or `RENEWCMS_TEST_POSTGRES_DSN='postgres://user:pass@host:5432/db?sslmode=disable' go test -tags nofrontend ./test/... -run Postgres -count=1`.
|
||||||
|
- `TestMySQLChunkedLookups` and `TestPostgresChunkedLookups` check that the batched `IN` lookups of the article repository (500 ids per batch) return the same rows as SQLite.
|
||||||
|
|
||||||
## Visual check against the design
|
## Visual check against the design
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ setting does, so a client holding an old value gets a fresh 200. `Cache-Control`
|
|||||||
Every generated file is a blob in `image.Storage` (local directory or S3) plus a row in
|
Every generated file is a blob in `image.Storage` (local directory or S3) plus a row in
|
||||||
`image_variant_files` (variant, source stored name, cached stored name, content type, size, settings
|
`image_variant_files` (variant, source stored name, cached stored name, content type, size, settings
|
||||||
fingerprint). The pair (variant, source) is unique. Concurrent first requests share one generation
|
fingerprint). The pair (variant, source) is unique. Concurrent first requests share one generation
|
||||||
(`singleflight` keyed by variant, settings and source); different variants or different images are
|
(a single-flight group keyed by variant, settings and source); different variants or different images are
|
||||||
generated in parallel.
|
generated in parallel.
|
||||||
|
|
||||||
Only a stored name that has an image record can be a variant source, so generated files and orphan blobs
|
Only a stored name that has an image record can be a variant source, so generated files and orphan blobs
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Gotchas
|
||||||
|
|
||||||
|
> What trips up API clients: session-only routes, error texts, rate limits and draft visibility.
|
||||||
|
|
||||||
|
## Session-only routes
|
||||||
|
|
||||||
|
An API key cannot call these routes. They answer `403` with a plain-text body and no JSON:
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP/1.1 403 Forbidden
|
||||||
|
|
||||||
|
This action needs a real session.
|
||||||
|
```
|
||||||
|
|
||||||
|
- `/v1/api-key/*`, `/v1/system/*`, `POST /v1/auth/verify`, `DELETE /v1/auth/register`.
|
||||||
|
- Every activity route under `/v1/activity`.
|
||||||
|
- Writes on sites (`POST`, `PATCH`, `DELETE` under `/v1/site`, so a key cannot create a site) and on authors (`POST`, `PATCH`, `DELETE` under `/v1/author`).
|
||||||
|
- `PATCH /v1/settings`.
|
||||||
|
|
||||||
|
Variant routes under `/v1/media/{id}/variants` answer `401` to a key instead. The sign-in routes (login, logout, register, password reset) ignore a key and give no `403`.
|
||||||
|
|
||||||
|
When a task needs one of these routes, stop and tell the user to do it in the app. Do not try to log in with a password.
|
||||||
|
|
||||||
|
## Error texts are plain text
|
||||||
|
|
||||||
|
Errors are not JSON. The body is one sentence; branch on the status code, and show the sentence to the user.
|
||||||
|
|
||||||
|
| Status | Typical text |
|
||||||
|
| --- | --- |
|
||||||
|
| `400` | `The request cannot be processed due to a mismatch in the format of the body.` |
|
||||||
|
| `401` | `You must be logged in to access this resource.` |
|
||||||
|
| `403` | `This action needs a real session.`, `Only the owner can do this.`, `Only owners and admins can do this.`, `You must verify your account to access this resource.` |
|
||||||
|
| `404` | `Article not found`, `Image not found` |
|
||||||
|
| `429` | `Too many requests. Please try again later.` |
|
||||||
|
|
||||||
|
An invalid, unknown, expired or revoked key answers `401`, even on public routes. It is never treated as anonymous.
|
||||||
|
|
||||||
|
## Rate limit: 429
|
||||||
|
|
||||||
|
Each key may make 120 requests per minute. The `429` response carries a `Retry-After` header holding the seconds to wait:
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP/1.1 429 Too Many Requests
|
||||||
|
Retry-After: 46
|
||||||
|
|
||||||
|
Too many requests. Please try again later.
|
||||||
|
```
|
||||||
|
|
||||||
|
Sleep for `Retry-After` seconds, then retry once. Do not retry in a tight loop. Prefer one list call with `size=100` over many single reads.
|
||||||
|
|
||||||
|
## Draft versus online
|
||||||
|
|
||||||
|
- Anonymous callers and unverified users only see online articles. A draft answers `404` with `Article not found`, the same as an id that does not exist.
|
||||||
|
- A verified session or a key sees the drafts its role may read: every draft with `content.read` at all, only its own with `content.read` at own. In lists, `counts.draft` is `0` for anonymous callers and `status=draft` answers an empty page.
|
||||||
|
- A role without `content.update`, `content.delete` or `content.publish` for an article gets `403` with the body `missing_permission` and the header `X-Missing-Permission`. Saving a draft never publishes it.
|
||||||
|
- `POST /v1/article/` always creates a draft. Publishing is a separate call, `PATCH /v1/article/{id}/publish`.
|
||||||
|
- Sitemaps list online articles only.
|
||||||
|
|
||||||
|
## Writes
|
||||||
|
|
||||||
|
- A title cannot be changed after creation. Create a new article instead.
|
||||||
|
- `PATCH /v1/article/{id}` accepts `body`, `site_id`, `author_ids` and the SEO fields only. `{}` and `null` values answer `400`, except `cover_image_id` and `social_image_id`, where `null` removes the image.
|
||||||
|
- Request bodies are HTML even though responses default to markdown. Send `<p>` and `<h2>`, not markdown syntax, or it is stored literally.
|
||||||
|
- Ids in paths are unsigned integers. Anything else answers `400`.
|
||||||
|
|
||||||
|
## Permissions follow the key's creator
|
||||||
|
|
||||||
|
A key acts as the user who made it. A key made by a user who is neither owner nor admin cannot do what only owners and admins can, and it answers `403 Only owners and admins can do this.` Check `GET /v1/auth/me` to read the role of the key.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Recipe: create, edit and publish an article
|
||||||
|
|
||||||
|
> Create a draft, replace its body, check its SEO, publish it and read it back, with an API key.
|
||||||
|
|
||||||
|
Set the key once. Every request below is real output from a running instance; ids and timestamps differ on yours.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export RENEWCMS_API_KEY=rcms_...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. Create a draft
|
||||||
|
|
||||||
|
Titles are 3 to 200 characters and cannot be changed later. The request body is HTML; the response body is markdown unless you pass `?format=html` or `?format=json`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST "{{BASE_URL}}/v1/article/" \
|
||||||
|
-H "Authorization: Bearer $RENEWCMS_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"title":"How we price a bathroom renovation","body":"<p>A bathroom renovation is priced by room size, plumbing changes and finish level.</p>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Status `201`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id":1,"title":"How we price a bathroom renovation","body":"A bathroom renovation is priced by room size, plumbing changes and finish level.","body_format":"markdown","is_online":false,"site_id":1,"authors":[],"created_at":"2026-10-23T01:11:16.767761797+02:00","updated_at":"2026-10-23T01:11:16.767761797+02:00","slug":"how-we-price-a-bathroom-renovation","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":[]}
|
||||||
|
```
|
||||||
|
|
||||||
|
The article starts offline (`is_online: false`) on the default site.
|
||||||
|
|
||||||
|
## 2. Replace the body, move it to a site, set the excerpt
|
||||||
|
|
||||||
|
`PATCH` takes only `body`, `site_id`, `author_ids` and the SEO fields. At least one is required; `{}` answers `400`. Send `{"body": ""}` to empty a body.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X PATCH "{{BASE_URL}}/v1/article/1" \
|
||||||
|
-H "Authorization: Bearer $RENEWCMS_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"body":"<h2>What drives the price</h2><p>Room size, plumbing changes and finish level.</p>","site_id":2,"excerpt":"What drives the price of a bathroom renovation."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Status `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id":1,"title":"How we price a bathroom renovation","body":"## What drives the price\n\nRoom size, plumbing changes and finish level.","body_format":"markdown","is_online":false,"site_id":2,"authors":[],"created_at":"2026-10-23T01:11:16.767761797+02:00","updated_at":"2026-10-23T01:11:16.771008847+02:00","slug":"how-we-price-a-bathroom-renovation","excerpt":"What drives the price of a bathroom renovation.","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":[]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Check the SEO before publishing
|
||||||
|
|
||||||
|
An empty JSON object checks the saved article. Pass any SEO field to check a change without saving it.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST "{{BASE_URL}}/v1/article/1/seo/check" \
|
||||||
|
-H "Authorization: Bearer $RENEWCMS_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Status `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"status":"suggestions","suggestions":[{"code":"desc_short","field":"seo_description","params":{"length":47,"min":70}},{"code":"cover_none","field":"cover_image_id","params":{}},{"code":"no_au","field":"author_ids","params":{}},{"code":"short","field":"body","params":{"length":66,"min":300}}],"slug":{"available":true,"suggestion":null}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Suggestions never block publishing.
|
||||||
|
|
||||||
|
## 4. Publish
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X PATCH "{{BASE_URL}}/v1/article/1/publish" \
|
||||||
|
-H "Authorization: Bearer $RENEWCMS_API_KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
Status `200`, same article object with `"is_online":true`. `PATCH /v1/article/{id}/unpublish` reverses it.
|
||||||
|
|
||||||
|
## 5. Read it back without credentials
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl "{{BASE_URL}}/v1/article/1?format=markdown"
|
||||||
|
```
|
||||||
|
|
||||||
|
Before step 4 this answers `404` with the text `Article not found`, because anonymous callers only see online articles.
|
||||||
Loaded 100 of 1372 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user