Compare commits
35
Commits
v3.6.0-beta
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c15206170 | ||
|
|
9d284e65a9 | ||
|
|
680b9a1308 | ||
|
|
58fd753559 | ||
|
|
3da62db61b | ||
|
|
c390fcfe8d | ||
|
|
45fbbc33b1 | ||
|
|
339e646eb8 | ||
|
|
a671b81f80 | ||
|
|
a0b3c58c52 | ||
|
|
d9158025d4 | ||
|
|
0806733d5c | ||
|
|
8c4a1825ab | ||
|
|
99a74dc3a2 | ||
|
|
0bdcca8a90 | ||
|
|
63b671c6f3 | ||
|
|
ed0686085a | ||
|
|
f3fcdf9ce9 | ||
|
|
018fc98295 | ||
|
|
03f5d54bb6 | ||
|
|
39ff84e967 | ||
|
|
f084ccb66f | ||
|
|
c92d8740ed | ||
|
|
c050db78d2 | ||
|
|
10a66cd979 | ||
|
|
bb191c389a | ||
|
|
c6d49db95e | ||
|
|
8cdd70bd81 | ||
|
|
b40a792d02 | ||
|
|
7a66318a73 | ||
|
|
89da2f6b14 | ||
|
|
fda2793888 | ||
|
|
63c0aec444 | ||
|
|
b39a41be88 | ||
|
|
5d6f378fce |
No files matched your search
@@ -29,6 +29,7 @@ CLAUDE.md
|
||||
AGENTS.md
|
||||
codex-workflow.md
|
||||
CODING_STANDARDS.md
|
||||
tools/product-videos/
|
||||
|
||||
# Generated LLM docs
|
||||
internal/infrastructure/llmdocs/generated/*
|
||||
|
||||
@@ -27,7 +27,7 @@ Upgrade with `docker compose pull && docker compose up -d`. The image is `git.fl
|
||||
|
||||
### Updates from the browser
|
||||
|
||||
On Linux with systemd, install v2.6.0-beta or newer manually, then follow [the browser-update setup guide](docs/deployment.md#browser-updates). It covers finding the actual database directory, installing the helper, testing a restart and troubleshooting. Other platforms use manual upgrades.
|
||||
On Linux with systemd, install the helper manually once, then follow [the browser-update setup guide](docs/deployment.md#browser-updates). It covers finding the actual database directory, installing the helper, testing a restart and troubleshooting. Other platforms use manual upgrades.
|
||||
|
||||
### Develop
|
||||
|
||||
@@ -43,6 +43,7 @@ task dev # API with hot reload + Vite HMR → http://localhost:51
|
||||
|
||||
| Doc | Contents |
|
||||
|---|---|
|
||||
| [docs/getting-started.md](docs/getting-started.md) | Set up an instance: download the binary or start Docker, fill the settings, HTTPS, first login |
|
||||
| [docs/development.md](docs/development.md) | Dev setup, how Vite and the API fit together, tests, lint, `task` commands |
|
||||
| [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 |
|
||||
@@ -52,10 +53,7 @@ task dev # API with hot reload + Vite HMR → http://localhost:51
|
||||
| [docs/performance.md](docs/performance.md) | Measured costs, such as the `?seo=` article filter |
|
||||
| [docs/api.md](docs/api.md) | API reference: authentication, API keys, routes and the activity log |
|
||||
| [docs/api/articles.md](docs/api/articles.md) | Reading articles: pagination, search, `format` and body schemas |
|
||||
| [docs/custom-content-types.md](docs/custom-content-types.md) | Custom content types: layering, phase 1 limitations and their fix path |
|
||||
| [docs/api/site-languages.md](docs/api/site-languages.md) | Site languages, first-time language setup and the public URL pattern |
|
||||
| [docs/custom-content-types.md](docs/custom-content-types.md) | Custom content types: layering and known limitations |
|
||||
| [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 |
|
||||
|
||||
## TODOs
|
||||
|
||||
- Cancel button when email validation pending or expired
|
||||
+2
-2
@@ -79,7 +79,7 @@ tasks:
|
||||
return 1
|
||||
fi
|
||||
echo "== $IMAGE on 127.0.0.1:$PORT"
|
||||
RENEWCMS_TEST_MYSQL_DSN="root:$PASSWORD@tcp(127.0.0.1:$PORT)/mysql" go test -tags nofrontend ./test/... -run MySQL -count=1 {{.CLI_ARGS}}
|
||||
RENEWCMS_TEST_MYSQL_DSN="root:$PASSWORD@tcp(127.0.0.1:$PORT)/mysql" go test -tags nofrontend ./test/... -run 'MySQL|TestLocalizationPersistence' -count=1 -timeout 20m {{.CLI_ARGS}}
|
||||
RESULT=$?
|
||||
docker rm -f "$NAME" >/dev/null 2>&1
|
||||
return $RESULT
|
||||
@@ -119,7 +119,7 @@ tasks:
|
||||
return 1
|
||||
fi
|
||||
echo "== $IMAGE on 127.0.0.1:$PORT"
|
||||
RENEWCMS_TEST_POSTGRES_DSN="postgres://postgres:$PASSWORD@127.0.0.1:$PORT/postgres?sslmode=disable" go test -tags nofrontend ./test/... -run Postgres -count=1 {{.CLI_ARGS}}
|
||||
RENEWCMS_TEST_POSTGRES_DSN="postgres://postgres:$PASSWORD@127.0.0.1:$PORT/postgres?sslmode=disable" go test -tags nofrontend ./test/... -run 'Postgres|TestLocalizationPersistence' -count=1 -timeout 20m {{.CLI_ARGS}}
|
||||
RESULT=$?
|
||||
docker rm -f "$NAME" >/dev/null 2>&1
|
||||
return $RESULT
|
||||
|
||||
+59
-10
@@ -10,6 +10,7 @@ import (
|
||||
domainEntry "RenewCMS/internal/domain/entry"
|
||||
domainImage "RenewCMS/internal/domain/image"
|
||||
domainInvite "RenewCMS/internal/domain/invite"
|
||||
domainLocalization "RenewCMS/internal/domain/localization"
|
||||
domainMail "RenewCMS/internal/domain/mail"
|
||||
domainPurge "RenewCMS/internal/domain/purge"
|
||||
domainRole "RenewCMS/internal/domain/role"
|
||||
@@ -45,6 +46,8 @@ import (
|
||||
type UseCases struct {
|
||||
apiKeyAuthOnce sync.Once
|
||||
apiKeyAuthMiddleware func(http.Handler) http.Handler
|
||||
publicReadLimitOnce sync.Once
|
||||
publicReadLimitMiddleware func(http.Handler) http.Handler
|
||||
Token *authtoken.Auth
|
||||
PurgeScheduler *scheduler.Runner
|
||||
ActivityCleanupScheduler *scheduler.Runner
|
||||
@@ -56,7 +59,12 @@ type UseCases struct {
|
||||
ResolveArticleSEOUseCase *useCases.ResolveArticleSEOUseCase
|
||||
CheckArticleSEOUseCase *useCases.CheckArticleSEOUseCase
|
||||
ListSitemapUseCase *useCases.ListSitemapUseCase
|
||||
ResolvePublicArticleUseCase *useCases.ResolvePublicArticleUseCase
|
||||
DeleteArticleUseCase *useCases.DeleteArticleUseCase
|
||||
CreateTranslationUseCase *useCases.CreateTranslationUseCase
|
||||
DescribeArticleUseCase *useCases.DescribeArticleUseCase
|
||||
MoveStoryUseCase *useCases.MoveStoryUseCase
|
||||
DeleteStoryUseCase *useCases.DeleteStoryUseCase
|
||||
ListAuthorsUseCase *useCases.ListAuthorsUseCase
|
||||
CreateAuthorUseCase *useCases.CreateAuthorUseCase
|
||||
CreateMyAuthorUseCase *useCases.CreateMyAuthorUseCase
|
||||
@@ -69,6 +77,10 @@ type UseCases struct {
|
||||
ListSitesUseCase *useCases.ListSitesUseCase
|
||||
CreateSiteUseCase *useCases.CreateSiteUseCase
|
||||
UpdateSiteUseCase *useCases.UpdateSiteUseCase
|
||||
GetSiteLanguagesUseCase *useCases.GetSiteLanguagesUseCase
|
||||
UpdateSiteLanguagesUseCase *useCases.UpdateSiteLanguagesUseCase
|
||||
GetLanguageSetupUseCase *useCases.GetLanguageSetupUseCase
|
||||
ApplyLanguageSetupUseCase *useCases.ApplyLanguageSetupUseCase
|
||||
SetDefaultSiteUseCase *useCases.SetDefaultSiteUseCase
|
||||
DeleteSiteUseCase *useCases.DeleteSiteUseCase
|
||||
CreateContentTypeUseCase *useCases.CreateContentTypeUseCase
|
||||
@@ -98,12 +110,19 @@ type UseCases struct {
|
||||
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
|
||||
ResetPasswordUseCase *useCases.ResetPasswordUseCase
|
||||
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
|
||||
RequestEmailChangeUseCase *useCases.RequestEmailChangeUseCase
|
||||
ResendEmailChangeUseCase *useCases.ResendEmailChangeUseCase
|
||||
CancelEmailChangeUseCase *useCases.CancelEmailChangeUseCase
|
||||
ConfirmEmailChangeUseCase *useCases.ConfirmEmailChangeUseCase
|
||||
ListUsersUseCase *useCases.ListUsersUseCase
|
||||
CreateInviteUseCase *useCases.CreateInviteUseCase
|
||||
CancelInviteUseCase *useCases.CancelInviteUseCase
|
||||
RenewInviteUseCase *useCases.RenewInviteUseCase
|
||||
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
|
||||
ChangeUserRoleUseCase *useCases.ChangeUserRoleUseCase
|
||||
RemoveUserUseCase *useCases.RemoveUserUseCase
|
||||
ExportUserDataUseCase *useCases.ExportUserDataUseCase
|
||||
DeleteOwnAccountUseCase *useCases.DeleteOwnAccountUseCase
|
||||
TransferOwnershipUseCase *useCases.TransferOwnershipUseCase
|
||||
GetInviteUseCase *useCases.GetInviteUseCase
|
||||
AcceptInviteUseCase *useCases.AcceptInviteUseCase
|
||||
@@ -167,12 +186,14 @@ func (c *UseCases) RunSchedulers(ctx context.Context) {
|
||||
}
|
||||
|
||||
type Repositories struct {
|
||||
ArticleRepo domainArticle.Repository
|
||||
ArticleRepo domainArticle.ListingRepository
|
||||
SiteRepo domainSite.Repository
|
||||
LocalizationRepo domainLocalization.Repository
|
||||
ContentTypeRepo domainContentType.Repository
|
||||
EntryRepo domainEntry.Repository
|
||||
AuthorRepo domainAuthor.Repository
|
||||
UserRepo domainUser.Repository
|
||||
UserEraser domainUser.Eraser
|
||||
RoleRepo domainRole.Repository
|
||||
ImageRepo domainImage.Repository
|
||||
Transactor domainTransaction.Transactor
|
||||
@@ -207,6 +228,8 @@ const (
|
||||
updateHelperFileName = "update-helper.json"
|
||||
updatesDirName = "updates"
|
||||
backupsDirName = "backups"
|
||||
|
||||
maxConcurrentDataExports = 2
|
||||
)
|
||||
|
||||
func imageStorageConfig(settings config.Settings) storage.Config {
|
||||
@@ -314,10 +337,12 @@ func getRepositories(connection *database.Connection, settings config.Settings)
|
||||
return &Repositories{
|
||||
ArticleRepo: persistence.NewArticleRepository(db),
|
||||
SiteRepo: persistence.NewSiteRepository(db),
|
||||
LocalizationRepo: persistence.NewLocalizationRepository(db),
|
||||
ContentTypeRepo: persistence.NewContentTypeRepository(db),
|
||||
EntryRepo: persistence.NewEntryRepository(db),
|
||||
AuthorRepo: persistence.NewAuthorRepository(db),
|
||||
UserRepo: persistence.NewUserRepository(db),
|
||||
UserEraser: persistence.NewAccountEraser(db),
|
||||
RoleRepo: persistence.NewRoleRepository(db),
|
||||
ImageRepo: persistence.NewImageRepository(db),
|
||||
Transactor: persistence.NewTransactor(db),
|
||||
@@ -349,9 +374,12 @@ func getRepositories(connection *database.Connection, settings config.Settings)
|
||||
|
||||
func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings config.Settings) *UseCases {
|
||||
hasher := hashing.NewBcryptHasher()
|
||||
mailDispatcher := mailer.NewAsyncDispatcher()
|
||||
sessionRevocation := useCases.NewSessionRevocationUseCase(repos.SessionRepo, time.Now)
|
||||
renderer := markdown.NewConverter()
|
||||
keyGenerator := hashing.NewSHA256KeyGenerator()
|
||||
recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default())
|
||||
mediaUsage := useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo)
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, repos.VariantFileRepo, recorder, repos.ContentTypeRepo, repos.EntryRepo, repos.Transactor, log.Default())
|
||||
|
||||
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder, repos.SiteRepo, repos.AuthorRepo)
|
||||
@@ -361,19 +389,26 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
usageTracker := usage.NewTracker(repos.VariantUsageRepo, time.Now)
|
||||
serveVariant := useCases.NewServeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, repos.ImageRepo, imageStorage, repos.VariantRenderer, useCases.ServeVariantLimits{MaxSourceBytes: settings.ImageVariantMaxSourceBytes, MaxSourcePixels: settings.ImageVariantMaxSourcePixels, MaxConcurrentRenders: settings.ImageVariantMaxConcurrentRenders}).WithUsageTracker(usageTracker)
|
||||
|
||||
moveStory := useCases.NewMoveStoryUseCase(repos.Transactor, repos.ArticleRepo, recorder)
|
||||
|
||||
return &UseCases{
|
||||
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
|
||||
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
|
||||
RetentionScheduler: scheduler.NewSystemRetentionRunner(useCases.NewEnforceRetentionUseCase(repos.UserRepo, repos.InviteRepo, repos.SessionRepo), log.Default()),
|
||||
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),
|
||||
Token: authtoken.New([]byte(settings.JWTSecret), authtoken.WithRevocationStore(sessionRevocation)),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.Transactor, renderer, recorder, repos.SiteRepo, repos.AuthorRepo),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ImageRepo),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo, repos.LocalizationRepo),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ImageRepo, repos.LocalizationRepo, moveStory),
|
||||
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),
|
||||
CreateTranslationUseCase: useCases.NewCreateTranslationUseCase(repos.Transactor, repos.ArticleRepo, repos.LocalizationRepo, renderer, recorder),
|
||||
DescribeArticleUseCase: useCases.NewDescribeArticleUseCase(repos.ArticleRepo, repos.LocalizationRepo),
|
||||
MoveStoryUseCase: moveStory,
|
||||
DeleteStoryUseCase: useCases.NewDeleteStoryUseCase(repos.Transactor, repos.ArticleRepo, recorder),
|
||||
ResolveArticleSEOUseCase: useCases.NewResolveArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo, repos.LocalizationRepo),
|
||||
CheckArticleSEOUseCase: useCases.NewCheckArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo, repos.LocalizationRepo),
|
||||
ListSitemapUseCase: useCases.NewListSitemapUseCase(repos.ArticleRepo, repos.SiteRepo, repos.LocalizationRepo),
|
||||
ResolvePublicArticleUseCase: useCases.NewResolvePublicArticleUseCase(repos.ArticleRepo, repos.SiteRepo, repos.LocalizationRepo, renderer),
|
||||
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),
|
||||
@@ -385,7 +420,11 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
ListPermissionsUseCase: useCases.NewListPermissionsUseCase(),
|
||||
ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo),
|
||||
CreateSiteUseCase: useCases.NewCreateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
|
||||
UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
|
||||
UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder, repos.ArticleRepo),
|
||||
GetSiteLanguagesUseCase: useCases.NewGetSiteLanguagesUseCase(repos.SiteRepo, repos.LocalizationRepo),
|
||||
UpdateSiteLanguagesUseCase: useCases.NewUpdateSiteLanguagesUseCase(repos.Transactor, recorder),
|
||||
GetLanguageSetupUseCase: useCases.NewGetLanguageSetupUseCase(repos.SiteRepo, repos.LocalizationRepo),
|
||||
ApplyLanguageSetupUseCase: useCases.NewApplyLanguageSetupUseCase(repos.Transactor, recorder),
|
||||
SetDefaultSiteUseCase: useCases.NewSetDefaultSiteUseCase(repos.SiteRepo, recorder),
|
||||
DeleteSiteUseCase: useCases.NewDeleteSiteUseCase(repos.SiteRepo, recorder),
|
||||
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(repos.ContentTypeRepo, repos.RoleRepo, time.Now),
|
||||
@@ -415,12 +454,19 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
|
||||
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder),
|
||||
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder),
|
||||
RequestEmailChangeUseCase: useCases.NewRequestEmailChangeUseCase(repos.UserRepo, hasher, repos.MailRepo, mailDispatcher, settings.PublicURL, time.Now, recorder),
|
||||
ResendEmailChangeUseCase: useCases.NewResendEmailChangeUseCase(repos.UserRepo, hasher, repos.MailRepo, mailDispatcher, settings.PublicURL, time.Now, recorder),
|
||||
CancelEmailChangeUseCase: useCases.NewCancelEmailChangeUseCase(repos.UserRepo, recorder),
|
||||
ConfirmEmailChangeUseCase: useCases.NewConfirmEmailChangeUseCase(repos.UserRepo, hasher, time.Now, recorder),
|
||||
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
|
||||
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, repos.ContentTypeRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
|
||||
CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, 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, repos.RoleRepo),
|
||||
ChangeUserRoleUseCase: useCases.NewChangeUserRoleUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder),
|
||||
RemoveUserUseCase: useCases.NewRemoveUserUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder),
|
||||
ExportUserDataUseCase: useCases.NewExportUserDataUseCase(repos.UserRepo, repos.RoleRepo, repos.AuthorRepo, repos.APIKeyRepo, repos.ArticleRepo, repos.EntryRepo, repos.ContentTypeRepo, repos.ImageRepo, repos.AuditRepo, time.Now, recorder, maxConcurrentDataExports),
|
||||
DeleteOwnAccountUseCase: useCases.NewDeleteOwnAccountUseCase(repos.UserRepo, repos.RoleRepo, hasher, repos.UserEraser, sessionRevocation, repos.MailRepo, mailDispatcher, settings.PublicURL, recorder, repos.AuthorRepo, repos.ImageRepo, mediaUsage, imageStorage, repos.VariantFileRepo),
|
||||
TransferOwnershipUseCase: useCases.NewTransferOwnershipUseCase(repos.UserRepo, repos.RoleRepo, recorder),
|
||||
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
|
||||
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, hasher, recorder),
|
||||
@@ -431,7 +477,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ArticleRepo),
|
||||
MergeMediaUseCase: mergeMedia,
|
||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo),
|
||||
GetMediaUsageUseCase: mediaUsage,
|
||||
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
|
||||
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
|
||||
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo),
|
||||
@@ -507,6 +553,9 @@ func InitContainer(settings config.Settings) (*UseCases, func() error) {
|
||||
if err := backfillImageMetadata(repos.ImageRepo, imageStorage, settings); err != nil {
|
||||
panic("Failed to backfill image metadata: " + err.Error())
|
||||
}
|
||||
if err := useCases.NewRefreshArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.LocalizationRepo).RefreshStale(); err != nil {
|
||||
panic("Failed to backfill the article SEO status: " + err.Error())
|
||||
}
|
||||
container := getUseCases(repos, imageStorage, settings)
|
||||
return container, container.runUsageTracker(connection.Close)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ package article
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/articleerr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
domain "RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -26,25 +26,41 @@ import (
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
CreateUseCase *useCases.CreateArticleUseCase
|
||||
GetUseCase *useCases.GetArticleUseCase
|
||||
ListUseCase *useCases.ListArticlesUseCase
|
||||
DeleteUseCase *useCases.DeleteArticleUseCase
|
||||
UpdateUseCase *useCases.UpdateArticleUseCase
|
||||
createUseCase *useCases.CreateArticleUseCase
|
||||
getUseCase *useCases.GetArticleUseCase
|
||||
listUseCase *useCases.ListArticlesUseCase
|
||||
deleteUseCase *useCases.DeleteArticleUseCase
|
||||
updateUseCase *useCases.UpdateArticleUseCase
|
||||
translationUseCase *useCases.CreateTranslationUseCase
|
||||
describeUseCase *useCases.DescribeArticleUseCase
|
||||
}
|
||||
|
||||
type PostArticle struct {
|
||||
Title string `json:"title" validate:"required,min=3,max=200"`
|
||||
Body string `json:"body"`
|
||||
Lang *string `json:"lang"`
|
||||
StoryID *uint32 `json:"story_id" validate:"omitempty,gte=1"`
|
||||
SourceArticleID *uint32 `json:"source_article_id" validate:"omitempty,gte=1"`
|
||||
Copy *bool `json:"copy"`
|
||||
Site *string `json:"site" validate:"omitempty,sitekey"`
|
||||
}
|
||||
|
||||
func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase) *Handler {
|
||||
type PostTranslation struct {
|
||||
Lang string `json:"lang" validate:"required"`
|
||||
Title string `json:"title" validate:"required,min=3,max=200"`
|
||||
SourceArticleID *uint32 `json:"source_article_id" validate:"omitempty,gte=1"`
|
||||
Copy *bool `json:"copy" validate:"required"`
|
||||
}
|
||||
|
||||
func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase, translation *useCases.CreateTranslationUseCase, describe *useCases.DescribeArticleUseCase) *Handler {
|
||||
return &Handler{
|
||||
CreateUseCase: create,
|
||||
GetUseCase: get,
|
||||
ListUseCase: list,
|
||||
DeleteUseCase: delete,
|
||||
UpdateUseCase: update,
|
||||
createUseCase: create,
|
||||
getUseCase: get,
|
||||
listUseCase: list,
|
||||
deleteUseCase: delete,
|
||||
updateUseCase: update,
|
||||
translationUseCase: translation,
|
||||
describeUseCase: describe,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +75,9 @@ type baseArticleResponse struct {
|
||||
Authors []author.Byline `json:"authors"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Lang string `json:"lang"`
|
||||
StoryID uint32 `json:"story_id"`
|
||||
Revision uint32 `json:"revision"`
|
||||
}
|
||||
|
||||
type articleResponse struct {
|
||||
@@ -75,6 +94,29 @@ type articleResponse struct {
|
||||
CanonicalURL string `json:"canonical_url"`
|
||||
Noindex bool `json:"noindex"`
|
||||
SEODismissed []string `json:"seo_dismissed"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Translations []domain.Translation `json:"translations"`
|
||||
Source *domain.Source `json:"source"`
|
||||
*editorialContext
|
||||
}
|
||||
|
||||
type editorialContext struct {
|
||||
SourceUnavailable bool `json:"source_unavailable"`
|
||||
MissingLangs []string `json:"missing_langs"`
|
||||
ExcludedFromPublic bool `json:"excluded_from_public"`
|
||||
PartialView bool `json:"partial_view"`
|
||||
}
|
||||
|
||||
func editorialContextFor(viewer access.Caller, described useCases.ArticleContext) *editorialContext {
|
||||
if viewer.UserID == 0 {
|
||||
return nil
|
||||
}
|
||||
return &editorialContext{
|
||||
SourceUnavailable: described.SourceUnavailable,
|
||||
MissingLangs: described.MissingLangs,
|
||||
ExcludedFromPublic: described.ExcludedFromPublic,
|
||||
PartialView: described.PartialView,
|
||||
}
|
||||
}
|
||||
|
||||
type listItemResponse struct {
|
||||
@@ -117,10 +159,13 @@ func toBaseResponse(article domain.Article, format domain.BodyFormat, viewer acc
|
||||
Authors: authors,
|
||||
CreatedAt: article.CreatedAt,
|
||||
UpdatedAt: article.UpdatedAt,
|
||||
Lang: article.Lang,
|
||||
StoryID: article.StoryID,
|
||||
Revision: article.EditRevision,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer access.Caller) (articleResponse, error) {
|
||||
func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer access.Caller, described useCases.ArticleContext) (articleResponse, error) {
|
||||
base, err := toBaseResponse(article, format, viewer)
|
||||
if err != nil {
|
||||
return articleResponse{}, err
|
||||
@@ -143,6 +188,10 @@ func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer
|
||||
CanonicalURL: article.CanonicalURL,
|
||||
Noindex: article.Noindex,
|
||||
SEODismissed: dismissed,
|
||||
Capabilities: described.Capabilities,
|
||||
Translations: described.Translations,
|
||||
Source: described.Source,
|
||||
editorialContext: editorialContextFor(viewer, described),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -154,8 +203,8 @@ func toListItemResponse(article domain.Article, summary domain.SEOSummary, forma
|
||||
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)
|
||||
func encodeArticle(article domain.Article, format domain.BodyFormat, viewer access.Caller, described useCases.ArticleContext) ([]byte, error) {
|
||||
response, err := toArticleResponse(article, format, viewer, described)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -188,25 +237,13 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
article, err := h.GetUseCase.GetArticle(id, format, access.CallerOf(r))
|
||||
article, err := h.getUseCase.GetArticle(id, format, access.CallerOf(r))
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrArticleNotFound) {
|
||||
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
writeArticleErrorResponse(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
articleJson, err := encodeArticle(article, format, access.CallerOf(r))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := w.Write(articleJson); err != nil {
|
||||
fmt.Printf("Failed to write response: %v", err)
|
||||
}
|
||||
h.writeArticle(w, r, http.StatusOK, article, format, printToStdout)
|
||||
}
|
||||
|
||||
func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -222,28 +259,98 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := validator.Validate.Struct(localArticle); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeValidationError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
createdArticle, err := h.CreateUseCase.CreateArticle(r.Context(), useCases.CreateArticleCommand{
|
||||
if localArticle.StoryID != nil {
|
||||
if localArticle.Site != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h.postTranslation(w, r, format, localArticle)
|
||||
return
|
||||
}
|
||||
if localArticle.SourceArticleID != nil || localArticle.Copy != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
lang := ""
|
||||
if localArticle.Lang != nil {
|
||||
lang = *localArticle.Lang
|
||||
}
|
||||
createdArticle, err := h.createUseCase.CreateArticle(r.Context(), useCases.CreateArticleCommand{
|
||||
Title: localArticle.Title,
|
||||
Body: localArticle.Body,
|
||||
Format: format,
|
||||
Lang: lang,
|
||||
Site: localArticle.Site,
|
||||
Caller: access.CallerOf(r),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrInvalidTitle) {
|
||||
http.Error(w, apperr.ArticleTitleErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !writeKnownArticleError(w, err) {
|
||||
http.Error(w, "Failed to create article", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeArticleJsonResponse(w, r, http.StatusCreated, createdArticle, format)
|
||||
h.writeArticle(w, r, http.StatusCreated, createdArticle, format, log.Printf)
|
||||
}
|
||||
|
||||
func (h *Handler) postTranslation(w http.ResponseWriter, r *http.Request, format domain.BodyFormat, request PostArticle) {
|
||||
if request.Lang == nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h.createTranslation(w, r, useCases.CreateTranslationCommand{
|
||||
StoryID: *request.StoryID,
|
||||
Lang: *request.Lang,
|
||||
Title: request.Title,
|
||||
Copy: request.Copy,
|
||||
SourceArticleID: request.SourceArticleID,
|
||||
Format: format,
|
||||
Caller: access.CallerOf(r),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) PostTranslation(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseArticleId(r)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
format, ok := parseBodyFormat(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request PostTranslation
|
||||
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
|
||||
}
|
||||
h.createTranslation(w, r, useCases.CreateTranslationCommand{
|
||||
ArticleID: id,
|
||||
Lang: request.Lang,
|
||||
Title: request.Title,
|
||||
Copy: request.Copy,
|
||||
SourceArticleID: request.SourceArticleID,
|
||||
Format: format,
|
||||
Caller: access.CallerOf(r),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) createTranslation(w http.ResponseWriter, r *http.Request, command useCases.CreateTranslationCommand) {
|
||||
created, err := h.translationUseCase.CreateTranslation(r.Context(), command)
|
||||
if err != nil {
|
||||
writeArticleErrorResponse(w, err)
|
||||
return
|
||||
}
|
||||
h.writeArticle(w, r, http.StatusCreated, created, command.Format, log.Printf)
|
||||
}
|
||||
|
||||
func writeArticleErrorResponse(w http.ResponseWriter, err error) {
|
||||
@@ -253,46 +360,15 @@ func writeArticleErrorResponse(w http.ResponseWriter, err error) {
|
||||
}
|
||||
|
||||
func writeKnownArticleError(w http.ResponseWriter, err error) bool {
|
||||
if access.WriteIfMissingPermission(w, err) {
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, domain.ErrArticleNotFound) {
|
||||
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, site.ErrSiteNotFound) {
|
||||
http.Error(w, apperr.SiteNotFoundErrorMessage, http.StatusNotFound)
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, author.ErrAuthorNotFound) {
|
||||
http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound)
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, domain.ErrSlugTaken) {
|
||||
writeSlugTaken(w, err)
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, domain.ErrInvalidSlug) {
|
||||
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, domain.ErrInvalidCanonicalURL) {
|
||||
http.Error(w, apperr.InvalidURLErrorMessage, http.StatusUnprocessableEntity)
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, image.ErrImageNotFound) {
|
||||
http.Error(w, apperr.ArticleImageNotFoundErrorMessage, http.StatusBadRequest)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return access.WriteIfMissingPermission(w, err) || articleerr.Write(w, err)
|
||||
}
|
||||
|
||||
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)
|
||||
const siteField = "Site"
|
||||
|
||||
var bodyMismatchFailures = map[string]string{
|
||||
"Copy": "required",
|
||||
"IfRevision": "min",
|
||||
"AuthorIDs": "unique",
|
||||
}
|
||||
|
||||
func writeValidationError(w http.ResponseWriter, err error) {
|
||||
@@ -304,12 +380,32 @@ func writeValidationError(w http.ResponseWriter, err error) {
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, failure := range failures {
|
||||
if failure.Field() == siteField {
|
||||
http.Error(w, apperr.InvalidSiteKeyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(failures) > 0 && bodyMismatchFailures[failures[0].Field()] == failures[0].Tag() {
|
||||
http.Error(w, apperr.BodyErrorMessage, 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))
|
||||
func printToStdout(format string, args ...any) {
|
||||
fmt.Printf(format, args...)
|
||||
}
|
||||
|
||||
func (h *Handler) writeArticle(w http.ResponseWriter, r *http.Request, status int, article domain.Article, format domain.BodyFormat, reportFailure func(string, ...any)) {
|
||||
caller := access.CallerOf(r)
|
||||
described, err := h.describeUseCase.Describe(article, caller)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
articleJson, err := encodeArticle(article, format, caller, described)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -319,7 +415,7 @@ func writeArticleJsonResponse(w http.ResponseWriter, r *http.Request, status int
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
if _, err := w.Write(articleJson); err != nil {
|
||||
log.Printf("Failed to write response: %v", err)
|
||||
reportFailure("Failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,13 +431,13 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b
|
||||
return
|
||||
}
|
||||
|
||||
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(r.Context(), id, access.CallerOf(r), isOnline, format)
|
||||
updatedArticle, err := h.updateUseCase.UpdateIsOnline(r.Context(), id, access.CallerOf(r), isOnline, format)
|
||||
if err != nil {
|
||||
writeArticleErrorResponse(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format)
|
||||
h.writeArticle(w, r, http.StatusOK, updatedArticle, format, log.Printf)
|
||||
}
|
||||
|
||||
type optionalImageID struct {
|
||||
@@ -368,9 +464,11 @@ func (o optionalImageID) toNullable() domain.Nullable[uint32] {
|
||||
}
|
||||
|
||||
type UpdateArticleBody struct {
|
||||
Title *string `json:"title"`
|
||||
IfRevision *uint32 `json:"if_revision" validate:"omitempty,min=1"`
|
||||
Body *string `json:"body"`
|
||||
SiteID *uint32 `json:"site_id" validate:"omitempty,gte=1"`
|
||||
AuthorIDs *[]uint32 `json:"author_ids" validate:"omitempty,dive,gte=1"`
|
||||
AuthorIDs *[]uint32 `json:"author_ids" validate:"omitempty,unique,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"`
|
||||
@@ -382,24 +480,13 @@ type UpdateArticleBody struct {
|
||||
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"`
|
||||
SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,seodismissed"`
|
||||
}
|
||||
|
||||
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 nonNullableArticleFields = []string{"title", "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 {
|
||||
@@ -426,16 +513,13 @@ func readUpdateArticleBody(r *http.Request) (UpdateArticleBody, bool) {
|
||||
return UpdateArticleBody{}, false
|
||||
}
|
||||
known, ok := countKnownFields(fields, nonNullableArticleFields)
|
||||
if !ok || known == 0 {
|
||||
if !ok || known == 0 || string(fields["if_revision"]) == "null" {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -462,7 +546,9 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
updatedArticle, err := h.UpdateUseCase.Update(r.Context(), id, access.CallerOf(r), useCases.UpdateArticleCommand{
|
||||
updatedArticle, err := h.updateUseCase.Update(r.Context(), id, access.CallerOf(r), useCases.UpdateArticleCommand{
|
||||
Title: request.Title,
|
||||
IfRevision: request.IfRevision,
|
||||
Body: request.Body,
|
||||
SiteID: request.SiteID,
|
||||
AuthorIDs: request.AuthorIDs,
|
||||
@@ -485,7 +571,7 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format)
|
||||
h.writeArticle(w, r, http.StatusOK, updatedArticle, format, log.Printf)
|
||||
}
|
||||
|
||||
func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -509,6 +595,30 @@ type listQuery struct {
|
||||
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"`
|
||||
Group *string `validate:"required_if=Tab missing,omitempty,eq=story"`
|
||||
Tab *string `validate:"omitempty,oneof=all missing"`
|
||||
Lang *string `validate:"omitempty,min=1"`
|
||||
Author *uint32 `validate:"omitempty,gte=1"`
|
||||
}
|
||||
|
||||
const groupByStory = "story"
|
||||
|
||||
type storyItemResponse struct {
|
||||
listItemResponse
|
||||
StoryUpdatedAt time.Time `json:"story_updated_at"`
|
||||
StoryAuthors []author.Byline `json:"story_authors"`
|
||||
Coverage []domain.Translation `json:"coverage"`
|
||||
MissingLangs []string `json:"missing_langs"`
|
||||
ExcludedFromPublic bool `json:"excluded_from_public"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
|
||||
type storyListResponse struct {
|
||||
Items []storyItemResponse `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
Counts domain.StoryCounts `json:"counts"`
|
||||
}
|
||||
|
||||
const defaultListSort = "updated_at:desc"
|
||||
@@ -562,7 +672,38 @@ func readListQuery(values url.Values) (listQuery, error) {
|
||||
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
|
||||
query := listQuery{Page: page, Size: size, Q: values.Get("q"), Status: status, Sort: sortValue, Site: siteKey, SEO: seoStatus}
|
||||
if err := readLocalizedQuery(values, &query); err != nil {
|
||||
return listQuery{}, err
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func readLocalizedQuery(values url.Values, query *listQuery) error {
|
||||
if values.Has("group") {
|
||||
group := values.Get("group")
|
||||
query.Group = &group
|
||||
}
|
||||
if values.Has("tab") {
|
||||
tab := values.Get("tab")
|
||||
query.Tab = &tab
|
||||
}
|
||||
if values.Has("lang") {
|
||||
tag, err := language.Normalize(values.Get("lang"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query.Lang = &tag
|
||||
}
|
||||
if values.Has("author") {
|
||||
id, err := strconv.ParseUint(values.Get("author"), 10, 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authorID := uint32(id)
|
||||
query.Author = &authorID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -572,14 +713,21 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
query, err := readListQuery(r.URL.Query())
|
||||
if err != nil || validator.Validate.Struct(query) != nil {
|
||||
if err != nil || validator.Validate.Struct(query) != nil || query.Group != nil && access.CallerOf(r).UserID == 0 {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page, err := h.ListUseCase.ListArticlesPage(useCases.ListArticlesCommand{
|
||||
if stringOrEmpty(query.Group) == groupByStory {
|
||||
h.listStories(w, r, format, query)
|
||||
return
|
||||
}
|
||||
|
||||
page, err := h.listUseCase.ListArticlesPage(useCases.ListArticlesCommand{
|
||||
Format: format,
|
||||
Caller: access.CallerOf(r),
|
||||
Lang: stringOrEmpty(query.Lang),
|
||||
Author: query.Author,
|
||||
Page: query.Page,
|
||||
Size: query.Size,
|
||||
Q: query.Q,
|
||||
@@ -603,17 +751,67 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
|
||||
responses = append(responses, response)
|
||||
}
|
||||
|
||||
articlesJson, err := json.Marshal(listResponse{Items: responses, Total: page.Total, Page: page.Page, Size: page.Size, Counts: page.Counts})
|
||||
writeListJSON(w, listResponse{Items: responses, Total: page.Total, Page: page.Page, Size: page.Size, Counts: page.Counts})
|
||||
}
|
||||
|
||||
func writeListJSON(w http.ResponseWriter, payload any) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := w.Write(articlesJson); err != nil {
|
||||
if _, err := w.Write(body); err != nil {
|
||||
log.Printf("Failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) listStories(w http.ResponseWriter, r *http.Request, format domain.BodyFormat, query listQuery) {
|
||||
caller := access.CallerOf(r)
|
||||
page, err := h.listUseCase.ListStoriesPage(useCases.ListStoriesCommand{
|
||||
Format: format,
|
||||
Caller: caller,
|
||||
Page: query.Page,
|
||||
Size: query.Size,
|
||||
Q: query.Q,
|
||||
Status: query.Status,
|
||||
Sort: parseSort(query.Sort),
|
||||
SiteKey: query.Site,
|
||||
SEO: query.SEO,
|
||||
Lang: stringOrEmpty(query.Lang),
|
||||
Tab: stringOrEmpty(query.Tab),
|
||||
Author: query.Author,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
responses := make([]storyItemResponse, 0, len(page.Rows))
|
||||
for _, row := range page.Rows {
|
||||
item, err := toListItemResponse(row.Article, row.SEO, format, caller)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
responses = append(responses, storyItemResponse{
|
||||
listItemResponse: item,
|
||||
StoryUpdatedAt: row.UpdatedAt,
|
||||
StoryAuthors: row.Authors,
|
||||
Coverage: row.Coverage,
|
||||
MissingLangs: row.MissingLangs,
|
||||
ExcludedFromPublic: row.ExcludedFromPublic,
|
||||
Capabilities: row.Capabilities,
|
||||
})
|
||||
}
|
||||
writeListJSON(w, storyListResponse{Items: responses, Total: page.Total, Page: page.Page, Size: page.Size, Counts: page.Counts})
|
||||
}
|
||||
|
||||
func stringOrEmpty(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteArticle(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseArticleId(r)
|
||||
if err != nil {
|
||||
@@ -621,15 +819,10 @@ func (h *Handler) DeleteArticle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.DeleteUseCase.DeleteArticle(r.Context(), id, access.CallerOf(r)); err != nil {
|
||||
if errors.Is(err, domain.ErrArticleNotFound) {
|
||||
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
||||
return
|
||||
if err := h.deleteUseCase.DeleteArticle(r.Context(), id, access.CallerOf(r)); err != nil {
|
||||
if !writeKnownArticleError(w, err) {
|
||||
http.Error(w, apperr.DeleteArticleFailedErrorMessage, http.StatusInternalServerError)
|
||||
}
|
||||
if access.WriteIfMissingPermission(w, err) {
|
||||
return
|
||||
}
|
||||
http.Error(w, "Failed to delete article", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package article
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
domain "RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type PublicHandler struct {
|
||||
resolveUseCase *useCases.ResolvePublicArticleUseCase
|
||||
}
|
||||
|
||||
func NewPublicHandler(resolve *useCases.ResolvePublicArticleUseCase) *PublicHandler {
|
||||
return &PublicHandler{resolveUseCase: resolve}
|
||||
}
|
||||
|
||||
type publicLookupResponse struct {
|
||||
articleResponse
|
||||
Alternates []domain.Alternate `json:"alternates"`
|
||||
}
|
||||
|
||||
type publicStoryResponse struct {
|
||||
articleResponse
|
||||
Alternates []domain.Alternate `json:"alternates"`
|
||||
RequestedLang string `json:"requested_lang"`
|
||||
Fallback bool `json:"fallback"`
|
||||
}
|
||||
|
||||
type publicStoryQuery struct {
|
||||
Fallback *string `validate:"omitempty,eq=default"`
|
||||
}
|
||||
|
||||
var publicErrors = []struct {
|
||||
cause error
|
||||
message string
|
||||
status int
|
||||
}{
|
||||
{domain.ErrArticleNotFound, apperr.ArticleNotFoundCode, http.StatusNotFound},
|
||||
{domain.ErrLanguageNotAvailable, apperr.LanguageNotAvailableCode, http.StatusNotFound},
|
||||
{site.ErrSiteNotFound, apperr.SiteNotFoundCode, http.StatusNotFound},
|
||||
{language.ErrInvalidLanguage, apperr.InvalidLanguageCode, http.StatusBadRequest},
|
||||
}
|
||||
|
||||
func writePublicError(w http.ResponseWriter, err error) {
|
||||
for _, known := range publicErrors {
|
||||
if errors.Is(err, known.cause) {
|
||||
http.Error(w, known.message, known.status)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func (h *PublicHandler) Lookup(w http.ResponseWriter, r *http.Request) {
|
||||
format, ok := parseBodyFormat(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
found, err := h.resolveUseCase.ByLangSlug(useCases.PublicLookupQuery{
|
||||
SiteKey: chi.URLParam(r, "key"),
|
||||
Lang: chi.URLParam(r, "lang"),
|
||||
Slug: chi.URLParam(r, "slug"),
|
||||
Format: format,
|
||||
})
|
||||
if err != nil {
|
||||
writePublicError(w, err)
|
||||
return
|
||||
}
|
||||
h.write(w, found, format, func(response articleResponse) any {
|
||||
return publicLookupResponse{articleResponse: response, Alternates: found.Alternates}
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PublicHandler) Story(w http.ResponseWriter, r *http.Request) {
|
||||
storyID, err := strconv.ParseUint(chi.URLParam(r, "story_id"), 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
format, ok := parseBodyFormat(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
values := r.URL.Query()
|
||||
query := publicStoryQuery{}
|
||||
if values.Has("fallback") {
|
||||
fallback := values.Get("fallback")
|
||||
query.Fallback = &fallback
|
||||
}
|
||||
if validator.Validate.Struct(query) != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
command := useCases.PublicStoryQuery{
|
||||
SiteKey: chi.URLParam(r, "key"),
|
||||
StoryID: uint32(storyID),
|
||||
FallbackDefault: query.Fallback != nil,
|
||||
Format: format,
|
||||
}
|
||||
if values.Has("lang") {
|
||||
requested := values.Get("lang")
|
||||
command.Lang = &requested
|
||||
}
|
||||
found, err := h.resolveUseCase.ByStory(command)
|
||||
if err != nil {
|
||||
writePublicError(w, err)
|
||||
return
|
||||
}
|
||||
h.write(w, found, format, func(response articleResponse) any {
|
||||
return publicStoryResponse{articleResponse: response, Alternates: found.Alternates, RequestedLang: found.RequestedLang, Fallback: found.Fallback}
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PublicHandler) write(w http.ResponseWriter, found useCases.PublicArticle, format domain.BodyFormat, shape func(articleResponse) any) {
|
||||
response, err := toArticleResponse(found.Article, format, found.Viewer, found.Context)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(shape(response))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
log.Printf("Failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ type CheckArticleSEOBody struct {
|
||||
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"`
|
||||
SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,seodismissed"`
|
||||
}
|
||||
|
||||
var checkNonNullableFields = []string{"body", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type EmailChangeHandler struct {
|
||||
RequestUseCase *useCases.RequestEmailChangeUseCase
|
||||
ResendUseCase *useCases.ResendEmailChangeUseCase
|
||||
CancelUseCase *useCases.CancelEmailChangeUseCase
|
||||
ConfirmUseCase *useCases.ConfirmEmailChangeUseCase
|
||||
}
|
||||
|
||||
type RequestEmailChangeCredentials struct {
|
||||
NewEmail string `json:"new_email" validate:"required,max=255,inviteemail"`
|
||||
CurrentPassword string `json:"current_password" validate:"required"`
|
||||
}
|
||||
|
||||
type ConfirmEmailChangeCredentials struct {
|
||||
Code string `json:"code" validate:"required"`
|
||||
}
|
||||
|
||||
func NewEmailChangeHandler(request *useCases.RequestEmailChangeUseCase, resend *useCases.ResendEmailChangeUseCase, cancel *useCases.CancelEmailChangeUseCase, confirm *useCases.ConfirmEmailChangeUseCase) *EmailChangeHandler {
|
||||
return &EmailChangeHandler{
|
||||
RequestUseCase: request,
|
||||
ResendUseCase: resend,
|
||||
CancelUseCase: cancel,
|
||||
ConfirmUseCase: confirm,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *EmailChangeHandler) Request(w http.ResponseWriter, r *http.Request) {
|
||||
var credentials RequestEmailChangeCredentials
|
||||
if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validator.Validate.Struct(credentials); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
pending, err := h.RequestUseCase.RequestEmailChange(r.Context(), useCases.RequestEmailChangeCommand{
|
||||
UserID: userId,
|
||||
NewEmail: credentials.NewEmail,
|
||||
CurrentPassword: credentials.CurrentPassword,
|
||||
})
|
||||
if err != nil {
|
||||
writeEmailChangeError(w, err)
|
||||
return
|
||||
}
|
||||
writePendingEmail(w, pending)
|
||||
}
|
||||
|
||||
func (h *EmailChangeHandler) Resend(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
pending, err := h.ResendUseCase.ResendEmailChange(r.Context(), userId)
|
||||
if err != nil {
|
||||
writeEmailChangeError(w, err)
|
||||
return
|
||||
}
|
||||
writePendingEmail(w, pending)
|
||||
}
|
||||
|
||||
func (h *EmailChangeHandler) Cancel(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.CancelUseCase.CancelEmailChange(r.Context(), userId); err != nil {
|
||||
writeEmailChangeError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *EmailChangeHandler) Confirm(w http.ResponseWriter, r *http.Request) {
|
||||
var credentials ConfirmEmailChangeCredentials
|
||||
if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validator.Validate.Struct(credentials); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
email, err := h.ConfirmUseCase.ConfirmEmailChange(r.Context(), userId, credentials.Code)
|
||||
if err != nil {
|
||||
writeEmailChangeError(w, err)
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(map[string]string{"email": email})
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func writePendingEmail(w http.ResponseWriter, pending string) {
|
||||
body, err := json.Marshal(map[string]string{"pending_email": pending})
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func writeEmailChangeError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, user.ErrUserNotFound):
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
case errors.Is(err, user.ErrWrongPassword):
|
||||
http.Error(w, apperr.WrongPasswordErrorCode, http.StatusForbidden)
|
||||
case errors.Is(err, user.ErrSameEmail):
|
||||
http.Error(w, apperr.SameEmailErrorCode, http.StatusBadRequest)
|
||||
case errors.Is(err, user.ErrNoPendingEmailChange):
|
||||
http.Error(w, apperr.NoPendingEmailChangeErrorCode, http.StatusNotFound)
|
||||
case errors.Is(err, user.ErrInvalidEmailChangeCode):
|
||||
http.Error(w, apperr.InvalidEmailChangeCodeErrorCode, http.StatusForbidden)
|
||||
case errors.Is(err, user.ErrEmailUnavailable):
|
||||
http.Error(w, apperr.EmailUnavailableErrorCode, http.StatusConflict)
|
||||
default:
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -40,16 +40,7 @@ func NewLoginHandler(token *authtoken.Auth, create *useCases.CreateUserUseCase,
|
||||
}
|
||||
|
||||
func removeJwtCookie(w http.ResponseWriter, isProduction bool) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: authtoken.CookieName,
|
||||
Value: "",
|
||||
Expires: time.Now(),
|
||||
MaxAge: -1,
|
||||
Secure: isProduction,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
})
|
||||
http.SetCookie(w, authtoken.ExpiredCookie(isProduction))
|
||||
}
|
||||
|
||||
func setJwtCookie(w http.ResponseWriter, token *authtoken.Auth, isProduction bool, u user.User) error {
|
||||
@@ -139,7 +130,12 @@ func (h *LoginHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *LoginHandler) RegistrationStatus(w http.ResponseWriter, _ *http.Request) {
|
||||
body, _ := json.Marshal(map[string]bool{"open": h.CreateUseCase.IsRegistrationOpen()})
|
||||
open, err := h.CreateUseCase.IsRegistrationOpen()
|
||||
if err != nil {
|
||||
http.Error(w, apperr.RegistrationFailedErrorMessage, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
body, _ := json.Marshal(map[string]bool{"open": open})
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package auth
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
"RenewCMS/api/pkg/payload"
|
||||
"RenewCMS/api/pkg/publicuser"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/role"
|
||||
@@ -33,22 +34,23 @@ type meAuthor struct {
|
||||
}
|
||||
|
||||
type meAccess struct {
|
||||
Role useCases.RoleRef `json:"role"`
|
||||
Role payload.RoleRef `json:"role"`
|
||||
Permissions []role.Grant `json:"permissions"`
|
||||
RoleAssignedBy *useCases.RoleAssigner `json:"role_assigned_by"`
|
||||
RoleAssignedBy *payload.RoleAssigner `json:"role_assigned_by"`
|
||||
}
|
||||
|
||||
type meResponse struct {
|
||||
publicuser.PublicUser
|
||||
PendingEmail *string `json:"pending_email"`
|
||||
Author *meAuthor `json:"author"`
|
||||
Access meAccess `json:"access"`
|
||||
}
|
||||
|
||||
func toMeAccess(access useCases.Access) meAccess {
|
||||
return meAccess{
|
||||
Role: useCases.NewRoleRef(access.Role),
|
||||
Role: payload.RoleRefOf(useCases.NewRoleRef(access.Role)),
|
||||
Permissions: access.Permissions,
|
||||
RoleAssignedBy: access.RoleAssignedBy,
|
||||
RoleAssignedBy: payload.RoleAssignerPtrOf(access.RoleAssignedBy),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +61,13 @@ func toMeAuthor(found *author.Author) *meAuthor {
|
||||
return &meAuthor{Byline: found.Byline(), ArticleCount: found.ArticleCount}
|
||||
}
|
||||
|
||||
func pendingEmailOf(account user.User) *string {
|
||||
if account.PendingEmail == "" {
|
||||
return nil
|
||||
}
|
||||
return &account.PendingEmail
|
||||
}
|
||||
|
||||
func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
@@ -88,7 +97,7 @@ func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
body, err := json.Marshal(meResponse{PublicUser: publicuser.New(loadedUser), Author: toMeAuthor(linked), Access: toMeAccess(access)})
|
||||
body, err := json.Marshal(meResponse{PublicUser: publicuser.New(loadedUser), PendingEmail: pendingEmailOf(loadedUser), Author: toMeAuthor(linked), Access: toMeAccess(access)})
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package language
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
domainLanguage "RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type SiteLanguagesHandler struct {
|
||||
GetUseCase *useCases.GetSiteLanguagesUseCase
|
||||
UpdateUseCase *useCases.UpdateSiteLanguagesUseCase
|
||||
}
|
||||
|
||||
func NewSiteLanguagesHandler(get *useCases.GetSiteLanguagesUseCase, update *useCases.UpdateSiteLanguagesUseCase) *SiteLanguagesHandler {
|
||||
return &SiteLanguagesHandler{GetUseCase: get, UpdateUseCase: update}
|
||||
}
|
||||
|
||||
type PutLanguage struct {
|
||||
Lang string `json:"lang"`
|
||||
Enabled *bool `json:"enabled" validate:"required"`
|
||||
SocialLocale *string `json:"social_locale"`
|
||||
}
|
||||
|
||||
type PutSiteLanguages struct {
|
||||
Revision *string `json:"revision" validate:"required"`
|
||||
Default *string `json:"default" validate:"required"`
|
||||
Languages []PutLanguage `json:"languages" validate:"required,dive"`
|
||||
}
|
||||
|
||||
func (p PutSiteLanguages) command(key string) useCases.UpdateSiteLanguagesCommand {
|
||||
entries := make([]useCases.SiteLanguageEntry, 0, len(p.Languages))
|
||||
for _, entry := range p.Languages {
|
||||
entries = append(entries, useCases.SiteLanguageEntry{Lang: entry.Lang, Enabled: *entry.Enabled, SocialLocale: entry.SocialLocale})
|
||||
}
|
||||
return useCases.UpdateSiteLanguagesCommand{Key: key, Revision: *p.Revision, Default: *p.Default, Languages: entries}
|
||||
}
|
||||
|
||||
func (h *SiteLanguagesHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
view, err := h.GetUseCase.Get(chi.URLParam(r, "key"))
|
||||
if err != nil {
|
||||
writeLocalizationError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, view)
|
||||
}
|
||||
|
||||
func (h *SiteLanguagesHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
var request PutSiteLanguages
|
||||
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, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
view, err := h.UpdateUseCase.Update(r.Context(), request.command(chi.URLParam(r, "key")))
|
||||
if err != nil {
|
||||
writeLocalizationError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, view)
|
||||
}
|
||||
|
||||
type SetupHandler struct {
|
||||
GetUseCase *useCases.GetLanguageSetupUseCase
|
||||
ApplyUseCase *useCases.ApplyLanguageSetupUseCase
|
||||
}
|
||||
|
||||
func NewSetupHandler(get *useCases.GetLanguageSetupUseCase, apply *useCases.ApplyLanguageSetupUseCase) *SetupHandler {
|
||||
return &SetupHandler{GetUseCase: get, ApplyUseCase: apply}
|
||||
}
|
||||
|
||||
type PostSetupSite struct {
|
||||
Key string `json:"key" validate:"required"`
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
|
||||
type PostSetup struct {
|
||||
Sites []PostSetupSite `json:"sites" validate:"required,dive"`
|
||||
}
|
||||
|
||||
func (h *SetupHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
view, err := h.GetUseCase.Get()
|
||||
if err != nil {
|
||||
writeLocalizationError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, view)
|
||||
}
|
||||
|
||||
func (h *SetupHandler) Apply(w http.ResponseWriter, r *http.Request) {
|
||||
var request PostSetup
|
||||
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, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
choices := make([]localization.SetupChoice, 0, len(request.Sites))
|
||||
for _, entry := range request.Sites {
|
||||
choices = append(choices, localization.SetupChoice{Key: entry.Key, Lang: entry.Lang})
|
||||
}
|
||||
result, err := h.ApplyUseCase.Apply(r.Context(), choices)
|
||||
if err != nil {
|
||||
writeLocalizationError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, value any) {
|
||||
body, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
log.Printf("Failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type localizationFailure struct {
|
||||
sentinel error
|
||||
status int
|
||||
body string
|
||||
}
|
||||
|
||||
var localizationFailures = []localizationFailure{
|
||||
{site.ErrSiteNotFound, http.StatusNotFound, apperr.SiteNotFoundErrorMessage},
|
||||
{localization.ErrSetupRequired, http.StatusConflict, apperr.SetupRequiredErrorMessage},
|
||||
{localization.ErrLanguagesChanged, http.StatusConflict, apperr.LanguagesChangedErrorMessage},
|
||||
{localization.ErrLanguageInUse, http.StatusConflict, apperr.LanguageInUseErrorMessage},
|
||||
{localization.ErrSetupInProgress, http.StatusConflict, apperr.SetupInProgressErrorMessage},
|
||||
{localization.ErrDefaultNotEnabled, http.StatusUnprocessableEntity, apperr.DefaultNotEnabledErrorMessage},
|
||||
{localization.ErrNoEnabledLanguage, http.StatusUnprocessableEntity, apperr.NoEnabledLanguageErrorMessage},
|
||||
{localization.ErrDuplicateLanguage, http.StatusUnprocessableEntity, apperr.DuplicateLanguageErrorMessage},
|
||||
{localization.ErrInvalidSocialLocale, http.StatusUnprocessableEntity, apperr.InvalidSocialLocaleErrorMessage},
|
||||
{localization.ErrSetupIncomplete, http.StatusUnprocessableEntity, apperr.SetupIncompleteErrorMessage},
|
||||
{localization.ErrDuplicateSite, http.StatusUnprocessableEntity, apperr.DuplicateSiteErrorMessage},
|
||||
{domainLanguage.ErrInvalidLanguage, http.StatusUnprocessableEntity, apperr.InvalidLanguageErrorMessage},
|
||||
{domainLanguage.ErrUnsupportedLanguage, http.StatusUnprocessableEntity, apperr.UnsupportedLanguageErrorMessage},
|
||||
}
|
||||
|
||||
func writeLocalizationError(w http.ResponseWriter, err error) {
|
||||
for _, failure := range localizationFailures {
|
||||
if errors.Is(err, failure.sentinel) {
|
||||
http.Error(w, failure.body, failure.status)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
@@ -95,6 +95,8 @@ type usageResponse struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Published *bool `json:"published,omitempty"`
|
||||
Fields []string `json:"fields"`
|
||||
StoryID *uint32 `json:"story_id,omitempty"`
|
||||
Lang *string `json:"lang,omitempty"`
|
||||
|
||||
ContentTypeKey string `json:"content_type_key,omitempty"`
|
||||
ContentTypeName string `json:"content_type_name,omitempty"`
|
||||
@@ -108,7 +110,7 @@ type usageFirstFieldBody struct {
|
||||
}
|
||||
|
||||
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}
|
||||
body := usageResponse{Type: usage.Type, ID: usage.ID, Title: usage.Title, Published: usage.Published, Fields: usage.Fields, StoryID: usage.StoryID, Lang: usage.Lang, 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}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package ownership
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/payload"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
@@ -45,7 +46,7 @@ func (h *Handler) Transfer(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
body, err := json.Marshal(transferred)
|
||||
body, err := json.Marshal(payload.OwnershipTransferOf(transferred))
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -3,6 +3,7 @@ package role
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/payload"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
domain "RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
@@ -118,6 +119,7 @@ var refusals = []refusal{
|
||||
{domain.ErrRoleNameTaken, http.StatusConflict, apperr.RoleNameTakenErrorMessage},
|
||||
{domain.ErrRoleConflict, http.StatusConflict, apperr.RoleConflictErrorMessage},
|
||||
{domain.ErrRoleHasMembers, http.StatusConflict, apperr.RoleHasMembersErrorMessage},
|
||||
{domain.ErrReassignTargetNotFound, http.StatusConflict, apperr.ReassignTargetNotFoundErrorMessage},
|
||||
{domain.ErrInvalidReassign, http.StatusBadRequest, apperr.InvalidReassignErrorMessage},
|
||||
{domain.ErrInvalidRoleName, http.StatusBadRequest, apperr.InvalidRoleNameErrorMessage},
|
||||
{domain.ErrUnknownPermission, http.StatusBadRequest, apperr.UnknownPermissionErrorMessage},
|
||||
@@ -152,7 +154,7 @@ func (h *Handler) List(w http.ResponseWriter, _ *http.Request) {
|
||||
writeRoleError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, listed)
|
||||
writeJSON(w, http.StatusOK, payload.RoleViewsOf(listed))
|
||||
}
|
||||
|
||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -165,7 +167,7 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
writeRoleError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, found)
|
||||
writeJSON(w, http.StatusOK, payload.RoleViewOf(found))
|
||||
}
|
||||
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -180,7 +182,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
writeRoleError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, created)
|
||||
writeJSON(w, http.StatusCreated, payload.RoleViewOf(created))
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -199,7 +201,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
writeRoleError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
writeJSON(w, http.StatusOK, payload.RoleViewOf(updated))
|
||||
}
|
||||
|
||||
func (h *Handler) Reset(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -216,7 +218,7 @@ func (h *Handler) Reset(w http.ResponseWriter, r *http.Request) {
|
||||
writeRoleError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, restored)
|
||||
writeJSON(w, http.StatusOK, payload.RoleViewOf(restored))
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -71,6 +71,7 @@ type PatchSite struct {
|
||||
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"`
|
||||
PublicURLPattern optional[string] `json:"public_url_pattern"`
|
||||
}
|
||||
|
||||
func (p PatchSite) toPatch() domain.Patch {
|
||||
@@ -85,6 +86,7 @@ func (p PatchSite) toPatch() domain.Patch {
|
||||
SiteName: p.SiteName,
|
||||
TitleTemplate: p.TitleTemplate,
|
||||
DefaultDescription: p.DefaultDescription,
|
||||
PublicURLPattern: domain.Nullable[string]{Set: p.PublicURLPattern.Set, Value: p.PublicURLPattern.Value},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +127,18 @@ func writeSiteError(w http.ResponseWriter, err error) {
|
||||
http.Error(w, apperr.InvalidTitleTemplateErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, domain.ErrInvalidLocale):
|
||||
http.Error(w, apperr.InvalidLocaleErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, domain.ErrInvalidPublicURLPattern):
|
||||
http.Error(w, apperr.InvalidPublicURLPatternErrorMessage, http.StatusUnprocessableEntity)
|
||||
case errors.Is(err, domain.ErrPublicURLScheme):
|
||||
http.Error(w, apperr.PublicURLSchemeErrorMessage, http.StatusUnprocessableEntity)
|
||||
case errors.Is(err, domain.ErrPublicURLHost):
|
||||
http.Error(w, apperr.PublicURLHostErrorMessage, http.StatusUnprocessableEntity)
|
||||
case errors.Is(err, domain.ErrPublicURLPlaceholder):
|
||||
http.Error(w, apperr.PublicURLPlaceholderErrorMessage, http.StatusUnprocessableEntity)
|
||||
case errors.Is(err, domain.ErrPublicURLNotEncoded):
|
||||
http.Error(w, apperr.PublicURLNotEncodedErrorMessage, http.StatusUnprocessableEntity)
|
||||
case errors.Is(err, domain.ErrPublicURLMissingSlug):
|
||||
http.Error(w, apperr.PublicURLMissingSlugErrorMessage, http.StatusUnprocessableEntity)
|
||||
case errors.Is(err, image.ErrImageNotFound):
|
||||
http.Error(w, apperr.SiteImageNotFoundErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, domain.ErrUseSetDefault):
|
||||
|
||||
@@ -37,6 +37,7 @@ type sitemapItem struct {
|
||||
URL string `json:"url"`
|
||||
ModifiedAt string `json:"modified_at"`
|
||||
Noindex bool `json:"noindex"`
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
|
||||
func sitemapIntParam(values url.Values, name string, fallback int) (int, error) {
|
||||
@@ -73,7 +74,7 @@ func (h *SitemapHandler) Sitemap(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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})
|
||||
items = append(items, sitemapItem{URL: entry.URL, ModifiedAt: entry.ModifiedAt.UTC().Format(time.RFC3339), Noindex: entry.Noindex, Lang: entry.Lang})
|
||||
}
|
||||
body, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package story
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/articleerr"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
MoveUseCase *useCases.MoveStoryUseCase
|
||||
DeleteUseCase *useCases.DeleteStoryUseCase
|
||||
}
|
||||
|
||||
func NewHandler(move *useCases.MoveStoryUseCase, delete *useCases.DeleteStoryUseCase) *Handler {
|
||||
return &Handler{MoveUseCase: move, DeleteUseCase: delete}
|
||||
}
|
||||
|
||||
func parseStoryID(r *http.Request) (uint32, error) {
|
||||
id, err := strconv.ParseUint(chi.URLParam(r, "story_id"), 10, 32)
|
||||
return uint32(id), err
|
||||
}
|
||||
|
||||
type moveRequest struct {
|
||||
Site *string `json:"site"`
|
||||
}
|
||||
|
||||
type moveResponse struct {
|
||||
StoryID uint32 `json:"story_id"`
|
||||
Site string `json:"site"`
|
||||
SiteID uint32 `json:"site_id"`
|
||||
ArticleIDs []uint32 `json:"article_ids"`
|
||||
}
|
||||
|
||||
func (h *Handler) Move(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseStoryID(r)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var request moveRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.Site == nil || *request.Site == "" {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := h.MoveUseCase.MoveStory(r.Context(), useCases.MoveStoryCommand{StoryID: id, SiteKey: *request.Site, Caller: access.CallerOf(r)})
|
||||
if err != nil {
|
||||
writeError(w, err, apperr.MoveStoryFailedErrorMessage)
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(moveResponse{StoryID: result.StoryID, Site: result.Site.Key, SiteID: result.Site.ID, ArticleIDs: result.ArticleIDs})
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
log.Printf("Failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseStoryID(r)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.DeleteUseCase.DeleteStory(r.Context(), useCases.DeleteStoryCommand{StoryID: id, Caller: access.CallerOf(r)}); err != nil {
|
||||
writeError(w, err, apperr.DeleteArticleFailedErrorMessage)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, err error, failure string) {
|
||||
if access.WriteIfMissingPermission(w, err) {
|
||||
return
|
||||
}
|
||||
if articleerr.Write(w, err) {
|
||||
return
|
||||
}
|
||||
http.Error(w, failure, http.StatusInternalServerError)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
domain "RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DeleteAccountHandler struct {
|
||||
DeleteUseCase *useCases.DeleteOwnAccountUseCase
|
||||
IsProduction bool
|
||||
}
|
||||
|
||||
type DeleteAccountRequest struct {
|
||||
CurrentPassword string `json:"current_password" validate:"required"`
|
||||
Confirmation string `json:"confirmation" validate:"required,eq=DELETE"`
|
||||
}
|
||||
|
||||
func NewDeleteAccountHandler(deleteAccount *useCases.DeleteOwnAccountUseCase, isProduction bool) *DeleteAccountHandler {
|
||||
return &DeleteAccountHandler{DeleteUseCase: deleteAccount, IsProduction: isProduction}
|
||||
}
|
||||
|
||||
func (h *DeleteAccountHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
var request DeleteAccountRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validator.Validate.Struct(request); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
command := useCases.DeleteOwnAccountCommand{UserID: userId, CurrentPassword: request.CurrentPassword}
|
||||
command.SessionID, command.SessionExpiresAt = sessionOf(r)
|
||||
if err := h.DeleteUseCase.Delete(r.Context(), command); err != nil {
|
||||
writeDeleteAccountError(w, err)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, authtoken.ExpiredCookie(h.IsProduction))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func sessionOf(r *http.Request) (string, time.Time) {
|
||||
tokenClaims, err := authtoken.FromContext(r.Context())
|
||||
if err != nil {
|
||||
return "", time.Time{}
|
||||
}
|
||||
id, _ := tokenClaims["jti"].(string)
|
||||
expiry, _ := tokenClaims["exp"].(float64)
|
||||
return id, time.Unix(int64(expiry), 0)
|
||||
}
|
||||
|
||||
func writeDeleteAccountError(w http.ResponseWriter, err error) {
|
||||
var lastAdmin domain.LastAdminError
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrUserNotFound):
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
case errors.Is(err, domain.ErrWrongPassword):
|
||||
http.Error(w, apperr.WrongPasswordErrorCode, http.StatusForbidden)
|
||||
case errors.As(err, &lastAdmin):
|
||||
w.Header().Set(apperr.LastRoleHeader, lastAdmin.Role)
|
||||
http.Error(w, apperr.LastAdminErrorCode, http.StatusConflict)
|
||||
case errors.Is(err, domain.ErrAccountNotDeletable):
|
||||
http.Error(w, apperr.AccountNotDeletableErrorCode, http.StatusConflict)
|
||||
default:
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type ExportHandler struct {
|
||||
ExportUseCase *useCases.ExportUserDataUseCase
|
||||
}
|
||||
|
||||
func NewExportHandler(export *useCases.ExportUserDataUseCase) *ExportHandler {
|
||||
return &ExportHandler{ExportUseCase: export}
|
||||
}
|
||||
|
||||
func (h *ExportHandler) Export(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
archive, err := h.ExportUseCase.Export(r.Context(), userId)
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
http.Error(w, "Service unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+archive.Filename+"\"")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(archive.Content)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(archive.Content)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
"RenewCMS/api/pkg/payload"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/domain/user"
|
||||
@@ -34,7 +35,7 @@ type inviteResponse struct {
|
||||
Kind string `json:"kind"`
|
||||
Email string `json:"email"`
|
||||
Status string `json:"status"`
|
||||
Role useCases.RoleRef `json:"role"`
|
||||
Role payload.RoleRef `json:"role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
@@ -43,7 +44,7 @@ type inviteListItem struct {
|
||||
ID uint32 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Email string `json:"email"`
|
||||
Role *useCases.RoleRef `json:"role"`
|
||||
Role *payload.RoleRef `json:"role"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -54,8 +55,8 @@ type userListItem struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
Role *useCases.RoleRef `json:"role"`
|
||||
RoleAssignedBy *useCases.RoleAssigner `json:"role_assigned_by"`
|
||||
Role *payload.RoleRef `json:"role"`
|
||||
RoleAssignedBy *payload.RoleAssigner `json:"role_assigned_by"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -107,7 +108,7 @@ func writeInviteResponse(w http.ResponseWriter, status int, entry useCases.Invit
|
||||
Kind: useCases.EntryKindInvite,
|
||||
Email: entry.Email,
|
||||
Status: string(entry.StatusAt(time.Now())),
|
||||
Role: entry.Role,
|
||||
Role: payload.RoleRefOf(entry.Role),
|
||||
CreatedAt: entry.CreatedAt,
|
||||
ExpiresAt: entry.ExpiresAt,
|
||||
})
|
||||
@@ -212,7 +213,7 @@ func (h *InviteHandler) ListUsers(w http.ResponseWriter, _ *http.Request) {
|
||||
ID: entry.ID,
|
||||
Kind: entry.Kind,
|
||||
Email: entry.Email,
|
||||
Role: entry.Role,
|
||||
Role: payload.RoleRefPtrOf(entry.Role),
|
||||
Status: entry.Status,
|
||||
CreatedAt: entry.CreatedAt,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/role"
|
||||
domain "RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type RemoveHandler struct {
|
||||
RemoveUserUseCase *useCases.RemoveUserUseCase
|
||||
}
|
||||
|
||||
func NewRemoveHandler(removeUser *useCases.RemoveUserUseCase) *RemoveHandler {
|
||||
return &RemoveHandler{RemoveUserUseCase: removeUser}
|
||||
}
|
||||
|
||||
func (h *RemoveHandler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||
targetID, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
expectedRoleID, err := strconv.ParseUint(r.URL.Query().Get("expected_role_id"), 10, 32)
|
||||
if err != nil || expectedRoleID == 0 {
|
||||
http.Error(w, apperr.InvalidRoleErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.RemoveUserUseCase.Remove(r.Context(), useCases.RemoveUserCommand{
|
||||
Caller: access.CallerOf(r),
|
||||
TargetID: uint32(targetID),
|
||||
ExpectedRoleID: uint32(expectedRoleID),
|
||||
})
|
||||
if err != nil {
|
||||
writeRemoveError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func writeRemoveError(w http.ResponseWriter, err error) {
|
||||
if writeRoleRefusal(w, err) {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, role.ErrOwnRole):
|
||||
http.Error(w, apperr.OwnRoleErrorMessage, http.StatusForbidden)
|
||||
case errors.Is(err, domain.ErrUserNotFound):
|
||||
http.Error(w, apperr.UserNotFoundErrorMessage, http.StatusNotFound)
|
||||
case errors.Is(err, domain.ErrRoleChanged):
|
||||
http.Error(w, apperr.UserRoleChangedErrorMessage, http.StatusConflict)
|
||||
default:
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package user
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/payload"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
@@ -16,8 +17,8 @@ func userListItemOf(entry useCases.DirectoryEntry) userListItem {
|
||||
Username: entry.Username,
|
||||
DisplayName: entry.DisplayName,
|
||||
Email: entry.Email,
|
||||
Role: entry.Role,
|
||||
RoleAssignedBy: entry.RoleAssignedBy,
|
||||
Role: payload.RoleRefPtrOf(entry.Role),
|
||||
RoleAssignedBy: payload.RoleAssignerPtrOf(entry.RoleAssignedBy),
|
||||
Status: entry.Status,
|
||||
CreatedAt: entry.CreatedAt,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type successSlots struct {
|
||||
mu sync.Mutex
|
||||
window time.Duration
|
||||
now func() time.Time
|
||||
taken map[string]time.Time
|
||||
}
|
||||
|
||||
func NewUserSuccessLimiter(window time.Duration) func(http.Handler) http.Handler {
|
||||
return NewUserSuccessLimiterAt(window, time.Now)
|
||||
}
|
||||
|
||||
func NewUserSuccessLimiterAt(window time.Duration, now func() time.Time) func(http.Handler) http.Handler {
|
||||
slots := &successSlots{window: window, now: now, taken: make(map[string]time.Time)}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := userRateLimitKey(r)
|
||||
claimed, retryAfter, granted := slots.claim(key)
|
||||
if !granted {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
||||
http.Error(w, apperr.TooManyRequestsErrorMessage, http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
succeeded := false
|
||||
defer func() {
|
||||
if !succeeded {
|
||||
slots.release(key, claimed)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(recorder, r)
|
||||
succeeded = recorder.status < http.StatusBadRequest
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *successSlots) claim(key string) (time.Time, int, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := s.now()
|
||||
s.sweepExpired(now)
|
||||
if claimedAt, held := s.taken[key]; held {
|
||||
return time.Time{}, retryAfterSeconds(s.window - now.Sub(claimedAt)), false
|
||||
}
|
||||
s.taken[key] = now
|
||||
return now, 0, true
|
||||
}
|
||||
|
||||
func (s *successSlots) release(key string, claimedAt time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if current, held := s.taken[key]; held && current.Equal(claimedAt) {
|
||||
delete(s.taken, key)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *successSlots) sweepExpired(now time.Time) {
|
||||
for key, claimedAt := range s.taken {
|
||||
if now.Sub(claimedAt) >= s.window {
|
||||
delete(s.taken, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (s *statusRecorder) WriteHeader(status int) {
|
||||
s.status = status
|
||||
s.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (s *statusRecorder) Unwrap() http.ResponseWriter {
|
||||
return s.ResponseWriter
|
||||
}
|
||||
@@ -63,15 +63,36 @@ const CachePurgeTargetErrorMessage = "A variant name or an image id is required
|
||||
const VariantUnknownMessage = "Unknown variant"
|
||||
const VariantUnsupportedParameterMessage = "Unsupported parameter"
|
||||
const SiteNotFoundErrorMessage = "site_not_found"
|
||||
const InvalidSiteKeyErrorMessage = "invalid_site_key"
|
||||
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 InvalidPublicURLPatternErrorMessage = "invalid_pattern"
|
||||
const PublicURLSchemeErrorMessage = "invalid_scheme"
|
||||
const PublicURLHostErrorMessage = "invalid_host"
|
||||
const PublicURLPlaceholderErrorMessage = "unknown_placeholder"
|
||||
const PublicURLNotEncodedErrorMessage = "not_encoded"
|
||||
const PublicURLMissingSlugErrorMessage = "missing_slug"
|
||||
|
||||
const SetupRequiredErrorMessage = "setup_required"
|
||||
const LanguagesChangedErrorMessage = "languages_changed"
|
||||
const LanguageInUseErrorMessage = "language_in_use"
|
||||
const DefaultNotEnabledErrorMessage = "default_not_enabled"
|
||||
const NoEnabledLanguageErrorMessage = "no_enabled_language"
|
||||
const InvalidLanguageErrorMessage = "invalid_language"
|
||||
const UnsupportedLanguageErrorMessage = "unsupported_language"
|
||||
const DuplicateLanguageErrorMessage = "duplicate_language"
|
||||
const InvalidSocialLocaleErrorMessage = "invalid_social_locale"
|
||||
const SetupInProgressErrorMessage = "setup_in_progress"
|
||||
const SetupIncompleteErrorMessage = "setup_incomplete"
|
||||
const DuplicateSiteErrorMessage = "duplicate_site"
|
||||
const SiteImageNotFoundErrorMessage = "image_not_found"
|
||||
const UseSetDefaultErrorMessage = "use_set_default"
|
||||
const InvalidReassignErrorMessage = "invalid_reassign"
|
||||
const ReassignTargetNotFoundErrorMessage = "target_role_not_found"
|
||||
const InvalidSocialTypeErrorMessage = "invalid_social_type"
|
||||
|
||||
const AuthorNotFoundErrorMessage = "author_not_found"
|
||||
@@ -126,3 +147,35 @@ const InvalidRoleErrorMessage = "invalid_role"
|
||||
const UserRoleChangedErrorMessage = "user_role_changed"
|
||||
const InvalidTransferErrorMessage = "invalid_transfer"
|
||||
const ConfirmationMismatchErrorMessage = "confirmation_mismatch"
|
||||
const WrongPasswordErrorCode = "wrong_password"
|
||||
const SameEmailErrorCode = "same_email"
|
||||
const NoPendingEmailChangeErrorCode = "no_pending_email_change"
|
||||
const InvalidEmailChangeCodeErrorCode = "invalid_email_change_code"
|
||||
const EmailUnavailableErrorCode = "email_unavailable"
|
||||
|
||||
const LastAdminErrorCode = "last_admin"
|
||||
const AccountNotDeletableErrorCode = "account_not_deletable"
|
||||
const LastRoleHeader = "X-Last-Role"
|
||||
|
||||
const RevisionConflictErrorMessage = "revision_conflict"
|
||||
const CurrentRevisionHeader = "X-Current-Revision"
|
||||
const LanguageUnavailableErrorMessage = "language_unavailable"
|
||||
const LanguageExistsErrorMessage = "language_exists"
|
||||
const ExistingArticleHeader = "X-Existing-Article-Id"
|
||||
const LanguageNotEnabledErrorMessage = "language_not_enabled"
|
||||
const MissingLanguagesHeader = "X-Missing-Languages"
|
||||
const LanguageDisabledErrorMessage = "language_disabled"
|
||||
const SourceUnavailableErrorMessage = "source_unavailable"
|
||||
const SourceOtherStoryErrorMessage = "source_other_story"
|
||||
const SlugConflictErrorMessage = "slug_conflict"
|
||||
const ConflictingArticlesHeader = "X-Conflicting-Articles"
|
||||
const StoryMoveRequiredErrorMessage = "story_move_required"
|
||||
const StoryBusyErrorMessage = "story_busy"
|
||||
const TargetSiteDeletedErrorMessage = "target_site_deleted"
|
||||
const MoveStoryFailedErrorMessage = "Failed to move story"
|
||||
const DeleteArticleFailedErrorMessage = "Failed to delete article"
|
||||
|
||||
const ArticleNotFoundCode = "article_not_found"
|
||||
const LanguageNotAvailableCode = "language_not_available"
|
||||
const SiteNotFoundCode = "site_not_found"
|
||||
const InvalidLanguageCode = "invalid_language"
|
||||
@@ -0,0 +1,100 @@
|
||||
package articleerr
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
domain "RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/domain/transaction"
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Write(w http.ResponseWriter, err error) bool {
|
||||
var conflict *domain.RevisionConflictError
|
||||
var existing *domain.LanguageExistsError
|
||||
var notEnabled *domain.LanguageNotEnabledError
|
||||
var addresses *domain.SlugConflictError
|
||||
var taken *domain.SlugTakenError
|
||||
switch {
|
||||
case errors.As(err, &conflict):
|
||||
w.Header().Set(apperr.CurrentRevisionHeader, strconv.FormatUint(uint64(conflict.Current), 10))
|
||||
http.Error(w, apperr.RevisionConflictErrorMessage, http.StatusConflict)
|
||||
case errors.As(err, &existing):
|
||||
w.Header().Set(apperr.ExistingArticleHeader, strconv.FormatUint(uint64(existing.ArticleID), 10))
|
||||
http.Error(w, apperr.LanguageExistsErrorMessage, http.StatusConflict)
|
||||
case errors.As(err, ¬Enabled):
|
||||
if len(notEnabled.Missing) > 0 {
|
||||
w.Header().Set(apperr.MissingLanguagesHeader, strings.Join(notEnabled.Missing, ","))
|
||||
}
|
||||
http.Error(w, apperr.LanguageNotEnabledErrorMessage, http.StatusUnprocessableEntity)
|
||||
case errors.As(err, &addresses):
|
||||
if len(addresses.ArticleIDs) > 0 {
|
||||
w.Header().Set(apperr.ConflictingArticlesHeader, joinIDs(addresses.ArticleIDs))
|
||||
}
|
||||
http.Error(w, apperr.SlugConflictErrorMessage, http.StatusConflict)
|
||||
case errors.Is(err, domain.ErrSlugTaken):
|
||||
if errors.As(err, &taken) && taken.Suggestion != "" {
|
||||
w.Header().Set(apperr.SlugSuggestionHeader, taken.Suggestion)
|
||||
}
|
||||
http.Error(w, apperr.ArticleSlugTakenErrorMessage, http.StatusConflict)
|
||||
default:
|
||||
return writeSentinel(w, err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func joinIDs(ids []uint32) string {
|
||||
parts := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
parts = append(parts, strconv.FormatUint(uint64(id), 10))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
type sentinelResponse struct {
|
||||
cause error
|
||||
message string
|
||||
status int
|
||||
}
|
||||
|
||||
var articleSentinels = []sentinelResponse{
|
||||
{domain.ErrInvalidTitle, apperr.ArticleTitleErrorMessage, http.StatusBadRequest},
|
||||
{domain.ErrArticleNotFound, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound},
|
||||
{site.ErrSiteNotFound, apperr.SiteNotFoundErrorMessage, http.StatusNotFound},
|
||||
{author.ErrAuthorNotFound, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound},
|
||||
{domain.ErrInvalidSlug, apperr.InvalidSlugErrorMessage, http.StatusBadRequest},
|
||||
{domain.ErrInvalidCanonicalURL, apperr.InvalidURLErrorMessage, http.StatusUnprocessableEntity},
|
||||
{image.ErrImageNotFound, apperr.ArticleImageNotFoundErrorMessage, http.StatusBadRequest},
|
||||
}
|
||||
|
||||
var localizationSentinels = []sentinelResponse{
|
||||
{domain.ErrLanguageUnavailable, apperr.LanguageUnavailableErrorMessage, http.StatusConflict},
|
||||
{domain.ErrLanguageTaken, apperr.LanguageUnavailableErrorMessage, http.StatusConflict},
|
||||
{domain.ErrLanguageDisabled, apperr.LanguageDisabledErrorMessage, http.StatusUnprocessableEntity},
|
||||
{domain.ErrSourceUnavailable, apperr.SourceUnavailableErrorMessage, http.StatusNotFound},
|
||||
{domain.ErrSourceOtherStory, apperr.SourceOtherStoryErrorMessage, http.StatusUnprocessableEntity},
|
||||
{domain.ErrInvalidTranslation, apperr.BodyErrorMessage, http.StatusBadRequest},
|
||||
{domain.ErrStoryMoveRequired, apperr.StoryMoveRequiredErrorMessage, http.StatusConflict},
|
||||
{localization.ErrSetupRequired, apperr.SetupRequiredErrorMessage, http.StatusConflict},
|
||||
{language.ErrInvalidLanguage, apperr.InvalidLanguageErrorMessage, http.StatusUnprocessableEntity},
|
||||
{language.ErrUnsupportedLanguage, apperr.UnsupportedLanguageErrorMessage, http.StatusUnprocessableEntity},
|
||||
{transaction.ErrLockContention, apperr.StoryBusyErrorMessage, http.StatusConflict},
|
||||
{domain.ErrTargetSiteDeleted, apperr.TargetSiteDeletedErrorMessage, http.StatusConflict},
|
||||
}
|
||||
|
||||
func writeSentinel(w http.ResponseWriter, err error) bool {
|
||||
for _, candidate := range slices.Concat(articleSentinels, localizationSentinels) {
|
||||
if errors.Is(err, candidate.cause) {
|
||||
http.Error(w, candidate.message, candidate.status)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package authtoken
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ExpiredCookie(secure bool) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: "",
|
||||
Expires: time.Now(),
|
||||
MaxAge: -1,
|
||||
Secure: secure,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package payload
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RoleRef struct {
|
||||
ID uint32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
BuiltIn bool `json:"built_in"`
|
||||
Locked bool `json:"locked"`
|
||||
}
|
||||
|
||||
type RoleAssigner struct {
|
||||
ID uint32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type RoleEditor struct {
|
||||
ID uint32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type RoleView struct {
|
||||
role.Role
|
||||
Members int `json:"members"`
|
||||
UpdatedAt *time.Time `json:"updated_at"`
|
||||
UpdatedBy *RoleEditor `json:"updated_by"`
|
||||
}
|
||||
|
||||
type OwnershipSuccessor struct {
|
||||
ID uint32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type OwnershipPredecessor struct {
|
||||
ID uint32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role RoleRef `json:"role"`
|
||||
}
|
||||
|
||||
type OwnershipTransfer struct {
|
||||
Owner OwnershipSuccessor `json:"owner"`
|
||||
FormerOwner OwnershipPredecessor `json:"former_owner"`
|
||||
}
|
||||
|
||||
func RoleRefOf(ref useCases.RoleRef) RoleRef {
|
||||
return RoleRef(ref)
|
||||
}
|
||||
|
||||
func RoleRefPtrOf(ref *useCases.RoleRef) *RoleRef {
|
||||
if ref == nil {
|
||||
return nil
|
||||
}
|
||||
converted := RoleRefOf(*ref)
|
||||
return &converted
|
||||
}
|
||||
|
||||
func RoleAssignerPtrOf(assigner *useCases.RoleAssigner) *RoleAssigner {
|
||||
if assigner == nil {
|
||||
return nil
|
||||
}
|
||||
converted := RoleAssigner(*assigner)
|
||||
return &converted
|
||||
}
|
||||
|
||||
func RoleViewOf(view useCases.RoleView) RoleView {
|
||||
converted := RoleView{Role: view.Role, Members: view.Members, UpdatedAt: view.UpdatedAt}
|
||||
if view.UpdatedBy != nil {
|
||||
editor := RoleEditor(*view.UpdatedBy)
|
||||
converted.UpdatedBy = &editor
|
||||
}
|
||||
return converted
|
||||
}
|
||||
|
||||
func RoleViewsOf(views []useCases.RoleView) []RoleView {
|
||||
converted := make([]RoleView, 0, len(views))
|
||||
for _, view := range views {
|
||||
converted = append(converted, RoleViewOf(view))
|
||||
}
|
||||
return converted
|
||||
}
|
||||
|
||||
func OwnershipTransferOf(transfer useCases.OwnershipTransfer) OwnershipTransfer {
|
||||
return OwnershipTransfer{
|
||||
Owner: OwnershipSuccessor(transfer.Owner),
|
||||
FormerOwner: OwnershipPredecessor{ID: transfer.FormerOwner.ID, Name: transfer.FormerOwner.Name, Role: RoleRefOf(transfer.FormerOwner.Role)},
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"RenewCMS/internal/domain/site"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
@@ -19,6 +20,7 @@ const AuthorNameTag = "authorname"
|
||||
const AuthorSlugTag = "authorslug"
|
||||
const SocialTypeTag = "socialtype"
|
||||
const ArticleSlugTag = "articleslug"
|
||||
const SEODismissedTag = "seodismissed"
|
||||
|
||||
var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`)
|
||||
|
||||
@@ -34,6 +36,7 @@ func InitValidator() {
|
||||
_ = Validate.RegisterValidation(AuthorSlugTag, isAuthorSlug, true)
|
||||
_ = Validate.RegisterValidation(SocialTypeTag, isSocialType, true)
|
||||
_ = Validate.RegisterValidation(ArticleSlugTag, isArticleSlug, true)
|
||||
_ = Validate.RegisterValidation(SEODismissedTag, isSEODismissed)
|
||||
}
|
||||
|
||||
func isArticleSlug(field validator.FieldLevel) bool {
|
||||
@@ -44,6 +47,10 @@ func isArticleSlug(field validator.FieldLevel) bool {
|
||||
return value == "" || article.IsValidSlug(value)
|
||||
}
|
||||
|
||||
func isSEODismissed(field validator.FieldLevel) bool {
|
||||
return slices.Contains(article.DismissCodes(), field.Field().String())
|
||||
}
|
||||
|
||||
func isAbsent(field validator.FieldLevel) bool {
|
||||
return field.Field().Kind() == reflect.Ptr && field.Field().IsNil()
|
||||
}
|
||||
|
||||
+136
-4
@@ -11,6 +11,7 @@ import (
|
||||
"RenewCMS/api/handlers/entry"
|
||||
"RenewCMS/api/handlers/frontend"
|
||||
"RenewCMS/api/handlers/imagevariant"
|
||||
languagehandler "RenewCMS/api/handlers/language"
|
||||
"RenewCMS/api/handlers/llms"
|
||||
"RenewCMS/api/handlers/media"
|
||||
"RenewCMS/api/handlers/ownership"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"RenewCMS/api/handlers/settings"
|
||||
"RenewCMS/api/handlers/site"
|
||||
"RenewCMS/api/handlers/staticimages"
|
||||
"RenewCMS/api/handlers/story"
|
||||
"RenewCMS/api/handlers/update"
|
||||
"RenewCMS/api/handlers/user"
|
||||
"RenewCMS/api/middleware"
|
||||
@@ -52,6 +54,17 @@ func GetHelloWorld(w http.ResponseWriter, _ *http.Request) {
|
||||
const (
|
||||
apiKeyMaxRequests = 120
|
||||
apiKeyWindow = time.Minute
|
||||
|
||||
publicReadMaxRequests = apiKeyMaxRequests
|
||||
publicReadWindow = apiKeyWindow
|
||||
|
||||
emailChangeMaxRequests = 5
|
||||
emailChangeWindow = 15 * time.Minute
|
||||
|
||||
dataExportWindow = 15 * time.Minute
|
||||
|
||||
accountDeleteMaxRequests = 5
|
||||
accountDeleteWindow = 15 * time.Minute
|
||||
)
|
||||
|
||||
func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
|
||||
@@ -61,6 +74,17 @@ func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
|
||||
return c.apiKeyAuthMiddleware
|
||||
}
|
||||
|
||||
func (c *UseCases) publicReadLimit(trustedProxies []string) func(http.Handler) http.Handler {
|
||||
c.publicReadLimitOnce.Do(func() {
|
||||
realIP := middleware.TrustedProxyRealIP(trustedProxies)
|
||||
limiter := middleware.NewRateLimiter(publicReadMaxRequests, publicReadWindow)
|
||||
c.publicReadLimitMiddleware = func(next http.Handler) http.Handler {
|
||||
return realIP(limiter(next))
|
||||
}
|
||||
})
|
||||
return c.publicReadLimitMiddleware
|
||||
}
|
||||
|
||||
func (c *UseCases) require(key string) func(http.Handler) http.Handler {
|
||||
return middleware.RequirePermission(c.GetUserUseCase, c.CheckPermissionUseCase, key)
|
||||
}
|
||||
@@ -86,7 +110,7 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
|
||||
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase)
|
||||
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.CreateTranslationUseCase, container.DescribeArticleUseCase)
|
||||
|
||||
seoHandler := article.NewSEOHandler(container.ResolveArticleSEOUseCase, container.CheckArticleSEOUseCase)
|
||||
|
||||
@@ -101,6 +125,7 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.With(container.requireArticle(role.KeyContentCreate)).Post("/", articleHandler.PostArticle)
|
||||
r.With(container.requireArticle(role.KeyContentCreate)).Post("/{id}/translations", articleHandler.PostTranslation)
|
||||
r.With(container.requireArticle(role.KeyContentDelete)).Delete("/{id}", articleHandler.DeleteArticle)
|
||||
r.With(container.requireArticle(role.KeyContentUpdate)).Patch("/{id}", articleHandler.UpdateArticleBody)
|
||||
r.With(container.requireArticle(role.KeyContentPublish)).Patch("/{id}/publish", articleHandler.PublishArticle)
|
||||
@@ -110,6 +135,34 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
return r
|
||||
}
|
||||
|
||||
func NewPublicRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(container.publicReadLimit(trustedProxies))
|
||||
|
||||
publicHandler := article.NewPublicHandler(container.ResolvePublicArticleUseCase)
|
||||
|
||||
r.Get("/site/{key}/lang/{lang}/article/{slug}", publicHandler.Lookup)
|
||||
r.Get("/site/{key}/story/{story_id}", publicHandler.Story)
|
||||
return r
|
||||
}
|
||||
|
||||
func NewStoryRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
|
||||
storyHandler := story.NewHandler(container.MoveStoryUseCase, container.DeleteStoryUseCase)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.With(container.requireArticle(role.KeyContentUpdate)).Post("/{story_id}/move", storyHandler.Move)
|
||||
r.With(container.requireArticle(role.KeyContentDelete)).Delete("/{story_id}", storyHandler.Delete)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
@@ -119,6 +172,7 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
||||
verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction)
|
||||
meHandler := auth.NewMeHandler(container.GetUserUseCase, container.GetMyAuthorUseCase, container.GetMyAccessUseCase)
|
||||
passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase)
|
||||
emailChangeHandler := auth.NewEmailChangeHandler(container.RequestEmailChangeUseCase, container.ResendEmailChangeUseCase, container.CancelEmailChangeUseCase, container.ConfirmEmailChangeUseCase)
|
||||
acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction)
|
||||
|
||||
r.Post("/register", loginHandler.Register)
|
||||
@@ -149,6 +203,22 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
||||
r.Post("/verify", verificationHandler.Verify)
|
||||
r.Delete("/register", verificationHandler.CancelRegistration)
|
||||
})
|
||||
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.Delete("/email/change", emailChangeHandler.Cancel)
|
||||
r.Post("/email/confirm", emailChangeHandler.Confirm)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.NewUserRateLimiter(emailChangeMaxRequests, emailChangeWindow))
|
||||
r.Post("/email/change", emailChangeHandler.Request)
|
||||
r.Post("/email/resend", emailChangeHandler.Resend)
|
||||
})
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(container.apiKeyAuth())
|
||||
@@ -159,13 +229,16 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
||||
return r
|
||||
}
|
||||
|
||||
func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
|
||||
func NewUserRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
userHandler := user.NewHandler(container.UpdateUserProfileUseCase)
|
||||
lookupHandler := user.NewLookupHandler(container.LookupUserNamesUseCase)
|
||||
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
|
||||
roleHandler := user.NewRoleHandler(container.ChangeUserRoleUseCase)
|
||||
removeHandler := user.NewRemoveHandler(container.RemoveUserUseCase)
|
||||
exportHandler := user.NewExportHandler(container.ExportUserDataUseCase)
|
||||
deleteAccountHandler := user.NewDeleteAccountHandler(container.DeleteOwnAccountUseCase, isProduction)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
@@ -186,6 +259,27 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
|
||||
r.Post("/invite/{id}/renew", inviteHandler.RenewInvite)
|
||||
})
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.With(middleware.NewUserSuccessLimiter(dataExportWindow)).Get("/me/export", exportHandler.Export)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(middleware.NewUserRateLimiter(accountDeleteMaxRequests, accountDeleteWindow))
|
||||
r.Delete("/me", deleteAccountHandler.Delete)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
@@ -197,6 +291,17 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
|
||||
r.Use(container.require(role.KeyUsersChangeRole))
|
||||
r.Put("/{id}/role", roleHandler.Change)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(container.require(role.KeyUsersRemove))
|
||||
r.Delete("/{id}", removeHandler.Remove)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -235,9 +340,11 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
|
||||
sitemapHandler := site.NewSitemapHandler(container.ListSitemapUseCase)
|
||||
|
||||
siteLanguagesHandler := languagehandler.NewSiteLanguagesHandler(container.GetSiteLanguagesUseCase, container.UpdateSiteLanguagesUseCase)
|
||||
|
||||
ownershipHandler := ownership.NewHandler(container.TransferOwnershipUseCase)
|
||||
|
||||
r.Get("/{key}/sitemap", sitemapHandler.Sitemap)
|
||||
r.With(container.publicReadLimit(trustedProxies)).Get("/{key}/sitemap", sitemapHandler.Sitemap)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(container.apiKeyAuth())
|
||||
@@ -245,6 +352,7 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Get("/", siteHandler.List)
|
||||
r.Get("/{key}/languages", siteLanguagesHandler.Get)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
@@ -257,12 +365,33 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
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.KeySettingsManageSites)).Put("/{key}/languages", siteLanguagesHandler.Update)
|
||||
r.With(container.require(role.KeySettingsDeleteSite)).Delete("/{id}", siteHandler.Delete)
|
||||
r.With(container.require(role.KeySystemTransferOwnership)).Post("/transfer-ownership", ownershipHandler.Transfer)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func NewLanguageRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
setupHandler := languagehandler.NewSetupHandler(container.GetLanguageSetupUseCase, container.ApplyLanguageSetupUseCase)
|
||||
|
||||
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.KeySettingsManageSites))
|
||||
r.Get("/setup", setupHandler.Get)
|
||||
r.Post("/setup", setupHandler.Apply)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func NewRoleRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -522,10 +651,13 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/story", NewStoryRouter(container, settings.TrustedProxies))
|
||||
})
|
||||
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
|
||||
r.Mount("/public", NewPublicRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/user", NewUserRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
||||
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/language", NewLanguageRouter(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))
|
||||
|
||||
+113
-21
@@ -3,15 +3,16 @@
|
||||
The full, searchable reference is the Integration page of the running app (`/integration`). It
|
||||
renders `web/src/data/apiCatalogue.ts`, which is the single source for this page. This file is for
|
||||
readers without the UI. Reading articles (pagination, `format`, body schemas) has its own page,
|
||||
[api/articles.md](api/articles.md), and API keys are covered in [api/api-keys.md](api/api-keys.md).
|
||||
[api/articles.md](api/articles.md), versions, translations, the story move and the story-grouped editorial list (`GET /v1/article/?group=story`) have [api/article-localization.md](api/article-localization.md), site languages and the one-time language setup are in [api/site-languages.md](api/site-languages.md), and API keys are covered in [api/api-keys.md](api/api-keys.md).
|
||||
|
||||
## Base URL
|
||||
The origin that serves the app, for example `https://cms.example`. Every path starts with `/v1`,
|
||||
for example `https://cms.example/v1/article/`.
|
||||
|
||||
## Authentication
|
||||
- Public routes need nothing: `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo`
|
||||
and `GET /v1/site/{key}/sitemap`. They return online articles, plus the drafts the caller's role may read
|
||||
- Public routes need nothing: `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo`,
|
||||
`GET /v1/site/{key}/sitemap`, `GET /v1/public/site/{key}/lang/{lang}/article/{slug}` and
|
||||
`GET /v1/public/site/{key}/story/{story_id}` (the two language routes ignore any credential and never return a draft, see [api/article-localization.md](api/article-localization.md#public-language-routes)). They return online articles, plus the drafts the caller's role may read
|
||||
(see [Content permissions](#content-permissions)); any other draft answers 404 `Article not found`.
|
||||
- Everything else needs a signed-in browser session (the `jwt` cookie) or an API key:
|
||||
`Authorization: Bearer rcms_...`. Create keys at `/integration/api-keys`. A key acts as its
|
||||
@@ -28,12 +29,12 @@ for example `https://cms.example/v1/article/`.
|
||||
`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 (30)
|
||||
## Routes a key can call (32)
|
||||
| Group | Routes |
|
||||
| --- | --- |
|
||||
| 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} |
|
||||
| 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}, POST /v1/article/{id}/translations, POST /v1/story/{story_id}/move |
|
||||
| Media | GET /v1/media/, GET /v1/media/{id}, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) |
|
||||
| Users and invites | GET /v1/user/ (`users.view`), PATCH /v1/user/me, 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`) |
|
||||
| Users and invites | GET /v1/user/ (`users.view`), PATCH /v1/user/me, GET /v1/user/me/export, DELETE /v1/user/me, POST /v1/user/invite (`users.invite`), DELETE /v1/user/invite/{id} (`users.invite`), POST /v1/user/invite/{id}/renew (`users.invite`), GET /v1/auth/me (with the linked `author`) |
|
||||
| Settings | GET /v1/settings |
|
||||
| Sites | GET /v1/site/, GET /v1/site/{key}/sitemap |
|
||||
| Authors | GET /v1/author/ |
|
||||
@@ -74,15 +75,15 @@ never changes the response or the change itself.
|
||||
| --- | --- |
|
||||
| `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` |
|
||||
| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge`, `media.variant_generate_all`, `media.variant_purge_image`, `media.variant_purge_all`, `media.variant_purge_unused` |
|
||||
| `users` | `user.register`, `user.verify`, `user.update_profile`, `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` |
|
||||
| `users` | `user.register`, `user.verify`, `user.update_profile`, `user.request_email_change`, `user.resend_email_change`, `user.cancel_email_change`, `user.confirm_email_change`, `user.export_data`, `user.delete_account` (flagged `security`), `user.change_role`, `user.remove`, `user.transfer_ownership` (flagged `security`), `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
|
||||
| `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete`, `site.languages_edit`, `site.language_setup` |
|
||||
| `authors` | `author.create`, `author.edit`, `author.delete` |
|
||||
| `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` |
|
||||
|
||||
Target types: `article`, `site`, `author`, `image`, `image_variant` (id and name are the variant name), `user`, `invite`,
|
||||
`api_key` and `release` (id and name are the tag).
|
||||
Only the fields `title`, `online`, `body`, `site`, `name`, `base_url`, `default_robots`, `locale`, `default_social_image`, `slug`, `canonical_url`, `noindex`, `cover_image`, `social_image`, `excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_alt`, `site_name`, `title_template`, `default_description`, `bio`, `website`, `social_links`, `avatar`, `user`, `authors`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes` (a site key is never a change; moving an article records `site` with the site names; changing the credited authors records `authors` with the names in order; the `user` change of an author holds usernames, never an email), with the
|
||||
kinds `text` and `body`; a body edit carries a signed character count delta and never the text; the free-text SEO fields (`excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_alt`) do the same under their own field name, while `slug`, `canonical_url`, `noindex`, `cover_image`, `social_image` (image file names) and the site fields `site_name`, `title_template`, `default_description` carry `before` and `after`. `seo_dismissed` is not an edit and records nothing. A
|
||||
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`, `public_url_pattern`, `bio`, `website`, `social_links`, `avatar`, `user`, `authors`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes` (a site key is never a change; moving an article records `site` with the site names; changing the credited authors records `authors` with the names in order; the `user` change of an author holds usernames, never an email), with the
|
||||
kinds `text` and `body`; a body edit carries a signed character count delta and never the text; the free-text SEO fields (`excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_alt`) do the same under their own field name, while `slug`, `canonical_url`, `noindex`, `cover_image`, `social_image` (image file names) and the site fields `site_name`, `title_template`, `default_description`, `public_url_pattern` carry `before` and `after`. `seo_dismissed` is not an edit and records nothing. A
|
||||
change that leaves a value as it was (same body, same publish state, same file name, same display
|
||||
name) records nothing. Params: `media.merge` and `media.purge` and `system.media_purge` carry
|
||||
`merged` and `articles_updated`, plus `failed_groups` when a group failed (still outcome `success`);
|
||||
@@ -145,7 +146,7 @@ session." with a key. The role is checked before the body.
|
||||
| --- | --- |
|
||||
| `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}` | Edit `name`, `base_url`, `default_robots`, `locale`, `default_social_image_id`, `site_name`, `title_template`, `default_description`, `public_url_pattern` (see [api/site-languages.md](api/site-languages.md)). 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. |
|
||||
|
||||
@@ -161,8 +162,8 @@ A site also carries `site_name` (at most 100 characters, `""` by default), `titl
|
||||
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
|
||||
body-mismatch message. `PATCH /v1/article/{id}` takes `title`, `body`, `site_id`, `author_ids`, the SEO
|
||||
fields below and `if_revision`, and nothing else: 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.
|
||||
@@ -173,11 +174,11 @@ RenewCMS renders no page and injects no meta tag: it returns the data a frontend
|
||||
|
||||
| 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`. |
|
||||
| `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, lang, alternates }`. 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/site/{key}/sitemap` | public | A JSON array `[{ url, modified_at, noindex, lang }]` of the eligible versions (published, language enabled) of the site with that key, by id ascending. `url` is the localized address when the site has a Public URL pattern, otherwise `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.
|
||||
`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 reads the status stored on each article (see [SEO status and suggestions](api/articles.md#seo-status-and-suggestions)), in SQL, before paging.
|
||||
|
||||
`X-Total-Count` is added to `Access-Control-Expose-Headers` next to `Link`, so a browser on an allowed origin can read it.
|
||||
|
||||
@@ -382,7 +383,8 @@ 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). |
|
||||
| 409 | `target_role_not_found` | `POST /v1/role/{id}/reassign` when the target role does not exist (any longer). |
|
||||
| 400 | `invalid_role_name`, `built_in_name`, `not_built_in`, `invalid_reassign` | Blank name after trim; renaming a built-in role; resetting a custom role; a bad reassign target (self, 0, a locked role, a role the caller cannot cover). |
|
||||
| 403 | `role_locked` | Editing, deleting or moving from the Owner role. |
|
||||
| 403 | `missing_permission` | The usual gate, plus `X-Missing-Permission: roles.edit_owner_only` when a built-in role is edited or reset without that permission. |
|
||||
| 403 | `own_role` | Editing, deleting or moving members out of the caller's own role. |
|
||||
@@ -398,13 +400,102 @@ at the same version and records nothing. Members see an edit on their next reque
|
||||
|
||||
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
|
||||
`changed` (`{ key, from, to }`) and `members`; `role.reassign` carries `from`, `to`, `to_name` (the destination role's name at move time; absent on older events) and `moved` (users and open invites together) and is recorded only when
|
||||
`moved` is above 0. A refusal records nothing.
|
||||
|
||||
`GET /v1/auth/me` lists per-type entries in `access.permissions` (scope `none` included), right after their wildcard.
|
||||
Deleting a content type removes its per-type rows from every role (the affected roles' `version` goes up by one), and
|
||||
creating one with the key of a deleted type clears any stale row for that key.
|
||||
|
||||
## Changing the email address
|
||||
|
||||
Four session-only routes under `/v1/auth` let a verified user change their own email address. A missing or invalid
|
||||
cookie is 401, an API key is 403 `This action needs a real session.` and an unverified user is 403
|
||||
`You must verify your account to access this resource.`
|
||||
|
||||
| Route | Body | Success |
|
||||
| --- | --- | --- |
|
||||
| `POST /v1/auth/email/change` | `{ "new_email": "...", "current_password": "..." }` | 200 `{ "pending_email": "<new>" }` |
|
||||
| `POST /v1/auth/email/resend` | none | 200 `{ "pending_email": "<new>" }` |
|
||||
| `DELETE /v1/auth/email/change` | none | 204, no body |
|
||||
| `POST /v1/auth/email/confirm` | `{ "code": "<raw code>" }` | 200 `{ "email": "<new>" }` |
|
||||
|
||||
`users.email` does not change until the link is confirmed, so the old address keeps receiving password-reset links and
|
||||
the password and username keep working. A request stores `pending_email`, a bcrypt hash of a random code and an
|
||||
expiration two hours ahead, then mails the raw code as a link to the new address (`verify-new-email`) and a notice to
|
||||
the old one (`email-change-notice`). A new request or a resend replaces the previous code; cancelling clears it. The
|
||||
link works once.
|
||||
|
||||
Errors are plain text and the code is the whole body: 400 `same_email` (the new address equals the current one,
|
||||
whatever its case), 403 `wrong_password`, 404 `no_pending_email_change` (resend only), 403
|
||||
`invalid_email_change_code` (nothing pending, wrong, expired, cancelled or issued to another user) and 409
|
||||
`email_unavailable` (the address belongs to another user at confirmation time; the pending change is cleared). A body
|
||||
that is not JSON is 400 with the body-mismatch message, and a failed validation is 400 with the validator text
|
||||
(`new_email` must be a full address, `current_password` and `code` are required).
|
||||
|
||||
A request for an address that another user already holds answers exactly like a success, stores the pending change and
|
||||
still sends the notice to the old address, but sends no verification mail, so the response never reveals that an account
|
||||
exists. A failed send is logged without the code and does not change the response. `POST /v1/auth/email/change` and
|
||||
`POST /v1/auth/email/resend` share one limit of 5 attempts per user per 15 minutes; the sixth is 429 with a
|
||||
`Retry-After` header. Cancel and confirm are not limited.
|
||||
|
||||
Audit actions (category `users`, source `web`): `user.request_email_change`, `user.resend_email_change` and
|
||||
`user.confirm_email_change` are flagged `security`; `user.cancel_email_change` is not, and cancelling with nothing
|
||||
pending records nothing. A refused request, resend or confirm records a failed event of the same action, flagged `security`, with a specific reason. No email address is written into an event, and a wrong password records a failed
|
||||
`user.request_email_change` with the reason `Wrong password.`
|
||||
|
||||
## Exporting your own data
|
||||
|
||||
`GET /v1/user/me/export` answers a ZIP of the caller's personal data. It needs a real, verified session: no cookie is 401,
|
||||
an API key is 403 "This action needs a real session." and an unverified account is 403. There is no body and no permission
|
||||
gate, since it only reads the caller's own data. The success answer is 200 with `Content-Type: application/zip`,
|
||||
`Cache-Control: no-store` and `Content-Disposition: attachment; filename="renewcms-export-<username>-<YYYY-MM-DD>.zip"`;
|
||||
the username is lowercased and reduced to `a-z`, `0-9` and `-`, and the date is the UTC day of the export. The archive is
|
||||
built in memory first, so a failure is a plain text 500 and never a half file.
|
||||
|
||||
| File | Content |
|
||||
| --- | --- |
|
||||
| `profile.json` | `id`, `username`, `display_name`, `email`, `pending_email`, `role {id, name}`, `is_verified`, `created_at`, `updated_at`, `author` (the linked author profile or `null`), `api_keys [{id, name, prefix, created_at, last_used_at, revoked_at}]` |
|
||||
| `authored-content.json` | `articles [{id, title, body, slug, site_id, is_online, created_at, updated_at, authors}]` created by the caller (drafts included) or published and crediting their author profile, with `authors [{id, name, slug}]`, and `entries [{id, content_type, data, created_at, updated_at}]` created by the caller; soft-deleted articles are left out |
|
||||
| `uploaded-media.json` | `images [{id, filename, mime, size, hash, uploaded_at}]`: references only, no file bytes; soft-deleted images are left out |
|
||||
| `account-activity.json` | `events [{id, created_at, action, category, target, outcome, security, source, ip, user_agent, params}]` where the caller is the actor, without events whose target is another user, an invite or another person's author profile |
|
||||
|
||||
The archive never holds the password hash, a verification, reset or email-change code, the token version or an API-key
|
||||
hash. One export succeeds per user every 15 minutes; a second attempt is 429 "Too many requests. Please try again later."
|
||||
with a `Retry-After` header in seconds. An export that fails with a 500 does not use the slot. A successful export records
|
||||
one `user.export_data` audit event (category `users`, not flagged `security`).
|
||||
|
||||
The archive is built in memory. At most 2 exports are built at the same time across all users (the constant
|
||||
`maxConcurrentDataExports` in `api/dependecyInjection.go`); another request waits for a free slot. If the request is
|
||||
cancelled or times out while waiting, the answer is 503 "Service unavailable" and the user's 15-minute slot is not used.
|
||||
|
||||
## Deleting your own account
|
||||
|
||||
`DELETE /v1/user/me` deletes the caller's account and personal data. It needs a real, verified session: no cookie is 401,
|
||||
an API key is 403 "This action needs a real session." and an unverified account is 403. It is not `DELETE /v1/user/{id}`,
|
||||
which stays the admin action and is unchanged.
|
||||
|
||||
Body: `{"current_password": "...", "confirmation": "DELETE"}`. The password is required, and `confirmation` must be exactly
|
||||
`DELETE` (case-sensitive, no spaces). The checks run in this order:
|
||||
|
||||
| Answer | Body | When |
|
||||
| --- | --- | --- |
|
||||
| 400 | the validator message, or "The request cannot be processed due to a mismatch in the format of the body." | the body is not JSON, the password is empty or `confirmation` is not `DELETE` |
|
||||
| 403 | `wrong_password` | the password is not the account's |
|
||||
| 409 | `last_admin`, with the header `X-Last-Role: Owner` or `X-Last-Role: Admin` | the caller holds the built-in Owner role and no other verified user does, or the built-in Admin role and no other verified user holds Owner or Admin. Other roles, custom ones included, are never blocked. |
|
||||
| 409 | `account_not_deletable` | the holders of the role changed between the check and the removal; nothing was removed |
|
||||
| 429 | "Too many requests. Please try again later." with `Retry-After` | more than 5 attempts per user in 15 minutes, counted before any check |
|
||||
| 500 | "Internal server error" | the removal failed; everything is rolled back |
|
||||
| 204 | none, and a `Set-Cookie` that expires `jwt` | the account is deleted |
|
||||
|
||||
The removal is one atomic operation: the user row, all the user's API keys, the linked author (deleted when no article
|
||||
credits it, otherwise kept as "Deleted user" with the slug `deleted-user-<author id>`, no bio, avatar, website or links and
|
||||
no user; the avatar image is deleted too, with its stored file after the commit, unless something else uses it), and the user's past activity events (actor and target names become "Deleted user", the IP address, user agent, `params` and `changes` are emptied, and author events on the user's own profile lose the profile name; the events stay). Articles, entries and images keep the old user id. The current session is revoked, and
|
||||
one `account-deleted` mail (the site address only, no personal data) is sent to the address the account had; a failed send
|
||||
is logged and does not change the 204. A successful deletion records one `user.delete_account` event (category `users`,
|
||||
flagged `security`, actor and target named "Deleted user", no email in the params); a refused attempt records a failed
|
||||
`user.delete_account` event with the reason `Wrong password.`, `Last Owner.` or `Last administrator.`.
|
||||
|
||||
## Users, invites and ownership
|
||||
|
||||
A user has exactly one stored role, `role_id`, and nothing else: there is no `role` string any more (the
|
||||
@@ -412,7 +503,7 @@ A user has exactly one stored role, `role_id`, and nothing else: there is no `ro
|
||||
`{ 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`.
|
||||
verify, register, invite accept, `GET /v1/auth/me`); `GET /v1/auth/me` adds `pending_email` (string or `null`), `author` and `access`.
|
||||
|
||||
`role_assigned_by` is `{ id, name }` or `null`: who last set the user's role. It is stored as the nullable column
|
||||
`role_assigned_by_id` on the user (no foreign key, no backfill, existing rows are `NULL`) and the name is never stored: it
|
||||
@@ -429,6 +520,7 @@ login, verify, register and invite-accept bodies do not change.
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /v1/user` | `users.view` | no | Users and open invites, newest first. A user is `{ id, kind: "user", username, display_name, email, role, role_assigned_by, status: "accepted", created_at }`; an invite is `{ id, kind: "invite", email, role, status, created_at }`. The roles are read with one `List` call. |
|
||||
| `PUT /v1/user/{id}/role` | `users.change_role` | yes | Body `{"role_id": N}` (required). Answers 200 with the user item, whose `role_assigned_by` is the caller. |
|
||||
| `DELETE /v1/user/{id}?expected_role_id=N` | `users.remove` | yes | Removes an accepted user (204, no body). `expected_role_id` is required (a whole number above 0, else 400 `invalid_role`) and must be the id of the role the target still holds: a compare-and-swap (`DeleteIfRole`), 409 `user_role_changed` otherwise. Order of checks: session, permission, id, `expected_role_id`, target (404 `user_not_found`), target is the caller (403 `own_role`), stale role (409), target role locked (403 `role_locked`), target role covered (403 `escalation`), delete. The removed user's cookie sessions and API keys answer 401 afterwards; articles, entries, media, invites and audit events they created stay. Records `user.remove` (category `users`, target the user); a refusal records nothing and a storage failure records it with outcome `failed`. Invites are cancelled with `DELETE /v1/user/invite/{id}`. |
|
||||
| `POST /v1/user/invite` | `users.invite` | no | Body `{"email", "role_id"}`; `role_id` is optional and 0 or absent means the Writer role. Answers 201 `{ id, kind: "invite", email, status, role, created_at, expires_at }`. |
|
||||
| `POST /v1/user/invite/{id}/renew` | `users.invite` | no | Renews an expired or pending invite; it keeps its role and the response carries it. |
|
||||
| `DELETE /v1/user/invite/{id}` | `users.invite` | no | Cancels an invite (204). No coverage is needed: cancelling removes power. |
|
||||
@@ -460,7 +552,7 @@ body, target, `confirm_email`, write. Every refusal writes nothing, not even the
|
||||
| 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. |
|
||||
| 409 | `user_role_changed` | `DELETE /v1/user/{id}` as well: the target's role changed between read and write (a compare-and-swap on the role that was read), or the caller no longer holds the Owner role on a transfer. |
|
||||
|
||||
Transferring ownership moves the Owner role to the target and gives the former owner the built-in Admin role in one
|
||||
transaction; both keep `token_version`, cookies and API keys, so the former owner's next request is decided by the
|
||||
@@ -489,7 +581,7 @@ caller's role, which is read on every request (a key follows its creator's role)
|
||||
| `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 |
|
||||
| `PATCH /v1/article/{id}` | `content.update` | `own` needs `created_by` to be the caller, then the article must be readable by the caller, else 404 |
|
||||
| `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 |
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
# Article versions, translations, revisions and story move
|
||||
|
||||
Contract for `features/article_translation_create.feature`, `features/article_edit_revision.feature` and `features/article_story_lifecycle.feature`. The SPA mocks `/v1` from this page. The article object itself is described in [articles.md](articles.md), the languages of a site in [site-languages.md](site-languages.md).
|
||||
|
||||
## Vocabulary
|
||||
|
||||
- A **story** groups the language versions of one article inside one site. A **version** is one article row: it has its own id, `lang`, title, slug, content, publication and `created_by`. A story holds at most one live version per language.
|
||||
- `revision` is the edit counter of a version, starting at `1`, raised by `1` per saved change (see [What raises revision](#what-raises-revision)). An internal content counter is also kept; it is never exposed on a version, only as `source.revision`.
|
||||
- A **reader** of a version is a caller who passes the existing read rule: an online version is readable by everyone, an offline one by a verified caller whose `content.read` covers it (`all`, or `own` and the caller created it).
|
||||
- **Missing means no readable version.** Everything an article read says about sibling versions (translations, source, error bodies, headers, counts) is limited to the versions the caller can read. No count, placeholder, total, header or error text ever names, counts or hints at a version the caller cannot read.
|
||||
|
||||
## Errors
|
||||
|
||||
Errors are plain text. The body is the code (or an existing message) followed by a line feed, and extra data travels in response headers. A refused request changes nothing.
|
||||
|
||||
| Header | Set on | Value |
|
||||
| --- | --- | --- |
|
||||
| `X-Current-Revision` | `409 revision_conflict` | the current `revision` of the version, an integer |
|
||||
| `X-Existing-Article-Id` | `409 language_exists` | id of the readable version that already holds the language |
|
||||
| `X-Conflicting-Articles` | `409 slug_conflict` | ids of the readable versions whose address is taken at the destination, comma separated, in creation order. Absent when none of them is readable |
|
||||
| `X-Missing-Languages` | `422 language_not_enabled` on a move or on `PATCH site_id` | tags missing or disabled at the destination, comma separated, sorted, readable versions only. Absent when empty |
|
||||
| `X-Slug-Suggestion` | `409 slug_taken` | first free `<slug>-N` (existing) |
|
||||
| `X-Missing-Permission` | `403 missing_permission` | the permission key (existing) |
|
||||
|
||||
The headers are set on those responses only. Same-origin SPA code reads them directly; cross-origin scripts do not see them (`Access-Control-Expose-Headers` is unchanged).
|
||||
|
||||
| Status | Body | Meaning |
|
||||
| --- | --- | --- |
|
||||
| 400 | `The request cannot be processed due to a mismatch in the format of the body.` | undecodable or invalid body, missing `copy`, `copy: false` with a source, `if_revision` invalid, an unknown or empty update |
|
||||
| 400 | `The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).` | invalid id in the path |
|
||||
| 400 | the validator text (for example `Key: 'PostTranslation.Title' Error:Field validation for 'Title' failed on the 'min' tag`) | `lang` or `title` missing, title shorter than 3 or longer than 200 characters on a create, `site` that is `""` or not a site key on `POST /v1/article/` |
|
||||
| 400 | `Article title must be between 3 and 200 characters.` | a `PATCH` title outside 3 to 200 runes |
|
||||
| 400 | `invalid_slug` | a `PATCH` slug that is not a slug |
|
||||
| 401 | `You must be logged in to access this resource.` | no valid session or key |
|
||||
| 403 | `missing_permission` | the caller lacks the permission (`X-Missing-Permission`); on `DELETE /v1/story/{story_id}`, a `content.delete` that is not at scope `all` |
|
||||
| 404 | `Article not found` | the version, or the story, does not exist or the caller cannot read it |
|
||||
| 404 | `source_unavailable` | the chosen source does not exist or is not readable; the same answer for both |
|
||||
| 404 | `site_not_found` | `POST /v1/story/{story_id}/move`, or `POST /v1/article/` with `site`, naming an unknown site key |
|
||||
| 409 | `revision_conflict` | `if_revision` is not the current `revision` (`X-Current-Revision`) |
|
||||
| 409 | `language_exists` | the language already has a readable version (`X-Existing-Article-Id`) |
|
||||
| 409 | `language_unavailable` | one neutral answer: the language is disabled on the site (the chosen site on `POST /v1/article/` with `site`), or its version exists but is not readable by the caller, or a concurrent request created it first |
|
||||
| 409 | `setup_required` | the version has no language yet (`lang` `""`): the site default is not confirmed (the chosen site on `POST /v1/article/` with `site`) |
|
||||
| 409 | `slug_taken` | a `PATCH` slug used by another article of the same site and language (`X-Slug-Suggestion`) |
|
||||
| 409 | `slug_conflict` | a move would put an address that already exists at the destination (`X-Conflicting-Articles`) |
|
||||
| 409 | `story_move_required` | `PATCH site_id` on a version whose story holds several versions |
|
||||
| 409 | `target_site_deleted` | `POST /v1/article/`: the target site was deleted after it was looked up and before the article was stored; nothing is stored, choose another site and retry. The SPA has no copy for it yet |
|
||||
| 409 | `story_busy` | the database could not lock the rows in time (lock timeout, deadlock or serialization failure); retry |
|
||||
| 422 | `invalid_language` | the tag is not a language tag |
|
||||
| 422 | `unsupported_language` | a valid tag outside the supported list |
|
||||
| 422 | `language_not_enabled` | the site (the chosen one on `POST /v1/article/` with `site`) has no language row for the tag (creation), or a destination site lacks or has disabled a language of the story (move, `X-Missing-Languages`) |
|
||||
| 422 | `language_disabled` | publishing an offline version whose language is disabled on its site |
|
||||
| 422 | `source_other_story` | the source belongs to another story |
|
||||
|
||||
## Fields added to an article read
|
||||
|
||||
Added to the article object of `GET /v1/article/{id}`, `POST`, `PATCH`, `publish`, `unpublish`, and for `revision` also to each item of `GET /v1/article/`. Every other key keeps its name, value and type.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 10,
|
||||
"lang": "en",
|
||||
"story_id": 5,
|
||||
"revision": 3,
|
||||
"capabilities": ["read", "edit", "publish", "delete", "create_translation"],
|
||||
"translations": [
|
||||
{"id": 10, "lang": "en", "title": "Renovation costs", "is_online": true, "language_enabled": true, "excluded_from_public": false, "source_article_id": null, "capabilities": ["read", "edit", "publish", "delete", "create_translation"]},
|
||||
{"id": 13, "lang": "ar", "title": "Takalif", "is_online": true, "language_enabled": true, "excluded_from_public": false, "source_article_id": 10, "capabilities": ["read", "create_translation"]},
|
||||
{"id": 12, "lang": "fr", "title": "Couts", "is_online": false, "language_enabled": false, "excluded_from_public": true, "source_article_id": 10, "capabilities": ["read", "edit", "publish", "delete", "create_translation"]}
|
||||
],
|
||||
"source": {"article_id": 10, "revision": 4},
|
||||
"source_unavailable": false,
|
||||
"missing_langs": ["de"],
|
||||
"excluded_from_public": false,
|
||||
"partial_view": false
|
||||
}
|
||||
```
|
||||
|
||||
- `revision` is a positive integer on every read and write, public reads included.
|
||||
- `capabilities` lists what the caller may do with this version, always in this order and always starting with `read`: `edit` (`content.update` covers it), `publish` (`content.publish`), `delete` (`content.delete`), `create_translation` (the caller holds `content.create`). An anonymous caller gets `["read"]`.
|
||||
- `translations` lists the readable versions of the story, the version being read included, with `id`, `lang`, `title`, `is_online`, `language_enabled` and their own `capabilities`. A version without language (`lang` `""`) reports `language_enabled: true`. The order is the default language of the site first, then the other tags in ascending order (`en`, `ar`, `de`, `fr`); a disabled language is still listed with `language_enabled: false`. A single-version article lists itself. An anonymous caller gets `[]`.
|
||||
- `source` is `{"article_id", "revision"}` when the version was created by copying another version and that source still exists and is readable by the caller, otherwise `null` (a version created empty, a deleted source, an unreadable source, an anonymous caller). `source.revision` is the content revision of the source at the moment of the copy.
|
||||
- `translations[].excluded_from_public` and `excluded_from_public` (the version being read) are `true` when the language keeps the version off the public language surfaces even once published: the language is disabled on the site, or the version has no language yet (`lang` `""`, the site default is not confirmed). The SPA reads this flag instead of re-deriving it from `language_enabled` and the site languages. A `false` flag says nothing about publication: drafts are off the public surfaces anyway.
|
||||
- `translations[].source_article_id` is the id of the version this entry tracks when that source is itself listed in `translations` (so readable), otherwise `null`.
|
||||
- The four keys below are sent to signed-in callers only; an anonymous read has none of them (its shape is unchanged).
|
||||
- `source_unavailable`: see [Versions the caller cannot read](#versions-the-caller-cannot-read).
|
||||
- `missing_langs`: the enabled languages of the site without a readable version in the story, default first then by tag, the same rule as the editorial list (`[]` when the caller cannot create, and `[]` for a story whose only versions have no language).
|
||||
- `excluded_from_public`: the flag above, for the version being read.
|
||||
- `partial_view`: see [Versions the caller cannot read](#versions-the-caller-cannot-read).
|
||||
|
||||
## Versions the caller cannot read
|
||||
|
||||
No answer reveals the existence, id, title or language of a version the caller cannot read. Where the editor needs to explain an absence, the API gives one neutral answer that is the same whether the version was deleted or is hidden from the caller, or a flag that depends on the caller's own permissions only.
|
||||
|
||||
| Need | Answer | Same for |
|
||||
| --- | --- | --- |
|
||||
| The tracked source of a version is gone | `"source": null` and `"source_unavailable": true`. The editor shows "The source isn't available to you" and asks to choose a new source | a deleted source and a source the caller cannot read |
|
||||
| Creating from a source that is gone | `404 source_unavailable` | a deleted, unknown or unreadable source |
|
||||
| Deleting a version that others track ("Review tracking will pause") | the dialog names the entries of `translations` whose `source_article_id` is the id being deleted | trackers the caller cannot read are never listed or counted, because `translations` lists readable versions only and their `source_article_id` is never sent |
|
||||
| Telling the caller that some versions may be out of view ("Versions you can't read aren't listed or counted.") | `partial_view` | `true` whenever the caller's `content.read` is not at scope `all`, whether or not the story holds a version the caller cannot read |
|
||||
| Deleting the whole article while some versions are out of view | `DELETE /v1/story/{story_id}` needs `content.delete` at scope `all` (`403 missing_permission` otherwise, decided before anything is read), deletes every version, readable or not, and answers `204` | a story with or without hidden versions |
|
||||
| Moving or editing a story with hidden versions | `article_ids` and the `X-Missing-Languages` and `X-Conflicting-Articles` headers name readable versions only (see [Moving a story](#moving-a-story-post-v1storystory_idmove)) | |
|
||||
|
||||
`partial_view` never depends on the stored data, so the note "Versions you can't read aren't listed or counted." shows for such a caller on every story, also when nothing is hidden. Showing it only when a hidden version exists would reveal that version.
|
||||
|
||||
## What raises revision
|
||||
|
||||
A `PATCH` raises `revision` by exactly `1` when it changes at least one stored value, whatever the number of fields, and not at all when it changes nothing. Publishing and unpublishing never raise it.
|
||||
|
||||
| Changed field | `revision` | internal content revision |
|
||||
| --- | --- | --- |
|
||||
| `title`, `body`, `excerpt`, `slug`, `cover_image_alt`, `seo_title`, `seo_description`, `social_title`, `social_description` | +1 | +1 |
|
||||
| `author_ids`, `cover_image_id`, `social_image_id`, `canonical_url`, `noindex`, `seo_dismissed`, `site_id` | +1 | unchanged |
|
||||
|
||||
Existing rows start at revision `1`. A story move raises each moved version by `1`.
|
||||
|
||||
## Editing: `PATCH /v1/article/{id}`
|
||||
|
||||
Verified session or API key with `content.update` on the version (`403 missing_permission` otherwise, checked first) and able to read it (`404 Article not found` otherwise, checked second), so a stale `if_revision` never reveals anything to a caller who cannot edit or read the version.
|
||||
|
||||
- `title`: 3 to 200 runes (a 4-byte emoji counts as one). `""`, 2 and 201 runes answer `400 Article title must be between 3 and 200 characters.`; `null` answers `400` with the body-mismatch message. Editing the title never changes the slug and never changes the publication: an online version shows the new title publicly at once.
|
||||
- `slug`: changed only when sent. `""` regenerates it from the current title. There is no redirect, the old address stops resolving. Unique per site and language: the same slug in two languages of a story is allowed. A slug already used by another article of the same site and language answers `409 slug_taken` with `X-Slug-Suggestion`. Sending the current slug changes nothing.
|
||||
- `if_revision`: optional integer `>= 1`. A value that is `0`, negative, a string, a fraction or `null` answers `400` (body mismatch). `if_revision` alone is not an update (`400`). When it differs from the current `revision`, nothing is stored and the answer is `409 revision_conflict` with `X-Current-Revision`, even when the rest of the request would change nothing. The compare and the write are one atomic step: of two concurrent saves with the same `if_revision`, one answers `200` and the other `409`. Without `if_revision` the last write wins. To overwrite after a conflict, send `if_revision` equal to the `X-Current-Revision` the conflict showed; if somebody saved in between, the answer is `409` again with the newer revision.
|
||||
- `site_id` on a version whose story holds several versions the caller can read answers `409 story_move_required` and changes nothing (hidden versions are not counted, so the answer never reveals them). When the caller reads only this version of a larger story, `content.update` is required on every version of the story, readable or not (`403 missing_permission` otherwise, naming no version) and the request runs [the story move](#moving-a-story-post-v1storystory_idmove) rules: every version moves in one transaction, language and slug refusals roll everything back, and headers name readable versions only. The other fields of the same request (`title`, `author_ids`, `slug`, SEO fields) are part of that transaction: a refusal of any of them, an unknown author or an invalid SEO value included, leaves every version on its original site and every `revision` unchanged, and the edited version's `revision` rises by exactly 1 for the whole request. To move a story with several readable versions, use [the story move](#moving-a-story-post-v1storystory_idmove). On a story with one version it moves the version when its language is enabled at the destination (`422 language_not_enabled`, with `X-Missing-Languages`, otherwise); a version without language moves without a language check. The slug rule of the destination still applies (`409 slug_taken`).
|
||||
- `lang`, `story_id`, `source_article_id` and `copy` in the body are ignored.
|
||||
|
||||
The `200` body is the full article object with the new `revision`.
|
||||
|
||||
## Publishing
|
||||
|
||||
`PATCH /v1/article/{id}/publish` on an offline version whose language is disabled on its site answers `422 language_disabled`. Publishing an already online version is idempotent (`200`, unchanged) even when its language is disabled; a version without language publishes as before. `unpublish` and `PATCH` stay available in a disabled language. Publishing or unpublishing one version never touches its siblings.
|
||||
|
||||
## Creating a translation
|
||||
|
||||
Two entry points share one implementation: the same result, errors, headers and stored data.
|
||||
|
||||
### `POST /v1/article/{id}/translations`
|
||||
|
||||
Verified session or API key with `content.create`; the version `{id}` must be readable by the caller.
|
||||
|
||||
```json
|
||||
{"lang": "fr", "title": "Couts de renovation", "copy": true, "source_article_id": 11}
|
||||
```
|
||||
|
||||
| Field | Rule |
|
||||
| --- | --- |
|
||||
| `lang` | required; normalized (`FR` is stored as `fr`) |
|
||||
| `title` | required, 3 to 200 characters |
|
||||
| `copy` | required boolean (`400` when absent) |
|
||||
| `source_article_id` | optional, only with `copy: true` (`400` with `copy: false`); default is `{id}`; must be a readable version of the same story |
|
||||
|
||||
The new version joins the story of `{id}`, in the same site, as an offline draft created by the caller, with `revision` `1`. The slug is generated from the new title in (site, language) and gets the next free `-N` suffix when taken.
|
||||
|
||||
- `copy: true` copies `body`, `excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_id`, `cover_image_alt`, `social_image_id` and the authors from the source, and never `slug`, `canonical_url` (empty), `noindex` (false) or `seo_dismissed` (empty). No image or file row is duplicated. The source and its content revision are captured in the same transaction as the copy: the read shows `"source": {"article_id": <source>, "revision": <content revision then>}`.
|
||||
- `copy: false` starts empty: empty text, no images, `authors` is the creating user's byline, `"source": null`.
|
||||
|
||||
Answers `201` with the created article object. Order of checks: permission, path id, body, version readable, `copy` rules, language tag, `setup_required`, source, language configuration. A story deleted in the meantime answers `404 Article not found`. Both entry points leave the stored `lang` normalized.
|
||||
|
||||
### `POST /v1/article/` with `story_id` (deprecated)
|
||||
|
||||
Deprecated: use `POST /v1/article/{id}/translations`. Both entry points run the same use case and keep working; new clients should not send `story_id`.
|
||||
|
||||
```json
|
||||
{"title": "Couts", "lang": "fr", "story_id": 5, "copy": true, "source_article_id": 10}
|
||||
```
|
||||
|
||||
`story_id` selects the story (`404 Article not found` when it has no readable version, the same answer as a missing story) and the first readable version, oldest first, plays the role of `{id}`. `lang` is then required (`400`). `source_article_id` and `copy` are only valid with `story_id` (`400` otherwise).
|
||||
|
||||
### `POST /v1/article/` without `story_id`
|
||||
|
||||
It creates a new story with one offline version, on the default site unless the body names a site.
|
||||
|
||||
```json
|
||||
{"title": "Neuf", "lang": "fr", "site": "atelier"}
|
||||
```
|
||||
|
||||
- `site` is optional: the site key. Absent or `null`, the default site is used exactly as before. A present `site` is validated like any site key (`400` with the plain-text body `invalid_site_key` for `""` or `Bad Key!`), then looked up (`404 site_not_found` for an unknown key, a plain text body, nothing stored). A site deleted after that lookup and before the article is stored answers `409 target_site_deleted` (plain text, nothing stored). Every caller who may create articles may create on every site: `content.create` is not scoped to a site, because a role grants each permission at a scope of `none`, `own` or `all` and has no per-site scope (see [Per-site permissions](#per-site-permissions)).
|
||||
- `lang` is optional. Absent, the version gets the starting language of the chosen site; present, it is normalized and must be an enabled language of the chosen site (`422 invalid_language`, `unsupported_language`, `language_not_enabled`; `409 language_unavailable` when disabled on that site; `409 setup_required` before that site's default language is confirmed). The same tag can therefore be accepted on one site and refused on another.
|
||||
- The slug is generated from the title and made free within the chosen site and language only.
|
||||
- `site` with `story_id`, `source_article_id` or `copy` answers `400` with the body mismatch message and stores nothing. `POST /v1/article/{id}/translations` has no `site` field: the translation keeps the site of its source and an unknown `site` key in its body is ignored.
|
||||
- The response is the created article object (`201`) with the same keys as before; `site_id` is the id of the chosen site. The article is offline, so it is invisible to anonymous reads and public lists, and `GET /v1/article/?site=<key>` lists it for readers.
|
||||
- `article.create` records `params.site` (the site key) on success; a refusal records one failed event with a plain reason (refusals by validation, `401` and `403` happen before the use case and record nothing).
|
||||
|
||||
## Deleting
|
||||
|
||||
`DELETE /v1/article/{id}` removes one version (`204`, empty body) and checks `content.delete` per version (`403 missing_permission`). Other versions and their story remain. Deleting the last version of a story removes the story. Nothing in the answer mentions a sibling. A deleted language can be created again in the same story; a language with a deleted version still counts as in use for removing the language from the site.
|
||||
|
||||
### Deleting the whole article: `DELETE /v1/story/{story_id}`
|
||||
|
||||
Verified session or API key with `content.delete` at scope `all` ("delete permission on every version"). Order of checks: path id (`400` with the uint32 message), `content.delete` held (`403 missing_permission`, `X-Missing-Permission: content.delete`), `content.delete` at scope `all` (`403 missing_permission`, same header; decided from the caller's role only, before any read), at least one readable version (`404 Article not found`, the same answer for an unknown story). Then every version of the story, readable or not, is deleted in one transaction under the locks of its sites, and the story row is removed: `204`, empty body.
|
||||
|
||||
- Atomic: a failure answers `500 Failed to delete article` and removes nothing; every version stays.
|
||||
- Each version is deleted like `DELETE /v1/article/{id}` does it (slug tombstone, soft delete, credits removed), so its slug is free again and its language still counts as in use for removing the language from the site.
|
||||
- Versions that tracked a deleted version read `source_unavailable: true` afterwards.
|
||||
- Activity: one `article.delete` per deleted version, oldest first, with `params.lang` and `params.story_id`. A failure records one failed `article.delete` on the oldest readable version; `403` and `404` record nothing.
|
||||
- A caller whose `content.delete` is at scope `own` deletes versions one by one; deleting the last one removes the story.
|
||||
|
||||
### Per-site permissions
|
||||
|
||||
Roles grant each permission at scope `none`, `own` or `all` and have no site dimension, so `content.create`, `content.delete` and the other content permissions apply to every site. A per-site permission (for example "may create on Journal only") is not supported; it needs a site scope in the role model first.
|
||||
|
||||
## Moving a story: `POST /v1/story/{story_id}/move`
|
||||
|
||||
```json
|
||||
{"site": "atelier"}
|
||||
```
|
||||
|
||||
Verified session or API key. The caller needs `content.update` on every version of the story, readable or not, and must read at least one (`404 Article not found` otherwise, also for an unknown story). `site` is the key of the destination (`400` when absent or empty, `404 site_not_found` when unknown). Answers `200`:
|
||||
|
||||
```json
|
||||
{"story_id": 5, "site": "atelier", "site_id": 2, "article_ids": [10, 12, 11, 13]}
|
||||
```
|
||||
|
||||
`article_ids` lists the readable versions only, oldest first. Every version moves in one transaction: `site_id` changes, slugs, languages, publication, content and authors are unchanged, `revision` of each moved version rises by 1 and its content revision does not. Moving to the current site answers `200` and changes nothing.
|
||||
|
||||
Everything is checked inside the transaction, under the locks of both sites, and nothing moves when something fails: every language of every version (readable or not) must be enabled at the destination (`422 language_not_enabled`, `X-Missing-Languages` limited to languages of readable versions, a language disabled at the destination counts as missing, a language disabled at the source does not matter), and no address may exist at the destination in the same language (`409 slug_conflict`, `X-Conflicting-Articles` limited to readable versions). A conflict that appears between the check and the write answers `409 slug_conflict` without the header.
|
||||
|
||||
## Editorial list by story: `GET /v1/article/?group=story`
|
||||
|
||||
An opt-in shape of the list for a signed-in, verified caller whose role resolves. Without `group` the response keeps the flat envelope `{ items, total, page, size, counts }` of the paginated list, whose `counts` are `all`, `online`, `draft` and `suggestions`. A caller without a valid session, or with an unverified user, who sends `group` gets 400 with the body-mismatch message and nothing is read.
|
||||
|
||||
| Parameter | Meaning |
|
||||
| --- | --- |
|
||||
| `group` | Only `story`. `group=`, any other value, or `group` from a public caller is 400. |
|
||||
| `lang` | A supported language tag, normalized like the language of a version (`fr`, `pt-br` becomes `pt-BR`). Empty, malformed or unsupported is 400. Also narrows the flat list (`items`, `total`, `counts.all`, `counts.online`, `counts.draft`). Disabled languages can be selected. |
|
||||
| `tab` | `all` (default) or `missing`. `review`, an empty value, any other value, and `missing` without `group=story` are 400. |
|
||||
| `author` | A byline id, an integer of at least 1; anything else is 400. Also narrows the flat list. |
|
||||
| `page`, `size`, `q`, `status`, `site`, `seo`, `sort` | Same values, defaults and validation as the flat list. |
|
||||
|
||||
Only versions the caller can read count: they are titled, credited, listed in `coverage`, searched and counted. A hidden version never appears, and a story whose versions are all hidden has no row.
|
||||
|
||||
**Rows.** With no `lang` a row is a story: `total` counts stories. With `lang` a row is the version of that language (`tab=all`), and the whole story's `coverage`, `story_authors` and `missing_langs` still ride on the row. With `tab=missing` rows are stories again: `lang=fr` lists the stories on a site that enables `fr` and that have a readable version with a language but none in `fr`; without `lang` it lists the stories that lack at least one enabled language, and a caller who cannot create (no `content.create`) gets `total` 0. A story whose readable versions all lack a language (`lang` `""`) is never listed as missing a language. A mixed story (a version without language plus at least one version with a language) is listed under Missing like any other: the version without language fills no language. A version of the story must satisfy `status`, `author`, `q` and `seo` together to make the story match; `q` ignores case and accents.
|
||||
|
||||
**Item.** The article list item of the representative version (the one in `lang`, otherwise the site default language version, otherwise the first readable one by language tag), plus:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `story_updated_at` | RFC 3339 | The latest `updated_at` among the readable versions of the story |
|
||||
| `story_authors` | array of bylines | The union of the bylines of the readable versions, without duplicates |
|
||||
| `coverage` | array | One entry per readable version, in the order of `translations` (default language, other enabled tags, disabled tags), with the same keys as a `translations` entry: `{ id, lang, title, is_online, language_enabled, excluded_from_public, source_article_id, capabilities }` |
|
||||
| `missing_langs` | array of tags | Enabled languages without a readable version, default first then by tag; `[]` when the caller cannot create or when no readable version has a language |
|
||||
| `excluded_from_public` | boolean | The flag of the representative version (see [Fields added to an article read](#fields-added-to-an-article-read)) |
|
||||
| `capabilities` | array | The capabilities of the representative version |
|
||||
|
||||
**Sort.** The whitelist is the flat one. `updated_at` uses the latest readable version of the story, `created_at` the earliest, `title` and `status` the representative; with `lang` the version itself. Ties end with `story_id` descending.
|
||||
|
||||
**Counts.** `{ all, online, draft, suggestions, missing }`. They follow `lang`, `q`, `author`, `seo` and `site` but never `status` or `tab`, and count the unit of the mode (stories, or versions of the selected language). `online` and `draft` count the units with a readable online or draft version. `missing` is the `total` the not-in view would give under the same filters (0 without `lang` for a caller who cannot create). `suggestions` is the number of stories with a readable version whose `seo_status` is `suggestions`, ignoring every parameter.
|
||||
|
||||
`seo_status`, `seo_suggestions`, the `seo` filter and `counts.suggestions` read the stored SEO status of each version (see [SEO status and suggestions](articles.md#seo-status-and-suggestions)); no request reads the bodies of the visible versions to compute them.
|
||||
|
||||
A repository failure at any step, counts included, answers 500 `Failed to retrieve articles`.
|
||||
|
||||
## Mock-ready examples
|
||||
|
||||
Statuses of the new and changed calls. Bodies are plain text unless shown as JSON; headers listed are the only extra ones.
|
||||
|
||||
| Call | Status | Headers | Body |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /v1/article/10` (see above) | 200 | | article object with `revision`, `capabilities`, `translations`, `source` |
|
||||
| `POST /v1/article/10/translations` `{"lang":"fr","title":"Couts","copy":true}` | 201 | | article object, `lang` `"fr"`, `"is_online": false`, `"revision": 1`, `"source": {"article_id": 10, "revision": 4}` |
|
||||
| `DELETE /v1/article/12` | 204 | | empty |
|
||||
| `DELETE /v1/story/5` by the owner | 204 | | empty |
|
||||
| `DELETE /v1/story/5` by a user holding `content.delete` at `own` | 403 | `X-Missing-Permission: content.delete` | `missing_permission` |
|
||||
| `DELETE /v1/story/5` when the database fails | 500 | | `Failed to delete article` |
|
||||
| `POST /v1/story/5/move` `{"site":"atelier"}` | 200 | | `{"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,12,11,13]}` |
|
||||
| `POST /v1/article/10/translations` `{"lang":"fr","title":"Couts"}` | 400 | | `The request cannot be processed due to a mismatch in the format of the body.` |
|
||||
| `PATCH /v1/article/10` `{"title":"ab"}` | 400 | | `Article title must be between 3 and 200 characters.` |
|
||||
| `POST /v1/article/10/translations` with a cookie of an anonymous client | 401 | | `You must be logged in to access this resource.` |
|
||||
| `POST /v1/story/5/move` by a user holding `content.update` at `own` | 403 | `X-Missing-Permission: content.update` | `missing_permission` |
|
||||
| `POST /v1/article/9999/translations` | 404 | | `Article not found` |
|
||||
| `POST /v1/article/10/translations` with `"source_article_id":9999` | 404 | | `source_unavailable` |
|
||||
| `POST /v1/story/5/move` `{"site":"nowhere"}` | 404 | | `site_not_found` |
|
||||
| `PATCH /v1/article/10` `{"body":"x","if_revision":3}` after somebody saved | 409 | `X-Current-Revision: 4` | `revision_conflict` |
|
||||
| `POST /v1/article/10/translations` `{"lang":"de","title":"Kosten","copy":true}` | 409 | `X-Existing-Article-Id: 11` | `language_exists` |
|
||||
| `POST /v1/article/10/translations` `{"lang":"fr","title":"Couts","copy":true}` with fr disabled, unreadable or raced | 409 | none | `language_unavailable` |
|
||||
| `POST /v1/article/30/translations` on an article without language | 409 | | `setup_required` |
|
||||
| `PATCH /v1/article/10` `{"slug":"taken-en"}` | 409 | `X-Slug-Suggestion: taken-en-2` | `slug_taken` |
|
||||
| `POST /v1/story/5/move` `{"site":"atelier"}` with an address taken at the destination | 409 | `X-Conflicting-Articles: 12` | `slug_conflict` |
|
||||
| `PATCH /v1/article/10` `{"site_id":2}` on a story with several readable versions | 409 | | `story_move_required` |
|
||||
| `POST /v1/article/10/translations` `{"lang":"pt","title":"Custos","copy":true}` with no `pt` row | 422 | | `language_not_enabled` |
|
||||
| `POST /v1/story/5/move` `{"site":"atelier"}` with no `ar` at the destination | 422 | `X-Missing-Languages: ar` | `language_not_enabled` |
|
||||
| `PATCH /v1/article/15/publish` with `es` disabled | 422 | | `language_disabled` |
|
||||
| `POST /v1/article/10/translations` `{"lang":"xx","title":"Couts","copy":true}` | 422 | | `unsupported_language` |
|
||||
| `POST /v1/article/10/translations` `{"lang":"english","title":"Couts","copy":true}` | 422 | | `invalid_language` |
|
||||
| `POST /v1/article/10/translations` with `"source_article_id":20` of another story | 422 | | `source_other_story` |
|
||||
|
||||
## Activity
|
||||
|
||||
No new action code. The existing events carry `params.lang` (the language of the version, omitted for a version without language): `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete`. A translation creation records `article.create` on the new version with `params.lang`, `params.story_id` and `params.source_article_id` (only when copied). A story move records one `article.edit` per moved version, with a `site` change (site names before and after) and `params.lang`; moving to the current site records nothing. A refused creation or move records one failed event with a plain reason that names no version; refusals answered `404` are not recorded.
|
||||
|
||||
## Public language routes
|
||||
|
||||
Contract for `features/localization_public_api_and_surfaces.feature`. Both routes are open to everyone: a `jwt` cookie or an API key is ignored, so a draft is never returned, whoever asks. They sit under `/v1/public` and are read only.
|
||||
|
||||
They are rate limited per client address together with `GET /v1/site/{key}/sitemap`: 120 requests per minute in total, the same budget an API key gets on the authenticated routes. The client address is the connection address, or `X-Real-IP` when the connection comes from one of `TRUSTED_PROXIES`. Over the budget the answer is `429` with `Retry-After` (seconds) and the body `Too many requests. Please try again later.`
|
||||
|
||||
A version is **published** when `is_online` is true, and **eligible** when it is published and its language is enabled on the site. A disabled language stays readable on both routes; only the alternates leave it out.
|
||||
|
||||
### Exact lookup: `GET /v1/public/site/{key}/lang/{lang}/article/{slug}`
|
||||
|
||||
Matches the site key, the language and the slug, and nothing else. It never falls back to another language, never follows an old slug and creates no redirect.
|
||||
|
||||
- `lang` is normalized (`FR` reads `fr`, `pt-br` reads `pt-BR`). A value that is not a language tag (`e`, `pt_BR`) is `400 invalid_language`; a valid tag with no version, or outside the supported list, is `404 article_not_found`.
|
||||
- `format` behaves as on `GET /v1/article/{id}`: `markdown` (default), `html` or `json`; anything else is `400` with `The format must be one of: markdown, html, json.`
|
||||
- An unassigned article (`lang` `""`), a draft, a soft-deleted version and a version of another site or language are all `404 article_not_found`, with the same body.
|
||||
- Answers `200` with the body of an anonymous `GET /v1/article/{id}` (same keys and values) and one more key, `alternates`.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 12,
|
||||
"lang": "fr",
|
||||
"story_id": 5,
|
||||
"slug": "comment-nous-chiffrons-une-renovation",
|
||||
"is_online": true,
|
||||
"alternates": [
|
||||
{"id": 10, "lang": "en", "story_id": 5, "slug": "how-we-price-a-bathroom-renovation", "url": "https://journal.example.com/en/how-we-price-a-bathroom-renovation"},
|
||||
{"id": 11, "lang": "de", "story_id": 5, "slug": "so-kalkulieren-wir-eine-badsanierung", "url": "https://journal.example.com/de/so-kalkulieren-wir-eine-badsanierung"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`alternates` lists the other eligible versions of the story: the served version, drafts and versions in a disabled language are left out, and nothing in the response names them. The order is the default language of the site first, then the other tags in ascending order. Each alternate has exactly `id`, `lang`, `story_id`, `slug` and `url`. `url` is the site Public URL pattern filled with the tag and the slug, or `null` when the site has no pattern. A story with one version answers `[]`.
|
||||
|
||||
### Story resolution: `GET /v1/public/site/{key}/story/{story_id}`
|
||||
|
||||
Resolves one story of the site, optionally with an explicit fallback to the default language. No other language is ever tried.
|
||||
|
||||
| Query | Rule |
|
||||
| --- | --- |
|
||||
| `lang` | optional. Absent, the site default language is requested. A malformed tag is `400 invalid_language` |
|
||||
| `fallback` | optional, only the value `default`. When the requested language has no published version, serve the published default-language version instead. Any other value, an empty one included, is `400` with the body-mismatch message |
|
||||
| `format` | as on the exact lookup |
|
||||
|
||||
The `200` body is the exact lookup body plus `requested_lang` (the language that was asked for, or the default when `lang` is absent) and `fallback` (`true` only when the default-language version replaced the requested one). `lang` is the language of the version served.
|
||||
|
||||
| Request | Answer |
|
||||
| --- | --- |
|
||||
| `?lang=fr`, a published `fr` version exists | the `fr` version, `fallback` false |
|
||||
| `?lang=fr`, none, no `fallback` | `404 language_not_available` |
|
||||
| `?lang=fr&fallback=default`, `fr` missing or unpublished, `en` published | the `en` version, `requested_lang` `fr`, `fallback` true |
|
||||
| `?lang=fr&fallback=default`, neither published | `404 language_not_available` (a third language is never returned) |
|
||||
| `?lang=en&fallback=default`, `en` unpublished | `404 language_not_available` |
|
||||
| no `lang` | the default-language version, `requested_lang` the default |
|
||||
| no `lang`, the site has no confirmed default language | `404 language_not_available` |
|
||||
| a story of another site, an unknown story, a story with no published version | `404 language_not_available` |
|
||||
|
||||
Changing the default language of a site changes only the requests that omit `lang`.
|
||||
|
||||
### Errors
|
||||
|
||||
Plain text, the body is the code followed by a line feed. `language_disabled` is not returned by these routes.
|
||||
|
||||
| Status | Body | When |
|
||||
| --- | --- | --- |
|
||||
| 400 | `invalid_language` | `lang` is not a language tag |
|
||||
| 400 | `The request cannot be processed due to a mismatch in the format of the body.` | `fallback` is not `default` |
|
||||
| 400 | `The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).` | `story_id` is not an unsigned integer |
|
||||
| 400 | `The format must be one of: markdown, html, json.` | invalid `format` |
|
||||
| 404 | `article_not_found` | exact lookup: no published version for the site, language and slug |
|
||||
| 404 | `language_not_available` | story resolution: no version can be served under the rules above |
|
||||
| 404 | `site_not_found` | unknown site key |
|
||||
| 429 | `Too many requests. Please try again later.` | more than 120 requests in a minute from one client address on the public routes and the sitemap together (`Retry-After` in seconds) |
|
||||
| 500 | `Internal server error` | a repository failure |
|
||||
|
||||
## Resolved SEO and sitemap, language aware
|
||||
|
||||
`GET /v1/article/{id}/seo` keeps its keys and adds `lang`, `alternates` (same shape and order as the public alternates), `og.locale_alternates` and `fallbacks.site_text_skipped`. Every other key keeps its name and type. An unassigned article (`lang` `""`) answers `lang` `""`, `alternates` `[]` and `og.locale_alternates` `[]`, with the legacy canonical and the raw site locale.
|
||||
|
||||
**Canonical.** `canonical_url` and `og.url` are, in order: the version `canonical_url` override (`fallbacks.canonical` `canonical_url`); the site `public_url_pattern` with `{lang}` and `{slug}` filled (`pattern`); the legacy `base_url/slug` (`site`), when the site has no pattern or the article has no language. A region tag is kept as written (`/pt-BR/reforma`). `json_ld.mainEntityOfPage.@id` equals `canonical_url`. Saving a pattern changes no override and no article.
|
||||
|
||||
**og:locale**, first match wins, never invented:
|
||||
|
||||
1. the explicit `social_locale` of the language row;
|
||||
2. the tag already has a region (`pt-BR` gives `pt_BR`);
|
||||
3. `Site.Locale` has the same language part and a region (`en-GB` with `en` gives `en_GB`);
|
||||
4. otherwise `""`, and `og.locale_alternates` is `[]`.
|
||||
|
||||
`og.locale_alternates` lists the social locales of the other eligible versions that resolve one, in the order of the alternates. `GET /v1/site/{key}/languages` reports rule 3 as `social_locale_state` `derived` (`not_set` when `Site.Locale` is bare such as `en`).
|
||||
|
||||
**Fallbacks in the language of the version.** The title and description chains are unchanged except for the site text: `default_description` is treated as written in the current default language of the site, so it is used only by an unassigned article or a version in that language. For any other language the description stays empty, `fallbacks.description` is `none` and `fallbacks.site_text_skipped` is `true`. Changing the default language changes which versions may use the site text. `title_template`, `site_name` and the image chain stay shared; `cover_image_alt` stays per version.
|
||||
|
||||
**Sitemap.** `GET /v1/site/{key}/sitemap` lists every eligible version once (published, language enabled; unassigned published articles keep their legacy entry). Each item is `{url, modified_at, noindex, lang}`; `url` is the localized address when the site has a pattern, otherwise `base_url/slug`. `X-Total-Count` counts the listed items. The route shares the rate limit of the public language routes.
|
||||
|
||||
**SEO check and status.** `POST /v1/article/{id}/seo/check`, the stored `seo_status` of the lists and the `seo` filter apply the same language rule as the resolved description: the site `default_description` can fill the description (and so avoid `desc_short` or raise `desc_long`) only for a version in the current default language of the site or without language.
|
||||
|
||||
## Media usage
|
||||
|
||||
`GET /v1/media/{id}/usage` article items gain `story_id` and `lang` (`""` for an unassigned article, whose `story_id` is the stored value). There is one article item per version, ordered by id; author, site and entry items carry neither key. `used_in` on `GET /v1/media/` counts a story once, so an image used by three versions of one story is one use. An unassigned article is its own group.
|
||||
+19
-8
@@ -1,8 +1,10 @@
|
||||
# Reading articles
|
||||
|
||||
Versions, translations, `revision`, `if_revision`, capabilities and the story move: [article-localization.md](article-localization.md).
|
||||
|
||||
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.
|
||||
`PATCH /v1/article/{id}` takes `title`, `body`, `site_id`, `author_ids` (the whole ordered list of author ids, see `docs/api.md`), the SEO fields listed below and the optional `if_revision`, and nothing else. The request must contain at least one of them (`if_revision` alone is not an update), 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`
|
||||
|
||||
@@ -42,7 +44,7 @@ Anonymous and unverified callers only see online articles, and an offline articl
|
||||
- `items` is `[]` when empty, never `null`. Each item is the article object below.
|
||||
- Items follow `sort` (default `updated_at:desc`) across the whole list before paging. `title` ignores case, `status` orders on online state (`status:asc` puts drafts first), and every ordering ends with `id` descending so pages never overlap.
|
||||
- `total` is the number of visible articles matching `q` and `status`, before paging. `page` and `size` echo the values applied.
|
||||
- `counts` ignores `q`, `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`.
|
||||
- `counts` ignores `q`, `status`, `site`, `seo`, `sort`, `page` and `size` (and `suggestions` also ignores `lang` and `author`), so tab counts do not move while searching. `suggestions` is the number of visible articles whose `seo_status` is `suggestions` (hidden and ready articles and dismissed codes are not counted); it equals the `total` of `?seo=suggestions` sent with no other filter by the same caller. For anonymous and unverified callers `all` equals `online` and `draft` is `0`, and `status=draft` answers `items: []` and `total: 0`.
|
||||
- Each item also carries `seo_status` and `seo_suggestions` (see [SEO status and suggestions](#seo-status-and-suggestions)). `seo` filters on the status before paging, so `total` follows it while `counts` do not.
|
||||
- A page beyond the last answers `200` with `items: []` and the real `total`.
|
||||
- An invalid `page`, `size`, `status`, `sort` or an over-long `q` answers `400` with the plain-text message `The request cannot be processed due to a mismatch in the format of the body.` An invalid `format` is reported first.
|
||||
@@ -87,10 +89,14 @@ Breaking change: the default used to be HTML. Clients that need HTML must send `
|
||||
"noindex": false,
|
||||
"seo_dismissed": [],
|
||||
"created_at": "2026-03-12T09:14:00Z",
|
||||
"updated_at": "2026-09-10T07:00:00Z"
|
||||
"updated_at": "2026-09-10T07:00:00Z",
|
||||
"lang": "fr-CA",
|
||||
"story_id": 5
|
||||
}
|
||||
```
|
||||
|
||||
`lang` is the full BCP 47 language tag of the article (`""` while the site default language is not confirmed) and `story_id` is the story the article belongs to. Both are present on every article object (list items, reads, create and update responses, public reads) as additive keys, and every other key is unchanged. They are read-only on `PATCH`, where `lang` and `story_id` are ignored. `POST /v1/article/` takes `title`, `body`, and optionally `lang`, `site` (a site key; the default site when absent or `null`), `story_id`, `source_article_id` and `copy` only as described in [article-localization.md](article-localization.md), which also documents `revision`, `capabilities`, `translations` and `source`, the additive keys of every article read. Supported tags: `en`, `en-GB`, `en-US`, `fr`, `fr-CA`, `pt`, `pt-BR`, `es`, `de`, `ar`. Existing articles each get their own story and `lang` `""` until the site default is confirmed.
|
||||
|
||||
`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.
|
||||
@@ -116,7 +122,7 @@ Limits are counted in characters (runes), not bytes. Absent fields are untouched
|
||||
|
||||
### 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.
|
||||
A slug is unique per site and language (`site_id`, `lang`, `slug`), never globally; unassigned articles (`lang` `""`) share the empty language, so they keep the per-site rule. 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.
|
||||
|
||||
@@ -126,20 +132,20 @@ Publishing, unpublishing and `noindex` never change a slug. A deleted article fr
|
||||
|
||||
`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`).
|
||||
`title`, `description`, `canonical_url`, `robots`, `og` (`type`, `title`, `description`, `image`, `image_alt`, `url`, `site_name`, `locale`, `locale_alternates`), `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`, `site_text_skipped`), `json_ld` (a ready schema.org `Article`), `lang` and `alternates`. The language rules (canonical from the Public URL pattern, `og:locale` order, site text only in the default language) are in [article-localization.md](article-localization.md#resolved-seo-and-sitemap-language-aware).
|
||||
|
||||
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` |
|
||||
| `description` | `seo_description`, `excerpt`, the first sentence of the body (tags removed), the site `default_description` (only for a version in the default language or without language) | `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` |
|
||||
| `canonical_url` | `canonical_url`, then the site `public_url_pattern` filled with `lang` and `slug`, then site `base_url` + `/` + `slug` (no pattern, or no language) | `canonical_url`, `pattern`, `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.
|
||||
`og.type` is `article`, `og.locale` is the explicit social locale of the language, else the region of the tag, else the region of the site locale with the same language, else `""`; an article without language keeps the raw site locale, `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
|
||||
|
||||
@@ -161,6 +167,10 @@ The fallback chain, first non-empty wins:
|
||||
|
||||
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).
|
||||
|
||||
The resolved description uses the site `default_description` only for a version in the current default language of its site or without language, so `desc_short` and `desc_long` follow the language of the version, in the check and in the lists alike.
|
||||
|
||||
**Stored status.** Each version stores its `seo_status` and `seo_suggestions`. Every write that can change them clears the stored value in the same statement: an edit of the version (title, body, slug, SEO fields, authors, site), a cover or social image deleted, restored or merged, a credited author deleted, a move to another site (story move or site deletion with reassignment), a language assigned by the setup, a change of the site `default_description`, and a change of the site default language. The lists recompute the cleared versions (and only those) before answering, and the server recomputes them at startup, so `seo_status`, `?seo=` and `counts.suggestions` always reflect the current data without reading every visible body per request. A value computed from data that changed in the meantime is never stored.
|
||||
|
||||
## 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.
|
||||
@@ -214,6 +224,7 @@ Example for `<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>`:
|
||||
| --- | --- |
|
||||
| `400` | `The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).` for an invalid id |
|
||||
| `400` | `The format must be one of: markdown, html, json.` for an unsupported format |
|
||||
| `400` | `invalid_site_key` for a `site` of `POST /v1/article/` that is not a valid site key |
|
||||
| `400` | `invalid_slug`, `image_not_found` or the validator text for an over-long SEO field, `cover_image_id: 0` or an unknown `seo_dismissed` code |
|
||||
| `404` | `Article not found` |
|
||||
| `409` | `slug_taken`, with the header `X-Slug-Suggestion: <first free slug>` (this response only) |
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# Site languages, language setup and public URL pattern
|
||||
|
||||
Contract for `features/site_languages.feature`, `features/language_setup.feature` and `features/site_public_url_pattern.feature`. The SPA mocks `/v1` from this page.
|
||||
|
||||
Errors are plain text whose body is exactly the code, with the status below. A body that cannot be decoded, or that lacks a required key, is 400 with the body-mismatch message ("The request cannot be processed due to a mismatch in the format of the body."). An anonymous caller is 401, a verified user without `settings.manage_sites` is 403 `missing_permission`. Writes need a real session; API keys are refused.
|
||||
|
||||
An owner is a verified user holding `settings.manage_sites`. A reader is any other verified user.
|
||||
|
||||
## Visibility exception (accepted)
|
||||
|
||||
`version_count` and `published_count` in `GET /v1/site/{key}/languages` are site-level aggregates of every non-deleted article (soft-deleted articles are not shown but still keep their language in use) of the language, drafts and offline ones included. They are visible to every signed-in reader, whatever that reader may open. This is a deliberate exception to the per-version visibility policy of `docs/design/localization-tables.md` ("Hidden versions"), accepted for slice 2. Later slices that expose titles or links of single versions must still filter by what the caller can read.
|
||||
|
||||
## Languages of a site
|
||||
|
||||
The supported tags are the list of `internal/domain/language` (`en`, `en-GB`, `en-US`, `fr`, `fr-CA`, `pt`, `pt-BR`, `es`, `de`, `ar`). Tags are normalized: `pt-br` is stored as `pt-BR`.
|
||||
|
||||
### `GET /v1/site/{key}/languages`
|
||||
|
||||
Any verified user (session or API key). 404 `site_not_found`.
|
||||
|
||||
```json
|
||||
{
|
||||
"revision": "9f2c...",
|
||||
"default": "en",
|
||||
"needs_setup": false,
|
||||
"languages": [
|
||||
{"lang": "en", "enabled": true, "is_default": true, "version_count": 7, "published_count": 6, "social_locale": null, "social_locale_state": "not_set"},
|
||||
{"lang": "fr", "enabled": true, "is_default": false, "version_count": 4, "published_count": 2, "social_locale": "fr_FR", "social_locale_state": "set"}
|
||||
],
|
||||
"available": [
|
||||
{"lang": "pt", "substitute": null},
|
||||
{"lang": "pt-PT", "substitute": "pt"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `revision` is an opaque digest of the rows (lang, default, enabled, social_locale). Counts do not change it. Send it back on `PUT`.
|
||||
- `languages` lists the default first, then the other enabled languages by tag, then the disabled ones by tag.
|
||||
- `social_locale_state` is `set` (an explicit `social_locale`), `derived` (no explicit value and the tag has a region: the effective value is the tag with an underscore, `en-GB` gives `en_GB`) or `not_set`. `social_locale` is only the explicit value, so the client computes the derived one from the tag.
|
||||
- `available` lists the tags an owner can pick. A tag with a non-null `substitute` is not supported itself; only the broader tag is. The server refuses it with `unsupported_language`, so the client sends the substitute once the user confirms.
|
||||
- `needs_setup` is true when the site has no confirmed default language. Then `default` is `null`, `languages` is `[]` and `PUT` answers 409 `setup_required`. Sites created after the migration have no language row and need setup too.
|
||||
|
||||
### `PUT /v1/site/{key}/languages`
|
||||
|
||||
Owner only. The whole set is replaced in one transaction under the site lock.
|
||||
|
||||
```json
|
||||
{
|
||||
"revision": "9f2c...",
|
||||
"default": "en",
|
||||
"languages": [
|
||||
{"lang": "en", "enabled": true, "social_locale": null},
|
||||
{"lang": "fr", "enabled": true, "social_locale": "fr_CA"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`revision`, `default`, `languages` and each `enabled` are required (400 when absent). `social_locale` is optional and `null` means none. A language left out of the list is removed. The answer is 200 with the new read shape. Sending the state that is already stored changes nothing and keeps the revision.
|
||||
|
||||
Checks run in this order:
|
||||
|
||||
| Order | Status | Body | When |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 404 | `site_not_found` | unknown key |
|
||||
| 2 | 409 | `setup_required` | the site has no confirmed default (any body) |
|
||||
| 3 | 409 | `languages_changed` | `revision` is not the current one; nothing is applied |
|
||||
| 4 | 422 | `invalid_language` | a tag is not a language tag (`english`, empty) |
|
||||
| 4 | 422 | `unsupported_language` | a well formed tag that is not supported (`xx`, `pt-PT`) |
|
||||
| 4 | 422 | `duplicate_language` | the same tag twice, case ignored |
|
||||
| 4 | 422 | `invalid_social_locale` | not `ll_RR` or `lll_RR` (letters, one underscore), empty string, or the language part differs from the tag's language |
|
||||
| 5 | 422 | `no_enabled_language` | no language is enabled, `languages: []` included |
|
||||
| 6 | 422 | `default_not_enabled` | `default` is not in the list, not enabled, or not a supported tag |
|
||||
| 7 | 409 | `language_in_use` | a removed language still has an article, draft, offline, online or soft-deleted (the display count `version_count` stays non-deleted only) |
|
||||
| any | 409 | `languages_changed` | the database could not lock the site in time because another change is running (lock contention); nothing is applied, reload and retry |
|
||||
|
||||
Entries are checked in list order for step 4. A rejected request changes nothing. Disabling a language never touches its articles, and no article changes `lang`, status or publication. A site always has exactly one default language: the rows are planned with one default and checked again before they are written. Changing the default language re-evaluates the stored SEO status of every article of the site, because the site `default_description` only counts for the default language (see [SEO status and suggestions](articles.md#seo-status-and-suggestions)). A `social_locale` is stored as `language_REGION` (`fr_fr` becomes `fr_FR`). The language part rule only compares with the tag; matching `Site.Locale` is deferred to slice 6.
|
||||
|
||||
## First-time language setup
|
||||
|
||||
One global setup for every site with no confirmed default language. Owner only (`GET` as well: a reader is 403).
|
||||
|
||||
### `GET /v1/language/setup`
|
||||
|
||||
```json
|
||||
{
|
||||
"sites": [
|
||||
{"key": "journal", "name": "Journal", "locale": "en", "article_count": 8, "suggestion": {"lang": "en", "substituted": false}},
|
||||
{"key": "notes", "name": "Notes", "locale": null, "article_count": 3, "suggestion": null}
|
||||
],
|
||||
"available": [{"lang": "en", "substitute": null}]
|
||||
}
|
||||
```
|
||||
|
||||
`sites` lists the pending sites by id. `suggestion` comes from `Site.Locale` and keeps the region (`pt-BR` stays `pt-BR`); `substituted` is true when only the broader tag is supported (`fr-BE` gives `fr`). `suggestion` is `null` without a locale or without a supported language. An empty `sites` list means nothing is pending.
|
||||
|
||||
### `POST /v1/language/setup`
|
||||
|
||||
```json
|
||||
{"sites": [{"key": "journal", "lang": "en"}, {"key": "notes", "lang": "en"}]}
|
||||
```
|
||||
|
||||
200 with `{"assigned": 16, "sites": [{"key": "journal", "lang": "en", "assigned": 8}]}`. `assigned` counts the articles that got a language (soft-deleted ones included). `sites` lists the sites confirmed by this call. The default language is created enabled and confirmed, the seeded unconfirmed row is replaced, and every article without a language takes the tag. Nothing is translated, linked or moved. Sites that already have a confirmed default are skipped, whatever tag is asked for, so a retry answers 200 with `assigned: 0`.
|
||||
|
||||
| Status | Body | When |
|
||||
| --- | --- | --- |
|
||||
| 400 | body-mismatch message | no `sites` key or malformed JSON |
|
||||
| 404 | `site_not_found` | a key does not exist |
|
||||
| 409 | `setup_in_progress` | another setup holds the site locks (nothing applied) |
|
||||
| 422 | `invalid_language` / `unsupported_language` | a bad `lang`, empty included |
|
||||
| 422 | `duplicate_site` | the same key twice |
|
||||
| 422 | `setup_incomplete` | a pending site is missing from the request |
|
||||
| 500 | `Internal server error` | a write failed; everything is rolled back |
|
||||
|
||||
All sites are applied in one transaction. Site rows are locked in ascending id order without waiting; a busy lock is `setup_in_progress`. The pending list is read again after the locks and any site that became pending in between is locked too, until the list is stable, so a site created while a setup starts is never left half handled.
|
||||
|
||||
The 200 body also has `skipped`: the keys, sorted, of requested sites that already had a confirmed default and were left untouched (always present, `[]` when none). Their requested `lang` is ignored, never applied.
|
||||
|
||||
```json
|
||||
{"assigned": 3, "sites": [{"key": "notes", "lang": "fr", "assigned": 3}], "skipped": ["journal"]}
|
||||
```
|
||||
|
||||
## Lock contention
|
||||
|
||||
Every locking path of `PUT /v1/site/{key}/languages` and `POST /v1/language/setup` runs inside the transactor. A lock that cannot be taken in time (SQLite `SQLITE_BUSY`/`SQLITE_LOCKED`, MySQL 3572 and 1205, PostgreSQL `55P03`) is recognised once, by the database driver, and reaches the use case as `transaction.ErrLockContention`. The PUT answers 409 `languages_changed` and the setup answers 409 `setup_in_progress`. Nothing is applied and a retry is safe. It is never a 500.
|
||||
|
||||
## SQLite does not lock rows
|
||||
|
||||
The pinned SQLite driver (`github.com/glebarez/sqlite`) drops the `FOR UPDATE` clause: the `SELECT ... FOR UPDATE [NOWAIT]` that locks a site row on MySQL and PostgreSQL is a plain read on SQLite. SQLite instead serializes writers on the whole database: a second writer waits up to the 5 s busy timeout and a transaction that read before another one committed fails with `SQLITE_BUSY` when it tries to write. Both outcomes keep the data consistent and the busy failure is answered as lock contention above. `test/localization_concurrency_test.go` pins the statuses (200 or 409, never 500) for a PUT racing a site creation and for two simultaneous setups.
|
||||
|
||||
## Activity log
|
||||
|
||||
Both writes are recorded in the `sites` category, once after the commit and never for a read or a no-op:
|
||||
|
||||
| Action | Target | Changes and params |
|
||||
| --- | --- | --- |
|
||||
| `site.languages_edit` | the site | `default_language` (before, after) when the default changed, `languages` (before, after: tags sorted, `(off)` for disabled, `[social_locale]`). A PUT that changes nothing records nothing. |
|
||||
| `site.language_setup` | one event per site confirmed by the POST | `default_language` ("" to the tag), param `assigned` (articles that got the language) |
|
||||
|
||||
A refused call is recorded as a failed event with a plain reason (for example "The languages changed since they were read."). An unknown site key is not recorded.
|
||||
|
||||
## Public URL pattern
|
||||
|
||||
`public_url_pattern` is a string or `null` on every site JSON (`GET /v1/site/`, `POST`, `PATCH`). It is read with the existing list and written with the existing `PATCH /v1/site/{id}` (owner). No new route. A `PATCH` without the key leaves it unchanged; `null` or `""` unsets it; surrounding spaces are trimmed.
|
||||
|
||||
Validation, in this order, answers 422 with the code as plain text: `invalid_pattern` (more than 512 characters), `invalid_scheme` (not `https://` or `http://`), `invalid_host` (no host, for example `https:///{slug}`), `unknown_placeholder` (anything between braces other than `{lang}` and `{slug}`, or a stray brace), `not_encoded` (a space, a non-ASCII character or other character that needs URL encoding; `%XX` sequences are fine), `missing_slug` (no `{slug}`). Repeated placeholders are allowed. Saving the pattern changes no article, canonical URL or public response, and is recorded in the activity log as the `public_url_pattern` field.
|
||||
@@ -94,9 +94,11 @@ The use cases `ListAuthors`, `CreateAuthor`, `CreateMyAuthor`, `UpdateAuthor`, `
|
||||
|
||||
`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.
|
||||
Slugs are unique per site and language, enforced by the unique index `idx_articles_site_slug` on `(site_id, lang, slug)` (the name is kept from the earlier `(site_id, slug)` index, which the migration replaces once, tracked by the `article_slug_index_per_language` marker). Each article belongs to a story (`stories` table) and a second unique index `idx_articles_story_lang` on `(story_id, lang)` allows one version per language per story; both indexes are created after the `article_stories` backfill marker, which gives every pre-existing article its own story. `site_languages` holds one default language per site, seeded once from `Site.Locale` as an unconfirmed suggestion (`site_language_suggestions` marker). `CreateArticle` owns the rule that a new article takes the confirmed default: it runs through `transaction.Transactor`, locks the site row (`site.Repository.LockSite`), reads the site's language rows through the `localization.Repository` port, lets `localization.ConfirmedDefaultTag` pick the confirmed default tag (or `""` while it is unconfirmed), picks the free slug for that language and calls `Create` with the resulting `Lang`, all in one transaction. `localization.Repository` is the only port for site languages; the former `language.Repository` and its `SiteLanguageRepository` adapter are gone. When the site row is gone by then (deleted after the use case looked it up), the use case answers `article.ErrTargetSiteDeleted` (`409 target_site_deleted`) instead of the lookup's `404`. `ArticleRepository.Create` only stores what it receives, checks that a given story row exists and opens the new story in the same transaction (the story rolls back with the article, `content_revision` starts at 1). `ApplyLanguageSetupUseCase` confirms a default and assigns the unassigned articles under the same site locks (taken with `NOWAIT`), so no article is left unassigned once the confirmation commits. The one-default-per-site invariant lives in the domain (`localization.Plan` builds rows with one default, `localization.RequireSingleDefault` checks it before `UpdateSiteLanguagesUseCase` writes); `LocalizationRepository.Replace` only writes the rows it receives. `article.Repository.SlugTaken` takes the language, matching the index. 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 and leaves uniqueness to the two indexes: each driver says whether an error is a unique violation (`Driver.IsUniqueViolation`: SQLite extended codes `2067` and `1555`, MySQL `1062` and `1586`, PostgreSQL `23505`, behind `database.IsUniqueViolation`), and the repository then re-reads which rule was hit and returns `ErrSlugTaken` or `ErrLanguageTaken`. `PATCH /v1/article/{id}` is one atomic edit: when it moves a story (the caller reads one version of a larger story), `UpdateArticleUseCase` depends on the `StoryMover` port and runs the move of the other versions and its own field edit in the single transaction of `MoveStoryUseCase.MoveStoryThen`, so a refusal after the move step moves nobody and changes no revision. Validation that needs no lock (authors, SEO fields, slug rules and the slug of the target site) runs before the transaction. Row locks are taken site first (ascending ids), then article rows (`ArticleRepository.Update` finds the site with a read outside its transaction, because a read inside would open the snapshot that MariaDB 11 then holds against the locked row, and re-checks the site after locking the article), in `ArticleRepository.Update`, `Create`, `Delete`, `CreateTranslation` and the story move, so a slug edit cannot deadlock a move; the move re-reads and re-locks until the set of sites of the story is stable. Publish and `UpdateBody` are targeted `UPDATE`s of their own columns (plus `updated_at`), so they never overwrite a concurrent edit and never touch the revisions. Driver lock errors (PostgreSQL `55P03`, `40P01` and `40001`, MySQL `3572`, `1205` and `1213`, MariaDB `1020` raised by its snapshot isolation) all become `transaction.ErrLockContention` (`409 story_busy`). SQLite ignores `FOR UPDATE`: there the single writer plus the compare-and-set on `edit_revision` in `writeArticleColumns` are what keep concurrent edits correct, not the row locks. 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. `DeleteStoryUseCase` (`DELETE /v1/story/{story_id}`) refuses a caller whose `content.delete` is not at scope `all` before reading anything, locks every site of the story (re-reading until the set is stabLine truncated
|
||||
|
||||
`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 `|`.
|
||||
The article persistence is split into ports by use: `article.Repository` (single versions and writes), `article.StoryReader` (the editorial list by story: pages, counts, versions of a page) and `article.SEOIndex` (the stored SEO status: `StaleSEO`, `StoreSEO`, `MarkSiteSEOStale`). `article.ListingRepository` joins the three for `ListArticlesUseCase`, the only use case that needs all of them; `ArticleRepository` implements every port, and `transaction.Repositories` exposes `ArticleSEO` so a transaction that changes the default language can clear the statuses of its site. The rules that compute the status stay in the use cases (they parse HTML through `golang.org/x/net/html`), so the repositories only clear and store it; see [performance.md](performance.md#the-seo-article-filter).
|
||||
|
||||
`database.Migrate` first sets `source_revision` to `0` where an older nullable column holds `NULL` (`BackfillNullSourceRevisions`, a no-op once no `NULL` is left), so `AutoMigrate` can make it `NOT NULL`; it then adds the columns with `AutoMigrate`, then runs `BackfillSiteTitleTemplates` and `BackfillArticleSlugs` (only rows with an empty slug or a duplicate within the same site and language, in id order, per site and language, 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`.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ A content type is a named schema (a key, a name and 1 to 50 fields) stored as on
|
||||
|
||||
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
|
||||
## Known limitations
|
||||
|
||||
- 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).
|
||||
|
||||
+3
-3
@@ -45,7 +45,7 @@ Once the restart helper is installed (it writes `update-helper.json` next to `DB
|
||||
|
||||
The owner's Restart button installs a downloaded update, or just restarts the site, without touching the server by hand. The app itself stays unprivileged: the systemd unit below keeps `ProtectSystem=strict` and only the data directory (the directory that holds `DB_FILE`) is writable. The only privileged code is a small helper, the same binary started as `renewcms apply-update` by a systemd service that runs as root.
|
||||
|
||||
First install v2.6.0-beta or newer manually, back up the database, uploads and current binary, and confirm the app runs as an unprivileged service user. The binary and its parent directory should be controlled by root. Owners can start updates and restarts; admins have read-only update status.
|
||||
First install the helper manually, back up the database, uploads and current binary, and confirm the app runs as an unprivileged service user. The binary and its parent directory should be controlled by root. Owners can start updates and restarts; admins have read-only update status.
|
||||
|
||||
#### Find the actual database directory
|
||||
|
||||
@@ -121,7 +121,7 @@ On the latest release you can test only a plain restart. Test installation, forc
|
||||
- **Restart is missing:** reload Settings, confirm you are owner, and inspect `GET /v1/system/update` in the browser's Network panel. For an idle owner, `can_apply: false` means the app did not detect the helper. Check that `update-helper.json` is beside the actual database and readable by the service user.
|
||||
- **Wrong helper directory:** disable the helper with the old `--data-dir`, then enable it with the correct directory and port. Do not change `DB_FILE` to compensate.
|
||||
- **Overlay does not clear:** inspect the helper journal, `update-result.json` and `update-running.json` in the actual database directory. Check localhost health on the configured port, then public health through the proxy. Do not cache `/healthz` at the proxy.
|
||||
- **Old binary panics on `--version`:** older releases may interpret it as normal startup and require server environment variables. v2.6.0-beta and newer print their version without loading configuration. A separately launched process failing this way does not stop the systemd service.
|
||||
- **Old binary panics on `--version`:** releases before v2.6.0-beta treat it as normal startup and require server environment variables. A separately launched process failing this way does not stop the systemd service.
|
||||
- **Download or verification fails:** inspect the app journal. All assets and redirects must use HTTPS on the configured feed host. The release must include the six binaries, `checksums.txt` and `checksums.txt.sig`.
|
||||
- **Update rolls back:** check the previous version and helper journal. Automatic rollback restores the binary, not the database; schema changes still require a recovery plan.
|
||||
|
||||
@@ -159,7 +159,7 @@ What is supported and what is not:
|
||||
|
||||
#### Verification status
|
||||
|
||||
A real v2.6.0-beta plain browser restart was confirmed on Linux/systemd on 2026-10-02: the path unit triggered the helper, a new server process started, the result was `succeeded`, and public health returned `ok`. Real signed installation, forced rollback and adversarial filesystem checks remain unverified. The manual cases are listed in [the restart QA procedure](../features/updates_restart_helper.qa.md).
|
||||
Only the plain browser restart has been confirmed on a real Linux/systemd machine. Signed installation, forced rollback and adversarial filesystem checks remain unverified; the manual cases are in [the restart QA procedure](../features/updates_restart_helper.qa.md).
|
||||
|
||||
## 2. Lay out the server
|
||||
|
||||
|
||||
+30
-133
@@ -1,143 +1,40 @@
|
||||
# RenewCMS design pass 2
|
||||
# RenewCMS design
|
||||
|
||||
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 (the brand mark excepted). Light and dark, at 1440×900 and 390×844. Radius 0. Behaviour comes from `features/`; where the two disagree, stop and ask.
|
||||
|
||||
## Boards
|
||||
- `RenewCMS Screens Back-office.dc.html`: every back-office screen, Settings → Media (variants, cache), and the group "States matrix: controls". Frames: `AppFrame` (shell), `DirectoryFrame` (Sites and Authors), `ActivityFrame`, `MediaFrame`, `VariantsFrame`, `IntegrationPage`, `IntegrationSearch`, `IntegrationImages`, `CodeSnippet`, `StatesMatrix`.
|
||||
- `RenewCMS Screens Auth.dc.html` (7 groups, 50 states), `RenewCMS Screens API keys.dc.html` (12 states).
|
||||
- `RenewCMS Prototype.dc.html`: updated to match (title-only New article, no body limit or counter, neutral count, pagination, link button, AppFrame's mobile drawer).
|
||||
- Unchanged: Foundations, Components, Handoff, Directions and Emails boards.
|
||||
## Files
|
||||
|
||||
## 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).
|
||||
| File | Content |
|
||||
| --- | --- |
|
||||
| `routes.md` | Routes with their auth requirement. |
|
||||
| `components.md` | Component inventory with its Nuxt UI mapping, then the full component specs. |
|
||||
| `states.md` | Every state per screen, plus the HTTP-status-to-copy table. |
|
||||
| `screens/<screen>.md` | Exact copy per state. `visual:check` takes the screen name from the file name. |
|
||||
| `foundations.md` | Tokens, spacing, type and contrast. |
|
||||
| `emails.md` | The mail templates. |
|
||||
| `decisions.md` | Product and interface decisions that are not visible in the specs: search and sharing, sites and authors, roles, account self-service and the interface defaults. |
|
||||
| `media-field.md` | Spec of the Media field type: control, states, copy, accessibility and API. |
|
||||
| `source/` | The raw export. The `.dc.html` boards are the visual reference, to open in a browser. `tokens/` and `emails/` hold the ready-to-use token and template files. |
|
||||
|
||||
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.
|
||||
## Boards (in `source/`)
|
||||
|
||||
## 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).
|
||||
- `RenewCMS Screens Back-office.dc.html`: every back-office screen, with the frames `AppFrame` (shell), `DirectoryFrame` (Sites and Authors), `ActivityFrame`, `MediaFrame`, `VariantsFrame`, `IntegrationPage`, `IntegrationSearch`, `IntegrationImages`, `CodeSnippet` and `StatesMatrix`.
|
||||
- `RenewCMS Screens Auth.dc.html`: log in, register, pending, verify, accept invite, forgot and reset password, verify new email, account deleted.
|
||||
- `RenewCMS Screens API keys.dc.html`.
|
||||
- `RenewCMS Screens Roles.dc.html`: the role editor, users and ownership (`RolesFrame`, data `roles-spec.js`).
|
||||
- `RenewCMS Screens Schema.dc.html`: custom content types, entries, relations and the Media field (`SchemaFrame`).
|
||||
- `RenewCMS Screens Account.dc.html`: change email, export your data and delete your account (`AccountCards`, copy in `account-copy.js`).
|
||||
- `RenewCMS Emails.dc.html`, `RenewCMS Foundations.dc.html`, `RenewCMS Components Primitives.dc.html`, `RenewCMS Components Product.dc.html`, `RenewCMS Handoff.dc.html`, `RenewCMS Directions.dc.html` and `RenewCMS Prototype.dc.html`.
|
||||
|
||||
## Specs
|
||||
routes.md · components.md · states.md · foundations.md · change-list.md · activity-spec.js · screens/*.md (24 files)
|
||||
Every state has a desktop and a mobile frame, and the theme switch gives light and dark. States that render a relative time or a countdown are tagged clock-sensitive in `states.md` and skipped by `task visual:check`.
|
||||
|
||||
## Status
|
||||
| Section | Status |
|
||||
|---|---|
|
||||
| A1 Activity export | Done: CSV only, synchronous; progress state removed; JSON proposed |
|
||||
| A2 Pagination `page`/`size` | Done in frames, routes.md, change-list.md |
|
||||
| A3 Unknown variant and unsupported parameter | Done: both 400 bodies in the Responses table |
|
||||
| A4 New tokens | Done: foundations.md table |
|
||||
| A5 screens/*.md | Done: 22 files, copy generated from the board source |
|
||||
| B1 Editor link | Done: 11 states |
|
||||
| B2 Duplicates | Done for sidebar, topbar, drawer, code block, modal geometry; see Not done |
|
||||
| B3 CodeSnippet | Done |
|
||||
| B4 Overlays | Done |
|
||||
| B5 Prototype | Done |
|
||||
| B6 Contrast and states matrix | Done for the pairs listed; matrix drawn |
|
||||
| B7 Consistency sweep | Done for the roles listed |
|
||||
| B8 Copy glossary | Done by search; not every line re-read |
|
||||
| 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 |
|
||||
## Known gaps
|
||||
|
||||
## State counts (Back-office board: 341 before pass 3, plus 68 for sites and authors = 409)
|
||||
| Group | States |
|
||||
|---|---|
|
||||
| Boot | 1 |
|
||||
| Home | 10 |
|
||||
| Articles | 14 |
|
||||
| Articles: pagination | 11 |
|
||||
| New article | 5 |
|
||||
| Editor | 41 |
|
||||
| Media | 52 |
|
||||
| Media picker | 13 |
|
||||
| Integration | 8 |
|
||||
| Activity | 9 |
|
||||
| Activity: filter cards | 10 |
|
||||
| Activity: export | 15 |
|
||||
| Activity details | 6 |
|
||||
| My activity | 3 |
|
||||
| Users | 17 |
|
||||
| Settings | 25 |
|
||||
| Settings: Media cleanup | 9 |
|
||||
| Purge dialog | 8 |
|
||||
| Purge results | 2 |
|
||||
| Media duplicates entry | 2 |
|
||||
| Settings: Retention | 6 |
|
||||
| Profile | 13 |
|
||||
| Global | 4 |
|
||||
| Integration: images | 9 |
|
||||
| Settings navigation | 5 |
|
||||
| Variants list | 12 |
|
||||
| Add edit variant | 11 |
|
||||
| Media library (variants) | 6 |
|
||||
| Image detail | 8 |
|
||||
| Cache panel | 10 |
|
||||
| Editor picker | 5 |
|
||||
- The Foundations, Components and Prototype boards do not show the account self-service pieces or every newer token.
|
||||
- Duplicate implementations that look alike are not merged in code: toasts, badges, empty states and modal shells.
|
||||
- The Back-office board still shows the owner, admin and user navigation. Writer frames use the restricted `user` navigation of `AppFrame`.
|
||||
- The Schema board group 8 (Permissions) is superseded by the role editor, and `/settings/permissions` redirects to `/settings/roles`.
|
||||
|
||||
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.
|
||||
## Article localization baseline (refreshed 2026-11-03)
|
||||
|
||||
## 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.
|
||||
2. Export dialog and menu copy now match the implemented endpoint (current view with filters, all with optional range).
|
||||
3. Articles pagination used `per_page`, sizes 10/25/50, default 10, 32 pages. Now `page`/`size`, 5/25/50/100, default 25, 13 pages; frames, route, URL examples and the change list updated.
|
||||
4. The Integration responses table now shows both 400 bodies, `unknown_variant` and `unsupported_parameter`.
|
||||
5. `--rc-sand` and `--rc-on-sand` (and the other `--rc-*` tokens) documented as new tokens with light and dark values and their uses.
|
||||
6. Raw colours removed from frames: crop handles and mask, tooltip, transparency checker, drag-drop text, selected-row text in MediaFrame, hex in the Prototype. Only the brand mark keeps hex.
|
||||
7. ActivityFrame, MediaFrame and VariantsFrame sidebars, topbars and (Variants) mobile drawer deleted; they render inside AppFrame. Activity now follows the sidebar order (Users, Activity, Integration, Settings).
|
||||
8. Prototype's own mobile drawer (Home, Articles, Users, Integration only) replaced by AppFrame's drawer.
|
||||
9. All code blocks on Integration (auth example, endpoint examples, error response), the base URL, the image URL builder and the one-time API key use CodeSnippet; the three older block styles are gone. CodeSnippet tabs changed from sand pills to the underline style used by tabs elsewhere.
|
||||
10. Variant form: mobile full page replaced by a 92% bottom sheet with grabber and close ×; back arrow removed.
|
||||
11. Dialog geometry unified (140 px top offset on desktop, flush bottom sheets with grabber on mobile, header 20 24 0, footer secondary left and primary right, close × 44 px on mobile). Changed: Media and Variants dialogs (mobile inset), Activity export (centered, header bar, 480 wide), API keys dialogs (border, 18 px title, 44 px buttons, 120 px offset).
|
||||
12. Current page in the paginator: border `--ui-primary` instead of sand (was 1.3:1).
|
||||
13. Page padding 24/16 everywhere (was 32 and 24); card padding 16 (was 16 and 20); section gap 16 (was 16, 20, 28); page title 20/28 (was 28/36, 24/32, 20/28); stat figure 24/32; card title 15/24; inputs 32/44 (were 40); one 13 px icon became 14; toast width 356 (was 360, 372).
|
||||
14. Copy: "Removed N …" in activity lines became "Deleted N …"; export titles and footers per the glossary.
|
||||
15. Media screen: variant count badge added to grid card, list row and mobile row, matching the Variants board.
|
||||
16. Editor toolbar on mobile was a cut-down 9-button subset; it now has the full toolbar with sideways scroll and edge fades.
|
||||
17. Editor words and characters show "0 words · 0 characters" for a new empty article (was a fallback sample count).
|
||||
|
||||
## Defaults chosen
|
||||
- Slide-over rule: only forms with a live preview and record details; on mobile always a 92% bottom sheet.
|
||||
- Hover, active, disabled and focus tokens for the matrix (components not specified before): primary hover `--ui-text`, active `--ui-text-toned`; destructive hover opacity 0.85 and active 0.75; disabled 40% (rows 55%).
|
||||
- Article pagination: API shape (array body, total in `X-Total-Count`) is a design assumption to confirm with the specifier.
|
||||
- The Remove action in the link popover has no confirmation; undo restores the link.
|
||||
- The link button is disabled when an image is selected.
|
||||
- The tooltip text is "Insert link (Ctrl+K)" and "Insert link (⌘K)" on macOS.
|
||||
- Mobile toolbar scrolls sideways; no wrap, no overflow menu.
|
||||
- Selected nav items keep the sand fill plus weight 500 and `aria-current` (the fill alone is 1.3:1 in light).
|
||||
- Syntax colouring dropped from code samples (one rendering).
|
||||
- Export "Download started" offers only Close.
|
||||
- New article keeps the title counter "n / 200" (title is limited to 200); the body has no limit and no counter.
|
||||
- Page title 20/28, page padding 24/16, card padding 16 (foundations.md).
|
||||
- Activity position in the sidebar follows AppFrame.
|
||||
- The Prototype shows a toast "Not in this prototype" for sidebar items it has no screen for (Media, Activity, Settings).
|
||||
|
||||
## Checks run
|
||||
- Opened `RenewCMS Screens Back-office.dc.html` in the preview: it loaded, 38 sections rendered, and I read the DOM of the Activity, Export, Media, Variants, Editor, Integration, Pagination, Picker, Editor picker and States matrix groups to confirm the frames render text and the sidebar. I did not read the console.
|
||||
- Computed the contrast table from token values with a script.
|
||||
- Searched frames and boards for raw hex and rgb values (only token definitions on the board roots and the brand mark remain), for exclamation marks, Remove and Delete, Update and Save, and per_page.
|
||||
- Generated screens/*.md and counted states from the board source; the counts equal the DOM counts for the Back-office board.
|
||||
|
||||
## Not done
|
||||
- Console errors on the Auth, API keys, Prototype, Foundations and Components boards were not read by me. The delivery check covers the Back-office board; the other boards use the changed frames (AppFrame, ApiKeysPage, AuthCard, MediaFrame) and may need a look.
|
||||
- Light and dark were checked for the Back-office board by reading DOM only; I did not view the screenshots of each state at 1440 and 390, so visual regressions from the shell change (slot mode) are possible.
|
||||
- Media screen: the "With variants / Without" filter, the "Largest total size" sort and a total-size column exist only on the Variants board states; they are not folded into the Media toolbar.
|
||||
- Media picker tiles and the Editor picker were not compared field by field.
|
||||
- Duplicated implementations that look alike are not merged in code: toasts (5), badges (2), empty states (2), modal shells (4). Only their visuals were aligned.
|
||||
- Hover, active and focus-visible are drawn once in the matrix; screens do not redraw them. Hover and active tokens for destructive buttons use opacity, not a token.
|
||||
- Contrast was not computed for every hover and active background, only the pairs listed in foundations.md.
|
||||
- The glossary pass was a search for known patterns, not a line-by-line read of every string. Titles in Title Case were not searched.
|
||||
- The Components, Handoff and Foundations boards were not updated to mention the new tokens, CodeSnippet or the matrix.
|
||||
- Integration still lists "max 10000" for article bodies, as the backend does today (change-list item 11).
|
||||
- The sandbox cannot export; no files were bundled.
|
||||
|
||||
## Pass 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.
|
||||
Start with [localization.md](localization.md) (authority, integration rules, open decisions, third-pass decisions), then [localization-tables.md](localization-tables.md) (generated from `source/locale-spec.js`), [localization-corrections.md](localization-corrections.md) (what the corrections pass rendered and left open) and [localization-changelog.md](localization-changelog.md) (state IDs and copy changed by the third pass). States are indexed in `states.md` and `screens/localization-*.md` (8 files, 183 states in 16 groups; L6 and L9 are release 2). The Articles list pixel gate is group L7s (single-language site) and L7 (several languages), not the Back-office Articles groups. References: `source/RenewCMS Screens Localization.dc.html`, `LocaleFrame.dc.html` (drawn inside the shared `AppFrame`), `locale-spec.js` and `locale-fixtures.js`. The LocaleLive prototype was not imported, and the Prototype board does not demonstrate localization.
|
||||
@@ -1,122 +0,0 @@
|
||||
# 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.
|
||||
|
||||
| # | Screen | Change | Done in design? | Needs backend (yes/no, what) | Needs new tokens or components | Frontend only |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | All | Add the `--rc-*` tokens (sand, on-sand, four soft grounds, overlay, two shadows) to the app CSS, light and dark (foundations.md) | Yes | No | 9 new tokens | Yes |
|
||||
| 2 | All | One sidebar, topbar and mobile drawer; Settings parent and child selected states; Activity sits between Users and Integration | Yes | No | NavItem, NavGroup | Yes |
|
||||
| 3 | All | Overlay pattern: dialog (desktop) and bottom sheet (mobile); slide-over only for the variant form and record details; footer secondary left, primary right | Yes | No | Modal wrapper rules (components.md) | Yes |
|
||||
| 4 | All | CodeSnippet is the only code block (Integration, Images, API keys, base URL, image URL) | Yes | No | CodeSnippet | Yes |
|
||||
| 5 | All | Copy glossary pass (Delete vs Remove, Save vs Update, toast phrasing) | Yes, by search; not every line re-read | No | None | Yes |
|
||||
| 6 | Settings | "Media" card with "Manage image variants" and "Cache usage" | Yes | No | MediaLinksCard | Yes |
|
||||
| 7 | Media, picker | Variant count badge on grid, list and mobile rows | Yes | No (variant counts already come with GET /v1/media?variants=) | Badge "N variants" | Yes |
|
||||
| 8 | Media | "With variants / Without" filter and "Largest total size" sort in the Media toolbar | Drawn on the Variants board only, not folded into the Media screen | No (GET /v1/media?variants=&sort=total_size exists) | None | Yes |
|
||||
| 9 | Image variants | Variant form as slide-over (desktop) and tall sheet (mobile) | Yes | No | None | Yes |
|
||||
| 10 | New article | Title only. Body removed from the modal; the editor opens straight away | Yes | Maybe: confirm `POST /v1/article/` accepts an empty `body` (it is "required" today); otherwise the frontend sends an empty string | NewArticleModal | Yes if empty body is accepted |
|
||||
| 11 | Editor | Remove the 10,000 character limit, counter, limit and over-limit states; neutral count | Yes | Yes: remove the body length validation on `POST /v1/article/` and `PATCH /v1/article/{id}` | EditorStatusBar | No |
|
||||
| 12 | Editor | Insert-link button (default, hover, active, disabled), tooltip, LinkModal, link popover and touch sheet, rendered link, toolbar scrolling at 390 px | Yes | No (editor only) | LinkModal, LinkPopover, ToolbarButton | Yes |
|
||||
| 13 | Articles | Pagination with `page` and `size` (5, 25, 50, 100; default 25), URL state, loading, error, out of range | Yes | **Yes:** `page`, `size` on `GET /v1/article/` (same convention and 400 as Activity); total in `X-Total-Count`, array body unchanged; Integration reference updated | Paginator, PageSizeSelect | No |
|
||||
| 13b | Media | Pagination under the grid and list, same Paginator and PageSizeSelect as Articles; `page` and `size` in the URL; saved size preference | Yes (9 states in the Media group) | **Yes:** `page`, `size` on `GET /v1/media/` (replaces `cursor` and `limit`); total in `X-Total-Count`, array body unchanged; Integration reference | None (reuses Paginator, PageSizeSelect) | No |
|
||||
| 14 | Activity | Failed-action and security cards as toggles, synced chip and segment | Yes | No | FilterCardToggle | Yes |
|
||||
| 15 | Activity | Export menu and dialog, CSV: current view (active filters) and all activity (no filters, optional `from`/`to`). Preparing, started, failed, nothing to export, 403 | Yes | No: `GET /v1/activity/export` is implemented (CSV, synchronous, owner and admin, same filters as the list) | ExportMenu, ExportDialog | Yes |
|
||||
| 16 | Activity | JSON export (format control CSV or JSON) | Yes, one state, labelled proposed | **Yes (proposed):** `format=json` on `GET /v1/activity/export` | Segmented format control | No |
|
||||
| 17 | Integration | Images and variants section, URL builder, response bodies `unknown_variant` and `unsupported_parameter` | Yes | No | UrlBuilder | 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 |
|
||||
| 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. |
|
||||
@@ -27,13 +27,14 @@ One component for every code or copyable value. Props: `tabs` (`{ k, label, code
|
||||
|
||||
## Editor: link
|
||||
- **Toolbar button** (icon `link`, after the list buttons): default `--ui-text-toned`; hover `--ui-bg-elevated` with tooltip "Insert link (Ctrl+K)" (macOS: "Insert link (⌘K)"); focus-visible 2 px `--ui-primary` outline; active when the cursor is inside a link (`aria-pressed`, `--rc-sand` and `--rc-on-sand`); disabled at 40% opacity when an image is selected. 28 px desktop, 40 px mobile.
|
||||
- **LinkModal**: dialog on desktop, bottom sheet on mobile. Fields "Link text" (prefilled from the selection), "URL" (http, https, mailto, relative path, #anchor), toggle "Open in new tab". Footer: Cancel left, "Insert link" or "Save link" right; "Remove link" as a text button in edit mode. Errors: "Enter the link text." and "Enter a valid URL. Use http(s), mailto, a path or #anchor."
|
||||
- **LinkModal**: dialog on desktop, bottom sheet on mobile. Fields "Link text" (prefilled from the selection), "URL" (http, https, mailto, relative path, #anchor), toggle "Open in new tab". Footer: Cancel left, "Insert link" or "Save link" right; "Remove link" as a text button in edit mode. Errors: "Enter the link text." and "Enter a valid URL. Use http(s), mailto, a path or #anchor." Helper text under the toggle: "Links open in the same tab unless you turn on a new tab."
|
||||
- **Link popover** (desktop): under the link, 288 px, URL in mono on top, then "Edit", "Open", "Remove" (error colour). Opens on click or Enter on a focused link; Esc closes. **Touch**: bottom sheet with the link text, the URL and three 48 px rows "Edit link", "Open link", "Remove link". Remove needs no confirmation (undo restores).
|
||||
- **Rendered link**: `--ui-text-highlighted`, 1 px underline, 3 px offset. Hover 2 px underline. Focus 2 px `--ui-primary` outline, offset 2. Selected: `--rc-sand` fill, `--rc-on-sand` text.
|
||||
- **Toolbar at 390 px**: the full toolbar (22 buttons) does not fit. It scrolls sideways in one row, never wraps; a 24 px fade (`--ui-bg` to transparent) marks hidden content on each side. Buttons stay 40 px.
|
||||
|
||||
## Paginator
|
||||
First, previous, page numbers, next, last; "Showing 26–50 of 312"; page-size select "5 per page", "25 per page", "50 per page", "100 per page" (default 25). Mobile: previous, "Page 2 of 13", next. Hidden when there is one page. 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`.
|
||||
## Paginator (AppPaginator)
|
||||
Shipped as `AppPaginator`; every list board (Articles, Activity, Media, Authors, Sites, Localization list) draws this one. Desktop: summary "Showing 1–5 of 8" (flex 1), page-size select, then first, previous, numbers, next, last. 390 px: summary and select on one row, then Previous, "Page N of M", Next at 44 px. The summary and select stay when there is one page; only the buttons hide.
|
||||
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. 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.
|
||||
|
||||
@@ -124,7 +125,7 @@ Frames: `SchemaFrame.dc.html` (all schema, content and entry bodies; relation, p
|
||||
|
||||
## Media field (pass 5)
|
||||
|
||||
Frames: `SchemaFrame.dc.html` (`MCtl`) and `MediaFrame.dc.html` (picker in field mode, usage). Full spec: `media-field-changes.md`.
|
||||
Frames: `SchemaFrame.dc.html` (`MCtl`) and `MediaFrame.dc.html` (picker in field mode, usage). Full spec: `media-field.md`.
|
||||
|
||||
| Component | Status | Nuxt UI | Notes |
|
||||
|---|---|---|---|
|
||||
@@ -157,3 +158,43 @@ Accessibility of the tri-state cell:
|
||||
| Announcement | Selected item is aria-checked="true". A polite live region announces changes: "Events, Update, set to Own." Disabled items are aria-disabled and described by the reason ("You can only grant what your role has."). |
|
||||
| Not by color alone | Selected segment: sand fill plus weight 600 plus a 2 px underline in --ui-primary. Locked: lock icon. Changed cell: square marker. Mixed: text badge. |
|
||||
| Targets | 26 px segments on desktop, 44 px on mobile. Focus: 2 px --ui-primary outline, offset 2 px. |
|
||||
|
||||
## Account (change email, export, delete)
|
||||
| Component | Status | Nuxt UI mapping | Where |
|
||||
|---|---|---|---|
|
||||
| EmailChangeForm | New | UFormField + UInput (email, password) + UButton, inside the Account UCard | Profile |
|
||||
| PendingEmailNotice | New | UAlert (info) + two UButton (outline) | Profile |
|
||||
| DataExportCard | New | UCard + UButton; notices as UAlert | Profile |
|
||||
| DangerZoneCard | New | UCard with border var(--ui-error), UButton color error | Profile |
|
||||
| DeleteAccountDialog | New | UModal (existing destructive ConfirmDialog shell) + UInput x2 + list + UAlert info + ULink | Profile |
|
||||
| BlockedDialog | New | UModal with dismissible false, no close button; UButton primary for the next step | Profile |
|
||||
Specs: field heights 40 (44 mobile), buttons 36 (44 mobile), card padding 16, section gap 16. Colors from tokens only. Destructive confirm is disabled at 40% opacity until valid. Implemented in AccountCards.dc.html and the AppFrame modal.
|
||||
|
||||
## Localization components (spec)
|
||||
| Component | Built from (Nuxt UI v4) | Notes |
|
||||
|---|---|---|
|
||||
| LanguageMenu | UDropdownMenu with a UButton trigger | Menu button with menu; rows carry badges and a Read only tag; no Add translation row without create permission |
|
||||
| CapabilityGate | none (logic) | Renders each action from capabilities[]; never from the role name |
|
||||
| EditorHeader | UButton, UBadge, UDropdownMenu | One row on desktop: language, badges, title, actions (title fills the remaining width). On mobile the title sits above the badges. At most one filled button. Collapse order and mobile bottom bar in the header priority table |
|
||||
| TitleInput | UInput (heading size) | Underline field, no box. Accessible name "Title (French)", no visible label; bounds 3 to 200; counter; helper under the field only while editing |
|
||||
| ActionOverflow | UDropdownMenu (desktop), UDrawer (mobile) | Disabled items carry a visible reason |
|
||||
| StatusBadge | UBadge variant soft | Publication: solid icon plus word |
|
||||
| FreshnessBadge | UBadge variant outline | Refresh icon plus word; icon-only at 1024 with aria-label |
|
||||
| SourcePane | UCard | Read-only reference with own scroll and dir/lang. Dashed outline and "Temporary reference" tag |
|
||||
| TrackedSourceBadge | UBadge variant soft color info | Distinct from the temporary reference |
|
||||
| ConflictDialog | UModal (desktop), UDrawer (mobile) | Four options; Keep my changes is the only filled button. Save over theirs is outlined in `--ui-error` with a triangle-alert icon (destructive cue, same size as the other secondary buttons) |
|
||||
| CoverageChips | UBadge variant outline | Collapse after 5 (1440) or 4 (1024, 390) with a +N chip; dashed for missing |
|
||||
| ListFilters | USelectMenu, UInput, UDrawer on mobile | Language filter groups Enabled and Disabled |
|
||||
| UnitLabel | plain text, aria-live polite | "8 articles", "4 French versions", "4 articles not in French" |
|
||||
| RowMenu | UDropdownMenu / UDrawer | Same actions on every width |
|
||||
| SocialLocaleField | UInput, UBadge | Derived, Set, Not set |
|
||||
| PublicUrlPattern | UInput, UAlert | Live preview from a real article, validation messages |
|
||||
| MoveArticlePanel | USelectMenu, UModal | Note lists readable versions only |
|
||||
|
||||
## Localization board additions
|
||||
- Sidebar: no Languages item. Languages lives under Settings → Sites → [site]. Nothing is added to `navItems`.
|
||||
- Toast icon box: 28 × 28 px, 16 px icon, tone colour on its `-soft` ground (`--rc-success-soft`, `--rc-info-soft`, `--rc-warning-soft`, `--rc-error-soft`).
|
||||
- Slide-over panel (Search and sharing, Media detail): desktop right-aligned over `--rc-overlay`, 960 px (Media detail 420 px), full height, header 16 24, close button; 390 px: bottom sheet at 92% height. Drawn inside the AppFrame shell, never free-standing.
|
||||
- Coverage chip overflow (+N): 22 px chip; at 390 px its tap area is 44 px high.
|
||||
- Language menu rows: a disabled-language version shows a Disabled tag and the footer explains publishing is off.
|
||||
- Destructive outline button: `--ui-error` text and border on `--ui-bg`, used only for Save over theirs.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Design decisions
|
||||
|
||||
Product and interface decisions that outlive the design passes. Behaviour comes from `features/`; where the two disagree, stop and ask. Screen copy and states are in `screens/*.md`, `states.md` and `routes.md`.
|
||||
|
||||
## Search and sharing (SEO)
|
||||
|
||||
### Interface
|
||||
**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.
|
||||
|
||||
### Decisions
|
||||
|
||||
| # | 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`. |
|
||||
|
||||
### Implicit decisions
|
||||
|
||||
- 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`.
|
||||
|
||||
## Sites and authors
|
||||
|
||||
### Who can do what
|
||||
|
||||
| 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
|
||||
- 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.
|
||||
|
||||
## Roles and permissions
|
||||
|
||||
Event names follow the backend: `role.create`, `role.edit`, `role.delete`, `role.reassign`, `user.change_role`, `user.transfer_ownership`, `user.remove`.
|
||||
|
||||
### Decisions
|
||||
|
||||
| # | 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. |
|
||||
|
||||
## Account self-service
|
||||
|
||||
Authored content is anonymized to "Deleted user". The email-change link lasts 2 hours. One export succeeds per user every 15 minutes, and a direct download is the export flow. "Last administrator" counts Owner. A change request for an address already in use answers like success. The account-deleted email goes to the address the account had.
|
||||
|
||||
## Interface defaults
|
||||
- Slide-over rule: only forms with a live preview and record details; on mobile always a 92% bottom sheet.
|
||||
- Hover, active, disabled and focus tokens for the matrix (components not specified before): primary hover `--ui-text`, active `--ui-text-toned`; destructive hover opacity 0.85 and active 0.75; disabled 40% (rows 55%).
|
||||
- Article pagination: Shipped shapes: the article list body is {items, total, page, size, counts}; the media list body is an array with the total in `X-Total-Count`. Users and authors follow their own routes (see routes.md).
|
||||
- The Remove action in the link popover has no confirmation; undo restores the link.
|
||||
- The link button is disabled when an image is selected.
|
||||
- The tooltip text is "Insert link (Ctrl+K)" and "Insert link (⌘K)" on macOS.
|
||||
- Mobile toolbar scrolls sideways; no wrap, no overflow menu.
|
||||
- Selected nav items keep the sand fill plus weight 500 and `aria-current` (the fill alone is 1.3:1 in light).
|
||||
- Syntax colouring dropped from code samples (one rendering).
|
||||
- Export "Download started" offers only Close.
|
||||
- New article keeps the title counter "n / 200" (title is limited to 200); the body has no limit and no counter.
|
||||
- Page title 20/28, page padding 24/16, card padding 16 (foundations.md).
|
||||
- Activity position in the sidebar follows AppFrame.
|
||||
- The Prototype shows a toast "Not in this prototype" for sidebar items it has no screen for (Media, Activity, Settings).
|
||||
@@ -14,3 +14,14 @@ Previews: `source/RenewCMS Emails.dc.html`.
|
||||
Three Go html/template files: emails/verification.html, emails/password-reset.html and emails/invitation.html . They use a table layout with inline CSS and a 600 px maximum width. The button is bulletproof: a colored table cell plus a padded link, so it survives Outlook. Each has a plain-URL fallback, a hidden preheader, and prefers-color-scheme overrides for clients that support them. The mark is live text in a black cell rather than an image, so it shows even when images are blocked. Previews below use example values.
|
||||
|
||||
Suggested subjects are "Verify your RenewCMS account", "Reset your RenewCMS password" and "<InvitedBy> invited you to RenewCMS", where .InvitedBy is the inviter's display name or username. html/template escapes the .Email value for the query string in the reset link (the @ becomes %40), and the confirm route reads it back decoded.
|
||||
|
||||
## Account emails (change email, deletion, export)
|
||||
Go `html/template` files built from `source/emails/verification.html` (same table layout, tokens, dark overrides, bulletproof button, URL fallback). English only.
|
||||
|
||||
| File | To | Subject | Variables |
|
||||
|---|---|---|---|
|
||||
| `source/emails/verify-new-email.html` | the new address | Confirm your new RenewCMS email address | `.Host`, `.Username`, `.VerificationCode`, linking to `{{.Host}}/account/email/confirm?c={{.VerificationCode}}` (expires in 2 hours, works once) |
|
||||
| `source/emails/email-change-notice.html` | the old address, at request time | Your RenewCMS email address change was requested | `.Host`, `.Username`, `.NewEmail`, linking to `{{.Host}}/profile` |
|
||||
| `source/emails/account-deleted.html` | the address the account had, sent once | Your RenewCMS account has been deleted | `.Host`, `.Username`, `.DeletedAt`; no button, generic text, no personal data |
|
||||
|
||||
Previews are rows on `source/RenewCMS Emails.dc.html`.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Localization board changelog (pass 3)
|
||||
|
||||
Board: RenewCMS Screens Localization. Frame: LocaleFrame. Spec: locale-spec.js. State IDs are unchanged; seven IDs moved to a different frame selector (marked "selector"). 183 states in 16 groups (was 169 after the corrections pass).
|
||||
|
||||
Where a board and the spec disagreed, the spec won. Decisions the spec did not make are marked **Decided**. Frames were redrawn from code and not compared against the shipped app, so each **Decided** item is the thing to check first.
|
||||
|
||||
## A. Redrawn to match the spec
|
||||
|
||||
| ID | Expected copy and layout |
|
||||
|---|---|
|
||||
| loc.l8.saveFailPublish | Error banner above the editor: "Couldn't save French" / "Your changes are kept here. Nothing was published. Try saving again." Badge "Save failed", primary "Try saving again". Error toast: "Couldn't save French" / "Nothing was published. Your changes are kept. Try again." |
|
||||
| loc.l8.done | Info banner "This version is online. Saving updates the live page right away." Badge Published, status "Saved". Toast "French published" / "Other versions weren't changed." |
|
||||
| loc.l4.empty | Start empty selected. Source version reads "No source", help "Optional. Freshness shows No source until you pick a tracked source in the editor." |
|
||||
| loc.l4.unreadable | Alert "The source isn't available to you" / "Choose another source you can read, or start empty. Your title is kept." Source version "Choose a version", error "Choose a version you can read." Create disabled |
|
||||
| loc.l3.translation | Same fields as Add translation: kind radios (Translation selected), Article ("No French version yet."), Language ("Only enabled languages this article doesn't have yet."), Source version, "Title in French", Starting content (Start empty / Copy from English, selected). Button "Create French draft" |
|
||||
| loc.l11b.moveSlug | Alert "An address is already used on Atelier" / "An address must be unique within its language on Atelier. Change the address below, then move again. Nothing was moved." Then "Conflicting version": French, the address in mono, "Edit slug". Note "Only versions you can read are named." |
|
||||
| loc.conflict (and every dialog built from it) | "Save over theirs" is outlined in `--ui-error` with a triangle-alert icon and an error-colour title icon. Keep my changes stays the only filled button |
|
||||
| Chip row, Articles list | The stray "0" came from `m && shift && …` rendering the number 0 in the mobile tab strip. Fixed with `!!shift`. Filter chips keep `width:max-content` |
|
||||
| loc.l7.* (all list states) | Pager is AppPaginator (see components.md). Desktop: "Showing 1–5 of 8", "5 per page", first, previous, 1 2, next, last. 390: summary and select, then Previous, "Page 2 of 2", Next. +N overflow chip keeps 22 px visual height and gets a 44 px tap area at 390. The tab strip is no longer shifted by the stray character |
|
||||
|
||||
## B. Redrawn inside the AppFrame
|
||||
|
||||
| ID | Now |
|
||||
|---|---|
|
||||
| loc.seo.noFallback, loc.url.unset, loc.l10.noSocial | Editor in the shell, with the Search and sharing slide-over (960 px; bottom sheet 92% at 390) |
|
||||
| loc.slug.published | Edit slug dialog over the same slide-over |
|
||||
| loc.l12.seoPreview (selector v:seo x:preview), loc.l12.siteFallback (selector v:seo x:siteFallback) | Same slide-over; siteFallback shows French and Arabic versions side by side as cards |
|
||||
| loc.l10.api | Integration page: sidebar item Integration active, breadcrumb "Integration" |
|
||||
| loc.l12.media | Media page (grid, one card selected) with the 420 px image detail panel: "Used in 2 articles · 4 versions · 1 file". Bottom sheet at 390 |
|
||||
| loc.l12.menu (selector v:li x:menuLang) | Articles list, French selected, row menu open: Open French, Open another language…, Publish French, Copy API URL, Delete French version (danger). 390: bottom sheet |
|
||||
| loc.l12.apiCopy (selector v:li x:apiCopy) | Articles list with toast "API URL copied" / "/v1/article/14?lang=fr". 390: toast across the bottom with 16 px margins |
|
||||
| loc.l1.single | "Social locale" and "Derived: pt_BR" on their own line at 390. The value was previously empty and is now drawn |
|
||||
|
||||
## C. Missing frames and copy
|
||||
|
||||
| Item | Decision |
|
||||
|---|---|
|
||||
| Banners | save failed, publish failed and online banners as in the Copy decisions table of the board |
|
||||
| Row menu | All languages: Open <language>, Open another language…, Add translation…, Unpublish <language>, Copy API URL, Delete <language> version. Language selected: Publish or Unpublish replaces Add translation… |
|
||||
| Disabled-language picker | New state **loc.l5.menuDisabled**. Spanish row shows Draft and a Disabled tag. Footer: "Spanish is disabled on this site. Its version stays readable and editable. It can't be published until an owner re-enables Spanish." |
|
||||
| Setup done | "Languages assigned" / "16 articles now have a language. Nothing was translated, and no articles were grouped." |
|
||||
| Version count line, 390 | **Decided: shown**, on its own line under each language name ("4 versions · 2 published"). Desktop unchanged |
|
||||
| Public URL preview | **Decided: site default language.** Uses the most recently updated article that has a version in the default language. Journal: https://journal.example.com/en/how-we-price-a-bathroom-renovation. A site with no articles previews a sample address. The Valid example in the spec changed from the French address |
|
||||
| invalid_host | New state **loc.l1b.invalidHost**. Pattern https://journal..example.com/{lang}/{slug}. Message "Enter a valid host name, such as journal.example.com." Added to the 422 list |
|
||||
| site.* activity labels | Added to the Activity frame and the board table: site.language.add "Added French to Journal"; .enable "Re-enabled Spanish on Journal"; .disable "Disabled German on Journal"; .remove "Removed Italian from Journal"; .default "Made French the default language of Journal"; .social_locale "Set the social locale for French on Journal to fr_FR"; site.public_url.update "Changed the public URL pattern of Journal"; site.language.setup "Assigned languages to 16 articles on Journal, Notes and Archive"; article.move "Moved "<title>" from Journal to Atelier" |
|
||||
| Toast icon box | 28 × 28 px, 16 px icon, tone colour on its -soft ground |
|
||||
| Languages sidebar item | **Decided: none.** routes.md and components.md say so |
|
||||
| loc.l11.session | Editor behind the dialog shows the unsaved changes. Title "Your session expired" / "Log in to continue. Your title, body and search fields for this French version are kept in this browser and restored after you log in, with a notice." Button "Log in" |
|
||||
| loc.l4.gone | Deleted and unreadable sources look identical after submit (same neutral 404): "The source isn't available to you". The spec Visibility row was updated. "Source deleted" remains only in the review banner (L9) |
|
||||
| loc.l8.deleteSource | "Delete the English version?" / "English is the tracked source for versions you can read." Alert "Review tracking will pause": "French, German and Arabic track English. They stay as they are and keep their review history. Each one asks you to choose a new source." |
|
||||
| loc.l11b.moveSelectHidden | Arabic not listed or counted; "Versions you can't read aren't listed or counted." |
|
||||
| loc.l7.review | Needs review tab, "2 articles need review" (no copy change) |
|
||||
|
||||
## D. Copy
|
||||
|
||||
| Item | Final copy |
|
||||
|---|---|
|
||||
| Delete article (loc.l8.deleteStoryOk) | Title "Delete English, French, German and Arabic?" / "The article and all of its translations will be deleted. This can't be undone." |
|
||||
| New state loc.l8.deleteStoryFail (partial delete) | "Couldn't delete the article" / "Nothing was deleted. Every version is still there. Try again in a moment." Deleting is atomic, so there is no partial result. Same copy in screens/articles.md row 13 |
|
||||
| New state loc.l8.deleteDone | Toast "French version deleted" / ""<title>" was deleted. English and German weren't changed." Editor opens English |
|
||||
| New state loc.l8.deleteStoryDone | Toast on the list: "Article deleted" / "The article and all of its translations were deleted." |
|
||||
| New state loc.l11b.moveFail | "Couldn't move the article" / "Nothing was moved. All 4 versions are still on Journal. Try again." Buttons Cancel, Try again |
|
||||
| loc.l11.conflict2 | Alert "Their version changed again" / "Someone else saved this version again at 14:41, before your save went through. Nothing was overwritten. Your changes are still here." (was "Couldn't replace theirs") |
|
||||
| loc.l3.multi, loc.l3.sitechange | Language help "Default language for Journal." (was "Default for Journal.") |
|
||||
|
||||
## E. Older design gaps
|
||||
|
||||
| Item | Status |
|
||||
|---|---|
|
||||
| Old Articles board | Replaced as the list pixel gate by new group L7s (single-language site), 7 states: loc.l7s.default, draft, noResults, empty, loading, fail, menu. No language filter, coverage column or tabs. Back-office groups kept as a copy reference |
|
||||
| routes.md /articles row | Rewritten: query params, response `{ items, total, page, size, counts }` |
|
||||
| Role-picker sand card | Already uses `--rc-on-sand` for name and description in RolesFrame; no change |
|
||||
| Media detail panel, "Used elsewhere" | Panel now draws the section below the article list: "Used elsewhere" with rows "Author avatar" (/authors) and "Site default social image" (/settings/sites). The "Used in N" badge counts articles, entries, avatars and the site image. loc.l12.media shows "Used in 2" with two articles. New state **loc.l12.mediaElsewhere**: an image used only as an author avatar, badge "Used in 1", "Articles" with "No article shows it.", then "Used elsewhere" with Author avatar. Tooltip and delete-dialog copy unchanged. MediaFrame already had four used-elsewhere states (avatar, site, both, none); those stay |
|
||||
| Editor "Create author" on 390 px (dialog, for me, slug taken, failed) | The Details sheet and the dialog were both drawn, with the shell's own sheet competing. On 390 the shell sheet is now off for these states, the Details sheet is drawn once, and the dialog sits above it (z-index 70). Not checked visually |
|
||||
| Selected media-card badges | Confirmed: "Used in N" and Draft outline badges keep an opaque `--ui-bg` ground and `--ui-text-toned` text on a selected sand card: 9.7:1 light, 11.6:1 dark (needs 4.5:1). The soft badges (Duplicate, variants) now also sit on `--ui-bg` with a currentColor border when selected |
|
||||
|
||||
## Header and counts
|
||||
Board header paragraph, README.md, states.md and localization-corrections.md now say 183 states in 16 groups.
|
||||
|
||||
## New state IDs
|
||||
loc.l12.mediaElsewhere, loc.l5.menuDisabled, loc.l1b.invalidHost, loc.l8.deleteDone, loc.l8.deleteStoryFail, loc.l8.deleteStoryDone, loc.l11b.moveFail, loc.l7s.default, loc.l7s.draft, loc.l7s.noResults, loc.l7s.empty, loc.l7s.loading, loc.l7s.fail, loc.l7s.menu.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Localization corrections and completion report
|
||||
|
||||
Board: RenewCMS Screens Localization. Frame: LocaleFrame. Fixtures: locale-fixtures.js. Spec: locale-spec.js. LocaleLive and the Handoff board were not touched.
|
||||
|
||||
## State counts
|
||||
169 states in 15 groups (was 109) at the end of that pass; the third pass brought it to 183 in 16 groups (see `localization-changelog.md`). Counts at the end of the corrections pass: L1 25, L1b 9, L2 7, L3 4, L4 10, L5 25, L5b 9, L6 12 (release 2), L7 16, L8 13, L9 11 (release 2), L10 5, L11 7, L11b 8, L12 8. 33 states are also drawn at 1024. The full ID table is in states.md, and the board lists IDs, selectors and fixtures per state.
|
||||
|
||||
IDs: 99 existing states keep their group, frame and selector. 70 are new. Ten existing states were rekeyed without changing meaning: the seven permission states moved from frame ed to perm (L5b), and the three move failures moved from L11 to L11b. Legacy IDs (loc.perm.*, loc.dup.*, loc.lang.*, loc.src.*, loc.review.changed, loc.conflict, loc.slug.published, loc.url.unset, loc.seo.noFallback) were kept as the canonical ID of the state they map to; the loc.lN.xxx alias is listed beside each. Nothing was removed without a replacement.
|
||||
|
||||
## Sections to files and states
|
||||
| Section | Files | State IDs |
|
||||
|---|---|---|
|
||||
| 1 Permissions | LocaleFrame, locale-spec.js | loc.perm.readonly, loc.l5b.roMenu, loc.perm.editNoPublish, loc.perm.publishNoEdit, loc.perm.siblingReadOnly, loc.perm.siblingHidden, loc.perm.createNotAll, loc.l5b.noAdd, loc.l5b.withAdd, loc.l5.cleanOnline |
|
||||
| 2 Header, actions, title | LocaleFrame, AppFrame (edHeader, edMobBar slots), spec tables Editor header priority, Action matrix, Title field states | loc.l5.default, dirty, dirtyPublish, cleanOnline, onlineDirty, saving, saved, saveFail, pubFail, ovf, focus, titleEdit, titleUnsaved, invalid, titleShort, titleLong, titleSaving, failed; loc.l8.unpublishDirty |
|
||||
| 3 Conflict | LocaleFrame | loc.conflict, loc.l11.conflictRestored, reloadConfirm, conflict2, download, restored, session |
|
||||
| 4 List | LocaleFrame, fixtures view() | loc.l7.all, page2, fr, frMissing, emptyMissing, noResults, frLoading, listFail, limited, review, author, langFilter, rowMenu, filters, tabsScroll, long |
|
||||
| 5 Languages and setup | LocaleFrame, routes.md | loc.l1.* (reenableDone, reenableFail, broader, defaultFail, removeFail, stale, social, socialEdit, socialInvalid, readonly), loc.l1b.*, loc.l2.noLocale, inProgress |
|
||||
| 6 Move, delete, add translation | LocaleFrame | loc.l11b.*, loc.l8.deleteStoryOk, deleteStory, loc.l4.long, unreadable, langOff |
|
||||
| 7 Review and reference (release 2) | LocaleFrame | loc.l9.*, loc.l6.* (arRef, narrow at 1024, rtl) |
|
||||
| 8 Metadata, API, surfaces | LocaleFrame | loc.l10.*, loc.l12.* |
|
||||
| 9 Fixtures | locale-fixtures.js, board tables Fixture index and Fixture totals check | all |
|
||||
| 10 Accessibility | spec table Accessibility and interaction, board table Contrast | all |
|
||||
| 11 Docs | routes.md, states.md, components.md, README.md, screens/localization-*.md (8 files, generated from the spec) | all |
|
||||
|
||||
## Rendered and inspected
|
||||
I rendered the frame at full size and looked at these. Everything else is specified in the spec and the board, and drawn by the same code, but I did not look at it.
|
||||
- 1440 light: ed.dirtyPublish, perm.ro, perm.p6, ed.menu, ed.titleLong, pd.unpublishDirty, pm.conflict, pm.moveSelect, fr.review, li.all, li.long, li.author, li.limited, sl.social, sl.broader, su.ready, pu.encoded, seo (default), api, sf.home, src.split.
|
||||
- 1024: ed.dirtyPublish (dark), ed.cleanOnline, src.rtl (dark), li.long, li.all.
|
||||
- 390: ed.dirtyPublish, ed.pubFail, ed.ovf, pm.conflict (all four options and the primary visible without scrolling), at.long (primary stays at the bottom), li.filters, li.long, li.rowMenu, li.page2, src.mobileSrc, sl.multi (dark), perm.ro (dark).
|
||||
- Dark: li.all 1440, ed.dirtyPublish 1024, src.rtl 1024, sl.multi 390, perm.ro 390.
|
||||
- The board's computed tables (fixture totals, state counts, contrast) were read in the browser. Every total matches the fixture. Contrast passes for every badge, chip and selected variant in light and dark. Disabled controls at 40% (2.6:1 light) are exempt.
|
||||
|
||||
## Not completed
|
||||
- Most states were not looked at. Only the list above was rendered and inspected. 1024 frames exist for 33 states and I looked at five.
|
||||
- Icons come from an online icon service and did not load in my screenshots, so icon presence and placement were not checked visually.
|
||||
- Header widths between 1024 and 1440 were not drawn; the collapse order is specified and shown at the two ends.
|
||||
- The mobile Languages rows are taller than needed (social locale wraps onto its own line). Not tuned.
|
||||
- Nuxt UI components are named in components.md; the frames are built from the design tokens, as before.
|
||||
- Archive, Notes and Gallery appear in single states, not as full lists.
|
||||
- Setup frames still list all sites, although the route is per site (see below).
|
||||
- LocaleLive and the Handoff board still describe the old behaviour.
|
||||
|
||||
## Documents that disagreed, and what I aligned to
|
||||
- Route: routes.md and the board said /settings/languages; the brief says /settings/sites/:key/languages. Aligned to the brief. The setup page lists every site, so :key is the site it was opened from.
|
||||
- Wording: the brief says "story" never appears in the UI, but section 1 uses "Delete story" and "You can't delete this story." I used "article" everywhere ("Delete article", "You can't delete this article."). Rule not changed; only the word.
|
||||
- Count: the brief's example "5 French versions" disagrees with the decided fixture (fr 4). Aligned to the fixture: "4 French versions".
|
||||
- routes.md still listed list params mode and needs_review and the old public route GET /v1/public/site/{key}/article/{slug}?lang=. Removed; aligned to tab and the two final public routes.
|
||||
- states.md said the frame was not yet updated for the permission states; replaced by the generated table.
|
||||
- The previous 1024 frames were a 1440 frame clipped to 1024. AppFrame now takes a width, so 1024 frames are real.
|
||||
- Section 2 asks for a "Retry publish" action; the banner and the primary both use that label.
|
||||
|
||||
## Rules I believe are wrong
|
||||
None changed. One to watch: Save over theirs sits at equal weight with Reload theirs, as required. If both stay unfilled, a user may not see that Save over theirs is the destructive one. The explanation text carries that. Left as is.
|
||||
@@ -0,0 +1,392 @@
|
||||
# Localization board tables
|
||||
|
||||
Generated from `source/locale-spec.js`. Read `localization.md` first: its explicit corrections supersede conflicting text below. API additions are proposals to be specified and accepted through `/slice`, not existing endpoints.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Question | Chosen behavior | Rationale | Existing behavior affected | Backend dependency | Release |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Language model | One story group, one article id per language, one version per locale | Reuses the article editor, permissions and publish flow | None for single-language sites | group_id, lang on articles; unique (group_id, lang) | Initial |
|
||||
| Default vs editing vs source | Three separate roles. Default is a site setting; editing language is the open article; source is chosen per translation | Default is not always the best source | None | source_article_id per version | Initial |
|
||||
| Locale scope | Per site, list of enabled languages plus one default | Matches site-level settings | Settings → Sites gains Languages | site_languages table | Initial |
|
||||
| Site locale field | Four distinct values: formatting locale (existing Site.Locale, still used for dates and numbers), default content language (new, seeded from the full Site.Locale and confirmed by the owner), article language (per version), social metadata locale (explicit or derived, never guessed) | Avoids silent reinterpretation | ResolveArticleSEO reads Site.Locale for social metadata (verify in repo); for localized articles it uses the article language | None | Initial |
|
||||
| Disable vs remove | Disable keeps content and published versions. Remove only at zero versions | No configuration action deletes or unpublishes | None | Count per language; 409 language_in_use | Initial |
|
||||
| Existing content | Setup assigns a language per site, in one transaction; each article becomes its own story | Metadata only; no guessed grouping | Articles gain lang | Bulk assign endpoint, atomic | Initial |
|
||||
| Titles | Editable after creation. A title change never alters an existing slug. Slug changes only through explicit Edit slug; no redirects are promised (none in the backend) | Translated titles must be revisable; URLs stay stable. Same rule on single-language sites | CHANGE: titles were immutable. Slug policy applies everywhere | Allow title in PUT; slug rules | Initial |
|
||||
| Slug uniqueness | Unique per (site, lang) | Same slug in two languages is normal | Today unique per site | Index change | Initial |
|
||||
| Language control | Dropdown beside the title; rows show version state; interface language unchanged | Compact for 2 to 20 languages | Editor heading | translations[] on GET article | Initial |
|
||||
| Switching | Opens another article id. Dirty form asks Keep editing, Discard, Save and switch | URL is the state; no lost work | None | None | Initial |
|
||||
| Add translation | Draft only; copy or start empty; images/authors reused; copied text flagged Needs review | Copying is a start, not completion | None | POST translations; 409 language_exists | Initial |
|
||||
| Source reference | Optional read-only pane; segment on mobile; no sync | Keeps the single editor default | None | Read access to source | Initial |
|
||||
| Direction | dir on editor and reference panes only | Shell stays LTR | None | Per-language dir from locale | Initial |
|
||||
| List: all languages | One row per story; title from default version, else first accessible; coverage chips | Avoids duplicates | Articles list | Grouped query, access-filtered | Initial |
|
||||
| List: language selected | One row per version; Not in French tab lists stories | Units always stated | Tabs and counts | lang, tab params | Initial |
|
||||
| Search | All languages: every accessible title. Language: that language only | Predictable | Search | Access-filtered | Initial |
|
||||
| Publishing | Per version; copy always names the language; no bulk publish | Prevents accidental scope | Publish button copy | None | Initial |
|
||||
| Deleting | Per version. Last version deletes the story. Article delete needs permission on all | Explicit scope | Delete dialog | None | Initial |
|
||||
| Freshness | Needs review when the source revision changes after the reviewed revision; Mark as reviewed records it | Reliable, not timestamp-based | None | content_revision on versions; reviewed_source_revision | Initial |
|
||||
| Revision-bumping fields | Title, body, excerpt, slug, image alt text, SEO and social text. Not authors, cover, site, publication | Only translatable content counts | None | Revision incremented in same write | Initial |
|
||||
| Site move | Moves the whole story; destination must enable every language; slug conflicts block | No splitting or merging | Site select in editor | Move endpoint, validation | Initial |
|
||||
| Hidden versions | Titles, counts, coverage, language summaries, previews, errors and deletion confirmations name only versions the user can read | No leakage | List counts, delete dialog | Filter at query level | Initial |
|
||||
| Machine translation | Not included | Out of scope | None | None | Later |
|
||||
| Cross-site stories | Not included | Out of scope | None | None | Later |
|
||||
| Bulk publish / review | Not included | No clear need | None | None | Later |
|
||||
| Slug policy | Slug generated once at creation. Changed only by Edit slug, before or after publishing. Published change warns: "Links to the old address will stop working. RenewCMS does not create redirects." | One policy, no redirect promise | Slug field | Unique (site, lang) | Initial |
|
||||
| Permission model | Capabilities per version: read, edit, publish, delete, create-translation, review. UI renders from capabilities[] on the article response, not from role names | Matches role/scope permissions | Editor, list | capabilities[]; server enforces every call | Initial |
|
||||
| Missing vs hidden | "Not in French" lists stories where French is absent or unreadable to the user. Add French translation on a story with an unreadable French returns a neutral 409 (see Visibility policy) | Discovery cannot leak existence | List | Conflict check ignores read permission; response is neutral | Initial |
|
||||
| Source-copy access | Read access to the chosen source is rechecked at submit, inside the transaction | Access can change while the dialog is open | Add translation | Neutral 403/404 | Initial |
|
||||
| Disabled languages | Versions stay readable and editable; publishing blocked until re-enabled; filter lists them under Disabled | No silent unpublishing | Site languages | enabled flag per site language | Initial |
|
||||
| Header priority | Left to right: language dropdown, publication badge, status badge (unsaved, saving, saved, failed), freshness badge, Read only tag, title input (fills the remaining width, same row on desktop), then reference button, Unpublish, Save, primary, overflow. At most one filled button. Collapse order: reference label, Unpublish to overflow, freshness to icon | The primary action is never hidden | Editor header | None | Initial |
|
||||
| Public URL pattern | Per-site owner setting with {lang} and {slug}; live preview from a real article; unset state links from "Public URL not configured" | No fabricated website URLs | Settings → Sites | public_url_pattern on site | Initial |
|
||||
| Social locale per language | Per-language owner field on Languages: Derived (from the tag), Set (typed), Not set. Format language_REGION | Never guess a region | Languages page | social_locale per site language | Initial |
|
||||
| Release split | Source reference (L6) and Freshness and review (L9) are release 2. Frames are drawn in full so they can be checked | Smaller first release | None | None | Release 2 |
|
||||
| UI wording | The UI says "article", never "story". No revision numbers, IDs or HTTP status names in user-facing copy. Internal notes may say story | Plain language | All localization copy | None | Initial |
|
||||
| Languages in the sidebar | No sidebar item. Languages is reached only through Settings → Sites → [site] → Languages, which routes.md lists as the only languages route | One route, one entry point, per-site scope | Sidebar unchanged | None | Initial |
|
||||
| Public URL preview | The preview uses the most recently updated article that has a version in the site default language, in that language. A site without articles previews a sample address | Predictable and never fabricates a language | Public URL page | None | Initial |
|
||||
| Deleting an article | Dialog names the readable versions and states that the article and all of its translations are deleted. Failure copy: "Couldn't delete the article" with "Nothing was deleted. Every version is still there. Try again in a moment." | Delete is atomic, so there is no partial delete | Delete dialog | None | Initial |
|
||||
| Destructive cue in the conflict dialog | Keep my changes is the only filled button. Save over theirs is outlined in --ui-error with a triangle-alert icon, the same size as the other secondary buttons | Marks the destructive choice without changing the equal-weight rule | Conflict dialog | None | Initial |
|
||||
|
||||
## Capability matrix (per version, enforced server-side)
|
||||
|
||||
| Case | Read | Edit | Publish | Delete | Create translation | Review | UI result |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| P1 Read-only version | Yes | No | No | No | If can create on site | No | Banner "You can read this version. You can't edit it." No Save, Publish, Delete; formatting toolbar hidden; fields read-only; the reference action remains |
|
||||
| P2 Edit, no publish | Yes | Yes | No | Per scope | Per scope | If can edit | Save shown; Publish hidden; hint "Publishing needs the publish permission" |
|
||||
| P3 Publish, no edit | Yes | No | Yes | Per scope | Per scope | No | Publish/Unpublish shown, fields read-only (only if existing permissions allow publish without edit) |
|
||||
| P4 Sibling readable, not editable | Yes | No | No | No | n/a | n/a | Opens read-only; row tag "Read only" |
|
||||
| P5 Sibling not readable | No | No | No | No | n/a | n/a | Row absent; no count, no placeholder |
|
||||
| P6 Can create, not modify all | Yes | Own versions | Own | Own | Yes | Own | Delete article and Move article disabled with the reason "You can't delete this article." Delete French version stays available if allowed |
|
||||
| Owner / Admin | Yes | Yes | Yes | Yes | Yes | Yes | All readable versions; not inferred from role label alone |
|
||||
|
||||
## Field ownership, copy and fallback
|
||||
|
||||
| Field | Own per version | Copied on "Copy from source" | Fallback if empty |
|
||||
| --- | --- | --- | --- |
|
||||
| Title | Yes | Entered by the editor | None (required) |
|
||||
| Slug | Yes | Generated from the new title | None |
|
||||
| Body | Yes | Yes, flagged Needs review | None |
|
||||
| Excerpt | Yes | Yes | First words of body |
|
||||
| SEO title and description | Yes | Yes | Title / excerpt, then site defaults |
|
||||
| Social text | Yes | Yes | SEO text |
|
||||
| Image alt text | Yes | Yes | None |
|
||||
| Cover and inline images | Reference, files shared | Yes, no duplicate files | None |
|
||||
| Authors | Yes | Yes, editable | None |
|
||||
| Site | Shared by the story | Same | None |
|
||||
| Publication and timestamps | Yes | No | None |
|
||||
|
||||
## API: existing vs proposed
|
||||
|
||||
| Capability | Status | Request | Errors |
|
||||
| --- | --- | --- | --- |
|
||||
| List articles | Existing, extended | GET /v1/article/?page=&size=&q=&status=&site=&seo= (existing). Proposed: lang, tab=all\|missing\|review (same names as the URL; no separate mode or needs_review) | 400 invalid page/size (SPA never shows it) |
|
||||
| Get article | Existing, extended | GET /v1/article/{id}. Proposed fields: lang, story_id, capabilities[], translations[] (readable only), source { article_id, revision, status: tracked\|inaccessible\|deleted } | 404 for hidden or deleted |
|
||||
| Create article | Existing, extended | POST /v1/article/. Proposed: lang, story_id, source_article_id, copy (routes to translation rules) | 409 language_unavailable · 422 language_not_enabled |
|
||||
| Edit | Existing, extended | PATCH /v1/article/{id}. Proposed: if_revision for overwrite recheck | 409 revision_conflict { current_revision } |
|
||||
| Publish / unpublish | Existing | PATCH /v1/article/{id}/publish, /unpublish. Per version | 403 missing_permission · 422 language_disabled |
|
||||
| Delete | Existing | DELETE /v1/article/{id}. Last version deletes the story | 403 · 404 |
|
||||
| Add translation | Proposed | POST /v1/article/{id}/translations { lang, title, source_article_id, copy }. Path id is any readable version; source_article_id selects the copy source; source_revision captured in the transaction | 409 language_unavailable (neutral) · 404 (neutral) · 422 · 403 |
|
||||
| Mark reviewed | Proposed | POST /v1/article/{id}/review { source_article_id, source_revision, translation_revision } | 409 source_changed · translation_changed · source_replaced · 403 |
|
||||
| Change tracked source | Proposed | PATCH /v1/article/{id}/source { source_article_id\|null } | 422 source_cycle · 422 source_other_story · 403 |
|
||||
| Site languages | Proposed | GET/PUT /v1/site/{key}/languages { default, languages[{ lang, enabled }] }, atomic | 422 default_not_enabled · no_enabled_language · 409 language_in_use · 409 languages_changed |
|
||||
| Setup | Proposed | GET/POST /v1/language/setup { sites:[{ key, lang }] }, idempotent per site key; articles created during setup get the site default; retry after failure is safe. UI route /settings/sites/:key/languages/setup | 403 · 409 setup_in_progress |
|
||||
| Move story | Proposed | POST /v1/story/{story_id}/move { site }, atomic, revalidates | 409 slug_conflict · 422 language_not_enabled · 403 (neutral) |
|
||||
| Publish with unsaved edits | Client sequence | PATCH /v1/article/{id} (save) then PATCH /v1/article/{id}/publish. The second call is sent only if the first returns 200. No new endpoint | Save failure: nothing published, edits stay unsaved |
|
||||
| Public article (two requests) | Proposed | Exact: GET /v1/public/site/{key}/lang/{lang}/article/{slug}. Resolve: GET /v1/public/site/{key}/story/{story_id}?lang=&fallback=default. Existing article-ID links unchanged | See Public lookup |
|
||||
| Public URL pattern | Proposed | GET/PUT /v1/site/{key} { public_url_pattern }. Placeholders {lang}, {slug}. Owner only | 422 unknown_placeholder · invalid_scheme · invalid_host · not_encoded · missing_slug · 403 |
|
||||
| Social locale | Proposed | PUT /v1/site/{key}/languages { languages[{ lang, enabled, social_locale\|null }] } | 422 invalid_social_locale |
|
||||
|
||||
## Accessibility and interaction
|
||||
|
||||
| Control | Accessible name and keyboard | Focus | Announcement |
|
||||
| --- | --- | --- | --- |
|
||||
| Language control | "Language: French. Switch version". Enter or Down opens a menu; arrows move; Enter selects; Escape closes | Into the list on open; back to the control on close | Polite: "Editing French version" |
|
||||
| Title input | Accessible name "Title (French)", no visible label (the language dropdown beside it names the version); aria-describedby points to the help or error text; invalid sets aria-invalid and the message is an alert | Stays in the field; on Try again, back to the field | Polite: "Saving title", "Title saved"; errors are announced |
|
||||
| Save, Save and publish, Retry publish | Buttons named by their text including the language ("Save and publish French"); busy state sets aria-busy; Tab order: title, language, status, reference, Unpublish, Save, primary, More actions | After save, stays on the button; after a failed save, on Try saving again | Polite: "French saved"; "Saved as a draft. Publishing failed. Try publishing again." |
|
||||
| More actions menu or sheet | Menu button "More actions"; items are menuitems; disabled items use aria-disabled and show the reason as text | First enabled item on open; back to More actions on close | None |
|
||||
| Reference button | "Show source reference" or "Hide source reference", icon-only at 1024 with the same name | Moves into the pane on show; back to the button on hide | Polite: "Showing English reference" |
|
||||
| Conflict dialog and sheet | role dialog, modal; options are buttons in order Keep my changes, Download my version, Reload theirs, Save over theirs; Escape equals Keep my changes | First button (Keep my changes) on open; back to the title on close | Polite: "Download started" |
|
||||
| Move panel and dialogs | Site select is a listbox named "Site"; the note is the description of the confirm button | Into the list; back to the site select | Polite: "Moved to Atelier" |
|
||||
| Add translation dialog | Focus trapped; Escape cancels unless creating | First field; back to the control | Polite: "French draft created" |
|
||||
| Source segment (mobile) | Two-button radiogroup, arrows switch | Stays on the segment | Polite: "Showing English source" |
|
||||
| Coverage chips | aria-label per chip: "French: draft, needs review". The "+3" chip is named "3 more languages: …" | Row, not chips. Dashed chips are buttons on desktop only when the user can create | None |
|
||||
| List unit label | aria-live polite: "4 articles not in French" | None | Polite on every filter change |
|
||||
| Status without color | Publication badges are solid with an icon (Published: check, Draft: pencil). Freshness badges are outlined with a refresh icon and a word. Save status always has an icon and a word | None | None |
|
||||
| Touch targets | On mobile every button, menu row, chip button and segment is at least 44 px high; the bottom bar keeps the primary visible | None | None |
|
||||
| Reduced motion | No slide or fade transitions on popovers, sheets and the tab strip scroll when prefers-reduced-motion is set; the saving spinner becomes a static icon with the word "Saving" | None | None |
|
||||
|
||||
## Acceptance scenarios
|
||||
|
||||
| # | Given / When / Then |
|
||||
| --- | --- |
|
||||
| 1 | Given a single-language site, when creating an article, then no language control appears anywhere. |
|
||||
| 2 | Given French is enabled, when an editor adds a French translation with "Copy from English", then a draft is created, the editor opens it, and the badge reads Draft and Needs review. |
|
||||
| 3 | When French is published, then English publication is unchanged. |
|
||||
| 4 | When English changes title or body after review, then French shows Needs review; changing authors does not. |
|
||||
| 5 | Given dirty French, when choosing English, then a three-choice dialog appears and no work is lost. |
|
||||
| 6 | Given a writer who cannot see the German draft, then German appears nowhere in lists, counts, menus or alternates. |
|
||||
| 7 | When disabling German with 12 versions, then all 12 stay and 3 published stay online. |
|
||||
| 8 | When two editors create French at once, then the second sees "French is no longer available" with entries kept. |
|
||||
| 9 | When moving a story to a site missing a language, then the move is blocked and nothing splits. |
|
||||
| 10 | When the interface language is Arabic or French, the editing language indicator still shows the article language. |
|
||||
| 11 | Given French has unsaved edits, when the user chooses Save and publish French and the save fails, then nothing is published and the edits stay unsaved with Try saving again. |
|
||||
| 12 | Given French is missing and unpublished, when a client resolves the story with lang=fr and fallback=default and the default is published, then the response is the default language with requested_lang fr and fallback true; with the default also unpublished it is 404 language_not_available. |
|
||||
| 13 | Given a site locale pt-BR, when setup seeds the default language, then it reads Portuguese (Brazil) pt-BR and the owner confirms it before anything applies. |
|
||||
|
||||
## Existing surfaces audit and verification
|
||||
|
||||
| Item | Note |
|
||||
| --- | --- |
|
||||
| Home | Recent articles show the default-language title with a language code when not default. States loc.l12.home, loc.l12.homeFr |
|
||||
| Activity | Events gain the language: "Published French version of …". State loc.l12.activity |
|
||||
| Article menu | Publish and Delete labels name the language on multilingual sites. State loc.l12.menu |
|
||||
| API URL copy | Copies the version URL with ?lang=. State loc.l12.apiCopy |
|
||||
| SEO preview | Shows the localized address and canonical. State loc.l12.seoPreview |
|
||||
| Media usage | Counts a shared image once; lists versions that use it. State loc.l12.media |
|
||||
| Site text fallback | A site-level text (for example a site description) is a fallback only for articles in the same language. A French article can use the French site text; an Arabic article with no Arabic site text gets "No fallback" and the tag is left out. State loc.l12.siteFallback |
|
||||
| Verification limit | Repository files were not accessible. Statements about them are assumptions marked verify in repo. |
|
||||
| Decided | Slug policy, hidden-version disclosure, Home counting articles, and review storing the source version only (no text diff) are decided in the other tables. |
|
||||
|
||||
## Visibility and anti-enumeration policy
|
||||
|
||||
| Situation | User can read it | User cannot read it |
|
||||
| --- | --- | --- |
|
||||
| Duplicate translation | "French already exists for this article." Action: Open French version | "French can't be created for this article." No Open action, no title, no hint a version exists |
|
||||
| Create conflict response | 409 language_exists with article_id | 409 language_unavailable; identical body for exists-unreadable, disabled and lost race |
|
||||
| Article delete confirmation | Names readable versions: "Delete English and French?" | Unreadable versions are not named or counted. If any blocks deletion: "You can't delete this article." plus Delete this version |
|
||||
| Source unavailable | Deleted or unreadable at submit: the same neutral 404, shown as "The source isn't available to you." The review banner (L9) says "Source deleted" only for a source the user already knew about | "The source isn't available to you." |
|
||||
| Missing translation tab | Row shown; Add French translation | Same row; a 409 shows the neutral copy |
|
||||
| Counts and summaries | Readable versions only | Never included |
|
||||
|
||||
## Public lookup and URLs
|
||||
|
||||
| Topic | Decision |
|
||||
| --- | --- |
|
||||
| CMS API vs website | CMS API URLs (/v1/...) are never shown as website URLs. Website URLs come from a proposed per-site public_url_pattern (owner), e.g. https://example.com/{lang}/{slug}. Existing convention unverified. |
|
||||
| Pattern not configured | "Public URL not configured" with a link to site settings. No URL is fabricated. API links by article ID remain. |
|
||||
| Exact lookup | GET /v1/public/site/{key}/lang/{lang}/article/{slug}. Matches site + language + slug only (slug is unique per site and language). Missing or unpublished: 404 article_not_found. It never falls back and never tries another language's slug |
|
||||
| Resolution by story | GET /v1/public/site/{key}/story/{story_id}?lang=&fallback=default. story_id is the stable story identifier, returned on every public response and in alternates[]. No lang means the site default language. Fallback is decided only by story_id |
|
||||
| Example: requested version published | story st_01, lang=fr, French published: 200, lang fr, fallback false |
|
||||
| Example: requested missing or unpublished | lang=fr, no published French. Without fallback: 404 language_not_available. With fallback=default and a published default (en): 200, lang en, requested_lang fr, fallback true |
|
||||
| Example: default also missing or unpublished | lang=fr, fallback=default, French and the default both unavailable: 404 language_not_available. No third language is tried |
|
||||
| Example: default requested but unpublished | lang=en, English unpublished, fallback=default: 404 language_not_available |
|
||||
| Compatibility | Article-ID endpoints (GET /v1/article/{id}) and existing public URLs keep working. Setup labels existing articles with the site default language |
|
||||
| Response identity | Always lang (actual language). alternates[] lists only published, enabled-language versions |
|
||||
| Canonical | The localized page itself, unless an explicit canonical override is set |
|
||||
| Default-language change | Does not change what existing URLs return; absent or unpublished default gives 404 unless fallback is requested |
|
||||
|
||||
## Locale semantics
|
||||
|
||||
| Value | Meaning | Used for |
|
||||
| --- | --- | --- |
|
||||
| Site formatting locale | Existing Site.Locale | SPA dates and numbers; kept |
|
||||
| Default content language | New per-site setting. Setup seeds it from the full Site.Locale when a supported language entry matches (en-GB, pt-BR). If only the language part is supported it seeds that and says so. No locale: the owner must choose. The owner confirms or changes it explicitly; nothing applies unconfirmed | Fallback, new-article default, setup |
|
||||
| Article language | BCP 47 tag per version (en, fr, pt-BR, ar) | html lang, API lang, hreflang |
|
||||
| Social metadata locale | Resolved in order: 1) explicit social locale on the site language (set by an owner, e.g. fr: fr_FR); 2) the language tag already has a region (pt-BR gives pt_BR); 3) Site.Locale has the same language part (site en-GB and article en give en_GB); 4) otherwise none: og:locale and og:locale:alternate are omitted for that version and the editor shows Not set. No region is invented for a bare language. og:locale:alternate lists other published versions whose locale resolves by the same rules; the rest are left out | og:locale, og:locale:alternate |
|
||||
| SEO fallbacks | Empty French SEO fields fall back to French title and excerpt. English site defaults are never applied silently to a non-default language: editor shows "Empty. English site default not used." and the tag is omitted | Editor labels: "Fallback: French title", "No fallback" |
|
||||
|
||||
## Article list modes and sample data
|
||||
|
||||
| Mode | Units and count | Representative title | Row opens | Search | Sort |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| All languages | Stories | Default-language version if readable, else first readable by language code | Representative version | Any readable title | Story updated: latest readable version |
|
||||
| Language selected | Versions in that language | That version | That version | That language only | Version updated |
|
||||
| Missing language | Stories lacking it or unreadable in it | As All languages | Representative version | Titles in any readable language | Story updated |
|
||||
| Filters | Status, Author, SEO, Needs review: a story matches if any readable version matches (language mode: the version) | | | | |
|
||||
| Story Author, Updated | Union of readable authors; latest readable update | | | | |
|
||||
| Differing sites | Language filter lists the union; Missing excludes sites that do not enable the language | | | | |
|
||||
| Dataset (locale-fixtures.js) | See the Fixture index. Journal: 8 articles, 17 versions, page size 5 (page 1 shows 5, page 2 shows 3). Enabled en (default), fr, de, ar; disabled es, it. Versions: en 7 (6 published), fr 4 (2 published, 2 Needs review), de 4 (3 published), ar 1, es 1, it 0. 4 articles have no French version. Needs translation 7, Needs review 2. Every list, tab count, unit label and pager is computed from locale-fixtures.js. | | | | |
|
||||
|
||||
## Review source and revision semantics
|
||||
|
||||
| Topic | Decision |
|
||||
| --- | --- |
|
||||
| Record | { article_id, source_article_id, source_revision, translation_revision, reviewed_by, reviewed_at } |
|
||||
| Confirmation guard | The dialog carries the versions the user saw. If either moved, the dialog says "The English source changed while you were reviewing. Review again." or "French was changed by someone else while you were reviewing. Review again." Nothing is marked |
|
||||
| Change tracked source | Explicit action Change tracked source. Result: Needs review. Earlier reviews are kept as history |
|
||||
| Cycles | Prevented (422 source_cycle). Source must be in the same story |
|
||||
| Access loss vs deletion | Access lost: "The source isn't available to you right now." history kept, Mark reviewed disabled. Deleted: "Source deleted" with Choose a new source |
|
||||
| Source replacement | Pick another readable version of the story; freshness becomes Needs review |
|
||||
| What changed | Changed-field summary only, shown as "Changed fields: Title, Body", with the source version date and the translation version date. No text diff and no revision numbers in the UI |
|
||||
| Concurrent edits | Mark reviewed applies only to the revisions the user saw |
|
||||
|
||||
## Title, slug and concurrent edit flows
|
||||
|
||||
| Case | Behavior |
|
||||
| --- | --- |
|
||||
| Title editing | Inline underline input in heading style, on the same row as the language, badges and actions on desktop (no box, no visible label). States: pristine, focus, editing, unsaved, invalid ("Enter a title"), too short, too long, saving, failed (value kept). Bounds 3 to 200 characters. Accessible label "Title (French)". Editing the title never changes the slug |
|
||||
| Slug | See Slug policy. Edit slug lives in Search and sharing and in the overflow menu; its note says "Editing the title never changes the slug." |
|
||||
| Conflict | "Someone else saved this version at <time>." Choices: Keep my changes (filled; nothing sent), Download my version, Reload theirs (confirm: "This discards your unsaved changes."), Save over theirs (sends the version fetched now; a newer save gives a new conflict) Save over theirs is outlined in the error colour with a warning icon. |
|
||||
| Overwrite scope | "Replaces the title, body and search fields of this version only. Other languages are untouched." |
|
||||
| Session expiry | Draft kept locally per article (title, body, search fields) until saved or discarded. After login: "Restored unsaved changes from <time>." |
|
||||
|
||||
## Language configuration lifecycle
|
||||
|
||||
| Action | Rule |
|
||||
| --- | --- |
|
||||
| Disable default | Blocked: "Choose another default first." |
|
||||
| Disable last enabled | Blocked: "At least one language must stay enabled." |
|
||||
| Disable with content | Allowed; versions remain; published stay online; publishing blocked for drafts |
|
||||
| Re-enable | Row action Re-enable; no data change |
|
||||
| Remove | Only at zero versions; never the default |
|
||||
| Save error | Dialog keeps language, default and toggle values; error above the footer |
|
||||
| Story moves | Disabled-language versions count toward destination requirements |
|
||||
|
||||
## Mobile list and prototype scope
|
||||
|
||||
| Item | Spec |
|
||||
| --- | --- |
|
||||
| Filter sheet | Site, Language, Status, Author, SEO; Clear all and "Show N results"; active filters show as chips with "Remove filter Status: Draft" and Clear all |
|
||||
| Tabs | Scrolls with edge fade and buttons "Scroll tabs left/right"; Not in French is always reachable |
|
||||
| Pager | Same page size as desktop (5); "Page 1 of 2"; the URL keeps page and size |
|
||||
| Row actions | Identical capability on every width: see Row actions. Mobile uses the row menu button (44 px), desktop shows the same button (28 px) |
|
||||
| Prototype flows | Not part of this pass. The LocaleLive prototype was not touched |
|
||||
|
||||
## Editor header priority
|
||||
|
||||
| Order | Item | 1440 | 1024 | 390 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Title input (underline, heading style; own row on mobile, shares the header row on desktop) | Full width | Full width | Full width |
|
||||
| 2 | Language dropdown | Visible | Visible | Visible, 44 px |
|
||||
| 3 | Publication badge | Visible | Visible | Visible |
|
||||
| 4 | Primary action (the only filled button) | Rightmost before More actions | Same | Fixed bottom bar, fills the width left by Save and More |
|
||||
| 5 | Status badge (Unsaved changes, Saving, Saved, Save failed) | Visible | Visible | Visible |
|
||||
| 6 | Save (secondary) | Visible | Visible | Bottom bar, next to the primary |
|
||||
| 7 | Freshness badge | Icon and word | Collapses first: icon only, name in aria-label | Wraps under the language row |
|
||||
| 8 | Read only tag | Visible | Visible | Visible |
|
||||
| 9 | Reference button | Icon and label | Collapses second: icon only | More actions sheet |
|
||||
| 10 | Unpublish (secondary) | Visible | Collapses third: More actions menu | More actions sheet |
|
||||
| 11 | More actions | Edit slug, Move article, Delete versions | Same plus Unpublish | Same plus reference and Unpublish |
|
||||
|
||||
## Action matrix
|
||||
|
||||
| State | Permissions | Primary (filled) | Secondary | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Clean draft | edit + publish | Publish French | Save (off) | loc.l5.default |
|
||||
| Clean draft | edit only | None | Save (off) | Hint: Publishing needs the publish permission |
|
||||
| Dirty draft | edit only | Save | None | loc.l5.dirty; "Saving keeps it as a draft." |
|
||||
| Dirty draft | edit + publish | Save and publish French | Save | loc.l5.dirtyPublish |
|
||||
| Clean online | edit + publish | None | Unpublish French, Save (off) | loc.l5.cleanOnline; "This version is online. Saving updates the live page right away." |
|
||||
| Dirty online | edit + publish | Save | Unpublish French | loc.l5.onlineDirty |
|
||||
| Saving | any | Saving… (busy) | None | loc.l5.saving |
|
||||
| Saved | any | Publish French if allowed | Save (off) | loc.l5.saved |
|
||||
| Save failed | edit | Try saving again | None | loc.l5.saveFail; nothing published |
|
||||
| Publish failed after save | edit + publish | Retry publish | Save (off) | loc.l5.pubFail; "Saved as a draft. Publishing failed. Try publishing again." |
|
||||
| Unpublish with edits | publish | Unchanged | Unpublish French | loc.l8.unpublishDirty; saved version goes offline, local edits stay, nothing is saved |
|
||||
| Read-only | read | None | None | Reference button remains |
|
||||
| Publish without edit | publish | Publish French | None | Fields read-only |
|
||||
|
||||
## Title field states
|
||||
|
||||
| State | Look | Message | Save |
|
||||
| --- | --- | --- | --- |
|
||||
| Pristine | Heading-size text, neutral border | None | Off |
|
||||
| Focus | Primary 2px underline | "Changing the title doesn't change the address." | Off |
|
||||
| Editing | Primary border, counter n / 200 | Same note | On |
|
||||
| Unsaved | Neutral border, Unsaved changes badge | Same note | On |
|
||||
| Invalid | Error border and icon | "Enter a title" | Blocked |
|
||||
| Too short | Error border, counter | "Use at least 3 characters" | Blocked |
|
||||
| Too long | Error border, counter 203 / 200 | "Use 200 characters or fewer" | Blocked |
|
||||
| Saving | Spinner | "Saving title…" | Busy |
|
||||
| Save failed | Error border and icon | "Couldn't save the title. Your text is kept." Try again | On |
|
||||
|
||||
## Row actions (same on every width)
|
||||
|
||||
| Action | Shown when | Desktop | Mobile |
|
||||
| --- | --- | --- | --- |
|
||||
| Open <language> | Always, for the version the row opens | Row click and menu | Row tap and menu |
|
||||
| Open another language… | More than one readable version | Menu | Menu |
|
||||
| Add translation… | User can create and a language is missing | Menu (dashed chip button as a shortcut) | Menu |
|
||||
| Publish / Unpublish <language> | User can publish | Menu | Menu |
|
||||
| Copy API URL | Always | Menu | Menu |
|
||||
| Delete <language> version | User can delete | Menu | Menu |
|
||||
|
||||
## Coverage chips for many languages
|
||||
|
||||
| Width | Chips shown | Overflow | Dashed missing chips |
|
||||
| --- | --- | --- | --- |
|
||||
| 1440 | 5 | "+3" chip for 8 languages; named "3 more languages: …" | Last in order; buttons only if the user can create; hidden otherwise |
|
||||
| 1024 | 4 (narrower column) | "+4" | Same |
|
||||
| 390 | 4, under the title | "+4" | Not buttons; added from the row menu |
|
||||
| Order | Default language, other existing versions, disabled-language versions, then missing | | Never shown for languages the user can't create |
|
||||
|
||||
## URL state (article list)
|
||||
|
||||
| Param | Values | Default |
|
||||
| --- | --- | --- |
|
||||
| site | Site key | First site |
|
||||
| lang | Language code; absent means all languages | all |
|
||||
| tab | all, missing, review (Published and Drafts are the status filter) | all |
|
||||
| q | Search text | empty |
|
||||
| status | online, draft | any |
|
||||
| author | Author name | any |
|
||||
| seo | attention | any |
|
||||
| page | Page number, starting at 1; filter changes reset it | 1 |
|
||||
| size | Rows per page | 5 in the fixture |
|
||||
|
||||
## Public URL pattern validation
|
||||
|
||||
| Case | Example | Message |
|
||||
| --- | --- | --- |
|
||||
| Valid | https://journal.example.com/{lang}/{slug} | Preview: https://journal.example.com/en/how-we-price-a-bathroom-renovation |
|
||||
| Unknown placeholder | https://journal.example.com/{language}/{slug} | Unknown placeholder {language}. Use {lang} and {slug}. |
|
||||
| Bad scheme | ftp://journal.example.com/{lang}/{slug} | Start with https:// or http://. |
|
||||
| Not encoded | https://journal.example.com/my pages/{lang}/{slug} | Remove spaces and characters that need encoding. Use letters, digits, - and _. |
|
||||
| Missing {slug} | https://journal.example.com/{lang} | Add {slug} so each article has its own address. |
|
||||
| Invalid host | https://journal..example.com/{lang}/{slug} | Enter a valid host name, such as journal.example.com. |
|
||||
| Unset | | Public URL not configured, with a link to this setting |
|
||||
|
||||
## Social locale per language
|
||||
|
||||
| State | Label | Meaning |
|
||||
| --- | --- | --- |
|
||||
| Derived | Derived: pt_BR | The language tag has a region. Not editable except by setting a value |
|
||||
| Set | Set: fr_FR | An owner typed it. Format language_REGION |
|
||||
| Not set | Not set | No region available. og:locale and og:locale:alternate are left out |
|
||||
| Invalid | fr-FR | Use letters and one underscore, for example fr_FR. |
|
||||
|
||||
## Move article
|
||||
|
||||
| Case | Behavior |
|
||||
| --- | --- |
|
||||
| Entry point | The site select in the editor details |
|
||||
| Note | "Moves all language versions of this article." Lists readable versions; a version the user can read but not edit is tagged Read only; unreadable versions are not listed or counted |
|
||||
| Confirm | "Move to Atelier?" with the same list |
|
||||
| Failures | Language missing in the destination (disabled languages count), address conflict (names only readable conflicting versions), no permission (neutral) |
|
||||
|
||||
## Release 2 groups
|
||||
|
||||
| Group | Release | Why |
|
||||
| --- | --- | --- |
|
||||
| L6 Source reference | Release 2 | Drawn in full so the checker has frames |
|
||||
| L9 Freshness and review | Release 2 | Drawn in full so the checker has frames |
|
||||
|
||||
## Copy decisions (pass 3)
|
||||
|
||||
| Item | Final copy or rule |
|
||||
| --- | --- |
|
||||
| Editor banner: save failed | Couldn't save French. Your changes are kept here. Nothing was published. Try saving again. |
|
||||
| Editor banner: publish failed | Saved as a draft. Publishing failed. Try publishing again. (detail: Your edits are saved. Only the publishing step failed.) |
|
||||
| Editor banner: online | This version is online. Saving updates the live page right away. |
|
||||
| Row menu, all languages | Open <language>, Open another language…, Add translation…, Unpublish <language>, Copy API URL, Delete <language> version |
|
||||
| Row menu, language selected | Open <language>, Open another language…, Publish or Unpublish <language>, Copy API URL, Delete <language> version |
|
||||
| Disabled-language version in the menu | Row shows the Disabled tag. Footer: "<Language> is disabled on this site. Its version stays readable and editable. It can't be published until an owner re-enables <Language>." |
|
||||
| Setup done | Languages assigned. N articles now have a language. Nothing was translated, and no articles were grouped. |
|
||||
| Languages list on 390 px | The version count line ("4 versions · 2 published") is shown, on its own line under the language name |
|
||||
| Single-language card on 390 px | "Social locale" and "Derived: pt_BR" sit on their own line below the language |
|
||||
| Partial or failed delete | Couldn't delete the article. Nothing was deleted. Every version is still there. Try again in a moment. |
|
||||
| Version deleted toast | French version deleted. "<title>" was deleted. English and German weren't changed. |
|
||||
| Article deleted toast | Article deleted. The article and all of its translations were deleted. |
|
||||
| Move failed | Couldn't move the article. Nothing was moved. All 4 versions are still on Journal. Try again. |
|
||||
| Second conflict | Their version changed again. Someone else saved this version again at <time>, before your save went through. Nothing was overwritten. Your changes are still here. |
|
||||
| New article, language help | Default language for <site>. |
|
||||
| Invalid host | Enter a valid host name, such as journal.example.com. |
|
||||
| Toast icon box | 28 × 28 px, 16 px icon, tone colour on its -soft ground (--rc-success-soft, --rc-info-soft, --rc-warning-soft, --rc-error-soft). Body and title as in the Toast table |
|
||||
| Activity: site.language.add | Added <language> to <site> |
|
||||
| Activity: site.language.enable | Re-enabled <language> on <site> |
|
||||
| Activity: site.language.disable | Disabled <language> on <site> |
|
||||
| Activity: site.language.remove | Removed <language> from <site> |
|
||||
| Activity: site.language.default | Made <language> the default language of <site> |
|
||||
| Activity: site.language.social_locale | Set the social locale for <language> on <site> to <value> |
|
||||
| Activity: site.public_url.update | Changed the public URL pattern of <site> |
|
||||
| Activity: site.language.setup | Assigned languages to N articles on <sites> |
|
||||
| Activity: article.move | Moved "<title>" from <site> to <site> |
|
||||
@@ -0,0 +1,125 @@
|
||||
# Article localization implementation handoff
|
||||
|
||||
Baseline imported 2026-10-31 and refreshed 2026-11-01 (completion pass) from `.claude/design`. This is a target design, not a statement that the feature exists. The user authorized this import and reconciliation. Feature and QA acceptance still happen per slice before implementation.
|
||||
|
||||
## Authority and imported artifacts
|
||||
|
||||
For localization, use this document for integration rules and corrections, `localization-tables.md` (generated from `source/locale-spec.js`) for the product tables, `screens/localization-*.md` and `states.md` for the state inventory, and `localization-corrections.md` for what the last design pass rendered, inspected and left open. Use `source/LocaleFrame.dc.html` for layout and rendered copy. This document supersedes conflicting board notes and older title/slug assumptions. Existing accepted `features/` behavior remains binding until a new accepted slice explicitly changes it. Report any remaining conflict at specification time; do not silently change behavior to match a mockup.
|
||||
|
||||
Imported sources are the Localization board, LocaleFrame, locale-spec.js, locale-fixtures.js (the single fixture set every list, count and pager derives from) and `localization-changelog.md` (state IDs and copy changed by the third pass). Since the third pass (2026-11-03), LocaleFrame draws inside the shared AppFrame, which gained optional editor-header slots and a width option; LocalizationShell is gone. Do not introduce a second application shell in production: continue using DashboardShell and existing editor components.
|
||||
|
||||
The LocaleLive prototype was deliberately not imported. The Prototype and Handoff boards remain unchanged and do not demonstrate localization. This written handoff replaces the missing localization handoff, not the missing interactive prototype. No rendered visual parity, keyboard flow, browser console check or production test is claimed by this import.
|
||||
|
||||
## Product model
|
||||
|
||||
A story groups article versions within one site. Each version keeps its article ID, creator/permissions, language, title, body, slug, SEO/social fields, alt text, publication and timestamps. Enforce one version per normalized locale per story. Files are shared references, not duplicated uploads. Authors and image selections are copied independently when requested. All versions share the story's site. Site changes therefore use an atomic story move rather than moving one linked version alone.
|
||||
|
||||
Site default, current editing language, temporary comparison reference and tracked review source are distinct. Changing `?ref=` changes only the comparison pane. It does not update the tracked source. No machine translation, cross-site grouping, bulk publishing, synced scrolling or text diff is included.
|
||||
|
||||
Use existing ID conventions for new records unless the accepted backend slice establishes a reason to differ. Example `st_01` in mockups is illustrative; it does not mandate string IDs.
|
||||
|
||||
## Languages and existing-content setup
|
||||
|
||||
Owner manages `/settings/sites/:key/languages`. Editors who need language metadata can read the allowed metadata without gaining site-management access. Gate all row menus and Re-enable actions as well as Add language.
|
||||
|
||||
At least one language is enabled and the default is enabled. Choose another default before retiring the current one. Remove only unused, non-default languages. Disable preserves content and online versions; editing remains permitted, but draft publication and new translation creation require re-enabling. Unpublish remains available under existing permissions. Existing disabled-language versions remain accessible through permitted lists and direct IDs.
|
||||
|
||||
Preserve full supported locale tags such as `pt-BR`. If only a broader tag is supported, explain the suggested substitution and require confirmation. No locale means an explicit choice, not an inferred translation. Setup assigns a language and a separate story to each existing article; it does not guess translation links.
|
||||
|
||||
Setup must be idempotent and atomic for its declared scope. The backend slice must define create/setup concurrency and safe retries before coding: an unassigned article must not become stranded or assigned to a different default halfway through setup. Keep stable article IDs, bodies, publication and API reads. Test migrations through the repository’s applicable persistence tests; HTTP fakes alone do not establish migration correctness.
|
||||
|
||||
## Editing, permissions and publication
|
||||
|
||||
Retain existing per-version read/update/publish/delete scopes and creator semantics. Translation membership grants no additional permission. Publishing and updating are separate permissions. A user allowed to update an online article can change its live content by saving, even without publish permission; this preserves the current direct-update model. This feature does not add unpublished revisions or editorial approval of updates.
|
||||
|
||||
Use this copy for an online editable version: “This version is online. Saving updates the live page right away.” For draft writers without publish: “Saving keeps it as a draft.” Do not claim saving an online article produces a draft.
|
||||
|
||||
Both viewports use Save and publish for a dirty offline version when update and publish are allowed. Save succeeds before publish is sent. Save failure retains dirty data and sends no publish request. If save succeeds but publishing fails, content is saved offline; offer retry publication without pretending the save failed. Recheck conflicts/permissions server-side on both operations. No transactional combined endpoint is promised. Unpublish does not discard or implicitly save local edits; explain the resulting saved/offline state and retain the draft buffer.
|
||||
|
||||
Titles become editable for single-language and multilingual articles, using existing 3–200 character validation. Title edits never regenerate an existing slug. Explicit slug edits are permitted before or after publication. Warn: “Links to the old address will stop working. RenewCMS does not create redirects.” No redirect capability is promised.
|
||||
|
||||
Retain title, body, search fields and base revision after failures. Reload-theirs requires discard confirmation; download local work before discarding when requested. Overwrite uses the revision shown by the conflict dialog as its expected revision; do not silently fetch a newer revision immediately before saving and thereby bypass confirmation of another writer's changes. A subsequent update must produce a fresh conflict.
|
||||
|
||||
Local recovery is scoped to authenticated user and article ID. Do not restore another user's content after account switching. Define cleanup on successful save, explicit discard, logout and access revocation in the slice acceptance criteria. Draft recovery must not grant server access.
|
||||
|
||||
## Visibility and list semantics
|
||||
|
||||
Only readable versions contribute titles, author unions, timestamps, coverage, results and counts. Never include hidden siblings in public alternates or error payloads. Readable creation conflicts may link to the version; otherwise show “French can't be created for this story.” An unreadable-version conflict has no ID, title or Open action. Missing means no readable version, not proof no stored version exists. Do not advertise stronger anti-enumeration guarantees than that policy provides.
|
||||
|
||||
Authenticated editorial all-language view counts stories, uses the readable default-language title or deterministic first readable locale, and opens that representative version. Updated is the latest readable version update; authors are the union of readable authors. Selected-language view counts versions. Missing-language view counts stories in sites enabling the target language and searches readable source titles. Other language-filtered searches match that language's titles. All-language search matches any readable title.
|
||||
|
||||
Use `lang` and `tab=all|missing|review` consistently in SPA and editorial API requests. Omit default `tab=all`. Missing requires a selected language. Review works in the active story/version mode. Preserve `page`, `size`, `q`, `status`, `site`, `seo` and existing sort syntax. Author filter is a new capability, not shipped behavior. Reset page on filter changes. Preserve the existing page-size choices and URL normalization.
|
||||
|
||||
For combined filters in story mode require a single readable version to satisfy the version-level predicates together; do not match an English author's version and a different French draft to fabricate a combined match. The accepted list slice specifies tab counts and their filter scope explicitly, preserving existing count behavior where possible. The union-language filter must distinguish disabled languages; missing excludes sites not enabling the selected language.
|
||||
|
||||
Preserve public `/v1/article/` pagination, object shape and access rules. Do not silently change anonymous lists to grouped stories. Define an explicit opt-in editorial grouping contract in the accepted backend list slice; new clients must not depend on an unannounced response-type switch.
|
||||
|
||||
## API target and compatibility
|
||||
|
||||
Existing `/v1/article/` POST/GET, `/v1/article/{id}` GET/PATCH/DELETE and PATCH publish/unpublish remain. The SPA keeps `/article/:id/edit` and the existing new-article entry. API capability tables describe proposed extensions, not functioning endpoints. Preserve snake_case and existing validation/error conventions; accepted HTTP specs establish exact bodies and status codes. Do not copy mockup JSON errors over current plain-text contracts indiscriminately.
|
||||
|
||||
Proposed extensions include language config/setup, article language/story metadata, translation creation, source/review tracking and atomic story moves. Use one translation-creation implementation for both UI entry points. Source must belong to the same story and be readable at submit. Copy snapshots and source revision are captured together server-side. Start-empty creates no tracked source and shows No source until explicitly linked.
|
||||
|
||||
Slugs become unique within `(site, lang)`. Exact public lookup is `/v1/public/site/{key}/lang/{lang}/article/{slug}` and never falls back. Story resolution is `/v1/public/site/{key}/story/{story_id}?lang=fr&fallback=default`. Without a published requested version, return not-found unless fallback was explicitly requested and the default version is published. Never try an arbitrary third language. Return actual `lang`, `requested_lang` and fallback status as appropriate. A default change affects requests omitting language; existing article-ID and explicit-language reads remain stable. Remove contrary “existing URLs never change” claims for language-omitted resolution.
|
||||
|
||||
Public alternates include eligible published versions only. Disabled published versions stay available by ID/exact read but are excluded from discovery alternates as designed. Decide noindex handling consistently with existing SEO output during the metadata slice.
|
||||
|
||||
Website URLs are not CMS API URLs. Use existing site URL conventions where compatible; a proposed owner-configured public URL pattern enables locale-aware links. No pattern/configuration means “Public URL not configured”; do not fabricate page URLs or change legacy canonical output before a slice explicitly accepts that change. Validate placeholders, URL scheme and encoding in the backend contract.
|
||||
|
||||
## Locale and SEO corrections
|
||||
|
||||
Article language determines content `lang`, direction and alternate-language identity; the SPA interface locale remains user-controlled. Existing `Site.Locale` currently participates in social metadata. Do not describe it as an existing SPA formatting setting: SPA formatting uses its own i18n locale. The feature changes article social metadata resolution deliberately rather than pretending the old field had no SEO purpose.
|
||||
|
||||
Preserve full article tags. Resolve social locale from explicit per-language configuration, a supported regional tag mapping, or a matching existing site locale; otherwise omit it and show Not set. Validate mappings instead of inventing a region. Bare languages do not imply a particular country. Respect existing canonical overrides.
|
||||
|
||||
Local SEO fallback uses the local title/body/excerpt according to existing fallback behavior. Do not silently inject a different language's site text. If a site textual fallback is used, its language must match the article language; default-language changes do not relabel existing site text. Image defaults may still be shared, with per-version alt text. The metadata slice must specify exact fallback order and conditional copy; “Description empty, tag omitted” is not universally correct when a local body/excerpt can supply it.
|
||||
|
||||
## Review and concurrency
|
||||
|
||||
Store source identity, source content revision, translation revision, reviewer and review time. Review confirmation must check both revisions and that the tracked source is unchanged. Source replacement must be in the same story, readable and cycle-free. Replacing it sets Needs review. Keep past review history when the source is deleted or access is temporarily lost; do not reveal unreadable source metadata. Access loss disables review while allowing permitted translation edits.
|
||||
|
||||
Content revision changes include title/body/excerpt/slug/alt/SEO/social text. Authors, site moves, image selection and publication do not increment the localization content revision. Use a separate edit concurrency version if needed so non-content updates still cannot be overwritten silently. Changed-field summaries must aggregate fields changed since the reviewed source revision, not merely the latest update. No text diff or immutable historical-source viewer is promised.
|
||||
|
||||
## Visual validation and implementation gaps
|
||||
|
||||
The board contains 16 groups and 183 states (L6 and L9 are release 2). L7s (single-language list) replaces the old Back-office Articles groups as the list's pixel-gate reference. Screen files map stable IDs to exported selectors. Markups are illustrations, not production accessible controls. Implement actual Nuxt UI input/menu/tab behavior. Retain the full existing editor toolbar; the shortened mockup toolbar does not authorize removing features.
|
||||
|
||||
The existing visual checker must be extended to discover localization's groups, state tables and 1024px reference where needed, with scoped fixtures and API mocks. Do not claim `task visual:check` can already verify the import. No threshold increases or exceptions are authorized. Clock-sensitive skips only apply to states whose rendering actually depends on current time, not whole features containing dates.
|
||||
|
||||
Known board limitations to settle in the relevant accepted slice: partial sample rows despite full-page totals; alternate empty-state fixtures; desktop/mobile action parity; read-only config controls; title validation bounds; source reference SEO metadata; restore/reload interactions; and state fixtures consistent across lists and editor. If matching the board would violate this handoff or an accepted scenario, document the discrepancy and obtain the workflow's explicit design decision before coding. Do not waive visual checks or silently improve the reference in a coder stage.
|
||||
|
||||
## Recommended slice order
|
||||
|
||||
1. Language value rules, site configuration and existing-content setup/migration contracts.
|
||||
2. Story/version storage, unique constraints and authorized translation creation.
|
||||
3. Article update/title/slug and revision conflict contracts, preserving public compatibility.
|
||||
4. Site language settings and setup SPA.
|
||||
5. Editor language navigation, translation dialog and publication scope.
|
||||
6. Editorial grouped/language/missing lists with mobile filters and pagination.
|
||||
7. Public story resolution, localized URL configuration and SEO metadata.
|
||||
8. Optional comparison pane, tracked-source review and freshness UI.
|
||||
9. Atomic story moves/deletion and integration regression coverage.
|
||||
|
||||
Split further to keep accepted slices reviewable. Include required backend support before each SPA slice. Each slice follows specifier acceptance → coder → size-gated cleaner/architect → hardener → qa. This order is planning guidance, not acceptance of Gherkin or permission to skip a gate.
|
||||
|
||||
## Open decisions and known design gaps (2026-11-01)
|
||||
|
||||
Settle these during specification of the slice named, before coding.
|
||||
|
||||
| Topic | Gap | Slice |
|
||||
| --- | --- | --- |
|
||||
| Setup route | The route is `/settings/sites/:key/languages/setup` but the page lists every site, and the proposed setup API is global. Decide one global page reached from a per-site banner, or a per-site page. | Languages and setup |
|
||||
| Visual coverage | About 45 of the original 169 states were rendered and inspected by the designer (see `localization-corrections.md`). Icons were never checked. Unseen frames may be broken; the visual check will reveal them. | All SPA slices |
|
||||
| Header widths | Editor header widths between 1024 and 1440 are specified, not drawn. The checker captures 1440, 1024 and 390 only. | Editor |
|
||||
| Mobile Languages rows | Rows are taller than needed. Tune in implementation. | Languages and setup |
|
||||
| Partial fixtures | Archive, Notes and Gallery exist only as single states. | Languages and setup |
|
||||
| Mobile list page 2 | The designer could not reproduce the overflow-chip and tab-strip issue on mobile page 2; they removed a stray character and gave the "+N" chip a 44 px tap area. Check it against the app. | Article list |
|
||||
|
||||
## Third-pass decisions (2026-11-03)
|
||||
|
||||
- No Languages item in the sidebar. Languages is reached from Settings → Sites → [site] → Languages, and from the setup banner.
|
||||
- Mobile list rows show the version count on its own line.
|
||||
- The Public URL pattern preview uses the site's default language (Journal previews the English address).
|
||||
- A deleted source and an unreadable source show the same message after submit: "The source isn't available to you".
|
||||
- "Save over theirs" is an error-coloured outline button with a warning icon; Keep my changes stays the only filled button.
|
||||
- "Delete article" is atomic: on failure nothing is removed and every version stays.
|
||||
@@ -1,4 +1,4 @@
|
||||
# Media field type: change list for implementation
|
||||
# Media field type
|
||||
|
||||
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.
|
||||
|
||||
@@ -101,21 +101,3 @@ HTTP to copy (picker and field), from states.md: 400 "That request wasn't valid.
|
||||
- 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.
|
||||
+31
-3
@@ -7,7 +7,7 @@
|
||||
| /settings/media/cache | Cache (admin) | none | GET /v1/image-variants/cache, POST /v1/image-variants/cache/purge {scope: all|unused|variant|image} |
|
||||
| /media | Media library | page, size, q, filter, sort, view | GET /v1/media/?page=&size=&q=&filter=&variants=&sort= (page, size; total in the `X-Total-Count` header) |
|
||||
| /media (detail) | Variants tab | none | GET /v1/media/{id}/variants, POST and DELETE /v1/media/{id}/variants/{name} |
|
||||
| /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 | site, lang, tab, q, status, author, seo, page, size | GET /v1/article/?page=&size=&q=&status=&site=&seo= returns `{ items, total, page, size, counts }`. Proposed on top: `lang`, `tab=all\|missing\|review` (see Localization). The old array body with `X-Total-Count` no longer applies to articles; media and authors still use it |
|
||||
| /activity | Activity | q, actor, category, outcome, security, range, from, to, page, size | GET /v1/activity (page, size), GET /v1/activity/export (shipped, CSV) |
|
||||
| /integration | Integration | none | none |
|
||||
| /settings/sites | Sites (owner only; other roles get a 403 page and no sidebar entry): list, add and edit slide-over, delete with reassignment. The key is read-only after creation | none | GET/POST /v1/site/, PATCH/DELETE /v1/site/{id} (DELETE takes `reassign_to`) |
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
## 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.
|
||||
**API:** `GET /v1/article/` accepts `page` (default 1) and `size` (5, 25, 50, 100; default 25), as `GET /v1/activity` does. The body stays a JSON array so existing consumers keep working; the total comes back in `X-Total-Count` (the response shape is to be confirmed with the specifier). Anonymous callers only see online articles, and the total counts only those. Invalid `page` or `size` answers 400, the same as Activity.
|
||||
**API:** `GET /v1/article/` accepts `page` (default 1) and `size` (5, 25, 50, 100; default 25), as `GET /v1/activity` does. The body is `{ items, total, page, size, counts }` (`counts` feeds the tab numbers); articles no longer send `X-Total-Count`, Media and Authors still do. Anonymous callers only see online articles, and the total counts only those. Invalid `page` or `size` answers 400, the same as Activity.
|
||||
|
||||
**Media API:** `GET /v1/media/` takes the same `page` and `size`, with the same 400 and the total in `X-Total-Count`; the array body stays. The earlier `cursor` and `limit` parameters are replaced by them.
|
||||
|
||||
@@ -73,4 +73,32 @@ Needs backend:
|
||||
| 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. |
|
||||
| Activity events | role.create, role.edit (with a diff of added and removed permissions), role.delete, role.reassign, user.change_role, user.transfer_ownership. Present tense, as the backend emits them. |
|
||||
|
||||
## Account: change email, export, delete
|
||||
| Route | Screen | Auth | API (assumed) |
|
||||
|---|---|---|---|
|
||||
| /account/email/confirm?c=CODE | Verify new email (AuthCard). States in screens/account.md | Session required; without one, inline log in (as /register/validate) | POST /v1/auth/email/confirm |
|
||||
| /profile | Gains change email, Your data, Danger zone | Any verified user | POST /v1/auth/email/change, POST /v1/auth/email/resend, DELETE /v1/auth/email/change, GET /v1/user/me/export, DELETE /v1/user/me |
|
||||
| /login?reason=account-deleted | One-time message after deletion | Public | none |
|
||||
|
||||
## Localization (proposed; existing routes unchanged)
|
||||
Existing: /articles, /article/:id/edit, GET/POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id}.
|
||||
|
||||
| Route | Purpose | Access |
|
||||
|---|---|---|
|
||||
| /settings/sites/:key/languages | Site languages, default, disable, re-enable, remove, social locale per language. The only languages route (no global Languages page) | Owner edits, others read |
|
||||
| /settings/sites/:key/languages/setup | First-time setup. :key is the site it was opened from; the page lists every site with unlabelled articles | Owner |
|
||||
| /settings/sites/:key/public-url | Public URL pattern (placeholders {lang}, {slug}) | Owner edits, others read |
|
||||
| /articles?site&lang&tab=all\|missing\|review&q&status&author&seo&page&size | Article list. Defaults omitted; filter changes reset page | Per article permission |
|
||||
| /article/:id/edit | Editor, one id per language version | Per version capability |
|
||||
| /article/:id/edit?ref=:sourceId | Temporary source reference (release 2). Does not change the tracked source | Read access to the reference |
|
||||
| /article/:id/edit?dialog=add-translation&lang=fr | Add translation dialog | create-translation |
|
||||
| /article/:id/edit?panel=seo | Search and sharing panel | edit |
|
||||
|
||||
Removed: /settings/languages and /settings/languages/setup (replaced by the site-scoped routes above). The old list params `mode` and `needs_review` are gone; `tab` replaces them.
|
||||
|
||||
Proposed API: GET /v1/article/ adds lang, tab=all|missing|review. GET /v1/article/{id} adds lang, story_id, capabilities[], translations[] (readable only), source. POST /v1/article/ adds lang, story_id, source_article_id, copy. POST /v1/article/{id}/translations. POST /v1/article/{id}/review (release 2). PATCH /v1/article/{id}/source (release 2). GET/PUT /v1/site/{key}/languages (adds social_locale per language). GET/PUT /v1/site/{key} adds public_url_pattern. GET/POST /v1/language/setup. POST /v1/story/{story_id}/move. PATCH /v1/article/{id} accepts if_revision (conflict response). Public: GET /v1/public/site/{key}/lang/{lang}/article/{slug} and GET /v1/public/site/{key}/story/{story_id}?lang=&fallback=default. The earlier GET /v1/public/site/{key}/article/{slug}?lang= is removed.
|
||||
Sidebar: there is no Languages item. Languages is reached only from Settings → Sites → [site] → Languages (and the Set article languages banner for first-time setup). The sidebar item list in components.md is unchanged.
|
||||
Field name is `lang` everywhere; the public list of other versions is `alternates`, the editor list is `translations`.
|
||||
Details: see Localization board tables (Public lookup and URLs, Visibility policy).
|
||||
@@ -0,0 +1,72 @@
|
||||
|
||||
# Account: change email, export my data, delete account
|
||||
|
||||
Route: `/profile` (extends the Account card, adds "Your data" and "Danger zone" cards). Verification landing: `/account/email/confirm?c=CODE`. API calls are assumed, not specified.
|
||||
|
||||
Placement: Profile already holds the read-only Email in the Account card and the Password card, so the email change extends that card; the two other features are cards at the bottom. No new navigation. Order: Display name, Account, Your role, Author profile, Password, Your data, Danger zone.
|
||||
|
||||
Boards: `RenewCMS Screens Account.dc.html` (Profile states), `RenewCMS Screens Auth.dc.html` (groups "Verify new email" and "Account deleted"). Frames: `AppFrame`, `AccountCards`, `AuthCard`. Copy source: `account-copy.js`. English only.
|
||||
|
||||
## Change email
|
||||
| State | Copy |
|
||||
|---|---|
|
||||
| Default | "Email" field, link "Change email" |
|
||||
| Form | "New email address" · "Current password" · "We'll send a verification link to the new address. Your email stays the same until you open it." · "Cancel" · "Send verification link" |
|
||||
| Invalid format | "Enter a full email address, like name@example.com." |
|
||||
| Same as current | "That's already your email address." |
|
||||
| Password missing | "Enter your current password." |
|
||||
| Wrong password (403) | "That password isn't correct. Try again." |
|
||||
| Sending | "Sending…" |
|
||||
| Pending | "Waiting for verification" · "We sent a link to marta.alves@atelier-renew.example. Until you open it, you keep logging in with marta@renew.example." · "Resend link" · "Cancel change"; toast "Verification link sent" / "Check marta.alves@atelier-renew.example. The link works once, within 2 hours." |
|
||||
| Resending | "Sending…" |
|
||||
| Resent | toast "Link sent again" / "The previous link no longer works." |
|
||||
| Resend rate limited (429) | "Resend in 4:30" (disabled) · "Too many requests. Try again in a few minutes." |
|
||||
| Cancelled | toast "Email change cancelled" / "You keep logging in with marta@renew.example." |
|
||||
| Rate limited (429) | "Too many requests. Try again in a few minutes.", Send disabled |
|
||||
| Server or network | "Something went wrong on our side. Nothing was changed. Try again in a moment." |
|
||||
|
||||
Rules: the old email stays active for login until the link is opened. The old address gets the notice email at request time. A request for an address already in use answers like success, so the UI and the response never reveal that an account exists.
|
||||
|
||||
## Verification landing (AuthCard)
|
||||
| State | Copy |
|
||||
|---|---|
|
||||
| Confirming | "Confirming your new email…" |
|
||||
| Success | "Your email address has changed" · "Your account now uses marta.alves@atelier-renew.example. Use it the next time you log in." · "Go to profile"; toast "Email address changed" / "Your account now uses marta.alves@atelier-renew.example." |
|
||||
| Invalid or expired (403) | "This link doesn't work" · "It may have expired (links last 2 hours), been cancelled or already been used." · "Request a new link from your profile." · "Go to profile" |
|
||||
| Address unavailable (409) | "We couldn't change your email address" · "This address can't be used for your account. Go back to your profile and try a different one." · "Go to profile" (same copy whether the address is taken or rejected) |
|
||||
| No session (401) | "Log in to confirm your new email" · "You opened this link somewhere you're not logged in. Log in here and we'll confirm the change right after." · "Log in and confirm" |
|
||||
| Missing code | "This link is incomplete" · "The confirmation code is missing. Open the link from your email again, or copy the full address into your browser." · "Go to log in" |
|
||||
| Server error | "Couldn't confirm the change" · "Something went wrong on our side. Nothing was changed. Try again in a moment." · "Try again" |
|
||||
|
||||
## Your data
|
||||
Contents listed in the card: Profile (Username, display name, email, role and author profile.); Authored content (Your articles and entries.); Uploaded media (References to the files you uploaded.); Account activity (Your log-ins and the actions recorded under your name.). Card copy: "Your data" · "Download a copy of the personal data RenewCMS holds about you, as JSON files in a ZIP.".
|
||||
| State | Copy |
|
||||
|---|---|
|
||||
| Direct (recommended): default | "Download my data" · "Ready in a few seconds." |
|
||||
| Preparing | "Preparing download…" |
|
||||
| Started | toast "Download started" / "renewcms-export-marta-2026-10-29.zip is in your downloads." |
|
||||
| Rate limited (429, one per 15 minutes, clock-sensitive) | "You can export once every 15 minutes" · "Your last export was at 09:41. Try again in 12 minutes." · "Available in 12:00" |
|
||||
| Error | "Couldn't prepare your data" · "Nothing was changed. Try again in a moment." |
|
||||
| Alternative, emailed link: default | "Request my data" · "Larger exports are prepared in the background. We'll email a download link to marta@renew.example when it's ready." |
|
||||
| Requested | "We're preparing your export" · "This can take a few minutes. We'll email a link to marta@renew.example when it's ready." · "Export requested"; toast "Export requested" / "Check marta@renew.example in a few minutes." |
|
||||
| Already in progress | "An export is already being prepared" · "We'll email a link to marta@renew.example when it's ready." |
|
||||
|
||||
Decision: direct download. An emailed export link is out of scope.
|
||||
|
||||
## Delete account
|
||||
Danger zone: "Danger zone" · "Delete your account and your personal data. This can't be undone." · "Delete my account".
|
||||
| State | Copy |
|
||||
|---|---|
|
||||
| Dialog | "Delete your account?" · "This permanently deletes your account. It can't be undone." · list: "Your username, display name and email address", "Your author profile and API keys", "Your sessions: you'll be logged out everywhere" · "Your articles and media stay" / "They stay on the site, credited to "Deleted user". Your name is removed from them." · "Export my data first" · "Current password" · "Type DELETE to confirm" · "Keep my account" · "Delete my account" |
|
||||
| Empty, typing | Confirm disabled until password is filled and the word equals "DELETE" (exact, case-sensitive) |
|
||||
| Wrong password (403) | "That password isn't correct. Try again." |
|
||||
| Deleting | "Deleting…"; fields and Cancel disabled; then sign out and redirect to /login?reason=account-deleted |
|
||||
| Rate limited (429) | "Too many requests. Try again in a few minutes." |
|
||||
| Server error | "Couldn't delete your account. Nothing was deleted. Try again in a moment." |
|
||||
| Blocked: last Owner or administrator | "You can't delete your account yet" · Owner: "You are the last Owner. RenewCMS needs at least one. Transfer ownership or promote another user first, then come back to delete your account." · Administrator: "You are the last administrator. RenewCMS needs at least one. Promote another user first, then come back to delete your account." · "Back to profile" · "Go to Users" |
|
||||
| Blocked: cannot be deleted | "This account can't be deleted" · "RenewCMS can't delete this account right now. Ask an administrator to help, then try again." · "Back to profile" |
|
||||
| Login after deletion | "Your account has been deleted" · "Your articles and media were kept without your name." |
|
||||
|
||||
Blocked dialogs have no close button, do not close on Escape or backdrop click, and always give a next step.
|
||||
|
||||
Authored content: anonymized to "Deleted user", stated in the dialog. Export-ready email: the link needs a logged-in session, stated in the email and the card hint. Account-deleted email: generic, no personal data, sent to the address the account had at deletion.
|
||||
@@ -93,3 +93,33 @@ Route: `/activity`. API: GET /v1/activity (server scoped to the user).
|
||||
| 1 | Default | No summary strip, no actor filter, no Export. Title becomes My activity. | |
|
||||
| 2 | Empty | The user has not done anything yet. | |
|
||||
| 3 | Details | Same slide-over, own event. | |
|
||||
|
||||
## Roles sync (pass 6): event names, failed sentences, categories
|
||||
|
||||
Event names are present tense, as the backend emits them: role.create, role.edit, role.delete, role.reassign, user.change_role, user.transfer_ownership. The outcome is a field; a failed event keeps its name and uses the failed sentence.
|
||||
|
||||
| Event | Sentence | Failed sentence |
|
||||
|---|---|---|
|
||||
| role.create | Role Contributor created | Failed to create the role Contributor |
|
||||
| role.edit | Role Contributor edited | Failed to edit the role Contributor |
|
||||
| role.delete | Role Intern deleted | Failed to delete the role Intern |
|
||||
| role.reassign | Moved 3 members from Intern to Writer (one member: Moved 1 member from Intern to Writer) | Failed to move 3 members from Intern to Writer |
|
||||
| user.change_role | Ines Duarte's role changed from Writer to Content lead | Failed to change the role of Ines Duarte |
|
||||
| user.remove | Removed Ines Duarte | Failed to remove Ines Duarte |
|
||||
| user.transfer_ownership | Ownership transferred from Marta Alves to Jonas Weber | Failed to transfer ownership to Jonas Weber |
|
||||
|
||||
Failed template: "Failed to {verb} {object}". A failed row shows the Failed badge after the sentence; the details panel shows "Why it failed" with the reason from the API.
|
||||
|
||||
Category filter values: Articles, Media, Users and invites (was "Users & invites"), Roles (new), API keys, Authentication, System. French and the failed sentences for every event are in screens/roles.md, group 8 and the table "Activity events: failed sentences, EN and FR". Frames: Roles board, group "8 Activity".
|
||||
|
||||
|
||||
### Fallback wordings (app sync)
|
||||
| Event | Condition | Sentence |
|
||||
|---|---|---|
|
||||
| role.reassign | target role unknown | Moved 3 members from Intern to another role |
|
||||
| role.reassign | count unknown | Moved the members of the role Intern |
|
||||
| role.reassign | failed, count unknown | Failed to move the members of the role Intern |
|
||||
| role.reassign | failed, one member | Failed to move 1 member |
|
||||
| user.remove | name unknown | Removed a user (proposal, not in the app) |
|
||||
|
||||
Event names match the backend: role.create, role.edit, role.delete, role.reassign, user.change_role, user.transfer_ownership, user.remove.
|
||||
@@ -1,10 +1,10 @@
|
||||
# Articles
|
||||
|
||||
Route: `/article`, `/articles?page=2&size=50`. API: GET /v1/article/ · PATCH …/publish · PATCH …/unpublish · DELETE /v1/article/{id} · GET /v1/article/?page=&size=&q=&status= (page, size; total in the X-Total-Count header; invalid values are normalised in the URL, never shown as an error).
|
||||
Route: `/article`, `/articles?page=2&size=50`. API: GET /v1/article/ · PATCH …/publish · PATCH …/unpublish · DELETE /v1/article/{id} · GET /v1/article/?page=&size=&q=&status=&site=&seo= returns { items, total, page, size, counts } (invalid values are normalised in the URL, never shown as an error).
|
||||
|
||||
Rows update when the server responds, not optimistically. Search, tab and sort are stored in the query string.
|
||||
|
||||
Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Articles", "Articles: pagination". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
|
||||
Visual reference and pixel gate: `RenewCMS Screens Localization.dc.html`, group L7s (single-language site) and L7 (several languages), drawn by LocaleFrame with the shipped AppPaginator. The Back-office groups "Articles" and "Articles: pagination" are superseded for the list layout; they stay as the copy reference for states the new groups do not repeat. Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
|
||||
|
||||
## Copy per state (generated from the board source, pass 2)
|
||||
|
||||
@@ -47,12 +47,12 @@ Rows update when the server responds, not optimistically. Search, tab and sort a
|
||||
| 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. | |
|
||||
| 12 | Delete: in progress | DELETE in flight. The dialog can't be dismissed. | |
|
||||
| 13 | Delete: failed | 500, a partial failure where the article is kept. The error shows inside the dialog. | "Couldn't delete the article, so it's still there. Try again in a moment." |
|
||||
| 13 | Delete: failed | 500 or network failure. Deleting is atomic, so nothing is removed and every version is still there. The error shows inside the dialog. | "Couldn't delete the article." / "Nothing was deleted. Every version is still there. Try again in a moment." |
|
||||
| 14 | Deleted | 204: the row is removed and focus moves to the next row. This is also where a delete from the editor ends up. | "Article deleted" / ""Winter checklist for timber windows" and its images are gone." |
|
||||
|
||||
### Articles: pagination (group "Articles: pagination", 11 states)
|
||||
|
||||
Route: `/articles?page=2&size=50`. API: GET /v1/article/?page=&size=&q=&status= (page, size; total in the X-Total-Count header; invalid values are normalised in the URL, never shown as an error).
|
||||
Route: `/articles?page=2&size=50`. API: GET /v1/article/?page=&size=&q=&status=&site=&seo= returns { items, total, page, size, counts } (invalid values are normalised in the URL, never shown as an error).
|
||||
|
||||
Page and page size live in the URL as page and size. Defaults (page 1, size 25) are omitted. Allowed sizes 5, 25, 50, 100. Search, tab or sort changes reset to page 1. Rows equal the page size; the frames draw at most 10 rows. The paginator hides when everything fits on one page; the summary stays. Mobile shows Previous, "Page N of M" and Next. Keyboard: Tab reaches page size, first, previous, each page, next, last; Enter or Space activates; the current page has aria-current="page"; ArrowDown opens the page-size list, Enter selects, Esc closes.
|
||||
|
||||
@@ -99,3 +99,6 @@ A quiet indicator in the Status cell on desktop and under the title on mobile ca
|
||||
| 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.
|
||||
|
||||
## Localization
|
||||
Localization: modes All languages (stories), Language selected (versions), Missing language (stories); mobile filter sheet, scrollable tabs, pager; state in URL. See routes.md.
|
||||
@@ -23,4 +23,4 @@ The sidebar gets a "Content" group, one item per custom type (a default icon: ty
|
||||
| 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.
|
||||
Spec, states, exact copy, measurements, backend needs and accessibility: `media-field.md`, section 3.5. Board groups: 5 Content and entry list.
|
||||
@@ -175,3 +175,6 @@ A read-only dialog (desktop, 720 px) or bottom sheet (mobile) with the resolved
|
||||
| 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 |
|
||||
|
||||
## Localization
|
||||
Localization: actions render from per-version capabilities (read, edit, publish, delete, create-translation, review); title is an inline input; slug changes only via Edit slug. See Localization board.
|
||||
@@ -27,4 +27,4 @@ The form is generated from the schema, one control per field in schema order. Re
|
||||
| 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.
|
||||
Spec, states, exact copy, measurements, backend needs and accessibility: `media-field.md`, section 3.6. Board groups: 6 Entry editor.
|
||||
@@ -12,7 +12,7 @@ Sticky section index on the right (desktop), "Jump to" select (mobile). A refere
|
||||
- Intro: "RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office."
|
||||
- Base URL: label "Base URL", a CodeSnippet with one tab ("Base URL") and the button "Copy" / "Copied" (2 s, announced).
|
||||
- Authentication, card "Public": "No key needed. Two read-only routes, and only online articles are returned." `GET /v1/article/`, `GET /v1/article/{id}`.
|
||||
- Card "API key": "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator." CodeSnippet tabs curl, fetch (GET /v1/auth/me). "Keys start with `rcms_` and are shown in full once, when you create them." Link "Manage API keys".
|
||||
- Card "API key": "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role as it is now. A key never has more rights than its creator. A route that needs a permission answers 403 "missing_permission" to a key whose creator's role lacks it." CodeSnippet tabs curl, fetch (GET /v1/auth/me). "Keys start with `rcms_` and are shown in full once, when you create them." Link "Manage API keys".
|
||||
- Note 1: "A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified."
|
||||
- Note 2: "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header."
|
||||
- Groups, 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.""
|
||||
@@ -23,7 +23,7 @@ Sticky section index on the right (desktop), "Jump to" select (mobile). A refere
|
||||
- Collapsed (44 px row): method badge, mono path, one-line description, chips, chevron. Desktop is one line; mobile is three (method and path, description, chips).
|
||||
- Expanded: "Request body" table (Name, Type, Required, Rule; "No request body." when empty), tabs fetch / curl / Response in one CodeSnippet with Copy, then "Errors": status chip plus one-line caption (also its `title` tooltip).
|
||||
- Session only: dashed border, muted fill, lock icon, "Session only" chip, no method colour, no expand.
|
||||
- Chips: access "Public", "Verified user", "Owner only"; key "API key OK" (success soft) or "Session only" (dashed, muted).
|
||||
- Chips: access "Public", "Verified user", "Needs permission" (routes gated by a role permission; the permission name is in the route description), "Owner only"; key "API key OK" (success soft) or "Session only" (dashed, muted).
|
||||
- Method badges: GET `--ui-bg-inverted`, POST success soft, PATCH warning soft, DELETE error soft. Existing tokens only.
|
||||
|
||||
## States
|
||||
@@ -34,7 +34,7 @@ Desktop 1440×900, mobile 390×844, light and dark.
|
||||
Plain text, one sentence. Used in the table and in the per-endpoint error captions.
|
||||
- 400: "The request cannot be processed due to a mismatch in the format of the body." / "The expiry must be a date in the future." Image requests (`/media/photo.png?v=`) answer 400 with a JSON body (`status`, `error`, `message`, `detail`) in two cases: `{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "unknown" }` for an unknown or disabled variant, and `{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" }` for any other query parameter (`width`, `height`, `format`, `quality`). Both are rows of the "Responses" table in "Images and variants".
|
||||
- 401: "You must be logged in to access this resource."
|
||||
- 403: "This action needs a real session." (session-only routes called with a key) / "Only the owner can do this." / "You must verify your account to access this resource." 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.
|
||||
- 403: "This action needs a real session." (session-only routes called with a key) / `missing_permission` (the role of the user, or of the key's creator, lacks the permission the route needs) / "You must verify your account to access this resource." Note under the 403 row: "`missing_permission` is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article." The inline parenthetical that said the same thing in the row's cause text was removed to avoid saying it twice.
|
||||
- 404: "Article not found" / "Image not found" / "Invite not found" / "API key not found" / `site_not_found` (GET /v1/site/{key}/sitemap only; body is the code, not a sentence)
|
||||
- 429: "Too many requests. Please try again later." A per-key limit also sends `Retry-After`.
|
||||
- 500: "Internal server error"
|
||||
@@ -164,6 +164,15 @@ Board: group "Integration", states 8a to 8h, desktop 1440×900 and mobile 390×8
|
||||
- The 1.5 s flash duration and the #ep-<id> hash are proposals.
|
||||
|
||||
|
||||
## App sync notes
|
||||
- Schema routes show the permission key as their access chip: schema.view (GET), schema.edit (POST, PUT), schema.delete (DELETE). Purge routes show "Needs permission" until the app names the key.
|
||||
- 403 body for these routes: "Your role doesn't include this action. Ask an owner or admin." (app string, messages.ts).
|
||||
- Entries intro: "Writing entries needs the entry permissions for that type." (proposal; the app has no string yet).
|
||||
- Remaining "owners and admins" wording (activity export, content-types notice) stays as the app ships it.
|
||||
- Article list body: {items, total, page, size, counts} (no X-Total-Count). counts keys: all, online, draft, suggestions.
|
||||
- Users group has 7 routes: list, PATCH me, invite, cancel invite, renew invite, DELETE /v1/user/{id}?expected_role_id=N (permission, 409 target_role_not_found), GET /v1/auth/me. Profile routes use /v1/auth/me.
|
||||
- 404 adds site_not_found (sitemap, unknown key). The 403 note sits under its row at full row width. Every 400 uses the generic body-mismatch text ("The request cannot be processed due to a mismatch in the format of the body."); "Invalid page or size." is gone.
|
||||
|
||||
## Custom content types round
|
||||
Board: group "Integration", states 9a to 9j. Source: `IntegrationPage.dc.html`, `IntegrationSearch.dc.html`. Every route an API key can call is now documented.
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Localization: conflicts and article moves
|
||||
Board: RenewCMS Screens Localization. Frame: LocaleFrame. Data: locale-fixtures.js (single fixture source), tables in locale-spec.js. UI copy says "article", never "story". No revision numbers, IDs or HTTP status names in user-facing text.
|
||||
|
||||
## L11 Conflicts and sessions
|
||||
Route: editor
|
||||
API: existing 401/403/404/409 patterns
|
||||
|
||||
The conflict dialog gives four choices. Keep my changes is the only filled button; the other three are secondary and equal. "Someone else saved this version at <time>". No revision numbers. On mobile the dialog is a bottom sheet with Keep my changes first, so it needs no scrolling at 390 px.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l11.session | Session expired | Draft kept in this browser; editor behind shows the unsaved changes | LocaleFrame{s:{v:"pm",x:"session"}} | 1440, 390 | Journal |
|
||||
| loc.l11.restored | Restored after login | Editor shows "Restored unsaved changes from 14:18" | LocaleFrame{s:{v:"pm",x:"restored"}} | 1440, 390 | Journal |
|
||||
| loc.conflict | Concurrent update | Four choices, local work kept; Save over theirs outlined in error colour with a warning icon | LocaleFrame{s:{v:"pm",x:"conflict"}} | 1440, 390 | Journal |
|
||||
| loc.l11.conflictRestored | Concurrent update after session expiry | Restored changes notice above the choices | LocaleFrame{s:{v:"pm",x:"conflictRestored"}} | 1440, 390 | Journal |
|
||||
| loc.l11.reloadConfirm | Reload theirs: confirm | "This discards your unsaved changes." | LocaleFrame{s:{v:"pm",x:"reloadConfirm"}} | 1440, 390 | Journal |
|
||||
| loc.l11.conflict2 | Second conflict | Save over theirs failed again; nothing overwritten | LocaleFrame{s:{v:"pm",x:"conflict2"}} | 1440, 390 | Journal |
|
||||
| loc.l11.download | Download started | Toast; changes still in the editor | LocaleFrame{s:{v:"pm",x:"download"}} | 1440, 390 | Journal |
|
||||
|
||||
## L11b Move article to another site
|
||||
Route: editor → site select
|
||||
API: proposed: POST /v1/story/{story_id}/move { site }
|
||||
|
||||
Entry point is the site select in the editor details. Articles move as a whole, only when the destination enables every language they contain (disabled ones included) and the user can edit all versions. The note lists readable versions only; versions the user can't read are not listed or counted. Moves are atomic and revalidate permissions and conflicts in the transaction.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l11b.moveSelect | Site select | Note lists all four versions, one tagged Read only | LocaleFrame{s:{v:"pm",x:"moveSelect"}} | 1440, 390 | Journal |
|
||||
| loc.l11b.moveSelectHidden | Site select, hidden version | Arabic not readable: not listed or counted | LocaleFrame{s:{v:"pm",x:"moveSelectHidden"}} | 1440, 390 | Journal |
|
||||
| loc.l11b.moveConfirm | Confirm | Move to Atelier? | LocaleFrame{s:{v:"pm",x:"moveConfirm"}} | 1440, 390 | Journal |
|
||||
| loc.l11b.moveBusy | In progress | Dialog locked | LocaleFrame{s:{v:"pm",x:"moveBusy"}} | 1440, 390 | Journal |
|
||||
| loc.l11b.moveDone | Moved | Toast | LocaleFrame{s:{v:"pm",x:"moveDone"}} | 1440, 390 | Journal |
|
||||
| loc.l11b.move | Language missing in destination | Notes enables English only | LocaleFrame{s:{v:"pm",x:"move"}} | 1440, 390 | Journal, Notes |
|
||||
| loc.l11b.moveSlug | Address conflict | Names the conflicting readable version and its address; unreadable ones are never named | LocaleFrame{s:{v:"pm",x:"moveSlug"}} | 1440, 390 | Journal, Atelier |
|
||||
| loc.l11b.movePerm | No permission | Neutral | LocaleFrame{s:{v:"pm",x:"movePerm"}} | 1440, 390 | Journal |
|
||||
| loc.l11b.moveFail | Move failed | Server error, nothing moved | LocaleFrame{s:{v:"pm",x:"moveFail"}} | 1440, 390 | Journal |
|
||||
@@ -0,0 +1,80 @@
|
||||
# Localization: editor header, permissions, publishing
|
||||
Board: RenewCMS Screens Localization. Frame: LocaleFrame. Data: locale-fixtures.js (single fixture source), tables in locale-spec.js. UI copy says "article", never "story". No revision numbers, IDs or HTTP status names in user-facing text.
|
||||
|
||||
## L5 Editor: header, actions and title
|
||||
Route: /article/{id}/edit (one id per version)
|
||||
API: proposed: GET /v1/article/{id} → translations[], capabilities[]
|
||||
|
||||
Header priority and the action matrix are in the tables below. One filled primary button at most: Save and publish when allowed, dirty and offline; otherwise Save when dirty; otherwise Publish when clean and offline. Save stays separate and secondary. Desktop collapse order as width shrinks (1440 → 1024): reference button loses its label, Unpublish moves to the overflow menu, the freshness badge becomes icon-only. Mobile: title, language, publication and dirty badges stay at the top; Save, primary and an overflow button sit in a fixed bottom bar; reference, Unpublish, Edit slug, Move and Delete go to the overflow sheet. The language control is a menu button with a menu (not a listbox).
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l5.default | Clean draft | Open a draft you can publish; title pristine | LocaleFrame{s:{v:"ed",x:"default"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l5.dirty | Dirty draft | Can edit, can't publish: Save is primary | LocaleFrame{s:{v:"ed",x:"dirty"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l5.dirtyPublish | Dirty draft, publish allowed | Save and publish French is primary; Save secondary | LocaleFrame{s:{v:"ed",x:"dirtyPublish"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l5.cleanOnline | Clean online | Unpublish only; no primary | LocaleFrame{s:{v:"ed",x:"cleanOnline"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l5.onlineDirty | Dirty online | Save updates the live page; Unpublish stays | LocaleFrame{s:{v:"ed",x:"onlineDirty"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l5.saving | Saving | Save in flight | LocaleFrame{s:{v:"ed",x:"saving"}} | 1440, 390 | Journal |
|
||||
| loc.l5.saved | Saved | Toast | LocaleFrame{s:{v:"ed",x:"saved"}} | 1440, 390 | Journal |
|
||||
| loc.l5.saveFail | Save failed | Stays unsaved; Try saving again; nothing published | LocaleFrame{s:{v:"ed",x:"saveFail"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l5.pubFail | Publish failed after save | Saved as a draft; Retry publish; edits shown as saved | LocaleFrame{s:{v:"ed",x:"pubFail"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l5.ovf | Overflow menu | More actions (sheet on mobile) | LocaleFrame{s:{v:"ed",x:"ovf"}} | 1440, 390 | Journal |
|
||||
| loc.l5.menu | Menu: four languages | Open menu | LocaleFrame{s:{v:"ed",x:"menu"}} | 1440, 390 | Journal |
|
||||
| loc.l5.two | Menu: two languages | Two-language site | LocaleFrame{s:{v:"ed",x:"two"}} | 1440, 390 | Journal |
|
||||
| loc.l5.many | Menu: many languages | Scrolls inside the viewport, with filter | LocaleFrame{s:{v:"ed",x:"many"}} | 1440, 390 | Journal |
|
||||
| loc.l5.unsaved | Switch with unsaved changes | Choose English while dirty | LocaleFrame{s:{v:"ed",x:"unsaved"}} | 1440, 390 | Journal |
|
||||
| loc.l5.loading | Loading version | Slow switch | LocaleFrame{s:{v:"ed",x:"loading"}} | 1440, 390 | Journal |
|
||||
| loc.l5.navFail | Switch failed | Stays on current version | LocaleFrame{s:{v:"ed",x:"navFail"}} | 1440, 390 | Journal |
|
||||
| loc.l5.gone | Version unavailable | Deleted or no access (neutral) | LocaleFrame{s:{v:"ed",x:"gone"}} | 1440, 390 | Journal |
|
||||
| loc.l5.focus | Title: focus | Field focused, not changed | LocaleFrame{s:{v:"ed",x:"focus"}} | 1440, 390 | Journal |
|
||||
| loc.l5.titleEdit | Title: editing | Typing; counter shown | LocaleFrame{s:{v:"ed",x:"titleEdit"}} | 1440, 390 | Journal |
|
||||
| loc.l5.titleUnsaved | Title: unsaved | Changed, focus left | LocaleFrame{s:{v:"ed",x:"titleUnsaved"}} | 1440, 390 | Journal |
|
||||
| loc.l5.invalid | Title: invalid | Empty: "Enter a title" | LocaleFrame{s:{v:"ed",x:"invalid"}} | 1440, 390 | Journal |
|
||||
| loc.l5.titleShort | Title: too short | Under 3 characters | LocaleFrame{s:{v:"ed",x:"titleShort"}} | 1440, 390 | Journal |
|
||||
| loc.l5.titleLong | Title: too long | Over 200 characters | LocaleFrame{s:{v:"ed",x:"titleLong"}} | 1440, 390 | Journal |
|
||||
| loc.l5.titleSaving | Title: saving | Save in flight | LocaleFrame{s:{v:"ed",x:"titleSaving"}} | 1440, 390 | Journal |
|
||||
| loc.l5.failed | Title: save failed | Text kept, still unsaved, Try again | LocaleFrame{s:{v:"ed",x:"failed"}} | 1440, 390 | Journal |
|
||||
| loc.l5.menuDisabled | Language menu with a disabled language | Spanish is disabled: Disabled tag, version stays readable | LocaleFrame{s:{v:"ed",x:"menuDisabled"}} | 1440, 390 | Journal |
|
||||
|
||||
## L5b Permission states (per version)
|
||||
Route: /article/{id}/edit
|
||||
API: capabilities[] on GET /v1/article/{id}; every PATCH/DELETE rechecked on the server
|
||||
|
||||
Every action renders from its own capability (read, edit, publish, delete, create translation, review). See the capability matrix. Missing-translation rows appear only for users who can create; unreadable siblings are absent with no placeholder and no count. The formatting toolbar is hidden whenever edit is missing.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.perm.readonly | P1 Read-only version | Banner, read-only fields, no Save, Publish, Delete, toolbar; reference stays | LocaleFrame{s:{v:"perm",x:"ro"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l5b.roMenu | P1 menu | No Add translation row | LocaleFrame{s:{v:"perm",x:"roMenu"}} | 1440, 390 | Journal |
|
||||
| loc.perm.editNoPublish | P2 Edit, no publish | Save only; hint about the publish permission | LocaleFrame{s:{v:"perm",x:"p2"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.perm.publishNoEdit | P3 Publish, no edit | Publish only; fields read-only | LocaleFrame{s:{v:"perm",x:"p3"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.perm.siblingReadOnly | P4 Sibling readable, not editable | Arabic row tagged Read only | LocaleFrame{s:{v:"perm",x:"p4"}} | 1440, 390 | Journal |
|
||||
| loc.perm.siblingHidden | P5 Sibling not readable | German absent, no count, no placeholder | LocaleFrame{s:{v:"perm",x:"p5"}} | 1440, 390 | Journal |
|
||||
| loc.perm.createNotAll | P6 Can create, not modify all | Delete article and Move disabled with the reason; Delete French version available | LocaleFrame{s:{v:"perm",x:"p6"}} | 1440, 390 | Journal |
|
||||
| loc.l5b.noAdd | Menu without Add translation | No create permission; Arabic is missing but offers nothing | LocaleFrame{s:{v:"perm",x:"noAdd"}} | 1440, 390 | Journal |
|
||||
| loc.l5b.withAdd | Menu with Add translation | Same article, user may create | LocaleFrame{s:{v:"perm",x:"withAdd"}} | 1440, 390 | Journal |
|
||||
|
||||
## L8 Publishing and deletion
|
||||
Route: same as the editor
|
||||
API: existing PATCH /v1/article/{id}/publish and /unpublish and DELETE per article id
|
||||
|
||||
Every action names its language. No bulk publish. With unsaved edits the primary reads Save and publish and saves first; if the save fails nothing is published and the edits stay unsaved; if the save works and publishing fails the edits show as saved. Unpublish never saves: the saved version goes offline and local edits stay in the editor. Saving an online version updates the live page immediately, and the editor says so. Deletion confirmations name only readable versions.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l8.publish | Publish French | Confirm, English untouched | LocaleFrame{s:{v:"pd",x:"publish"}} | 1440, 390 | Journal |
|
||||
| loc.lang.publishDisabled | Publishing blocked: language disabled | French disabled on the site | LocaleFrame{s:{v:"pd",x:"publishDisabled"}} | 1440, 390 | Journal |
|
||||
| loc.l8.saveAndPublish | Save and publish French | Unsaved edits: saves first, then publishes | LocaleFrame{s:{v:"pd",x:"saveAndPublish"}} | 1440, 390 | Journal |
|
||||
| loc.l8.saveFailPublish | Save failed, nothing published | Error banner above the editor and an error toast; Save failed badge, primary Try saving again, edits kept, nothing published | LocaleFrame{s:{v:"pd",x:"saveFailPublish"}} | 1440, 390 | Journal |
|
||||
| loc.l8.unpublish | Unpublish French | Confirm | LocaleFrame{s:{v:"pd",x:"unpublish"}} | 1440, 390 | Journal |
|
||||
| loc.l8.unpublishDirty | Unpublish with unsaved edits | Saved version goes offline; edits kept; nothing saved | LocaleFrame{s:{v:"pd",x:"unpublishDirty"}} | 1440, 390 | Journal |
|
||||
| loc.l8.delete | Delete French version | Confirm | LocaleFrame{s:{v:"pd",x:"delete"}} | 1440, 390 | Journal |
|
||||
| loc.l8.deleteSource | Delete a source version | Names the readable versions that track the source; warns review tracking pauses | LocaleFrame{s:{v:"pd",x:"deleteSource"}} | 1440, 390 | Journal |
|
||||
| loc.l8.deleteLast | Delete the only version | Deletes the article | LocaleFrame{s:{v:"pd",x:"deleteLast"}} | 1440, 390 | Journal |
|
||||
| loc.l8.deleteStoryOk | Delete article (all readable versions) | Names readable versions only | LocaleFrame{s:{v:"pd",x:"deleteStoryOk"}} | 1440, 390 | Journal |
|
||||
| loc.l8.deleteStory | Delete article blocked | Neutral, no version named | LocaleFrame{s:{v:"pd",x:"deleteStory"}} | 1440, 390 | Journal |
|
||||
| loc.l8.done | Published | Toast "French published" and the online banner; badge Published, status Saved | LocaleFrame{s:{v:"pd",x:"done"}} | 1440, 390 | Journal |
|
||||
| loc.l8.fail | Publish failed | Toast | LocaleFrame{s:{v:"pd",x:"fail"}} | 1440, 390 | Journal |
|
||||
| loc.l8.deleteDone | French version deleted | Toast; editor opens English | LocaleFrame{s:{v:"pd",x:"deleteDone"}} | 1440, 390 | Journal |
|
||||
| loc.l8.deleteStoryFail | Delete article failed | Dialog error; nothing deleted | LocaleFrame{s:{v:"pd",x:"deleteStoryFail"}} | 1440, 390 | Journal |
|
||||
| loc.l8.deleteStoryDone | Article deleted | Toast on the Articles list | LocaleFrame{s:{v:"li",x:"deleteStoryDone"}} | 1440, 390 | Journal |
|
||||
@@ -0,0 +1,43 @@
|
||||
# Localization: article list and coverage
|
||||
Board: RenewCMS Screens Localization. Frame: LocaleFrame. Data: locale-fixtures.js (single fixture source), tables in locale-spec.js. UI copy says "article", never "story". No revision numbers, IDs or HTTP status names in user-facing text.
|
||||
|
||||
## L7 Article list and coverage
|
||||
Route: /articles?site&lang&tab=all|missing|review&q&status&author&seo&page&size
|
||||
API: proposed: lang, tab=all|missing|review on GET /v1/article/ (same names as the URL)
|
||||
|
||||
Journal: 8 articles, 17 versions, page size 5. The list header shows the unit: "8 articles" (all languages, one row per article with its translations), "4 French versions" (language selected), "4 articles not in French" (missing tab), "7 articles need translation", "2 articles need review". Coverage chips: default language first, then other existing versions, then disabled-language versions, then dashed missing chips (only for languages the user can create). Chips collapse after 5 at 1440 and 4 at 1024 and 390, leaving a "+N" chip. Row actions are the same on every width: Open, Open another language, Add translation, Publish or Unpublish, Copy API URL, Delete version; every row has the row menu button. All state is in the URL.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l7.all | All languages, page 1 | 5 of 8 articles | LocaleFrame{s:{v:"li",x:"all"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.page2 | All languages, page 2 (last) | 3 of 8 articles | LocaleFrame{s:{v:"li",x:"page2"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.fr | French selected | 4 French versions | LocaleFrame{s:{v:"li",x:"fr"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.frMissing | Not in French | 4 articles not in French | LocaleFrame{s:{v:"li",x:"frMissing"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.emptyMissing | Nothing missing | Press: all articles translated; no summary, no pager | LocaleFrame{s:{v:"li",x:"emptyMissing"}} | 1440, 1024, 390 | Press |
|
||||
| loc.l7.noResults | No results | Search "zinc"; every count 0 | LocaleFrame{s:{v:"li",x:"noResults"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.frLoading | Loading | Counts shown as – | LocaleFrame{s:{v:"li",x:"frLoading"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.listFail | Failed | Filters kept | LocaleFrame{s:{v:"li",x:"listFail"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.limited | Limited access | Reads English only, can't create: no dashed chips | LocaleFrame{s:{v:"li",x:"limited"}} | 1440, 1024, 390 | Journal (limited scope) |
|
||||
| loc.l7.review | Needs review tab | 2 articles need review | LocaleFrame{s:{v:"li",x:"review"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.author | Author filter | Marta Alves: 3 articles, chips and Clear all | LocaleFrame{s:{v:"li",x:"author"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.langFilter | Language filter | Enabled and Disabled groups | LocaleFrame{s:{v:"li",x:"langFilter"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.rowMenu | Row menu | Same actions on desktop and mobile | LocaleFrame{s:{v:"li",x:"rowMenu"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.filters | Mobile filter sheet | Journal, French, Draft: 2 results | LocaleFrame{s:{v:"li",x:"filters"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.tabsScroll | Tabs scrolled | Not in French reachable; fade and chevrons | LocaleFrame{s:{v:"li",x:"tabsScroll"}} | 1440, 390 | Journal |
|
||||
| loc.l7.long | Eight languages, long labels, filters | Atelier: chips collapse to +3, +4, +4; Draft and SEO filters | LocaleFrame{s:{v:"li",x:"long"}} | 1440, 1024, 390 | Atelier |
|
||||
|
||||
## L7s Article list, single-language site
|
||||
Route: /articles?site=archive&q&status&page&size
|
||||
API: GET /v1/article/?page=&size=&q=&status=&site= returns { items, total, page, size, counts }
|
||||
|
||||
Replaces the old Articles board frames as the pixel-gate reference for the list. A site with one language has no language filter, no coverage column, no Needs translation or Needs review tabs, and its menu labels name no language. The pager is AppPaginator: summary "Showing 1–5 of 5", rows-per-page select, first, previous, page numbers, next and last (390 px: summary and select, then Previous, "Page N of M", Next). Hidden controls when everything fits on one page; the summary stays.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l7s.default | Default | Archive: 5 articles on one page, no language filter, coverage column or tabs | LocaleFrame{s:{v:"li",x:"single"}} | 1440, 390 | Archive |
|
||||
| loc.l7s.draft | Draft filter | Status: Draft | LocaleFrame{s:{v:"li",x:"singleDraft"}} | 1440, 390 | Archive |
|
||||
| loc.l7s.noResults | No results | Search "zinc" | LocaleFrame{s:{v:"li",x:"singleNoResults"}} | 1440, 390 | Archive |
|
||||
| loc.l7s.empty | Empty | Gallery: no articles | LocaleFrame{s:{v:"li",x:"singleEmpty"}} | 1440, 390 | Gallery |
|
||||
| loc.l7s.loading | Loading | Counts shown as – | LocaleFrame{s:{v:"li",x:"singleLoading"}} | 1440, 390 | Archive |
|
||||
| loc.l7s.fail | Failed | Filters kept | LocaleFrame{s:{v:"li",x:"singleFail"}} | 1440, 390 | Archive |
|
||||
| loc.l7s.menu | Row menu | Open, Unpublish, Copy API URL, Delete article: no language named | LocaleFrame{s:{v:"li",x:"singleMenu"}} | 1440, 390 | Archive |
|
||||
@@ -0,0 +1,16 @@
|
||||
# Localization: metadata and public API
|
||||
Board: RenewCMS Screens Localization. Frame: LocaleFrame. Data: locale-fixtures.js (single fixture source), tables in locale-spec.js. UI copy says "article", never "story". No revision numbers, IDs or HTTP status names in user-facing text.
|
||||
|
||||
## L10 Metadata and public API
|
||||
Route: Search and sharing panel · /integration
|
||||
API: existing slug, SEO fields; proposed alternates[], lang; exact lookup by site + lang + slug; resolution by story_id
|
||||
|
||||
Slug is unique per (site, lang). Exact lookup is by site + language + slug and never falls back. Language resolution and fallback use the stable story_id. Website URLs come from the site's Public URL pattern; without it the panel says "Public URL not configured" and links to the setting. Canonical is the localized page itself unless an override is set. SEO labels: "Fallback: French title", "No fallback", "Empty. English site default not used."
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.seo.noFallback | Search and sharing: French | Search and sharing slide-over on the editor: localized slug, canonical, alternates, fallback labels, Edit slug | LocaleFrame{s:{v:"seo",x:""}} | 1440, 390 | Journal |
|
||||
| loc.slug.published | Edit slug | Edit slug dialog over the Search and sharing slide-over: published version warns about old links | LocaleFrame{s:{v:"seo",x:"editSlug"}} | 1440, 390 | Journal |
|
||||
| loc.url.unset | Public URL not configured | Search and sharing slide-over: no pattern, no fabricated URL | LocaleFrame{s:{v:"seo",x:"unset"}} | 1440, 390 | Journal |
|
||||
| loc.l10.noSocial | Social locale not set | Search and sharing slide-over: fr has no region and none is set, og:locale omitted | LocaleFrame{s:{v:"seo",x:"noSocial"}} | 1440, 390 | Journal |
|
||||
| loc.l10.api | Integration: languages | Integration page: exact lookup, resolve by article group, errors | LocaleFrame{s:{v:"api",x:""}} | 1440, 390 | Journal |
|
||||
@@ -0,0 +1,34 @@
|
||||
# Localization: new article and add translation
|
||||
Board: RenewCMS Screens Localization. Frame: LocaleFrame. Data: locale-fixtures.js (single fixture source), tables in locale-spec.js. UI copy says "article", never "story". No revision numbers, IDs or HTTP status names in user-facing text.
|
||||
|
||||
## L3 New article
|
||||
Route: /articles (New article modal)
|
||||
API: existing POST /v1/article/ + proposed lang, story_id
|
||||
|
||||
Single-language sites show no language field. Multilingual sites show Language and a New article / Translation of an existing article choice. The translation path uses the same endpoint and rules as Add translation. The picker lists only articles the user can read; the chosen version is the source.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l3.single | Single language | Title and site only | LocaleFrame{s:{v:"na",x:"single"}} | 1440, 390 | Archive |
|
||||
| loc.l3.multi | Multilingual: new article | Language defaults to the site default | LocaleFrame{s:{v:"na",x:"multi"}} | 1440, 390 | Journal |
|
||||
| loc.l3.translation | Translation of an article | Same fields as Add translation: article, language, source version, title, starting content | LocaleFrame{s:{v:"na",x:"translation"}} | 1440, 390 | Journal |
|
||||
| loc.l3.sitechange | Site changed | Selected language not offered by the new site | LocaleFrame{s:{v:"na",x:"sitechange"}} | 1440, 390 | Journal, Notes |
|
||||
|
||||
## L4 Add a translation
|
||||
Route: /article/{id}/edit?dialog=add-translation&lang=fr
|
||||
API: proposed: POST /v1/article/{id}/translations
|
||||
|
||||
Always creates a draft. Source: the user picks which readable version to copy from (default: the default-language version if readable, else the first readable). The source version is captured in the same transaction as the copy. Copied text stays in the source language until edited; the version starts as Needs review. Start empty: no tracked source, so freshness shows No source. The primary action stays in the sticky footer on mobile; the body scrolls.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l4.form | Copy from source | Editor → language menu → Add translation | LocaleFrame{s:{v:"at",x:"form"}} | 1440, 390 | Journal |
|
||||
| loc.l4.empty | Start empty | Title only; Source version reads No source, Freshness will show No source | LocaleFrame{s:{v:"at",x:"empty"}} | 1440, 390 | Journal |
|
||||
| loc.l4.saving | Creating | Submit in flight | LocaleFrame{s:{v:"at",x:"saving"}} | 1440, 390 | Journal |
|
||||
| loc.dup.readable | Exists, readable | Conflict; Open offered | LocaleFrame{s:{v:"at",x:"dup"}} | 1440, 390 | Journal |
|
||||
| loc.dup.hidden | Exists, not readable | Neutral error, no Open | LocaleFrame{s:{v:"at",x:"dupHidden"}} | 1440, 390 | Journal |
|
||||
| loc.l4.fail | Failed | Server error, entries kept | LocaleFrame{s:{v:"at",x:"fail"}} | 1440, 390 | Journal |
|
||||
| loc.l4.gone | Source deleted | Deleted while the dialog was open: same neutral message as unreadable, no distinction shown | LocaleFrame{s:{v:"at",x:"gone"}} | 1440, 390 | Journal |
|
||||
| loc.l4.unreadable | Source no longer readable | Access lost between opening and submitting: same neutral message as deleted | LocaleFrame{s:{v:"at",x:"unreadable"}} | 1440, 390 | Journal |
|
||||
| loc.l4.langOff | Language disabled mid-flow | An owner disabled French while the dialog was open | LocaleFrame{s:{v:"at",x:"langOff"}} | 1440, 390 | Journal |
|
||||
| loc.l4.long | Long dialog | Long title, many authors and images; body scrolls, footer stays | LocaleFrame{s:{v:"at",x:"long"}} | 1440, 390 | Journal |
|
||||
@@ -0,0 +1,43 @@
|
||||
# Localization: source reference and review (release 2)
|
||||
Board: RenewCMS Screens Localization. Frame: LocaleFrame. Data: locale-fixtures.js (single fixture source), tables in locale-spec.js. UI copy says "article", never "story". No revision numbers, IDs or HTTP status names in user-facing text.
|
||||
|
||||
## L6 Source reference (release 2) (release 2)
|
||||
Route: /article/{id}/edit?ref=:sourceId
|
||||
API: proposed: GET /v1/article/{id} (read-only fetch of the reference)
|
||||
|
||||
Release 2. The temporary reference (?ref=, dashed outline, "Temporary reference") and the tracked review source (solid "Tracked source" badge in the review banner) are separate: changing the reference never changes the tracked source. Show source reference is in the editor header (desktop, icon-only at 1024) and the overflow sheet (mobile). Each pane scrolls independently. dir and lang go on the title and body of the editing pane and of the reference, never on the app shell.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l6.split | Side by side | Show source reference | LocaleFrame{s:{v:"src",x:"split"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l6.closed | Reference hidden | Hide source reference; button returns | LocaleFrame{s:{v:"src",x:"closed"}} | 1440, 390 | Journal |
|
||||
| loc.l6.reopen | Reopened | Scroll positions kept | LocaleFrame{s:{v:"src",x:"reopen"}} | 1440, 390 | Journal |
|
||||
| loc.l6.changeRef | Change reference | Menu; tracked source stays English | LocaleFrame{s:{v:"src",x:"changeRef"}} | 1440, 390 | Journal |
|
||||
| loc.l6.narrow | Narrow desktop (1024) | True 1024 frame | LocaleFrame{s:{v:"src",x:"narrow"}} | 1024, 390 | Journal |
|
||||
| loc.l6.long | Long content | Both panes scroll independently | LocaleFrame{s:{v:"src",x:"long"}} | 1440, 390 | Journal |
|
||||
| loc.l6.rtl | Arabic editing, English reference | dir=rtl on the Arabic title and body only | LocaleFrame{s:{v:"src",x:"rtl"}} | 1440, 390 | Journal |
|
||||
| loc.l6.arRef | French editing, Arabic reference | dir=rtl on the reference pane only | LocaleFrame{s:{v:"src",x:"arRef"}} | 1440, 390 | Journal |
|
||||
| loc.l6.mobileEdit | Mobile: editing | Segment | LocaleFrame{s:{v:"src",x:"mobileEdit"}} | 1440, 390 | Journal |
|
||||
| loc.l6.mobileSrc | Mobile: source | Segment | LocaleFrame{s:{v:"src",x:"mobileSrc"}} | 1440, 390 | Journal |
|
||||
| loc.l6.mobileClosed | Mobile: closed | Show source reference in the bottom bar or sheet | LocaleFrame{s:{v:"src",x:"mobileClosed"}} | 1440, 390 | Journal |
|
||||
| loc.l6.unavail | Reference unavailable | Deleted or unreadable | LocaleFrame{s:{v:"src",x:"unavail"}} | 1440, 390 | Journal |
|
||||
|
||||
## L9 Freshness and review (release 2) (release 2)
|
||||
Route: editor banner · list badge
|
||||
API: proposed: POST /v1/article/{id}/review { source_article_id, source_revision, translation_revision } (internal fields; never shown)
|
||||
|
||||
Release 2. Publication (solid badge) and freshness (outlined badge with refresh icon) are separate. Labels: No version, Draft, Needs review, Reviewed. A copied draft starts as Needs review. The dialog shows "Changed fields: Title, Body", the source version with its date and the translation version with its date. No revision numbers appear in the UI.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l9.warn | Source updated | English changed after review | LocaleFrame{s:{v:"fr",x:"warn"}} | 1440, 390 | Journal |
|
||||
| loc.l9.pub | Published, needs review | Still online | LocaleFrame{s:{v:"fr",x:"pub"}} | 1440, 390 | Journal |
|
||||
| loc.l9.review | Mark reviewed | Confirm with changed fields and dates | LocaleFrame{s:{v:"fr",x:"review"}} | 1440, 390 | Journal |
|
||||
| loc.review.changed | Source changed again | During the review | LocaleFrame{s:{v:"fr",x:"changed"}} | 1440, 390 | Journal |
|
||||
| loc.l9.changedTr | Translation changed | Saved by someone else while confirming | LocaleFrame{s:{v:"fr",x:"changedTr"}} | 1440, 390 | Journal |
|
||||
| loc.l9.replace | Choose tracked source | Cycle prevented | LocaleFrame{s:{v:"fr",x:"replace"}} | 1440, 390 | Journal |
|
||||
| loc.src.inaccessible | Source unavailable (access) | History kept | LocaleFrame{s:{v:"fr",x:"inaccessible"}} | 1440, 390 | Journal |
|
||||
| loc.src.deleted | Source deleted | Choose a new source | LocaleFrame{s:{v:"fr",x:"gone"}} | 1440, 390 | Journal |
|
||||
| loc.l9.done | Reviewed | Toast | LocaleFrame{s:{v:"fr",x:"done"}} | 1440, 390 | Journal |
|
||||
| loc.l9.fail | Review failed | Toast | LocaleFrame{s:{v:"fr",x:"fail"}} | 1440, 390 | Journal |
|
||||
| loc.l9.frReview | Published and needs review in the list | French tab, Needs review: 2 versions | LocaleFrame{s:{v:"li",x:"frReview"}} | 1440, 1024, 390 | Journal |
|
||||
@@ -0,0 +1,71 @@
|
||||
# Localization: site languages, public URL and setup
|
||||
Board: RenewCMS Screens Localization. Frame: LocaleFrame. Data: locale-fixtures.js (single fixture source), tables in locale-spec.js. UI copy says "article", never "story". No revision numbers, IDs or HTTP status names in user-facing text.
|
||||
|
||||
## L1 Site languages
|
||||
Route: /settings/sites/:key/languages
|
||||
API: proposed: GET/PUT /v1/site/{key}/languages
|
||||
|
||||
Navigation: Settings → Sites → [site] → Languages only. The site name is in the heading and breadcrumb. Site formatting locale, default content language, article language and social locale are four separate values. At least one language stays enabled and the default must be enabled: to retire the default, first Make default on another language, then Disable. Disable and Remove never delete or unpublish content. Disabled languages show a Disabled tag and a Re-enable action; their versions stay readable and editable per permission. Drafts can be published only after Re-enable ("French is disabled on this site. Re-enable it to publish."). The language filter lists them under Disabled. Social locale per language has three states: Derived (from the language code, e.g. pt-BR gives pt_BR), Set (an owner typed it, e.g. fr_FR) and Not set (og:locale left out). Only owners edit it.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l1.single | Single language | Owner opens Languages on Archive (one language, pt-BR kept) | LocaleFrame{s:{v:"sl",x:"single"}} | 1440, 390 | Archive |
|
||||
| loc.l1.multi | Multilingual | Journal: four enabled, two disabled (Spanish, Italian) | LocaleFrame{s:{v:"sl",x:"multi"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l1.loading | Loading | Request in flight | LocaleFrame{s:{v:"sl",x:"loading"}} | 1440, 390 | Journal |
|
||||
| loc.l1.error | Load failed | Network or server error | LocaleFrame{s:{v:"sl",x:"error"}} | 1440, 390 | Journal |
|
||||
| loc.l1.add | Add a language | Add language | LocaleFrame{s:{v:"sl",x:"add"}} | 1440, 390 | Journal |
|
||||
| loc.l1.saveFail | Add failed | Server error, selection kept | LocaleFrame{s:{v:"sl",x:"saveFail"}} | 1440, 390 | Journal |
|
||||
| loc.l1.adddup | Duplicate language | Already enabled | LocaleFrame{s:{v:"sl",x:"adddup"}} | 1440, 390 | Journal |
|
||||
| loc.l1.broader | Only a broader tag supported | Add Portuguese (Portugal): substitution explained, confirmation required | LocaleFrame{s:{v:"sl",x:"broader"}} | 1440, 390 | Journal |
|
||||
| loc.l1.default | Make default | Row menu | LocaleFrame{s:{v:"sl",x:"default"}} | 1440, 390 | Journal |
|
||||
| loc.l1.defaultFail | Set default failed | Server error, nothing changed | LocaleFrame{s:{v:"sl",x:"defaultFail"}} | 1440, 390 | Journal |
|
||||
| loc.l1.disable | Disable with content | Row menu → Disable German | LocaleFrame{s:{v:"sl",x:"disable"}} | 1440, 390 | Journal |
|
||||
| loc.l1.stale | Languages changed | Another owner changed them: "Languages changed. Reload." | LocaleFrame{s:{v:"sl",x:"stale"}} | 1440, 390 | Journal |
|
||||
| loc.lang.disableDefault | Disable default blocked | Disable English | LocaleFrame{s:{v:"sl",x:"disableDefault"}} | 1440, 390 | Journal |
|
||||
| loc.lang.disableLast | Disable last blocked | Only one enabled | LocaleFrame{s:{v:"sl",x:"disableLast"}} | 1440, 390 | Journal |
|
||||
| loc.l1.reenable | Re-enable | Spanish row → Re-enable | LocaleFrame{s:{v:"sl",x:"reenable"}} | 1440, 390 | Journal |
|
||||
| loc.l1.reenableDone | Re-enabled | Toast after success | LocaleFrame{s:{v:"sl",x:"reenableDone"}} | 1440, 390 | Journal |
|
||||
| loc.l1.reenableFail | Re-enable failed | Dialog error, still disabled | LocaleFrame{s:{v:"sl",x:"reenableFail"}} | 1440, 390 | Journal |
|
||||
| loc.l1.remove | Remove (no content) | Italian (0 versions) | LocaleFrame{s:{v:"sl",x:"remove"}} | 1440, 390 | Journal |
|
||||
| loc.l1.removeFail | Remove failed | Dialog error, still listed | LocaleFrame{s:{v:"sl",x:"removeFail"}} | 1440, 390 | Journal |
|
||||
| loc.l1.removeBlocked | Remove blocked | Spanish has versions | LocaleFrame{s:{v:"sl",x:"removeBlocked"}} | 1440, 390 | Journal |
|
||||
| loc.l1.readonly | Read-only view | Non-owner: list and social locale readable; no controls | LocaleFrame{s:{v:"sl",x:"readonly"}} | 1440, 390 | Journal |
|
||||
| loc.l1.saved | Saved | After Add language | LocaleFrame{s:{v:"sl",x:"saved"}} | 1440, 390 | Journal |
|
||||
| loc.l1.social | Social locale: three states | Atelier: Derived en_GB and pt_BR, Set fr_FR, Not set | LocaleFrame{s:{v:"sl",x:"social"}} | 1440, 1024, 390 | Atelier |
|
||||
| loc.l1.socialEdit | Social locale: edit | Edit on French (Set: fr_FR) | LocaleFrame{s:{v:"sl",x:"socialEdit"}} | 1440, 390 | Atelier |
|
||||
| loc.l1.socialInvalid | Social locale: invalid | fr-FR typed with a hyphen | LocaleFrame{s:{v:"sl",x:"socialInvalid"}} | 1440, 390 | Atelier |
|
||||
|
||||
## L1b Public URL pattern (owner setting)
|
||||
Route: /settings/sites/:key/public-url
|
||||
API: proposed: GET/PUT /v1/site/{key} { public_url_pattern }
|
||||
|
||||
Owner-only. One pattern per site, for example https://example.com/{lang}/{slug}. Supported placeholders: {lang} (language code) and {slug} (the article address in that language). {slug} is required: without it every article would share one address. The preview uses a real article of the site. When unset, article panels show "Public URL not configured" and link here. No URL is invented.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l1b.set | Set | Pattern saved, preview shown | LocaleFrame{s:{v:"pu",x:"set"}} | 1440, 390 | Journal |
|
||||
| loc.l1b.editing | Editing | Live preview follows the field | LocaleFrame{s:{v:"pu",x:"editing"}} | 1440, 390 | Journal |
|
||||
| loc.l1b.unknown | Unknown placeholder | {language} typed | LocaleFrame{s:{v:"pu",x:"unknown"}} | 1440, 390 | Journal |
|
||||
| loc.l1b.scheme | Bad scheme | ftp:// typed | LocaleFrame{s:{v:"pu",x:"scheme"}} | 1440, 390 | Journal |
|
||||
| loc.l1b.encoded | Not encoded | A space in the path | LocaleFrame{s:{v:"pu",x:"encoded"}} | 1440, 390 | Journal |
|
||||
| loc.l1b.noSlug | Missing {slug} | Pattern without {slug} | LocaleFrame{s:{v:"pu",x:"noSlug"}} | 1440, 390 | Journal |
|
||||
| loc.l1b.saved | Saved | Toast | LocaleFrame{s:{v:"pu",x:"saved"}} | 1440, 390 | Journal |
|
||||
| loc.l1b.unset | Unset | No pattern; opened from "Public URL not configured" | LocaleFrame{s:{v:"pu",x:"unset"}} | 1440, 390 | Journal |
|
||||
| loc.l1b.viewOnly | Read-only view | Non-owner: pattern readable, no controls | LocaleFrame{s:{v:"pu",x:"viewOnly"}} | 1440, 390 | Journal |
|
||||
| loc.l1b.invalidHost | Invalid host | Pattern with an empty host label | LocaleFrame{s:{v:"pu",x:"invalidHost"}} | 1440, 390 | Journal |
|
||||
|
||||
## L2 First-time setup for existing articles
|
||||
Route: /settings/sites/:key/languages/setup
|
||||
API: proposed: GET /v1/language/setup, POST /v1/language/setup { sites:[{ key, lang }] } (lang is the full tag, e.g. pt-BR)
|
||||
|
||||
Opened from the Languages banner of any site; :key is the site it was opened from, and the page lists every site that still has unlabelled articles. Setup assigns a language per site and applies in one transaction. It never translates, links or moves articles. Each site is seeded from its full Site.Locale when a matching language entry exists: pt-BR becomes Portuguese (Brazil). A site with no locale has no seed and the owner must choose. Nothing applies until the owner confirms every row, including a site with no articles. Retrying is safe.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l2.ready | Ready to assign | Journal (en), Notes (no locale, must choose), Archive (pt-BR kept) | LocaleFrame{s:{v:"su",x:"ready"}} | 1440, 390 | Journal, Notes, Archive |
|
||||
| loc.l2.noLocale | No-locale site | Notes only: the owner must choose | LocaleFrame{s:{v:"su",x:"noLocale"}} | 1440, 390 | Notes |
|
||||
| loc.l2.empty | Site with no articles | Gallery has no articles; en-GB kept, confirm and Save | LocaleFrame{s:{v:"su",x:"empty"}} | 1440, 390 | Gallery |
|
||||
| loc.l2.applying | Applying | Request in flight | LocaleFrame{s:{v:"su",x:"applying"}} | 1440, 390 | Journal, Notes, Archive |
|
||||
| loc.l2.inProgress | Setup already running | Another owner started it; starting again is safe | LocaleFrame{s:{v:"su",x:"inProgress"}} | 1440, 390 | Journal, Notes, Archive |
|
||||
| loc.l2.done | Done | 16 articles labelled | LocaleFrame{s:{v:"su",x:"done"}} | 1440, 390 | Journal, Notes, Archive |
|
||||
| loc.l2.fail | Failed | Nothing changed; choices kept; retry is safe | LocaleFrame{s:{v:"su",x:"fail"}} | 1440, 390 | Journal, Notes, Archive |
|
||||
@@ -0,0 +1,20 @@
|
||||
# Localization: existing surfaces
|
||||
Board: RenewCMS Screens Localization. Frame: LocaleFrame. Data: locale-fixtures.js (single fixture source), tables in locale-spec.js. UI copy says "article", never "story". No revision numbers, IDs or HTTP status names in user-facing text.
|
||||
|
||||
## L12 Existing surfaces
|
||||
Route: Home · Activity · Media · editor details
|
||||
API: existing; language added where it applies
|
||||
|
||||
Single-language sites show none of this. Home counts articles, not versions.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l12.home | Home: recent articles | Default-language title; code only when not default | LocaleFrame{s:{v:"sf",x:"home"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l12.homeFr | Home: reader of French only | Titles in French with code FR | LocaleFrame{s:{v:"sf",x:"homeFr"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l12.activity | Activity | Events name the language | LocaleFrame{s:{v:"sf",x:"activity"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l12.media | Media usage | Media page with the image detail panel: shared image counted once, versions listed, "Used in 2" | LocaleFrame{s:{v:"sf",x:"media"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l12.mediaElsewhere | Media usage: used elsewhere only | Image used only as an author avatar: badge "Used in 1", "Articles" with "No article shows it.", "Used elsewhere" with Author avatar linking to /authors | LocaleFrame{s:{v:"sf",x:"mediaElsewhere"}} | 1440, 390 | Journal |
|
||||
| loc.l12.menu | Article menu labels | Articles list, French selected: row menu names the language (Publish French, Delete French version). Bottom sheet at 390 | LocaleFrame{s:{v:"li",x:"menuLang"}} | 1440, 390 | Journal |
|
||||
| loc.l12.apiCopy | API URL copy | Articles list: toast after Copy API URL with the ?lang= address. Bottom toast at 390 | LocaleFrame{s:{v:"li",x:"apiCopy"}} | 1440, 390 | Journal |
|
||||
| loc.l12.seoPreview | SEO preview | Search and sharing slide-over: localized address and canonical | LocaleFrame{s:{v:"seo",x:"preview"}} | 1440, 390 | Journal |
|
||||
| loc.l12.siteFallback | Site text fallback | Search and sharing slide-over: site text used only in its own language | LocaleFrame{s:{v:"seo",x:"siteFallback"}} | 1440, 390 | Journal |
|
||||
@@ -47,4 +47,4 @@ The Library tab of the picker gets a Variant select. Options are Original plus t
|
||||
| 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.
|
||||
Spec, states, exact copy, measurements, backend needs and accessibility: `media-field.md`, section 3.7. Board groups: 10 Media picker for a field.
|
||||
@@ -10,7 +10,7 @@ Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Media", "Media
|
||||
|
||||
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
|
||||
|
||||
### Media (group "Media", 52 states)
|
||||
### Media (group "Media", 56 states)
|
||||
|
||||
Route: `/media`. API: GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage.
|
||||
|
||||
@@ -115,4 +115,10 @@ A secondary "Purge duplicates" button (icon merge) in the toolbar next to the so
|
||||
| 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.
|
||||
Spec, states, exact copy, measurements, backend needs and accessibility: `media-field.md`, section 3.8. Board groups: 11 Media library usage.
|
||||
|
||||
## App sync (batch A)
|
||||
- Detail panel, "Used elsewhere" below the articles and entries lists: rows "Author avatar" (/authors) and "Site default social image" (/settings/sites). States: avatar only, site only, both, none (section absent). The "Used in N" badge includes these, N = articles + entries + elsewhere. "Not used in any article" appears only when N is 0; otherwise the line reads "No article shows it."
|
||||
- Selected card (grid, mobile list): outline badges ("Used in N", "Draft") keep opaque bg-default over the sand tint, text colour unchanged. --ui-text-toned on --ui-bg: 9.7:1 light (#444 on #fff), 11.7:1 dark (#cfccc7 on #111).
|
||||
- Media library board (variants): adds tabs (All, Unused, Duplicates), Select all, grid/list toggle, Duplicate badges, the variants filter row and the Details | Variants tab row. Variant URL: /static/uploadedImages/photo.png?v=thumb.
|
||||
- Not yet done: mobile picker sheet height and tile count, desktop frame of the mobile-only state, stray miniatures.
|
||||
@@ -23,3 +23,6 @@ Recommendation: a modal. Title is the only field. The body is not asked for here
|
||||
| 3 | Submitting | Request in flight. Fields are disabled and the dialog can't be dismissed. | "Creating…" / "Choosing tiles for a small bathroom" |
|
||||
| 4 | Title rejected (400) | A server 400 that got past the browser checks. The raw error isn't shown. | "Something in the form isn't valid. Check the highlighted fields." / "Choosing tiles for a small bathroom" / "Check this title and try again." |
|
||||
| 5 | Error | 500 or network failure. | "Couldn't create the article. Your text is still here. Try again in a moment." / "Choosing tiles for a small bathroom" |
|
||||
|
||||
## Localization
|
||||
Localization: multilingual sites show Language and New story / Translation of; same rules as POST /v1/article/{id}/translations.
|
||||
@@ -64,3 +64,7 @@ The "Author profile" card sits between Account and Password in every Profile fra
|
||||
| 3 | Created | 201: the card shows the author. The author is named from the display name and linked to the user. | Author profile created · Marta Alves is now your byline. · Edit author · Linked to your account. New articles start with this author. |
|
||||
| 4 | Created, slug adjusted | The slug was taken, so the server added a number. No error is shown. | Your slug is marta-alves-2 · marta-alves was already used by another author. You can change it in Edit author. |
|
||||
| 5 | Edit own author | Edit author on the card, by a role without author rights. The form opens with the linked user locked and no Unlink. There is no delete action in the form or on this screen. | Edit Marta Alves · Linked user · marta@renew.example · This is your author profile. Only an owner or admin can change the link. · Save changes |
|
||||
| 6 | No author profile, no display name | The user has no linked author and has not set a display name. | Uses your username, marta. You can change it afterwards. |
|
||||
| 7 | Edit own author: owner or admin | Edit author on the card, by an owner or admin. The form opens with the linked user and Unlink is allowed. | Edit Marta Alves · Linked user · marta@renew.example · Unlink · Save changes |
|
||||
|
||||
Order on the page: Display name, Account, Your role (role card), Author profile, Password.
|
||||
+336
-15
@@ -42,7 +42,7 @@ Copy: Edit · Duplicate · Delete (disabled) · Built-in roles can't be deleted.
|
||||
|
||||
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.
|
||||
Copy: ReadOnlyBanner: You can see roles, but only people who manage roles can create or change them.
|
||||
|
||||
### Error (Available now)
|
||||
|
||||
@@ -208,7 +208,7 @@ Copy: Delete Writer? · Built-in roles can't be deleted. · Writer is built in
|
||||
|
||||
## 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
|
||||
Route: /users · API: PUT /v1/user/{id}/role, POST /v1/user/invite, POST /v1/site/transfer-ownership · Auth: Users: view; change role, invite, remove; owner for transfer
|
||||
|
||||
The Role column shows a role badge with an icon: crown (Owner), shield (Admin), pencil (Writer), person (custom). The owner row is protected. Pending invites show their role.
|
||||
|
||||
@@ -234,7 +234,7 @@ Copy: Change role (disabled) · Transfer ownership · Remove user (disabled) ·
|
||||
|
||||
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
|
||||
Copy: Change role for Ines Duarte · Their API keys follow the new role. · Admin · Writer · · Current · Contributor · Content lead · Cancel · Change role
|
||||
|
||||
### Change role: options blocked (Available now)
|
||||
|
||||
@@ -258,7 +258,7 @@ Copy: Transfer ownership · Choose who becomes the owner. Only members can be ch
|
||||
|
||||
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
|
||||
Copy: Transfer ownership to Jonas Weber · This takes effect straight away. · Jonas Weber becomes the owner. · You become an Admin. You can't take ownership back unless they transfer it. · Only the owner can apply updates, delete a site and change activity retention. · Your API keys follow your new role. · Type jonas@renewbuild.example to confirm · The email must match exactly. · Back · Transfer ownership
|
||||
|
||||
### Transfer ownership: done (Available now)
|
||||
|
||||
@@ -268,7 +268,7 @@ 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
|
||||
Route: Every page · API: GET /v1/auth/me returns role and permission keys · Auth: Any
|
||||
|
||||
Rule: hide whole areas the person can never use; disable, with a reason, an action that is blocked on something they can see; show read-only pages with a banner. Writer in these frames is the built-in Writer; Contributor shows "own" scope.
|
||||
|
||||
@@ -352,9 +352,9 @@ New event types. The detail panel summarises a role diff as Added and Removed li
|
||||
|
||||
### List with role events (Available now)
|
||||
|
||||
Trigger: Five new events.
|
||||
Trigger: Six role events. Event names: role.create, role.edit, user.change_role, user.transfer_ownership, role.reassign, role.delete.
|
||||
|
||||
Copy: Role Contributor created · Role Contributor edited · Ines Duarte's role changed from Writer to Content lead · Ownership transferred from Marta Alves to Jonas Weber · Role Intern deleted
|
||||
Copy: Role Contributor created · Role Contributor edited · Ines Duarte's role changed from Writer to Content lead · Ownership transferred from Marta Alves to Jonas Weber · Moved 3 members from Intern to Writer · Role Intern deleted
|
||||
|
||||
### Detail: role diff (Available now)
|
||||
|
||||
@@ -362,17 +362,41 @@ Trigger: Open "Role Contributor edited".
|
||||
|
||||
Copy: Activity details · What changed · Added · Delete entries (Own) · Removed · Publish · 4 members have this role.
|
||||
|
||||
### Failed role events (Available now)
|
||||
|
||||
Trigger: The same six events with outcome Failed. The sentence starts with "Failed to" and the Failed badge follows it.
|
||||
|
||||
Copy: Failed to create the role Contributor · Failed to edit the role Contributor · Failed to change the role of Ines Duarte · Failed to transfer ownership to Jonas Weber · Failed to move 3 members from Intern to Writer · Failed to delete the role Intern · Failed
|
||||
|
||||
### Failed event: details (Available now)
|
||||
|
||||
Trigger: Open "Failed to edit the role Contributor". The reason comes from the API.
|
||||
|
||||
Copy: Activity details · Why it failed · Jonas Weber saved changes to Contributor first, so this edit was refused. · The role was not changed.
|
||||
|
||||
### Category filter (Available now)
|
||||
|
||||
Trigger: The Category select open. Roles is a new category; Users & invites is renamed Users and invites.
|
||||
|
||||
Copy: All categories · Articles · Media · Users and invites · Roles · API keys · Authentication · System
|
||||
|
||||
## 9 Profile: your role
|
||||
|
||||
Route: /profile · API: GET /v1/me · Auth: Any
|
||||
Route: /profile · API: GET /v1/auth/me · Auth: Any
|
||||
|
||||
The person sees their own role and a read-only summary.
|
||||
|
||||
### Role card (Available now)
|
||||
### Role card: custom role (Available now)
|
||||
|
||||
Trigger: Any signed-in user.
|
||||
Trigger: Any signed-in user with a custom role. The card shows only what access.role carries (id, name, built_in, locked): the name as a role badge and a Custom tag. The one-line summary and Assigned by were removed.
|
||||
|
||||
Copy: Your role · Contributor · 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.
|
||||
Copy: Your role · Contributor · Custom · See what I can do · Your API keys follow this role. If your role changes, they change too.
|
||||
|
||||
### Role card: Owner (Available now)
|
||||
|
||||
Trigger: The owner. Same card; the Owner badge is followed by Locked (locked is true) and Built-in.
|
||||
|
||||
Copy: Your role · Owner · Locked · Built-in · See what I can do · Your API keys follow this role. If your role changes, they change too.
|
||||
|
||||
### See what I can do (Available now)
|
||||
|
||||
@@ -434,6 +458,180 @@ Trigger: Toast, and the bar closes.
|
||||
|
||||
Copy: Role created · Contributor is ready. Assign it from Users.
|
||||
|
||||
## 12 Toasts: role changed and stale state
|
||||
|
||||
Route: Any page · API: PUT /v1/user/{id}/role, PUT /v1/role/{id}, DELETE /v1/user/{id}, POST /v1/site/transfer-ownership · Auth: As the action
|
||||
|
||||
Warning toasts say that the thing the person acted on changed first, that nothing was changed, and offer Reload. They stay until dismissed or Reload is pressed. Info toasts (404) close after 6 s. Success toasts close after 6 s. Final EN and FR copy is in the table "Final copy: toasts and errors, EN and FR".
|
||||
|
||||
### Role changed (Available now)
|
||||
|
||||
Trigger: 200 on PUT /v1/user/{id}/role. The row updates in place and a success toast confirms.
|
||||
|
||||
Copy: Role changed · Ines Duarte is now Content lead, and their API keys follow the new role.
|
||||
|
||||
### Role edited elsewhere (409) (Available now)
|
||||
|
||||
Trigger: 409 on an action from the roles list (Duplicate, Delete, Move members) because the role version changed after the list loaded. Inside the role editor the Conflict banner is used instead. Reload refetches GET /v1/role.
|
||||
|
||||
Copy: Role changed elsewhere · Jonas Weber edited Contributor, so nothing was saved. Reload and try again. · Reload
|
||||
|
||||
### Role deleted elsewhere (Available now)
|
||||
|
||||
Trigger: Any action on a role deleted by someone else. The row is removed from the list. A target role deleted during Move members or Change role shows an inline error instead.
|
||||
|
||||
Copy: That role no longer exists · It was deleted somewhere else, so we removed it from the list.
|
||||
|
||||
### Role not changed: member's role changed first (409) (Available now)
|
||||
|
||||
Trigger: Change role was confirmed after someone else had changed the same member. The dialog is closed and the list is stale. Reload refetches GET /v1/user/.
|
||||
|
||||
Copy: Role not changed · Ines Duarte's role changed in the meantime, so nothing was saved. Reload and try again. · Reload
|
||||
|
||||
### Ownership not transferred: target's role changed first (409) (Available now)
|
||||
|
||||
Trigger: Transfer ownership was confirmed after the target's role changed, and the dialog was closed in the meantime. Nothing was transferred.
|
||||
|
||||
Copy: Ownership not transferred · Jonas Weber's role changed in the meantime, so nothing was transferred. Check it and try again. · Reload
|
||||
|
||||
### User not removed: member's role changed first (409) (Available now)
|
||||
|
||||
Trigger: Remove user was confirmed after the member's role changed. Nothing was removed.
|
||||
|
||||
Copy: User not removed · Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again. · Reload
|
||||
|
||||
## 13 Inline errors: editor and dialogs
|
||||
|
||||
Route: Role editor, Change role, Delete role, Invite · API: POST/PUT /v1/role, PUT /v1/user/{id}/role, POST /v1/role/{id}/reassign, POST /v1/user/invite · Auth: As the action
|
||||
|
||||
Errors stay in the dialog or form that caused them, above the fields or under the field they belong to. The dialog stays open and keeps what the person entered. The primary button stays on so the same action can be tried again, except when the person has to choose again.
|
||||
|
||||
### Role editor: save failed (500) (Available now)
|
||||
|
||||
Trigger: 500 or network failure on POST or PUT /v1/role. Every edit is kept and Save stays on.
|
||||
|
||||
Copy: Couldn't save the role · Your changes are still here. Try again in a moment. · Try again
|
||||
|
||||
### Change role: failed (500) (Available now)
|
||||
|
||||
Trigger: 500 or network failure. The choice is kept.
|
||||
|
||||
Copy: Couldn't change the role · Nothing was changed. Try again in a moment.
|
||||
|
||||
### Delete role: target role deleted (Available now)
|
||||
|
||||
Trigger: 409 because the chosen target role was deleted meanwhile. The picker is cleared and the action is off until a role is chosen.
|
||||
|
||||
Copy: Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to.
|
||||
|
||||
### Delete role: role gained members (Available now)
|
||||
|
||||
Trigger: 409 role_has_members when a role that had no members got one after the dialog opened. The dialog turns into the blocked state.
|
||||
|
||||
Copy: Role still has members · Intern now has 1 member. Move them to another role first. · Move 1 member and delete role (disabled until a role is chosen)
|
||||
|
||||
### Delete role: move failed (500) (Available now)
|
||||
|
||||
Trigger: 500 or network failure on reassign. The chosen role is kept.
|
||||
|
||||
Copy: Couldn't move the members · Nothing was changed. Try again in a moment.
|
||||
|
||||
### Invite: invalid email (Available now)
|
||||
|
||||
Trigger: 400 or client check. Shown under Email.
|
||||
|
||||
Copy: Enter a valid email address.
|
||||
|
||||
### Invite: address already has an account (409) (Available now)
|
||||
|
||||
Trigger: The address belongs to a user. Shown under Email. Same text as the API.
|
||||
|
||||
Copy: This address already has an account.
|
||||
|
||||
### Invite: role above yours (403) (Available now)
|
||||
|
||||
Trigger: An admin picked a role with more power than their own. Shown under Role.
|
||||
|
||||
Copy: Your role can't assign a role with more power than yours.
|
||||
|
||||
### Invite: send failed (500) (Available now)
|
||||
|
||||
Trigger: 500 or network failure. Email and role are kept.
|
||||
|
||||
Copy: Couldn't send the invite · Try again in a moment.
|
||||
|
||||
## 14 Read = Own: what the person sees
|
||||
|
||||
Route: /articles, /content/:key, /settings/roles/:id · API: GET /v1/article/, GET /v1/content-type/{key}/entry · Auth: As the role
|
||||
|
||||
A role with Read set to Own sees online articles plus its own drafts. For custom content types it sees only entries it created. Counts, totals and pagination follow the same rule, so nothing reveals how many hidden items exist. This is different from Update or Delete set to Own, where other people's rows are still listed with a View only mark.
|
||||
|
||||
### Role editor: Read set to Own (Available now)
|
||||
|
||||
Trigger: Junior writer has Read: Own on every type. A note under the grid explains what Own means for Read. The preview panel uses type-specific sentences.
|
||||
|
||||
Copy: Read: Own · Articles show online articles plus the person's own drafts. Custom content types show only entries the person created. Status counts follow the same rule. · Can read online articles and their own drafts, create entries and edit only their own. · Can read only entries they created, create entries and edit only their own.
|
||||
|
||||
### Articles list: Read = Own (Available now)
|
||||
|
||||
Trigger: Pedro Lima, Junior writer. Three online articles by others and his two own drafts. Counts: All 5, Online 3, Draft 2. Drafts by others are not listed and not counted.
|
||||
|
||||
Copy: Articles · Online articles and the drafts you created. · Drafts by others are hidden · Your role reads online articles and the drafts you created. The counts follow the same rule. · All 5 · Online 3 · Draft 2 · New article
|
||||
|
||||
### Articles list: no drafts of their own (Available now)
|
||||
|
||||
Trigger: The Draft tab when the person owns nothing. Counts: All 3, Online 3, Draft 0.
|
||||
|
||||
Copy: You have no drafts · Drafts you create appear here. Your role can't see drafts by other people. · New article · All 3 · Online 3 · Draft 0
|
||||
|
||||
### Entry list: Read = Own (Available now)
|
||||
|
||||
Trigger: Events with Read: Own. Only entries the person created, no pager, count line shows only their own entries.
|
||||
|
||||
Copy: Events · Your role shows only entries you created. · Entries by others aren't shown · Your role reads only entries you created. Ask an owner or admin if you need to see more. · 3 entries · New entry
|
||||
|
||||
### Entry list: no entries of their own (Available now)
|
||||
|
||||
Trigger: The person has created none. Other people's entries exist but are not hinted at.
|
||||
|
||||
Copy: You haven't created an event yet · Your role shows only entries you created, so other people's events aren't listed here. Use New entry to add one. · New entry
|
||||
|
||||
## 15 Role 409 conflicts: stale save, change and transfer
|
||||
|
||||
Route: /settings/roles/:id, /users · API: PUT /v1/role/{id}, PUT /v1/user/{id}/role, POST /v1/site/transfer-ownership · Auth: As the action
|
||||
|
||||
Roles carry a version. A stale request returns 409 and nothing is changed. The message appears where the person is working (banner in the editor, alert inside a dialog), never as a toast while that surface is open. The form always keeps what the person entered. Recovery: in the editor, review the differences and either keep the edits on top of the saved role or use the saved role; in a dialog, reload the member and try again.
|
||||
|
||||
### Stale save: banner (Available now)
|
||||
|
||||
Trigger: 409 role_conflict on PUT (version mismatch). Banner above the form. Edits are kept. Save is off until the person reloads or reviews.
|
||||
|
||||
Copy: This role changed while you were editing · Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits. · Reload role · Review differences · 2 unsaved changes. Save is off until you reload
|
||||
|
||||
### Stale save: review differences (Available now)
|
||||
|
||||
Trigger: Review differences. A dialog lists only the settings where the saved role and the edits disagree. Keep my edits reloads the role and puts the edits back on top (Save turns on). Use the saved role drops the edits. Reload role in the banner is the same as Use the saved role.
|
||||
|
||||
Copy: How the saved role differs from your edits · Jonas Weber saved changes to Contributor at 11:02. · Setting · Saved · Yours · Events · Update · Own · All · Events · Delete · None · Own · Use the saved role · Keep my edits
|
||||
|
||||
### Stale save: edits kept after reload (Available now)
|
||||
|
||||
Trigger: After Keep my edits. The form shows the saved role with the edits on top, changed cells marked, Save on.
|
||||
|
||||
Copy: Role reloaded · Your 2 changes are still here. Review them, then save. · 2 unsaved changes
|
||||
|
||||
### Change role: member's role changed first (409) (Available now)
|
||||
|
||||
Trigger: Change role confirmed after someone else changed the same member. The dialog stays open, the current role now shows the new value, and the choice is kept. Change role tries again against the new version.
|
||||
|
||||
Copy: Role changed in the meantime · Ines Duarte is now Admin. Your choice is kept. Check it and try again. · Change role
|
||||
|
||||
### Transfer ownership: target's role changed first (409) (Available now)
|
||||
|
||||
Trigger: Transfer confirmed after the target's role changed. The typed email is kept, the primary action is off until Reload member refreshes the target.
|
||||
|
||||
Copy: Role changed in the meantime · Jonas Weber is now Writer. What you typed is kept. Check the change, then try again. · Reload member · Transfer ownership (disabled until reloaded)
|
||||
|
||||
## Tables
|
||||
|
||||
The board lists, and this file repeats, the tables below.
|
||||
@@ -480,6 +678,7 @@ The board lists, and this file repeats, the tables below.
|
||||
| Row the person can read but not edit (Own) | View only mark on the row; Edit and Delete disabled in the menu | You can only edit content you created. |
|
||||
| Settings page they can read | Read-only: locked fields, banner, Save off | Admin on General without Edit general settings |
|
||||
| Owner-only action visible to an admin | Disabled, Owner only tag | Apply update |
|
||||
| Item the role may not read (Read = Own) | Not listed and not counted. No View only mark, no hint that it exists. | Another person's draft article, or an entry created by someone else |
|
||||
|
||||
### HTTP status to copy (additions)
|
||||
|
||||
@@ -490,6 +689,9 @@ The board lists, and this file repeats, the tables below.
|
||||
| role_has_members | 409 | Role still has members | {n} members have this role. Move them to another role first. |
|
||||
| role_name_taken | 409 | Name already used | A role named "{name}" already exists. Choose a different name. |
|
||||
| role_conflict | 409 | This role changed while you were editing | {name} saved changes to {role} at {time}. Reload to see them, or review how they differ from your edits. |
|
||||
| role_conflict (change role) | 409 | Role not changed | Dialog alert: {user} is now {role}. Your choice is kept. Check it and try again. Toast if the dialog was closed. |
|
||||
| role_conflict (remove, transfer) | 409 | User not removed / Ownership not transferred | {user}'s role changed in the meantime, so nothing was removed (transferred). Check it and try again. |
|
||||
| role_not_found | 404 | That role no longer exists | It was deleted somewhere else, so we removed it from the list. |
|
||||
| escalation | 403 | You can't grant that | You can only grant what your role has. |
|
||||
| (page level) | 403 | You don't have access to this page | Your role, {role}, doesn't include it. Ask an owner or admin if you need it. |
|
||||
|
||||
@@ -528,8 +730,8 @@ The board lists, and this file repeats, the tables below.
|
||||
| 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. |
|
||||
| Version field | role.version for the conflict state (409 role_conflict). The same 409 covers a change of role or a removal when the member's role changed first, and an ownership transfer when the target's role changed first. |
|
||||
| Activity events | role.create, role.edit (with a diff of added and removed permissions), role.delete, role.reassign, user.change_role, user.transfer_ownership. Present tense, as the backend emits them. The Failed outcome is a field on the event, not a separate name. |
|
||||
|
||||
### Existing screens that change
|
||||
|
||||
@@ -548,6 +750,87 @@ The board lists, and this file repeats, the tables below.
|
||||
| Activity and filters | New event types, role diff in the detail panel. |
|
||||
| Error and 403 copy | Two codes and the page-level state added. |
|
||||
|
||||
### Activity events: sentences, EN and FR
|
||||
|
||||
| Event name | EN | FR |
|
||||
|---|---|---|
|
||||
| role.create | Role Contributor created | Rôle Contributor créé |
|
||||
| role.edit | Role Contributor edited | Rôle Contributor modifié |
|
||||
| role.delete | Role Intern deleted | Rôle Intern supprimé |
|
||||
| role.reassign | Moved 3 members from Intern to Writer (1 member: Moved 1 member from Intern to Writer) | 3 membres déplacés de Intern vers Writer (1 membre : 1 membre déplacé de Intern vers Writer) |
|
||||
| user.change_role | Ines Duarte's role changed from Writer to Content lead | Le rôle d'Ines Duarte est passé de Writer à Content lead |
|
||||
| user.transfer_ownership | Ownership transferred from Marta Alves to Jonas Weber | Propriété transférée de Marta Alves à Jonas Weber |
|
||||
|
||||
### Activity events: failed sentences, EN and FR
|
||||
|
||||
| Event name (outcome failed) | EN | FR |
|
||||
|---|---|---|
|
||||
| Template | Failed to {verb} {object} | Échec de {action} {objet} |
|
||||
| role.create | Failed to create the role Contributor | Échec de la création du rôle Contributor |
|
||||
| role.edit | Failed to edit the role Contributor | Échec de la modification du rôle Contributor |
|
||||
| role.delete | Failed to delete the role Intern | Échec de la suppression du rôle Intern |
|
||||
| role.reassign | Failed to move 3 members from Intern to Writer | Échec du déplacement de 3 membres de Intern vers Writer |
|
||||
| user.change_role | Failed to change the role of Ines Duarte | Échec du changement de rôle d'Ines Duarte |
|
||||
| user.transfer_ownership | Failed to transfer ownership to Jonas Weber | Échec du transfert de la propriété à Jonas Weber |
|
||||
| user.invite | Failed to invite sofia@renewbuild.example | Échec de l'invitation de sofia@renewbuild.example |
|
||||
| user.remove | Failed to remove Ines Duarte | Échec du retrait d'Ines Duarte |
|
||||
| Reason: role_conflict | Jonas Weber saved changes to Contributor first, so this edit was refused. | Jonas Weber a enregistré des modifications de Contributor avant, donc cette modification a été refusée. |
|
||||
| Reason: role_locked | The Owner role can't be changed or deleted. | Le rôle Owner ne peut être ni modifié ni supprimé. |
|
||||
| Reason: missing_permission | The role of this person doesn't include that permission. | Le rôle de cette personne n'inclut pas cette autorisation. |
|
||||
|
||||
### Activity filter: category names, EN and FR
|
||||
|
||||
| Value | EN | FR |
|
||||
|---|---|---|
|
||||
| (none) | All categories | Toutes les catégories |
|
||||
| articles | Articles | Articles |
|
||||
| media | Media | Médias |
|
||||
| users | Users and invites (was "Users & invites") | Utilisateurs et invitations |
|
||||
| roles (new, to confirm) | Roles | Rôles |
|
||||
| api_keys | API keys | Clés API |
|
||||
| auth | Authentication | Authentification |
|
||||
| system | System | Système |
|
||||
|
||||
### Final copy: toasts and errors, EN and FR
|
||||
|
||||
| State | EN | FR |
|
||||
|---|---|---|
|
||||
| Toast: role changed | Role changed · Ines Duarte is now Content lead, and their API keys follow the new role. | Rôle modifié · Ines Duarte a maintenant le rôle Content lead, et ses clés API suivent le nouveau rôle. |
|
||||
| Toast: role edited elsewhere (409) | Role changed elsewhere · Jonas Weber edited Contributor, so nothing was saved. Reload and try again. · Reload | Rôle modifié ailleurs · Jonas Weber a modifié Contributor, donc rien n'a été enregistré. Rechargez et réessayez. · Recharger |
|
||||
| Toast: role deleted elsewhere | That role no longer exists · It was deleted somewhere else, so we removed it from the list. | Ce rôle n'existe plus · Il a été supprimé ailleurs, nous l'avons donc retiré de la liste. |
|
||||
| Toast: member's role changed before a change of role (409) | Role not changed · Ines Duarte's role changed in the meantime, so nothing was saved. Reload and try again. · Reload | Rôle non modifié · Le rôle d'Ines Duarte a changé entre-temps, donc rien n'a été enregistré. Rechargez et réessayez. · Recharger |
|
||||
| Toast: target's role changed before a transfer (409) | Ownership not transferred · Jonas Weber's role changed in the meantime, so nothing was transferred. Check it and try again. · Reload | Propriété non transférée · Le rôle de Jonas Weber a changé entre-temps, donc rien n'a été transféré. Vérifiez-le et réessayez. · Recharger |
|
||||
| Toast: member's role changed before a removal (409) | User not removed · Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again. · Reload | Utilisateur non retiré · Le rôle d'Ines Duarte a changé entre-temps, donc rien n'a été retiré. Vérifiez-le et réessayez. · Recharger |
|
||||
| Editor: name required | Give the role a name. | Donnez un nom au rôle. |
|
||||
| Editor: name taken (409) | A role named "Writer" already exists. Choose a different name. | Un rôle nommé « Writer » existe déjà. Choisissez un autre nom. |
|
||||
| Editor: save failed (500) | Couldn't save the role · Your changes are still here. Try again in a moment. · Try again | Impossible d'enregistrer le rôle · Vos modifications sont toujours là. Réessayez dans un instant. · Réessayer |
|
||||
| Editor: stale save banner (409) | This role changed while you were editing · Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits. · Reload role · Review differences | Ce rôle a changé pendant que vous le modifiiez · Jonas Weber a enregistré des modifications de Contributor à 11:02. Rechargez pour les voir, ou comparez-les à vos modifications. · Recharger le rôle · Voir les différences |
|
||||
| Editor: review differences | How the saved role differs from your edits · Setting · Saved · Yours · Use the saved role · Keep my edits | Comment le rôle enregistré diffère de vos modifications · Réglage · Enregistré · Vous · Utiliser le rôle enregistré · Garder mes modifications |
|
||||
| Editor: edits kept after reload | Role reloaded · Your 2 changes are still here. Review them, then save. | Rôle rechargé · Vos 2 modifications sont toujours là. Relisez-les, puis enregistrez. |
|
||||
| Editor: Read = Own note | Read: Own · Articles show online articles plus the person's own drafts. Custom content types show only entries the person created. Status counts follow the same rule. | Lecture : Les siennes · Les articles affichent les articles en ligne et les brouillons de la personne. Les types de contenu personnalisés n'affichent que les entrées créées par la personne. Les compteurs de statut suivent la même règle. |
|
||||
| Change role: member's role changed (409) | Role changed in the meantime · Ines Duarte is now Admin. Your choice is kept. Check it and try again. | Rôle modifié entre-temps · Ines Duarte a maintenant le rôle Admin. Votre choix est conservé. Vérifiez-le et réessayez. |
|
||||
| Change role: failed (500) | Couldn't change the role · Nothing was changed. Try again in a moment. | Impossible de modifier le rôle · Rien n'a été modifié. Réessayez dans un instant. |
|
||||
| Change role: role above yours | Your role can't assign a role with more power than yours. | Votre rôle ne peut pas attribuer un rôle plus puissant que le vôtre. |
|
||||
| Delete role: target deleted | Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to. | Writer n'existe plus · Il a été supprimé ailleurs. Choisissez un autre rôle pour y déplacer les membres. |
|
||||
| Delete role: role gained members (409) | Role still has members · Intern now has 1 member. Move them to another role first. | Le rôle a encore des membres · Intern a maintenant 1 membre. Déplacez-le d'abord vers un autre rôle. |
|
||||
| Delete role: move failed (500) | Couldn't move the members · Nothing was changed. Try again in a moment. | Impossible de déplacer les membres · Rien n'a été modifié. Réessayez dans un instant. |
|
||||
| Invite: invalid email | Enter a valid email address. | Saisissez une adresse e-mail valide. |
|
||||
| Invite: account exists (409) | This address already has an account. | Cette adresse a déjà un compte. |
|
||||
| Invite: role above yours | Your role can't assign a role with more power than yours. | Votre rôle ne peut pas attribuer un rôle plus puissant que le vôtre. |
|
||||
| Invite: send failed (500) | Couldn't send the invite · Try again in a moment. | Impossible d'envoyer l'invitation · Réessayez dans un instant. |
|
||||
| Transfer: target's role changed (409) | Role changed in the meantime · Jonas Weber is now Writer. What you typed is kept. Check the change, then try again. · Reload member | Rôle modifié entre-temps · Jonas Weber a maintenant le rôle Writer. Ce que vous avez saisi est conservé. Vérifiez le changement, puis réessayez. · Recharger le membre |
|
||||
|
||||
### Read = Own: what each surface returns
|
||||
|
||||
| Surface | Read = All | Read = Own |
|
||||
|---|---|---|
|
||||
| Articles list | Every article, including drafts by others | Online articles plus the person's own drafts |
|
||||
| Status counts (All, Online, Draft) | Counted over every article | Counted over the visible articles only: Draft counts own drafts, Online counts every online article |
|
||||
| Custom type entry list | Every entry | Only entries the person created. The count line and pager use that number. |
|
||||
| API list calls and X-Total-Count | Every item | Only the visible items. The total does not reveal hidden ones. |
|
||||
| Direct link to a hidden item (proposed) | Opens | The existing 404 page: Entry not found. No hint that the item exists. |
|
||||
| Empty state | Existing empty states | You have no drafts · You haven't created an event yet. New article or New entry stays available when Create is on. |
|
||||
|
||||
### Decisions to confirm
|
||||
|
||||
| # | Decision made | Alternative |
|
||||
@@ -555,11 +838,49 @@ The board lists, and this file repeats, the tables below.
|
||||
| 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. |
|
||||
| 4 | Update or Delete set to Own: rows others created show "View only" with a lock icon, not a hidden row. Read set to Own: those rows are not returned, so they are hidden and not counted. | Hide other people's entries for Update and Delete too. |
|
||||
| 5 | Reset to default exists for built-in roles edited by the owner. | No reset. |
|
||||
| 6 | Activity own/all scope uses the same None / Own / All control. | Toggle plus a separate scope select. |
|
||||
| 7 | Transfer ownership takes effect at once, no email to the new owner. | The new owner must accept. |
|
||||
| 8 | A user with Roles: manage cannot edit their own role, even in a custom role. | Allow narrowing only. |
|
||||
|
||||
State count: 90.
|
||||
|
||||
State count: 61.
|
||||
|
||||
## Read = Own: singular noun
|
||||
The singular noun in Read = Own copy ("entries you created", "event") is derived from the type name. Irregular names ("People", "Series") need a per-type singular field. Drawn as a Later item.
|
||||
|
||||
## 2b Role editor: app sync
|
||||
|
||||
Presets: Read only sets Read: All and everything else None for every type. Write own sets Read: All, Create: On, Update: Own, Delete and Publish: None. Full content sets Read, Update, Delete and Publish: All and Create: On. A preset overwrites every type row, including custom rows, and every changed cell counts as an unsaved change.
|
||||
|
||||
"N unsaved changes" counts cells in the content grid plus general toggles that differ from the loaded role. Name and description count as one each. Setting a cell back to its loaded value removes it from the count.
|
||||
|
||||
The footer bar is pinned to the bottom. The content grid scrolls sideways inside its card below 720 px. There is no leave guard on the delete dialog: it holds no unsaved edits.
|
||||
|
||||
| State | Trigger | Copy |
|
||||
|---|---|---|
|
||||
| No Schema: view | The editor's role lacks schema.view, so custom types can't be listed. | Custom content types aren't listed · Your role doesn't include Schema: view, so only Articles is shown. The permissions of custom types stay as they are. |
|
||||
| Reset to default: confirm | Built-in role, owner only. | Reset Writer to default? · Writer goes back to its built-in permissions. 5 members and the API keys they created are affected as soon as you save. This can't be undone. · Cancel · Reset to default (proposal) |
|
||||
| Reset to default: done | | Role reset · Writer is back to its defaults. Save to apply it to 5 members. (proposal) |
|
||||
| Review differences: name and description | Name and description appear as rows above the permission rows. | Name · Contributor · Event writer · Description · Writes posts · Writes events |
|
||||
| Duplicate | /settings/roles/new?from=<id>. Name prefilled "<role> copy"; nothing saved until Save changes. | Copied from Contributor · The permissions are the same. Give the copy a name that fits, then save. (proposal) |
|
||||
| role_not_found page | /settings/roles/:id for a deleted role. | That role no longer exists · It was deleted somewhere else, so we removed it from the list. · Back to Roles |
|
||||
| invalid_role_name (400) | Server refuses a blank name. | Give the role a name. |
|
||||
| Generic 400 | Any other 400 on save. | Couldn't save the role · Something in the form isn't valid. Check the highlighted fields. |
|
||||
| Role created | 201 on create. | Role created · Contributor is ready. Assign it from Users. |
|
||||
| Built-in role delete | Reached by link. | Delete Writer? · Built-in roles can't be deleted. |
|
||||
| Change role: choice is now current | After the re-read the chosen role equals the member's current role. | Change role is disabled. |
|
||||
|
||||
### Preview sentence templates (beyond the three examples)
|
||||
| Setting | Sentence |
|
||||
|---|---|
|
||||
| Read: None | No access to entries. |
|
||||
| Read: Own | Can read only entries they created. |
|
||||
| Read: All | Can read any entry. |
|
||||
| Create: On | Can create entries. |
|
||||
| Update: Own / All | Can edit only their own / any entry. |
|
||||
| Delete: Own / All | Can delete only their own / any entry. |
|
||||
| Publish: Own / All | Can publish only their own / any entry. |
|
||||
| Mixed rows | Listed per type, after "All content types". |
|
||||
Sentences join with commas and "and"; types that differ from the wildcard get their own line.
|
||||
@@ -4,7 +4,8 @@ All states on this page are **Later**: relations, permissions and schema export
|
||||
|
||||
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)
|
||||
### 7 Relation field and picker (group "7 Relations", 5 states)
|
||||
The picker exists in the app, so this group is no longer under "Later" in the build order; the route and the permission row stay as written.
|
||||
|
||||
Route: `/schema/:key, /content/:key/:id`. API: not built. Auth: Owner or admin (picker), any verified user (form).
|
||||
|
||||
@@ -13,8 +14,9 @@ All later. The model reads as two sentences, with a live preview of both sides.
|
||||
| # | 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 |
|
||||
| 2 | Relation in the entry form | Search and pick; chips for many. The picker loads 25 results per page (sample: 25 of 62 venues for "rue"); the list scrolls and ends with a footer. | Venue · Speakers · Search to add · An event can have many speakers. · Showing 25 of 62 · Load more | Later |
|
||||
| 3 | Relation picker: loading more | "Load more" pressed. Skeleton rows follow the last loaded result, the button shows a loader and is disabled, the footer count stays. Focus stays on the button, then moves to the first new row. | Showing 25 of 62 · Loading… · (skeleton rows) | Later |
|
||||
| 4 | Relation in the list | One chip, two chips and +N, or a dash. | One chip for a single relation. Up to two chips and a "+N" for many. Empty shows a dash. | Later |
|
||||
|
||||
### 8 Permissions grid (group "8 Permissions", 2 states)
|
||||
|
||||
@@ -29,7 +31,7 @@ All later. Rows are types, columns are actions, one role at a time. Built-in row
|
||||
|
||||
### 9 Schema export and import (group "9 Export and import", 3 states)
|
||||
|
||||
Route: `/schema (dialogs)`. API: not built. Auth: Owner or admin.
|
||||
Route: `/schema (dialogs)`. API: not built. Auth: permissions schema.view (open and read), schema.edit (create and change), schema.delete (delete a type).
|
||||
|
||||
All later. Dev-to-production flow: export a file, import it elsewhere, preview, same compatibility review as group 3, explicit confirm.
|
||||
|
||||
@@ -38,3 +40,5 @@ All later. Dev-to-production flow: export a file, import it elsewhere, preview,
|
||||
| 1 | Export | Export on /schema. | Export schema · Download the fields of your content types as a file. Entries aren't included. · All content types · 3 content types · One content type · Choose which one · File name · renewcms-schema.json · Import this file on another RenewCMS site to create the same content types. · Cancel · Download file | Later |
|
||||
| 2 | Import preview | A file was chosen. | Import schema · renewcms-schema.json · 3 content types · Create 1, change 1, no change 1 · Recipes · New content type with 4 fields. · Events · Add 2 fields, remove Description. Events has 37 entries on this site. · Removes data · Press mentions · Same as on this site. · Existing entries are checked against the changes when you import. If any entry doesn't fit, nothing is imported. · Cancel · Import schema | Later |
|
||||
| 3 | Import refused | The server answers schema_incompatible. | Nothing was imported · Some entries on this site don't fit the imported fields. RenewCMS can't tell which entries. The flagged change can cause this. · Try again | Later |
|
||||
|
||||
| 4 | Relation picker: load more failed | Load more failed. Loaded rows stay, an inline error replaces the count, the button reads Try again. Focus stays on the button, then moves to the first new row. | Couldn't load more results. The 25 shown are still here. · Try again | Available now |
|
||||
@@ -1,6 +1,6 @@
|
||||
# Schema
|
||||
|
||||
Route: `/schema`, `/schema/new`, `/schema/:key`. API: GET/POST /v1/content-type · GET/PUT/DELETE /v1/content-type/{key}. Auth: owner or admin.
|
||||
Route: `/schema`, `/schema/new`, `/schema/:key`. API: GET/POST /v1/content-type · GET/PUT/DELETE /v1/content-type/{key}. Auth: permissions schema.view (open and read), schema.edit (create and change), schema.delete (delete a type).
|
||||
|
||||
Custom content types: an owner or admin defines a type (for example Events) made of 1 to 50 ordered fields; any verified user then writes entries. Everything about a type happens on one screen. Primary words: content type, field, choice list, entry. "Key" is kept for the API identifier. The key of a type is chosen at creation and never changes; the key and type of a saved field never change. Built-in types (Articles, Authors, Sites, Media, Users) are listed with a lock and a "Built-in" badge and open a read-only view. Non-owner, non-admin users see no Schema entry; a direct link shows a 403 page.
|
||||
|
||||
@@ -8,7 +8,7 @@ Visual reference: `RenewCMS Screens Schema.dc.html`, groups "Schema list", "Sche
|
||||
|
||||
### 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.
|
||||
Route: `/schema, /schema/:key (built-in)`. API: GET /v1/content-type. Auth: permissions schema.view (open and read), schema.edit (create and change), schema.delete (delete a type).
|
||||
|
||||
Sidebar entry "Schema" for owners and admins only. Built-in types are listed with a lock and a Built-in badge, always after the custom ones. They open a read-only view. Sample data: the field counts of built-ins are illustrative.
|
||||
|
||||
@@ -23,7 +23,7 @@ Sidebar entry "Schema" for owners and admins only. Built-in types are listed wit
|
||||
|
||||
### 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.
|
||||
Route: `/schema/new, /schema/:key`. API: POST /v1/content-type, GET/PUT /v1/content-type/{key}. Auth: permissions schema.view (open and read), schema.edit (create and change), schema.delete (delete a type).
|
||||
|
||||
One screen, three regions: type name and key on top, ordered field list on the left, field inspector on the right, collapsible API preview at the bottom. Mobile: the list is the page, the inspector is a 92% sheet, the API preview is a sheet. Field keys and types of saved fields are locked; fields added since the last save can still choose both.
|
||||
|
||||
@@ -48,7 +48,7 @@ One screen, three regions: type name and key on top, ordered field list on the l
|
||||
|
||||
### 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.
|
||||
Route: `/schema/:key (dialogs)`. API: PUT /v1/content-type/{key}, DELETE /v1/content-type/{key}. Auth: permissions schema.view (open and read), schema.edit (create and change), schema.delete (delete a type).
|
||||
|
||||
The review opens on Save when the type already has entries (37 here). It is informational: the server decides compatibility. Destructive changes (removing a field or a choice, tightening a limit, making a field unique or required) carry a warning flag. Per-change entry counts and a full diff are later.
|
||||
|
||||
@@ -78,4 +78,13 @@ One frame per type: inspector options, the entry form control (default and open
|
||||
| 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.
|
||||
Spec, states, exact copy, measurements, backend needs and accessibility: `media-field.md`, section 3.1 to 3.4. Board groups: 1 Schema list, 2 Schema builder, 3 Review, 4 Field types.
|
||||
|
||||
## Permissions (app sync)
|
||||
Three keys gate Schema: schema.view (list and read types, and the Schema sidebar entry), schema.edit (create and change types and fields), schema.delete (delete a content type). The built-in Owner and Admin roles hold all three. The app still words the 403 as "Only owners and admins can…"; the design keeps that copy until the app changes. A role with schema.view only sees the builder read-only: Save, Add field, Move and Remove are hidden.
|
||||
|
||||
## Mobile field sheet
|
||||
The field sheet carries Move up, Move down and Remove (44 px, equal widths) above Done. Move up is disabled on the first field, Move down on the last.
|
||||
|
||||
## Saving
|
||||
Save always opens the review first, with or without entries. During Apply the dialog reads Applying… and the entries info bar is hidden.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Route: `/users`. API: GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id (pending) · POST /v1/user/invite/:id/renew (pending).
|
||||
|
||||
One shared workspace: every user sees the same articles and this same list. Columns: email, display name, role (owner or admin), invite status and created date. A pending invite can be cancelled and an expired one renewed; accepted accounts have no row actions. An invite is Pending until its link is used, Expired after 3 days, and Accepted once the account is created.
|
||||
One shared workspace: every user sees the same articles and this same list. Columns: email, display name, role badge, invite status and created date. A pending invite can be cancelled and an expired one renewed; accepted accounts have no row actions. An invite is Pending until its link is used, Expired after 3 days, and Accepted once the account is created.
|
||||
|
||||
Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Users". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
|
||||
|
||||
@@ -29,7 +29,7 @@ Every state below is drawn in light and dark (theme switch on the board), at 144
|
||||
|
||||
Route: `/users`. API: GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id (pending) · POST /v1/user/invite/:id/renew (pending).
|
||||
|
||||
One shared workspace: every user sees the same articles and this same list. Columns: email, display name, role (owner or admin), invite status and created date. A pending invite can be cancelled and an expired one renewed; accepted accounts have no row actions. An invite is Pending until its link is used, Expired after 3 days, and Accepted once the account is created.
|
||||
One shared workspace: every user sees the same articles and this same list. Columns: email, display name, role badge, invite status and created date. A pending invite can be cancelled and an expired one renewed; accepted accounts have no row actions. An invite is Pending until its link is used, Expired after 3 days, and Accepted once the account is created.
|
||||
|
||||
| # | State | Trigger | Copy |
|
||||
|---|---|---|---|
|
||||
@@ -50,3 +50,9 @@ One shared workspace: every user sees the same articles and this same list. Colu
|
||||
| 15 | Renew invite: done | 200: the row becomes Pending with today's date, moves to the top, and the old link stops working. Clock-sensitive: shows a relative time or a countdown. | "Today" / "Invite renewed" / "p.huber@gmail.com has 3 days to accept the new link." |
|
||||
| 16 | Renew invite: failed | 500 or network failure. The row stays Expired and the button can be pressed again. | "Couldn't renew the invite" / "Try again in a moment." |
|
||||
| 17 | Invite sent | 201: the dialog closes, the new invite appears at the top as Pending, and a toast confirms. | "lea.martin@renew.example" / "Today" / "lea.martin@renew.example has 3 days to accept." |
|
||||
|
||||
## Spec notes (app sync)
|
||||
- The Users table has no "Assigned by" column. Columns: email, display name, role, invite status, created.
|
||||
- A blocked role in the RolePicker shows the generic RolePicker reason, not a per-role sentence. The current role carries a "Current" mark; Owner is disabled with its reason.
|
||||
- Own row: Change role is disabled with "You can't change your own role". Remove user is disabled with "You can't remove yourself. Ask another owner or admin."
|
||||
- Remove user: dialog "Remove {0}?" / "They lose access straight away, and the API keys they created stop working. Their articles, entries and media stay." / Remove user (pending: "Removing…"). Success toast "User removed" / "{0} no longer has access." 500: "Couldn't remove the user" / "Nothing was changed. Try again in a moment." 409 target_role_not_found: toast "User not removed" / "{0}'s role changed in the meantime, so nothing was removed. Check it and try again." / Reload. {0} is the display name, falling back to the username.
|
||||
@@ -1,32 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,29 +0,0 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,131 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<script src="./account-copy.js"></script>
|
||||
</helmet>
|
||||
<div style="display:contents;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px;color:var(--ui-text)">
|
||||
<sc-if value="{{ pEmail }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display:flex;flex-direction:column;gap:6px;min-width:0">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px"><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ c.emailLabel }}</span><sc-if value="{{ showChange }}"><span data-act="ac-change" role="button" style="min-height:{{ linkH }};display:flex;align-items:center;font-weight:500;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px">{{ c.changeEmail }}</span></sc-if></div>
|
||||
<div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ old }}</span><span style="display:flex;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ pPanel }}" hint-placeholder-val="{{ false }}">
|
||||
<sc-if value="{{ form }}">
|
||||
<div style="display:flex;flex-direction:column;gap:14px;padding:{{ panelPad }};background:var(--ui-bg-muted);border:1px solid var(--ui-border)">
|
||||
<sc-if value="{{ form.notice }}"><div role="alert" style="display:flex;gap:8px;padding:10px 12px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat"></span></span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ form.notice }}</span></div></sc-if>
|
||||
<sc-for list="{{ form.fields }}" as="f" hint-placeholder-count="2">
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<label style="font-weight:500;color:var(--ui-text-highlighted)">{{ f.label }}</label>
|
||||
<div style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;border:1px solid {{ f.bd }};box-shadow:{{ f.sh }};background:{{ f.bg }};color:{{ f.fg }};font-size:{{ inF }};overflow:hidden;white-space:nowrap"><span style="overflow:hidden;text-overflow:ellipsis;letter-spacing:{{ f.ls }}">{{ f.shown }}</span><sc-if value="{{ f.caret }}"><span style="flex-shrink:0;display:inline-block;width:1px;height:18px;background:var(--ui-text-highlighted);margin-left:1px"></span></sc-if></div>
|
||||
<sc-if value="{{ f.err }}"><span style="display:flex;gap:6px;align-items:center;color:var(--ui-error);font-size:12px;line-height:18px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat"></span>{{ f.err }}</span></sc-if>
|
||||
</div>
|
||||
</sc-for>
|
||||
<span style="color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty">{{ c.formHelp }}</span>
|
||||
<div style="display:flex;flex-direction:{{ actDir }};justify-content:flex-end;gap:8px">
|
||||
<span data-act="ac-form-cancel" style="min-height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;text-align:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ form.cancelFg }};font-weight:500">{{ c.cancel }}</span>
|
||||
<span data-act="ac-form-send" style="min-height:{{ btnH }};padding:6px 12px;display:flex;align-items:center;justify-content:center;gap:6px;text-align:center;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;opacity:{{ form.op }}"><sc-if value="{{ form.sending }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span></sc-if>{{ form.sendLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ pend }}">
|
||||
<div role="status" style="display:flex;flex-direction:column;gap:12px;padding:12px 14px;background:var(--rc-info-soft);color:var(--ui-info)">
|
||||
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/mail.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/mail.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:600">{{ c.pendTitle }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty;overflow-wrap:anywhere">{{ pend.text }}</span></div></div>
|
||||
<div style="display:flex;flex-direction:{{ colDir }};gap:8px">
|
||||
<span data-act="ac-resend" style="min-height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;text-align:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;opacity:{{ pend.op }}"><sc-if value="{{ pend.sending }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span></sc-if>{{ pend.resendLabel }}</span>
|
||||
<span data-act="ac-cancel-change" style="min-height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;text-align:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500">{{ c.cancelChange }}</span>
|
||||
</div>
|
||||
<sc-if value="{{ pend.note }}"><span style="display:flex;gap:6px;align-items:center;color:var(--ui-error);font-size:12px;line-height:18px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat"></span>{{ pend.note }}</span></sc-if>
|
||||
</div>
|
||||
</sc-if>
|
||||
</sc-if>
|
||||
<sc-if value="{{ pCards }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display:flex;flex-direction:column;gap:16px;flex-shrink:0">
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;flex-shrink:0;display:flex;flex-direction:column;gap:14px">
|
||||
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">{{ c.dataTitle }}</span><span style="color:var(--ui-text-muted);text-wrap:pretty">{{ c.dataDesc }}</span></div>
|
||||
<div style="display:grid;grid-template-columns:{{ itemCols }};gap:12px 16px">
|
||||
<sc-for list="{{ items }}" as="it" hint-placeholder-count="4">
|
||||
<div style="display:flex;gap:10px;min-width:0"><span style="display:flex;padding-top:2px;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ it.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ it.icon }}.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ it.title }}</span><span style="color:var(--ui-text-muted);text-wrap:pretty">{{ it.text }}</span></div></div>
|
||||
</sc-for>
|
||||
</div>
|
||||
<sc-if value="{{ data.notice }}"><div role="status" style="display:flex;gap:10px;padding:12px 14px;background:{{ data.notice.bg }};color:{{ data.notice.fg }}"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ data.notice.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ data.notice.icon }}.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">{{ data.notice.title }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ data.notice.text }}</span></div></div></sc-if>
|
||||
<div style="display:flex;flex-direction:{{ colDir }};align-items:{{ rowAlign }};gap:12px">
|
||||
<span data-act="ac-export" style="min-height:{{ btnH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;text-align:center;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;opacity:{{ data.op }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ data.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ data.icon }}.svg') center / contain no-repeat"></span>{{ data.label }}</span>
|
||||
<span style="flex:1;min-width:0;color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty">{{ data.hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-error);display:flex;flex-direction:column;flex-shrink:0">
|
||||
<div style="padding:16px;display:flex;flex-direction:{{ colDir }};align-items:{{ rowAlign }};justify-content:space-between;gap:14px">
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:2px;min-width:0"><span style="display:flex;align-items:center;gap:8px;font-weight:600;font-size:14px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/triangle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/triangle-alert.svg') center / contain no-repeat"></span>{{ c.dzTitle }}</span><span style="color:var(--ui-text-muted);text-wrap:pretty">{{ c.dzDesc }}</span></div>
|
||||
<span data-act="ac-delete" style="flex-shrink:0;min-height:{{ btnH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;text-align:center;background:var(--ui-error);color:var(--ui-bg);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat"></span>{{ c.dzBtn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script>
|
||||
class Component extends DCLogic {
|
||||
componentDidMount() {
|
||||
if (!window.ACCOUNT_COPY) this.t = setInterval(() => { if (window.ACCOUNT_COPY) { clearInterval(this.t); this.forceUpdate() } }, 100)
|
||||
}
|
||||
componentWillUnmount() { clearInterval(this.t) }
|
||||
renderVals() {
|
||||
const p = this.props, m = !!p.mobile, part = p.part || 'cards', ac = p.ac || {}
|
||||
const C = p.c || (window.ACCOUNT_COPY && window.ACCOUNT_COPY.en) || {}
|
||||
const old = ac.old || 'marta@renew.example', nw = ac.new || 'marta.alves@atelier-renew.example'
|
||||
const fmt = s => (s || '').split('{new}').join(nw).split('{old}').join(old)
|
||||
const tone = t => ({ bg: 'var(--rc-' + t + '-soft)', fg: 'var(--ui-' + t + ')' })
|
||||
const fld = (label, val, ph, o = {}) => ({
|
||||
label, shown: val || ph || '', caret: !!o.focus && !o.dis,
|
||||
fg: o.dis ? 'var(--ui-text-dimmed)' : val ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)',
|
||||
bd: o.err ? 'var(--ui-error)' : o.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)',
|
||||
sh: o.err ? 'inset 0 0 0 1px var(--ui-error)' : o.focus ? 'inset 0 0 0 1px var(--ui-primary)' : 'none',
|
||||
bg: o.dis ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)', err: o.err || '', ls: o.mask && val ? '0.12em' : 'normal'
|
||||
})
|
||||
const F = ac.form
|
||||
const form = F ? {
|
||||
fields: [
|
||||
fld(C.newEmail, F.email, C.newEmailPh, { err: F.errEmail, focus: F.focus === 'email', dis: F.sending }),
|
||||
fld(C.password, F.pw ? '•'.repeat(F.pw.length) : '', '', { err: F.errPw, focus: F.focus === 'pw', dis: F.sending, mask: true })
|
||||
],
|
||||
notice: F.notice || '', sending: !!F.sending, sendLabel: F.sending ? C.sending : C.send,
|
||||
op: F.disabled ? '0.4' : '1', cancelFg: F.sending ? 'var(--ui-text-dimmed)' : 'var(--ui-text-highlighted)'
|
||||
} : null
|
||||
const P = ac.pending
|
||||
const pend = P ? {
|
||||
text: fmt(C.pendText), sending: P.resend === 'sending',
|
||||
resendLabel: P.resend === 'sending' ? C.sending : P.resend === 'rate' ? C.resendRate : C.resend,
|
||||
op: P.resend === 'rate' ? '0.4' : '1',
|
||||
note: P.resend === 'rate' ? C.rate : P.note || ''
|
||||
} : null
|
||||
const D = ac.data || {}, async_ = D.mode === 'async', ds = D.state || 'idle'
|
||||
let data = { label: async_ ? C.request : C.download, icon: async_ ? 'mail' : 'download', op: '1', hint: async_ ? fmt(C.asyncHint) : C.dlHint, notice: null }
|
||||
if (!async_ && ds === 'preparing') data = { ...data, label: C.preparing, icon: 'loader-circle', hint: '' }
|
||||
if (!async_ && ds === 'rate') data = { ...data, label: C.exRateBtn, op: '0.4', icon: 'download', hint: '', notice: { ...tone('info'), icon: 'timer', title: C.exRateT, text: C.exRateB } }
|
||||
if (ds === 'error') data = { ...data, notice: { ...tone('error'), icon: 'circle-x', title: C.exErrT, text: C.exErrB } }
|
||||
if (async_ && ds === 'queued') data = { ...data, label: C.queuedBtn, op: '0.4', icon: 'mail-check', hint: '', notice: { ...tone('success'), icon: 'mail-check', title: C.queuedT, text: fmt(C.queuedB) } }
|
||||
if (async_ && ds === 'inprogress') data = { ...data, label: C.queuedBtn, op: '0.4', icon: 'mail-check', hint: '', notice: { ...tone('info'), icon: 'info', title: C.inprogT, text: fmt(C.inprogB) } }
|
||||
return {
|
||||
c: C, old, form, pend, data,
|
||||
items: (C.dataItems || []).map(x => ({ icon: x[0], title: x[1], text: x[2] })),
|
||||
pEmail: part === 'email', pPanel: part === 'panel', pCards: part === 'cards',
|
||||
showChange: !F && !P,
|
||||
inH: m ? '44px' : '40px', inF: m ? '16px' : '14px', btnH: m ? '44px' : '36px', linkH: m ? '44px' : '20px',
|
||||
panelPad: '16px', actDir: m ? 'column-reverse' : 'row', colDir: m ? 'column' : 'row', rowAlign: m ? 'stretch' : 'center',
|
||||
itemCols: m ? 'minmax(0,1fr)' : 'repeat(2,minmax(0,1fr))', delAlign: m ? 'stretch' : 'flex-start'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -16,11 +16,11 @@
|
||||
<sc-if value="{{ empty }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:48px 24px;display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">No API keys yet</span><span style="color:var(--ui-text-toned);max-width:360px;text-wrap:pretty">Create a key to call the API from a script or service.</span><sc-if value="{{ desk }}"><span style="height:36px;padding:0 14px;display:flex;align-items:center;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">Create key</span></sc-if></div></sc-if>
|
||||
<sc-if value="{{ showList }}">
|
||||
<sc-if value="{{ desk }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
|
||||
<div style="display:grid;grid-template-columns:{{ cols }};gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Name</span><span>Key</span><span>Status</span><sc-if value="{{ owner }}"><span>Created by</span></sc-if><span>Created</span><span>Last used</span><span>Expires</span><span></span></div>
|
||||
<div style="display:grid;grid-template-columns:{{ cols }};gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Name</span><span>Key</span><span>Status</span><sc-if value="{{ owner }}"><span>Created by</span></sc-if><span>Follows role</span><span>Created</span><span>Last used</span><span>Expires</span><span></span></div>
|
||||
<sc-for list="{{ rows }}" as="r" hint-placeholder-count="4"><div style="display:grid;grid-template-columns:{{ cols }};gap:12px;height:48px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border);background:{{ r.bg }}">
|
||||
<sc-if value="{{ r.skel }}"><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:70%;background:var(--ui-bg-accented)"></span><span style="height:20px;width:56px;background:var(--ui-bg-accented)"></span><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span></span><span></span><span></span></sc-if>
|
||||
<sc-if value="{{ r.skel }}"><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:70%;background:var(--ui-bg-accented)"></span><span style="height:20px;width:56px;background:var(--ui-bg-accented)"></span><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:50%;background:var(--ui-bg-accented)"></span><span></span><span></span><span></span></sc-if>
|
||||
<sc-if value="{{ r.name }}"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;color:{{ r.fg }}">{{ r.name }}</span><span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ r.fg2 }}">{{ r.prefix }}</span><span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:{{ r.sbg }};color:{{ r.sfg }};font-size:12px;font-weight:500">{{ r.status }}</span></span>
|
||||
<sc-if value="{{ owner }}"><span style="color:{{ r.fg2 }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ r.by }}</span></sc-if><span style="color:{{ r.fg2 }}">{{ r.created }}</span><span style="color:{{ r.fg2 }}">{{ r.used }}</span><span style="color:{{ r.fg2 }}">{{ r.exp }}</span>
|
||||
<sc-if value="{{ owner }}"><span style="color:{{ r.fg2 }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ r.by }}</span></sc-if><span><span style="display:inline-flex;align-items:center;height:20px;padding:0 6px;border:1px solid var(--ui-border-accented);color:{{ r.fg2 }};font:400 12px 'IBM Plex Mono',monospace">{{ r.role }}</span></span><span style="color:{{ r.fg2 }}">{{ r.created }}</span><span style="color:{{ r.fg2 }}">{{ r.used }}</span><span style="color:{{ r.fg2 }}">{{ r.exp }}</span>
|
||||
<span style="display:flex;justify-content:flex-end"><sc-if value="{{ r.canRevoke }}"><span style="height:28px;padding:0 10px;display:flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">Revoke</span></sc-if></span></sc-if>
|
||||
</div></sc-for></div></sc-if>
|
||||
<sc-if value="{{ mob }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);margin:0 -16px;border-left:0;border-right:0">
|
||||
@@ -28,7 +28,7 @@
|
||||
<sc-if value="{{ r.skel }}"><span style="height:14px;width:55%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:40%;background:var(--ui-bg-accented)"></span></sc-if>
|
||||
<sc-if value="{{ r.name }}"><div style="display:flex;align-items:center;gap:8px"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;font-size:15px;color:{{ r.fg }}">{{ r.name }}</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:{{ r.sbg }};color:{{ r.sfg }};font-size:12px;font-weight:500">{{ r.status }}</span></div>
|
||||
<span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ r.fg2 }}">{{ r.prefix }}</span>
|
||||
<span style="color:{{ r.fg2 }}">Created {{ r.created }}<sc-if value="{{ owner }}"> by {{ r.by }}</sc-if> · Last used {{ r.used }} · Expires {{ r.exp }}</span>
|
||||
<span style="color:{{ r.fg2 }}">Created {{ r.created }}<sc-if value="{{ owner }}"> by {{ r.by }}</sc-if> · Follows role {{ r.role }} · Last used {{ r.used }} · Expires {{ r.exp }}</span>
|
||||
<sc-if value="{{ r.canRevoke }}"><span style="align-self:flex-start;height:44px;padding:0 14px;display:flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">Revoke</span></sc-if></sc-if>
|
||||
</div></sc-for></div></sc-if>
|
||||
</sc-if>
|
||||
@@ -69,6 +69,7 @@ class Component extends DCLogic {
|
||||
let all = owner
|
||||
? [R('Website build', 'rcms_Kf3x…', 'active', 'Marta Alves', '12 Mar 2026', '3 Oct 2026', 'None'), R('Nightly media import', 'rcms_Mq7b…', k.revoked ? 'revoked' : 'active', 'Jonas', '2 Apr 2026', '3 Oct 2026', '31 Dec 2026'), R('Staging preview', 'rcms_Zt2d…', 'expired', 'Marta Alves', '14 Jun 2026', '20 Aug 2026', '30 Aug 2026'), R('Old migration script', 'rcms_Hn5w…', 'revoked', 'Inès Duarte', '3 Apr 2026', '9 May 2026', 'None')]
|
||||
: [R('Nightly media import', 'rcms_Mq7b…', 'active', 'Jonas', '2 Apr 2026', '3 Oct 2026', '31 Dec 2026'), R('Old CI token', 'rcms_Lp9c…', 'revoked', 'Jonas', '18 Jan 2026', '2 Feb 2026', 'None')]
|
||||
all = all.map(r => ({ ...r, role: r.by === (owner ? 'Marta Alves' : 'Jonas') ? (owner ? 'owner' : 'admin') : '—' }))
|
||||
const st = k.state || 'list'
|
||||
const rows = st === 'loading' ? Array.from({ length: 4 }, () => ({ skel: true, bg: 'var(--ui-bg)' })) : all
|
||||
const cp = { reveal: true }
|
||||
@@ -87,7 +88,7 @@ class Component extends DCLogic {
|
||||
mob, desk: !mob, owner, rows, pad: mob ? '16px 16px 84px' : '24px', pinned: mob && !m, h1: '20px/28px',
|
||||
sub: owner ? 'Keys act as the user who created them. As an owner you see everyone\'s keys.' : 'Keys act as you, with your role. You only see your own keys.',
|
||||
err: st === 'error', empty: st === 'empty', showList: st === 'list' || st === 'loading',
|
||||
cols: owner ? 'minmax(0,1.3fr) 110px 76px minmax(0,1fr) 100px 100px 100px 80px' : 'minmax(0,1.3fr) 110px 76px 100px 100px 100px 80px',
|
||||
cols: owner ? 'minmax(0,1.3fr) 110px 76px minmax(0,1fr) 90px 100px 100px 100px 80px' : 'minmax(0,1.3fr) 110px 76px 90px 100px 100px 100px 80px',
|
||||
toast: !!k.toast, toastPos: mob ? 'left:16px;right:16px;bottom:84px' : 'right:24px;bottom:24px;width:320px',
|
||||
modal: !!m, m: m || {}, closeSz: mob ? '44px' : '28px', btnH: mob ? '44px' : '32px', mAlign: mob ? 'flex-end' : 'flex-start', mPad: mob ? '0' : '140px 16px 16px', mW: mob ? '100%' : '460px', btnDir: mob ? 'column-reverse' : 'row', keyDir: mob ? 'column' : 'row'
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
<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>
|
||||
<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><sc-if value="{{ a.busy }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-muted);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span>{{ a.busy }}</span></sc-if><sc-if value="{{ a.showOn }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ a.showDraft }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if><sc-if value="{{ a.seoSug }}"><span title="{{ a.seoTitle }}" style="margin-left:6px;height:22px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--rc-warning-soft);color:var(--ui-warning);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat"></span>{{ a.seoN }}</span></sc-if><sc-if value="{{ a.seoHid }}"><span title="Hidden from search engines" style="margin-left:6px;height:22px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;flex-shrink:0;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat"></span>Hidden</span></sc-if></span>
|
||||
<span style="color:var(--ui-text-muted)">{{ a.c }}</span><span style="color:var(--ui-text-muted)">{{ a.u }}</span>
|
||||
<span data-act="menu-{{ a.id }}" style="width:32px;height:32px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);background:{{ a.menuBg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat"></span></span>
|
||||
<sc-if value="{{ a.menu }}"><div role="menu" style="position:absolute;right:12px;top:40px;z-index:2;width:220px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div data-act="open-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit</div><div data-act="pub-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat"></span>{{ a.menuPub }}</div><div data-act="copyurl" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat"></span>Copy API URL</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="del-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat"></span>Delete…</div></div></sc-if>
|
||||
@@ -125,7 +125,7 @@
|
||||
<sc-for list="{{ rows }}" as="a" hint-placeholder-count="6">
|
||||
<div data-act="open-{{ a.id }}" style="position:relative;display:flex;align-items:center;gap:8px;padding:12px 4px 12px 16px;border-bottom:1px solid var(--ui-border);min-height:68px;opacity:{{ a.op }}">
|
||||
<sc-if value="{{ a.skel }}"><div style="flex:1;display:flex;flex-direction:column;gap:8px"><span style="width:70%;height:14px;background:var(--ui-bg-accented)"></span><span style="width:40%;height:12px;background:var(--ui-bg-accented)"></span></div></sc-if>
|
||||
<sc-if value="{{ a.t }}"><div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:6px"><span style="font-weight:500;font-size:15px;line-height:21px;color:var(--ui-text-highlighted)">{{ a.t }}</span><div style="display:flex;align-items:center;gap:10px;font-size:12px;color:var(--ui-text-muted)"><sc-if value="{{ a.busy }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span>{{ a.busy }}</span></sc-if><sc-if value="{{ a.showOn }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ a.showDraft }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if><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
|
||||
<sc-if value="{{ a.t }}"><div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:6px"><span style="font-weight:500;font-size:15px;line-height:21px;color:var(--ui-text-highlighted)">{{ a.t }}</span><div style="display:flex;align-items:center;gap:10px;font-size:12px;color:var(--ui-text-muted)"><sc-if value="{{ a.busy }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span>{{ a.busy }}</span></sc-if><sc-if value="{{ a.showOn }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ a.showDraft }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if><sc-if value="{{ a.seoSug }}"><span title="{{ a.seoTitle }}" style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--rc-warning-soft);color:var(--ui-warning);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat"></span>{{ a.seoN }}</span></sc-if><sc-if value="{{ a.seoHid }}"><span title="Hidden from search engines" style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;flex-shrink:0;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat"></span>Hidden</span></sc-if><span>Updated {{ a.u }}</span></div><sc-if value="{{ authorsCol }}"><div style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--ui-text-muted);min-width:0"><sc-if value="{{ a.hasAu }}"><span style="display:flex;align-items:center"><sc-for list="{{ a.avs }}" as="v" hint-placeholder-count="2"><span style="width:22px;height:22px;margin-left:{{ v.ml }};flex-shrink:0;display:flex;align-items:center;justify-content:center;background:{{ v.bg }};color:var(--ui-text-highlighted);border:2px solid var(--ui-bg);font:600 9px/1 'IBM Plex Sans',sans-serif">{{ v.i }}</span></sc-for></span></sc-if><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ a.auFirst }}</span><sc-if value="{{ multi }}"><span>· {{ a.site }}</span></sc-if></div></sc-if></div><span data-act="menu-{{ a.id }}" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis-vertical.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis-vertical.svg') center / contain no-repeat"></span></span></sc-if><sc-if value="{{ a.menu }}"><div role="menu" style="position:absolute;right:8px;top:56px;z-index:2;width:220px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div data-act="open-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center Line truncated
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
@@ -148,11 +148,11 @@
|
||||
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:14px;max-width:560px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">404</span><span style="font:600 24px/32px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">This article doesn't exist</span><span style="color:var(--ui-text-toned);font-size:14px;line-height:22px">It may have been deleted, or the link is wrong.</span><span data-act="nav-articles" style="align-self:flex-start;height:{{ btnH }};padding:0 12px;display:flex;align-items:center;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">All articles</span></div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ edFound }}">
|
||||
<div style="flex-shrink:0;background:var(--ui-bg);border-bottom:1px solid var(--ui-border);padding:{{ edHeadPad }};display:flex;flex-wrap:wrap;align-items:center;gap:{{ edHeadGap }}">
|
||||
<div style="flex-shrink:0;background:var(--ui-bg);border-bottom:1px solid var(--ui-border);padding:{{ edHeadPad }};display:flex;flex-wrap:wrap;align-items:center;gap:{{ edHeadGap }}">{{ edHeader }}<sc-if value="{{ edDefHead }}">
|
||||
<sc-if value="{{ desk }}"><span data-act="back" style="width:32px;height:32px;display:flex;align-items:center;justify-content:center;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/arrow-left.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/arrow-left.svg') center / contain no-repeat"></span></span></sc-if>
|
||||
<div style="flex:1 1 {{ edTitleBasis }};min-width:0;display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<sc-if value="{{ s.ed.loading }}"><span style="width:320px;max-width:70%;height:20px;background:var(--ui-bg-accented)"></span><span style="width:60px;height:22px;background:var(--ui-bg-accented)"></span></sc-if>
|
||||
<sc-if value="{{ edReady }}"><span title="The title can't be changed after creation." style="font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ edTitle }}</span><sc-if value="{{ edPending }}"><span style="height:26px;padding:0 9px;display:inline-flex;align-items:center;gap:5px;border:1px solid var(--ui-border-accented);color:var(--ui-text-muted);font-size:13px;font-weight:500"><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>{{ s.ed.pending }}</span></sc-if><sc-if value="{{ edOnline }}"><span style="height:26px;padding:0 9px;display:inline-flex;align-items:center;gap:5px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:13px;font-weight:500"><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>Online</span></sc-if><sc-if value="{{ edDraft }}"><span style="height:26px;padding:0 9px;display:inline-flex;align-items:center;gap:5px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:13px;font-weight:500"><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/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>
|
||||
<sc-if value="{{ edReady }}"></sc-if><span title="{{ edTitleTip }}" style="font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted);{{ edTitleCss }}">{{ edTitle }}</span><sc-if value="{{ lg }}"><span role="button" aria-haspopup="listbox" aria-expanded="{{ lg.open }}" aria-label="{{ lg.label }}" style="height:{{ lg.h }};max-width:100%;width:max-content;padding:0 8px;display:inline-flex;align-items:center;gap:6px;border:1px solid {{ lg.bd }};background:var(--ui-bg);color:var(--ui-text-highlighted);font-size:13px;font-weight:500;white-space:nowrap;flex-shrink:0"><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/languages.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/languages.svg') center / contain no-repeat"></span><span>{{ lg.name }}</span><span style="flex-shrink:0;font:400 12px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ lg.code }}</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></span><sc-if value="{{ lg.ro }}"><span style="height:26px;padding:0 9px;display:inline-flex;align-items:center;gap:5px;background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:13px;font-weight:500"><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>Read-only</span></sc-if><sc-if value="{{ lg.fresh }}"><span style="height:26px;padding:0 9px;display:inline-flex;align-items:center;gap:5px;border:1px solid var(--ui-warning);background:var(--ui-bg);color:var(--ui-warning);font-size:13px;font-weight:500"><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/refresh-cw.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/refresh-cw.svg') center / contain no-repeat"></span>{{ lg.fresh }}</span></sc-if><sc-if value="{{ edPending }}"><span style="height:26px;padding:0 9px;display:inline-flex;align-items:center;gap:5px;border:1px solid var(--ui-border-accented);color:var(--ui-text-muted);font-size:13px;font-weight:500"><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>{{ s.ed.pending }}</span></sc-if><sc-if value="{{ edOnline }}"><span style="height:26px;padding:0 9px;display:inline-flex;align-items:center;gap:5px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:13px;font-weight:500"><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>Online</span></sc-if><sc-if value="{{ edDraft }}"><span style="height:26px;padding:0 9px;display:inline-flex;align-items:center;gap:5px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:13px;font-weight:500"><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/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>
|
||||
</div>
|
||||
<sc-if value="{{ edReady }}">
|
||||
<span aria-live="polite" style="display:flex;align-items:center;gap:6px;white-space:nowrap;flex-shrink:0;color:{{ save.fg }}"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ save.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ save.icon }}.svg') center / contain no-repeat"></span>{{ save.t }}<sc-if value="{{ save.retry }}"><span data-act="save" style="height:24px;padding:0 8px;display:flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);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/rotate-cw.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/rotate-cw.svg') center / contain no-repeat"></span>Retry</span></sc-if></span>
|
||||
@@ -161,17 +161,17 @@
|
||||
<span data-act="preview" style="display:flex;align-items:center;gap:8px;white-space:nowrap;flex-shrink:0;color:var(--ui-text-toned)"><span style="width:32px;height:18px;padding:2px;display:flex;justify-content:{{ pvJust }};background:{{ pvBg }}"><span style="width:14px;height:14px;background:var(--ui-bg)"></span></span>Preview</span>
|
||||
<span data-act="save" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;white-space:nowrap;flex-shrink:0;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500;opacity:{{ saveOp }}"><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/save.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/save.svg') center / contain no-repeat"></span>Save</span>
|
||||
<span data-act="publish" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;white-space:nowrap;flex-shrink:0;font-weight:500;background:{{ pub.bg }};color:{{ pub.fg }};border:1px solid {{ pub.bd }};opacity:{{ pub.op }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ pub.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ pub.icon }}.svg') center / contain no-repeat"></span>{{ pub.t }}</span>
|
||||
<span data-act="overflow" role="button" aria-label="Delete article" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;white-space:nowrap;flex-shrink:0;font-weight:500;border:1px solid var(--ui-error);color:var(--ui-error);cursor:pointer"><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</span>
|
||||
<span data-act="overflow" role="button" aria-label="Delete article" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;white-space:nowrap;flex-shrink:0;font-weight:500;border:1px solid var(--ui-error);color:var(--ui-error);cursor:pointer"><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>{{ delText }}</span>
|
||||
</sc-if>
|
||||
</sc-if>
|
||||
</div>
|
||||
<sc-if value="{{ showToolbar }}">
|
||||
</sc-if></div>
|
||||
{{ edBanner }}<sc-if value="{{ showToolbar }}">
|
||||
<div role="toolbar" aria-label="Formatting" style="flex-shrink:0;padding:6px {{ tbPadX }};display:flex;align-items:center;gap:2px;border-bottom:1px solid var(--ui-border);position:relative;overflow:{{ tbOverflow }};background:var(--ui-bg)">
|
||||
<div style="display:flex;align-items:center;gap:2px;flex-shrink:0;transform:translateX({{ tbShift }})"><sc-for list="{{ toolbar }}" as="t" hint-placeholder-count="20"><sc-if value="{{ t.sep }}"><span style="width:1px;height:20px;background:var(--ui-border-accented);margin:0 6px;flex-shrink:0"></span></sc-if><sc-if value="{{ t.btn }}"><span data-act="tb-{{ t.icon }}" style="height:{{ tbBtn }};min-width:{{ tbBtn }};padding:0 6px;flex-shrink:0;display:flex;align-items:center;justify-content:center;gap:4px;position:relative;color:{{ t.fg }};opacity:{{ t.op }};outline:{{ t.ring }};outline-offset:1px;background:{{ t.bg }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ t.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ t.icon }}.svg') center / contain no-repeat"></span>{{ t.text }}<sc-if value="{{ t.tip }}"><span role="tooltip" style="position:absolute;top:100%;left:50%;transform:translateX(-50%);margin-top:6px;z-index:30;white-space:nowrap;padding:4px 8px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font:400 12px/16px 'IBM Plex Sans',sans-serif">{{ t.tip }}</span></sc-if></span></sc-if></sc-for></div><sc-if value="{{ tbFadeL }}"><span style="position:absolute;left:0;top:0;bottom:0;width:24px;background:linear-gradient(to right,var(--ui-bg),transparent);pointer-events:none"></span></sc-if><sc-if value="{{ tbFadeR }}"><span style="position:absolute;right:0;top:0;bottom:0;width:32px;background:linear-gradient(to left,var(--ui-bg),transparent);pointer-events:none"></span></sc-if>
|
||||
</div>
|
||||
</sc-if>
|
||||
<div style="flex:1;min-height:0;display:flex">
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;background:{{ edBg }}">
|
||||
{{ srcPane }}<div style="flex:1;min-width:0;display:flex;flex-direction:column;background:{{ edBg }}">
|
||||
<div data-act="type" style="flex:1;min-height:0;overflow:hidden;padding:{{ edPad }};font-family:'Source Serif 4',serif;color:var(--ui-text-highlighted)">
|
||||
<sc-if value="{{ s.ed.loading }}"><div style="display:flex;flex-direction:column;gap:14px;max-width:672px"><span style="height:14px;background:var(--ui-bg-accented)"></span><span style="height:14px;background:var(--ui-bg-accented);width:92%"></span><span style="height:14px;background:var(--ui-bg-accented);width:80%"></span><span style="height:22px;background:var(--ui-bg-accented);width:45%;margin-top:18px"></span><span style="height:14px;background:var(--ui-bg-accented)"></span><span style="height:14px;background:var(--ui-bg-accented);width:70%"></span></div></sc-if>
|
||||
<sc-if value="{{ edReady }}">
|
||||
@@ -181,7 +181,7 @@
|
||||
<div style="font-size:{{ h2F }};line-height:1.3;font-weight:600;margin:32px 0 12px">What goes into the price</div>
|
||||
<ul style="margin:0 0 20px;padding-left:28px;list-style:square;font-size:{{ pF }};line-height:1.66"><li>Stripping out and making good</li><li>Plumbing moves, if any</li><li>Tiles, fittings and a day for silicone</li></ul>
|
||||
<sc-if value="{{ s.ed.image }}"><div style="margin:28px 0;aspect-ratio:16/9;background:var(--ui-bg-elevated);border:1px dashed var(--ui-border-accented);display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);font:400 13px 'IBM Plex Sans',sans-serif">/static/uploadedImages/3f9c…e1.jpg</div></sc-if>
|
||||
<p style="margin:0 0 20px;font-size:{{ pF }};line-height:1.66">We don't add a margin on materials you choose yourself.<sc-if value="{{ caret }}"><span style="display:inline-block;width:2px;height:1.1em;background:var(--ui-text-highlighted);vertical-align:-3px;margin-left:1px"></span></sc-if></p></sc-if><sc-if value="{{ edBody }}"><p style="margin:0 0 20px;font-size:{{ pF }};line-height:1.66">{{ edBody }}<sc-if value="{{ caret }}"><span style="display:inline-block;width:2px;height:1.1em;background:var(--ui-text-highlighted);vertical-align:-3px;margin-left:1px"></span></sc-if></p><sc-if value="{{ s.ed.image }}"><div style="margin:28px 0;aspect-ratio:16/9;background:var(--ui-bg-elevated);border:1px dashed var(--ui-border-accented);display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);font:400 13px 'IBM Plex Sans',sans-serif">/static/uploadedImages/3f9c…e1.jpg</div></sc-if></sc-if>
|
||||
<p style="margin:0 0 20px;font-size:{{ pF }};line-height:1.66">We don't add a margin on materials you choose yourself.<sc-if value="{{ caret }}"><span style="display:inline-block;width:2px;height:1.1em;background:var(--ui-text-highlighted);vertical-align:-3px;margin-left:1px"></span></sc-if></p></sc-if>{{ edBodyEl }}<sc-if value="{{ edBody }}"><p style="margin:0 0 20px;font-size:{{ pF }};line-height:1.66">{{ edBody }}<sc-if value="{{ caret }}"><span style="display:inline-block;width:2px;height:1.1em;background:var(--ui-text-highlighted);vertical-align:-3px;margin-left:1px"></span></sc-if></p><sc-if value="{{ s.ed.image }}"><div style="margin:28px 0;aspect-ratio:16/9;background:var(--ui-bg-elevated);border:1px dashed var(--ui-border-accented);display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);font:400 13px 'IBM Plex Sans',sans-serif">/static/uploadedImages/3f9c…e1.jpg</div></sc-if></sc-if>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
@@ -195,17 +195,17 @@
|
||||
<div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="meta-toggle" role="button" tabindex="0" aria-expanded="{{ metaOpen }}" aria-controls="meta-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ metaRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><sc-if value="{{ metaOpen }}"><div id="meta-body" style="display:flex;flex-direction:column;gap:16px">{{ edMeta }}</div></sc-if><sc-if value="{{ metaClosed }}"><span style="padding-left:22px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ metaSummary }}</span></sc-if></div><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="seo-toggle" role="button" tabindex="0" aria-expanded="{{ seoOpen }}" aria-controls="seo-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ seoRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Search and sharing</span></div><sc-if value="{{ seoOpen }}"><div id="seo-body" style="display:flex;flex-direction:column;gap:12px">{{ edSeo }}</div></sc-if><sc-if value="{{ seoClosed }}"><span style="padding-left:22px;color:{{ seoFg }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ seoSummary }}</span></sc-if></div>
|
||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:8px 12px"><span style="color:var(--ui-text-muted)">Created</span><span>12 Mar 2026, 09:14</span><span style="color:var(--ui-text-muted)">Updated</span><span>Today, 11:02</span><span style="color:var(--ui-text-muted)">ID</span><span style="font-family:'IBM Plex Mono',monospace">12</span><span style="color:var(--ui-text-muted)">Images</span><span>{{ imgCount }}</span></div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px"><span style="color:var(--ui-text-muted)">Public API URL</span><span style="display:flex;align-items:center;gap:6px;padding:6px 8px;background:var(--ui-bg-elevated);font:400 12px 'IBM Plex Mono',monospace"><span style="flex:1">/v1/article/12</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/copy.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/copy.svg') center / contain no-repeat"></span></span><span style="color:var(--ui-text-muted);font-size:12px;line-height:18px">{{ apiNote }}</span></div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;padding-top:12px;border-top:1px solid var(--ui-border)"><span style="color:var(--ui-text-muted)">Title</span><span style="font-size:12px;line-height:18px;color:var(--ui-text-toned)">Titles can't be changed after an article is created. To use a different title, create a new article and delete this one.</span></div>
|
||||
<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)">{{ edTitleNote }}</span></div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
<sc-if value="{{ mob }}"><sc-if value="{{ edReady }}">
|
||||
<sc-if value="{{ mob }}">{{ edMobBar }}<sc-if value="{{ edDefMob }}"><sc-if value="{{ edReady }}">
|
||||
<div style="flex-shrink:0;padding:12px 16px;border-top:1px solid var(--ui-border);background:var(--ui-bg);display:flex;flex-direction:column;gap:8px">
|
||||
<sc-if value="{{ counter.msg }}"><span style="color:{{ counter.fg }};font-size:13px;line-height:19px">{{ counter.msg }}</span></sc-if>
|
||||
<div style="display:flex;gap:8px"><span data-act="details" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)"><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/panel-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/panel-right.svg') center / contain no-repeat"></span></span><span data-act="save" style="flex:1;height:44px;padding:0 12px;white-space:nowrap;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500;font-size:15px;opacity:{{ saveOp }}">Save</span><span data-act="publish" style="flex:1 0 auto;padding:0 16px;white-space:nowrap;height:44px;display:flex;align-items:center;justify-content:center;gap:6px;font-weight:500;font-size:15px;background:{{ pub.bg }};color:{{ pub.fg }};border:1px solid {{ pub.bd }};opacity:{{ pub.op }}">{{ pub.t }}</span></div>
|
||||
</div>
|
||||
</sc-if></sc-if>
|
||||
</sc-if></sc-if></sc-if>
|
||||
</sc-if>
|
||||
</sc-if>
|
||||
|
||||
@@ -263,7 +263,7 @@
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ isProfile }}">
|
||||
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:16px;max-width:720px;overflow:hidden">
|
||||
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:16px;max-width:720px;flex-shrink:0;margin-top:{{ pfShift }}">
|
||||
<div style="display:flex;flex-direction:column;gap:2px"><span style="font:600 20px/28px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Profile</span><span style="color:var(--ui-text-muted)">Your account on this RenewCMS workspace.</span></div>
|
||||
<sc-if value="{{ pfLoading }}"><div aria-busy="true" style="display:flex;flex-direction:column;gap:16px"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><span style="width:120px;height:14px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span></div><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><span style="width:90px;height:14px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span></div><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><span style="width:100px;height:14px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span></div></div></sc-if>
|
||||
<sc-if value="{{ pfReady }}">
|
||||
@@ -281,11 +281,12 @@
|
||||
</div>
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:14px">
|
||||
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Account</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Set when your account was created. You log in with your username, even after adding a display name.</span></div>
|
||||
<div style="display:grid;grid-template-columns:{{ pfCols }};gap:14px"><div style="display:flex;flex-direction:column;gap:6px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Username</span><div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">marta</span><span style="display:flex;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div></div><div style="display:flex;flex-direction:column;gap:6px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Email</span><div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">marta@renew.example</span><span style="display:flex;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div></div></div>
|
||||
<div style="display:grid;grid-template-columns:{{ pfCols }};gap:14px"><div style="display:flex;flex-direction:column;gap:6px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Username</span><div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">marta</span><span style="display:flex;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div></div><dc-import name="AccountCards" part="email" ac="{{ pfAc }}" c="{{ pfC }}" mobile="{{ mob }}" hint-size="100%,60px" style="display:block;height:auto;min-height:0;flex-shrink:0"></dc-import></div>
|
||||
<sc-if value="{{ pfPanel }}"><dc-import name="AccountCards" part="panel" ac="{{ pfAc }}" c="{{ pfC }}" mobile="{{ mob }}" hint-size="100%,60px" style="display:block;height:auto;min-height:0;flex-shrink:0"></dc-import></sc-if>
|
||||
</div>
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:14px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Author profile</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Your public byline on articles. It's separate from your account.</span></div>
|
||||
<sc-if value="{{ pAu.err }}"><div role="alert" style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Couldn't create your author profile</span><span style="color:var(--ui-text-toned)">Try again in a moment.</span></div></div></sc-if>
|
||||
<sc-if value="{{ pAu.none }}"><span style="color:var(--ui-text-toned);text-wrap:pretty">You don't have an author profile yet. Create one to be credited on your own articles.</span><div style="display:flex;flex-direction:{{ pwDir }};align-items:{{ pwAlign }};gap:12px"><span style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;opacity:{{ pAu.op }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ pAu.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ pAu.icon }}.svg') center / contain no-repeat"></span>{{ pAu.btn }}</span><span style="color:var(--ui-text-muted);font-size:12px">Uses your display name, Marta Alves. You can change it afterwards.</span></div></sc-if>
|
||||
<sc-if value="{{ pAu.none }}"><span style="color:var(--ui-text-toned);text-wrap:pretty">You don't have an author profile yet. Create one to be credited on your own articles.</span><div style="display:flex;flex-direction:{{ pwDir }};align-items:{{ pwAlign }};gap:12px"><span style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;opacity:{{ pAu.op }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ pAu.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ pAu.icon }}.svg') center / contain no-repeat"></span>{{ pAu.btn }}</span><span style="color:var(--ui-text-muted);font-size:12px">{{ pAu.uses }}</span></div></sc-if>
|
||||
<sc-if value="{{ pAu.has }}"><div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap"><span style="width:40px;height:40px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:var(--rc-sand);color:var(--rc-on-sand);border:1px solid var(--ui-border);font:600 14px/1 'IBM Plex Sans',sans-serif">MA</span><div style="flex:1;min-width:140px;display:flex;flex-direction:column"><span style="font-weight:500;color:var(--ui-text-highlighted)">Marta Alves</span><span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ pAu.slug }}</span></div><span style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit author</span></div>
|
||||
<sc-if value="{{ pAu.slugNote }}"><div role="status" style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-info-soft);color:var(--ui-info)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Your slug is {{ pAu.slug }}</span><span style="color:var(--ui-text-toned)">marta-alves was already used by another author. You can change it in Edit author.</span></div></div></sc-if>
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--ui-text-muted);font-size:12px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat"></span>Linked to your account. New articles start with this author.</div></sc-if></div>
|
||||
@@ -294,6 +295,7 @@
|
||||
<div style="display:flex;flex-direction:{{ pwDir }};align-items:{{ pwAlign }};gap:12px"><div style="flex:1;min-width:0"><div style="display:flex;flex-direction:column;gap:6px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Password</span><div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;letter-spacing:0.12em">••••••••••••</span><span style="display:flex;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div></div></div><span data-act="pf-reset" style="height:{{ inH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;flex-shrink:0;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;white-space:nowrap;opacity:{{ rsBtn.op }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ rsBtn.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ rsBtn.icon }}.svg') center / contain no-repeat"></span>{{ rsBtn.t }}</span></div>
|
||||
<sc-if value="{{ rsNote }}"><div role="status" style="display:flex;gap:10px;padding:12px 14px;background:{{ rsNote.bg }}"><span style="display:flex;padding-top:2px;color:{{ rsNote.fg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ rsNote.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ rsNote.icon }}.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;color:{{ rsNote.fg }}">{{ rsNote.title }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ rsNote.text }}</span></div></div></sc-if>
|
||||
</div>
|
||||
<dc-import name="AccountCards" part="cards" ac="{{ pfAc }}" c="{{ pfC }}" mobile="{{ mob }}" hint-size="100%,60px" style="display:block;height:auto;min-height:0;flex-shrink:0"></dc-import>
|
||||
</sc-if>
|
||||
</div>
|
||||
</sc-if>
|
||||
@@ -316,9 +318,12 @@
|
||||
<div style="padding:20px 24px 0;display:flex;align-items:flex-start;gap:12px"><div style="flex:1;display:flex;flex-direction:column;gap:4px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.modal.title }}</span><sc-if value="{{ s.modal.desc }}"><span style="color:var(--ui-text-muted);text-wrap:pretty">{{ s.modal.desc }}</span></sc-if></div><sc-if value="{{ s.modal.close }}"><span data-act="modal-close" style="width:{{ closeSz }};height:{{ closeSz }};display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></sc-if></div>
|
||||
<div style="padding:20px 24px 0;display:flex;flex-direction:column;gap:16px">
|
||||
<sc-if value="{{ s.modal.notice }}"><div role="alert" style="display:flex;gap:8px;padding:10px 12px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat"></span></span><span style="color:var(--ui-text-toned)">{{ s.modal.notice }}</span></div></sc-if>
|
||||
<sc-if value="{{ s.modal.list }}"><div style="display:flex;flex-direction:column;gap:6px"><sc-for list="{{ mList }}" as="li" hint-placeholder-count="3"><div style="display:flex;gap:8px;align-items:flex-start;color:var(--ui-text-toned)"><span style="display:flex;padding-top:3px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">{{ li.t }}</span></div></sc-for></div></sc-if>
|
||||
<sc-if value="{{ mWarn }}"><div role="status" style="display:flex;gap:10px;padding:12px 14px;background:{{ mWarn.bg }};color:{{ mWarn.fg }}"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ mWarn.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ mWarn.icon }}.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">{{ mWarn.title }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ mWarn.text }}</span></div></div></sc-if>
|
||||
<sc-for list="{{ mFields }}" as="f" hint-placeholder-count="0">
|
||||
<div style="display:flex;flex-direction:column;gap:6px"><div style="display:flex;justify-content:space-between"><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ f.label }}</span><sc-if value="{{ f.count }}"><span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ f.countFg }}">{{ f.count }}</span></sc-if></div><div style="min-height:{{ f.h }};padding:{{ f.p }};display:flex;align-items:{{ f.ai }};border:1px solid {{ f.bd }};box-shadow:{{ f.sh }};background:{{ f.bg }};color:{{ f.fg }};font-size:{{ inF }}">{{ f.shown }}</div><sc-if value="{{ f.err }}"><span style="display:flex;gap:6px;align-items:center;color:var(--ui-error);font-size:12px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat"></span>{{ f.err }}</span></sc-if><sc-if value="{{ f.help }}"><span style="color:var(--ui-text-muted);font-size:12px">{{ f.help }}</span></sc-if></div>
|
||||
</sc-for>
|
||||
<sc-if value="{{ s.modal.link }}"><span data-act="modal-link" role="link" style="align-self:flex-start;min-height:{{ closeSz }};display:flex;align-items:center;gap:6px;color:var(--ui-text-highlighted);font-weight:500;text-decoration:underline;text-underline-offset:3px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/download.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/download.svg') center / contain no-repeat"></span>{{ s.modal.link }}</span></sc-if>
|
||||
<sc-if value="{{ s.modal.toggle }}"><div style="display:flex;align-items:center;gap:12px;min-height:{{ inH }}"><span style="width:32px;height:18px;padding:2px;display:flex;justify-content:{{ s.modal.toggle.j }};background:{{ s.modal.toggle.bg }}"><span style="width:14px;height:14px;background:var(--ui-bg)"></span></span><span style="color:var(--ui-text-highlighted);font-weight:500">Open in new tab</span></div></sc-if>
|
||||
<sc-if value="{{ s.modal.removeLink }}"><div style="display:flex;align-items:center;gap:8px;min-height:{{ inH }};color:var(--ui-error);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat"></span>Remove link</div></sc-if>
|
||||
<sc-if value="{{ s.modal.upload }}">
|
||||
@@ -362,7 +367,7 @@
|
||||
</div>
|
||||
<div style="padding:20px 24px;display:flex;flex-direction:{{ mBtnDir }};justify-content:flex-end;gap:8px">
|
||||
<sc-if value="{{ s.modal.cancel }}"><span data-act="modal-cancel" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:{{ mCancelFg }};font-weight:500">{{ s.modal.cancel }}</span></sc-if>
|
||||
<sc-if value="{{ s.modal.confirm }}"><span data-act="modal-confirm" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:{{ mConfirmBg }};color:var(--ui-bg);font-weight:500"><sc-if value="{{ s.modal.loading }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span></sc-if>{{ s.modal.confirm }}</span></sc-if>
|
||||
<sc-if value="{{ s.modal.confirm }}"><span data-act="modal-confirm" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:{{ mConfirmBg }};color:var(--ui-bg);font-weight:500;opacity:{{ mConfirmOp }};text-align:center"><sc-if value="{{ s.modal.loading }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span></sc-if>{{ s.modal.confirm }}</span></sc-if>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -391,7 +396,7 @@ class Component extends DCLogic {
|
||||
const k = s.kind || 'home'
|
||||
const navItems = s.role === 'user' ? [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['authors', 'feather', 'Authors'], ['activity', 'history', 'Activity']] : [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['authors', 'feather', 'Authors'], ['users', 'users', 'Users'], ['schema', 'shapes', 'Schema'], ['activity', 'history', 'Activity'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
|
||||
const cr0 = s.crumbs || [], setOpen = s.nav === 'settings' || !!s.sideOpen, parentOn = (key) => s.nav === key && (key !== 'settings' || cr0.length <= 1)
|
||||
const nav = navItems.map(([key, icon, t]) => { const on = parentOn(key), open = key === 'settings' && setOpen; return { key, icon, t, bg: on ? 'var(--rc-sand)' : 'transparent', badge: key === 'settings' && !!s.navBadge, fg: on ? 'var(--rc-on-sand)' : open ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on || open ? 500 : 400, chev: key === 'settings' ? (open ? 'chevron-down' : 'chevron-right') : '', chevML: key === 'settings' && s.navBadge ? '8px' : 'auto', sub: open, 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 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'; const q = cr0[cr0.length - 1] === 'Languages'; 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 }, { t: 'Languages', ic: 'languages', bg: q ? 'var(--rc-sand)' : 'transparent', fg: q ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: q ? 500 : 400 }] })() : [], hasAccess: s.role !== 'user', subs3: s.role === 'user' ? [] : [(() => { const o = cr0[1] === 'Roles'; return { t: 'Roles', ic: 'shield', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 } })()], subs: ['Variants', 'Cache'].map(x => { const o = cr0[cr0.length - 1] === x; return { t: x, ic: x === 'Variants' ? 'layers' : 'database', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 } }) } })
|
||||
const CT = s.content || [], ctOn = s.contentOn, ctMax = 5, ctVis = CT.slice(0, Math.max(ctMax, CT.findIndex(x => x.key === ctOn) + 1)), contentRows = (m ? (s.contentOpen ? ctVis : []) : ctVis).map(x => { const on = x.key === ctOn; return { key: x.key, name: x.name, icon: x.icon || 'file-text', bg: on ? 'var(--rc-sand)' : 'transparent', fg: on ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: on ? 500 : 400 } })
|
||||
const crumbs = (s.crumbs || []).map((t, i, a) => ({ t, sep: i > 0, fg: i === a.length - 1 ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', fw: i === a.length - 1 ? 500 : 400 }))
|
||||
const L = s.list || {}
|
||||
@@ -536,16 +541,20 @@ class Component extends DCLogic {
|
||||
const toolbar = toolbarDef.map(x => { if (x === '|') return { sep: true, btn: false }; const base = { sep: false, btn: true, icon: x[0], text: x[1] || '', bg: x[2] ? 'var(--ui-bg-accented)' : 'transparent', fg: 'var(--ui-text-toned)', op: '1', ring: 'none', tip: '' }; if (x[0] !== 'link') return base; return { ...base, bg: lbs === 'hover' ? 'var(--ui-bg-elevated)' : lbs === 'active' ? 'var(--rc-sand)' : 'transparent', fg: lbs === 'hover' ? 'var(--ui-text-highlighted)' : lbs === 'active' ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', op: lbs === 'disabled' ? '0.4' : '1', ring: lbs === 'focus' ? '2px solid var(--ui-primary)' : 'none', tip: lbs === 'hover' && !m ? 'Insert link (Ctrl+K)' : '' } })
|
||||
const lkT = LK.text || 'default'
|
||||
const lk = { on: !!LK.on, pop: !!LK.pop && !m, sheet: !!LK.pop && m, popEdit: 'transparent', style: 'color:' + (lkT === 'selected' ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';text-decoration:underline;text-decoration-thickness:' + (lkT === 'hover' ? '2px' : '1px') + ';text-underline-offset:3px;' + (lkT === 'selected' ? 'background:var(--rc-sand);' : '') + (lkT === 'focus' ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : '') }
|
||||
const LG = e.lang || null
|
||||
if (LG && !e.pending && pub) pub = { ...pub, t: pub.t.replace('Save and publish', 'Save and publish ' + LG.name).replace(/^Publish$/, 'Publish ' + LG.name).replace(/^Unpublish$/, 'Unpublish ' + LG.name) }
|
||||
const lg = LG ? { name: LG.name, code: LG.code, open: !!LG.open, ro: !!LG.ro, fresh: LG.fresh || '', bd: LG.open ? 'var(--ui-primary)' : 'var(--ui-border-accented)', h: m ? '44px' : '26px', label: 'Language: ' + LG.name + '. Switch version' } : null
|
||||
const edReady = k === 'editor' && !e.loading && !e.notfound
|
||||
const edTitle = e.title || 'How we price a bathroom renovation'
|
||||
return {
|
||||
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,
|
||||
lg, srcPane: this.props.srcPane || null, edBanner: this.props.edBanner || null, edTitleCss: LG ? 'flex:0 1 100%;min-width:0' : '', edTitleNote: LG ? 'Each language has its own title. Changing it doesn\'t change the URL; edit the slug in Search and sharing.' : "Titles can't be changed after an article is created. To use a different title, create a new article and delete this one.", edTitleTip: LG ? 'Titles can be changed. The URL only changes if you edit the slug.' : "The title can't be changed after creation.", delText: LG ? 'Delete ' + LG.name + ' version' : 'Delete',
|
||||
lk, edSeo: this.props.edSeo || null, seoOpen: !!this.props.edSeo, seoClosed: !this.props.edSeo, seoRot: this.props.edSeo ? '0deg' : '-90deg', seoSummary: e.seoSummary || 'Ready', seoFg: /suggest/.test(e.seoSummary || '') ? 'var(--ui-warning)' : 'var(--ui-text-muted)', naOn: !!L.needs, naBg: L.needs ? 'var(--rc-sand)' : 'var(--ui-bg)', naFg: L.needs ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', naBd: L.needs ? 'var(--rc-sand)' : 'var(--ui-border-accented)', naFw: L.needs ? 500 : 400, naN: L.naState === 'pending' ? '…' : L.naState === 'fail' ? '!' : L.naState === 'absent' ? '' : String(L.needsN ?? 3), edMeta: this.props.edMeta || null, metaOpen: !!this.props.edMeta, metaClosed: !this.props.edMeta, metaRot: this.props.edMeta ? '0deg' : '-90deg', metaSummary: e.metaSummary || 'Inès Duarte', edSheet: m && !!e.sheet, pAu: (() => { const q = s.pa || {}; return { none: q.k !== 'has', has: q.k === 'has', err: !!q.err, slug: q.slug || 'marta-alves', slugNote: !!q.slug, btn: q.loading ? 'Creating…' : 'Create my author profile', icon: q.loading ? 'loader-circle' : 'feather', op: q.loading ? 0.7 : 1, uses: q.user ? 'Uses your username, marta. You can change it afterwards.' : 'Uses your display name, Marta Alves. You can change it afterwards.' } })(), showSites: !s.role || s.role === 'owner', authorsCol: L.authors !== false, multi: !!L.multi, multiDesk: !m && !!L.multi, multiMob: m && !!L.multi, gridCols: (L.authors !== false ? 'minmax(0,1fr) 104px ' : 'minmax(0,1fr) ') + (L.multi ? '130px ' : '') + '110px 150px 140px 44px', sfText: L.site || 'All sites', sfFg: L.site ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', sfMenu: !!L.siteMenu, sfOpts: ['All sites', 'Renew', 'Journal', 'Projects', 'Archive'].map(x => { const on = (L.site || 'All sites') === x; return { t: x, on, op: on ? 1 : 0, bg: on ? 'var(--ui-bg-elevated)' : 'transparent' } }), tbOverflow: m ? 'hidden' : 'visible', tbShift: m ? -(LK.shift || 0) + 'px' : '0px', tbFadeL: m && !!LK.shift, tbFadeR: m && !LK.end, act, cl, rt, purgeDlg, rowDir: m ? 'column' : 'row', rowAlign: m ? 'stretch' : 'flex-start', rowJust: m ? 'flex-start' : 'space-between', rowGap: m ? '12px' : '16px', setScroll: -(s.scrollY || 0) + 'px', offText: s.offText || 'You\'re offline. You can keep writing, but saving and publishing will wait until you\'re back online.', cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn,
|
||||
langSeg: [['en','English'],['fr','Français']].map(([k,tt])=>{const on=(s.lang||'en')===k;return {act:'lang-'+k,code:k,t:tt,on,op:on?1:0}}), themeSeg: [['system','monitor','System'],['light','sun','Light'],['dark','moon','Dark']].map(([k,icon,tt])=>{const on=(s.mode||'light')===k;return {act:'theme-'+k,icon,t:tt,on,op:on?1:0}}), menuOpen: !!s.userMenu, deskMenu: !m && !!s.userMenu, mobMenu: m && !!s.userMenu, pfItemBg: 'var(--ui-bg-elevated)',
|
||||
chipBg: s.userMenu || s.nav === 'profile' ? 'var(--rc-sand)' : 'transparent', chipFg: s.userMenu || s.nav === 'profile' ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)',
|
||||
isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading,
|
||||
upBtnDir: m ? 'column' : 'row', upBtnAlign: m ? 'stretch' : 'flex-start', cardPad: '16px', cardPadX: m ? '16px' : '20px', inH: m ? '44px' : '40px', pfCols: m ? 'minmax(0,1fr)' : 'minmax(0,1fr) minmax(0,1.4fr)', pwDir: m ? 'column' : 'row', pwAlign: m ? 'stretch' : 'flex-end', edTitle, edBody: e.body || '', sampleBody: !e.body, tabs, rows, save, counter, pub, mFields, up, toasts, endpoints, toolbar,
|
||||
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 || '', edBodyEl: this.props.edBodyEl || null, sampleBody: !e.body && !this.props.edBodyEl, tabs, rows, save, counter, pub, mFields, up, toasts, endpoints, toolbar,
|
||||
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' : (s.w ? s.w + 'px' : '1440px'), fh: m ? '844px' : (s.h ? s.h + 'px' : '900px'), dir: m ? 'column' : 'row',
|
||||
mLead: s.mLead || 'menu',
|
||||
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,
|
||||
@@ -572,7 +581,7 @@ class Component extends DCLogic {
|
||||
emptyBg: L.empty && L.empty.primary ? 'var(--ui-primary)' : 'var(--ui-bg)', emptyFg: L.empty && L.empty.primary ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)', emptyBd: L.empty && L.empty.primary ? 'var(--ui-primary)' : 'var(--ui-border-accented)',
|
||||
edFound: k === 'editor' && !e.notfound, edReady, edPending: edReady && !!e.pending, edOnline: edReady && !e.pending && online, edDraft: edReady && !e.pending && !online,
|
||||
edHeadPad: m ? '12px 16px' : '14px 24px', edHeadGap: m ? '8px 12px' : '12px', edTitleBasis: m ? '100%' : '200px',
|
||||
showToolbar: k === 'editor' && !e.notfound && !e.preview,
|
||||
showToolbar: k === 'editor' && !e.notfound && !e.preview && !e.readonly, edDefHead: !this.props.edHeader, edDefMob: !this.props.edMobBar, edHeader: this.props.edHeader || null, edMobBar: this.props.edMobBar || null,
|
||||
tbPadX: m ? '8px' : '24px', tbBtn: m ? '40px' : '28px',
|
||||
edBg: e.preview ? 'var(--ui-bg-muted)' : 'var(--ui-bg)', edPad: m ? '24px 16px' : '40px 48px', edMargin: e.preview ? '0 auto' : '0',
|
||||
h1F: m ? '28px' : '32px', h2F: m ? '23px' : '26px', pF: m ? '17px' : '18px', caret: !e.preview,
|
||||
@@ -580,7 +589,7 @@ class Component extends DCLogic {
|
||||
saveOp: (dirty && !blocked) ? '1' : '0.4', imgCount: e.imgs != null ? String(e.imgs) : (e.image ? '4' : '3'), apiNote: online ? 'Live. Your website can read this article.' : 'Returns 404 until the article is online.',
|
||||
copyIcon: s.copied ? 'check' : 'copy', copyText: s.copied ? 'Copied' : 'Copy', copyFg: s.copied ? 'var(--ui-success)' : 'var(--ui-text-highlighted)',
|
||||
errPad: m ? '40px 20px' : '64px 48px',
|
||||
closeSz: m ? '44px' : '28px', modalAlign: m ? 'flex-end' : 'flex-start', modalPad: m ? '0' : (md.crop ? '56px 16px 16px' : '140px 16px 16px'), modalW: m ? '100%' : (md.crop ? '640px' : md.wide ? '560px' : '460px'), mBtnDir: m ? 'column-reverse' : 'row',
|
||||
closeSz: m ? '44px' : '28px', modalAlign: m ? 'flex-end' : 'flex-start', modalPad: m ? '0' : (md.crop ? '56px 16px 16px' : '140px 16px 16px'), modalW: m ? '100%' : (md.crop ? '640px' : md.wide ? '560px' : '460px'), mBtnDir: m ? 'column-reverse' : 'row', mConfirmOp: md.disabled ? '0.4' : '1', mList: (md.list || []).map(t => ({ t })), mWarn: md.warn ? { ...md.warn, bg: 'var(--rc-' + md.warn.tone + '-soft)', fg: 'var(--ui-' + md.warn.tone + ')' } : null, pfShift: pf.shift ? '-' + (m ? pf.shift[1] : pf.shift[0]) + 'px' : '0px', pfAc: pf.ac || {}, pfC: s.copy || null, pfPanel: !!(pf.ac && (pf.ac.form || pf.ac.pending)),
|
||||
mConfirmBg: md.tone === 'danger' ? 'var(--ui-error)' : 'var(--ui-primary)', mCancelFg: md.loading ? 'var(--ui-text-dimmed)' : 'var(--ui-text-highlighted)',
|
||||
hasToasts: toasts.length > 0, tL: m ? '16px' : 'auto', tR: m ? '16px' : '24px', tB: m ? '16px' : '24px', tW: m ? 'auto' : '356px'
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ class Component extends DCLogic {
|
||||
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.' }),
|
||||
F('Site name', f.sname || f.name || 'Renew', { val: !!f.sname, focus: p.focus === 'sname', help: 'Shown in titles through the template, and sent as the Open Graph site name. Empty uses the site name, then "Renew".' }),
|
||||
D('display:flex;flex-direction:column;gap:6px', F('Title template', f.tpl, { mono: true, focus: p.focus === 'tpl', err: er.tpl, help: er.tpl ? null : 'Use %title% for the article title and %site% for the site name.' }), !er.tpl && D('display:flex;gap:6px;align-items:center;padding:8px 10px;background:var(--ui-bg-elevated);min-width:0', S('flex-shrink:0;font-size:12px;color:var(--ui-text-muted)', 'Example'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ui-text-highlighted)', f.tpl.replace('%title%', 'How we price a bathroom renovation').replace('%site%', f.sname || 'Renew')))),
|
||||
F('Default description', f.ddesc || 'One or two sentences about the site.', { opt: true, area: true, val: !!f.ddesc, count: [...f.ddesc].length + ' / 320', help: 'Used when an article has no description, excerpt or body text.' }),
|
||||
D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Default robots policy'), Seg([['Index', 'Index'], ['Noindex', 'Noindex']], f.robots), S('color:var(--ui-text-muted);font-size:12px;line-height:18px', f.robots === 'Index' ? 'Search engines may list this site\'s pages.' : 'Asks search engines not to list this site\'s pages.')),
|
||||
@@ -313,13 +313,14 @@ class Component extends DCLogic {
|
||||
body.push(D('border:1px solid var(--ui-border);background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);' + (m ? '' : ''), D('padding:4px', ...inner), st === 'error' || mt.noCreate ? null : create))
|
||||
}
|
||||
if (mt.grab != null) body.push(S('font-size:12px;line-height:18px;color:var(--ui-text-toned);padding:6px 8px;background:var(--ui-bg-elevated)', au[mt.grab].name + ' grabbed. Position ' + (mt.grab + 1) + ' of ' + n + '. Use the up and down arrow keys to move, Space to drop, Escape to cancel.'))
|
||||
else if (mt.live) body.push(S('font-size:12px;line-height:18px;color:var(--ui-text-toned);padding:6px 8px;background:var(--ui-bg-elevated)', mt.live))
|
||||
else if (n > 1 && mt.open !== 'authors') body.push(S('font-size:12px;line-height:18px;color:var(--ui-text-muted)', 'Drag to reorder. The first author is the primary byline.'))
|
||||
out.push(D('display:flex;flex-direction:column;gap:8px', ...body))
|
||||
return D('display:flex;flex-direction:column;gap:16px', ...out)
|
||||
}
|
||||
const newAuthorModal = () => {
|
||||
const x = s.modal
|
||||
return Shell({ z: 60, title: 'Create author', desc: 'Adds a byline for this article. You can add a bio and avatar later in Authors.', cancel: 'Cancel', confirm: x.loading ? 'Creating…' : 'Create author', loading: x.loading, close: true, cdis: !x.name },
|
||||
return Shell({ z: 70, 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 }),
|
||||
@@ -344,13 +345,14 @@ class Component extends DCLogic {
|
||||
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 SugRow = (c, o = {}, i = 0) => D('display:flex;flex-direction:column;gap:6px', D('display:flex;align-items:flex-start;gap:8px;opacity:' + (o.busy === i ? 0.6 : 1), S('display:flex;padding-top:2px;color:var(--ui-warning)', I('lightbulb', 14)), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S('text-wrap:pretty;color:var(--ui-text-highlighted)', SUG[c][0]), Link('Go to ' + SUG[c][1], 'arrow-right')), !o.compact && S('width:' + (m ? 44 : 24) + 'px;height:' + (m ? 44 : 24) + 'px;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I(o.busy === i ? 'loader-circle' : 'x', 14))), o.fail === i && D('margin-left:22px;display:flex;flex-direction:column;gap:4px;padding:8px 10px;background:var(--ui-bg);border:1px solid var(--ui-error)', S('font-weight:600;color:var(--ui-error)', 'Couldn\'t dismiss the suggestion'), S('color:var(--ui-text-muted)', 'Try again in a moment.'), Link('Try again', 'rotate-cw')))
|
||||
const Sugs = (codes, st, o = {}) => {
|
||||
const box = (...c) => D('display:flex;flex-direction:column;gap:12px;padding:12px;background:var(--ui-bg-elevated)', ...c)
|
||||
if (st === 'loading') return box(D('display:flex;align-items:center;gap:8px;color:var(--ui-text-toned)', I('loader-circle', 14), 'Checking this article…'), ...[70, 90, 55].map(w => S('display:block;height:12px;width:' + w + '%;background:var(--ui-bg-accented)')))
|
||||
if (st === 'error') return box(Alert('error', 'circle-x', 'Couldn\'t check this article.', 'Your article is unchanged. Search and sharing still saves.', 'Try again'))
|
||||
if (!codes.length) return box(D('display:flex;align-items:flex-start;gap:8px', S('display:flex;padding-top:2px;color:var(--ui-success)', I('circle-check', 14)), D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;color:var(--ui-text-highlighted)', 'All good'), S('color:var(--ui-text-muted)', o.dismissed ? o.dismissed + ' dismissed suggestions are hidden.' : 'Nothing to suggest for this article.')), o.dismissed && Link('Show dismissed')))
|
||||
const shown = o.compact ? codes.slice(0, 3) : codes
|
||||
return box(...shown.map(c => 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.' ))
|
||||
return box(...shown.map((c, i) => SugRow(c, o, i)), o.compact && codes.length > 3 && S('color:var(--ui-text-muted);font-size:12px', '+' + (codes.length - 3) + ' more in Search and sharing'), !o.compact && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', 'Suggestions never block saving or publishing. Dismiss one and it stays hidden for this article.' ))
|
||||
}
|
||||
const SEOSIDE = (k) => {
|
||||
const codes = k.codes || [], st = k.state || 'ok'
|
||||
@@ -388,8 +390,8 @@ class Component extends DCLogic {
|
||||
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)
|
||||
const right = D('display:flex;flex-direction:column;gap:18px;min-width:0', SERP(p, site), SOC(p, site), D('display:flex;flex-direction:column;gap:8px', S('font-weight:600;color:var(--ui-text-highlighted)', 'Suggestions'), Sugs(p.codes || [], p.sugState, { dismissed: p.dismissed, busy: p.busy, fail: p.fail })), D('display:flex;gap:8px;align-items:center', B('What the frontend gets', { icon: 'braces', h: m ? 44 : 28, fs: 12, px: 10 })))
|
||||
const body = m ? D('display:flex;flex-direction:column;gap:18px', Sugs(p.codes || [], p.sugState, { dismissed: p.dismissed, busy: p.busy, fail: p.fail }), left, SERP(p, site), SOC(p, site), B('What the frontend gets', { icon: 'braces' })) : D('display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:28px;height:100%;overflow:hidden', D('overflow:hidden;min-height:0', left), right)
|
||||
return Panel({ title: 'Search and sharing', w: 960, z: 60, notice: p.notice, saving: dis, cdis: p.noChange, confirm: dis ? 'Saving…' : 'Save changes' }, body)
|
||||
}
|
||||
const API = { title: ATT + ' | Renew', description: AD1, canonical_url: 'https://renew.example/' + ASLUG, robots: 'index, follow', og: { type: 'article', title: ATT + ' | Renew', description: AD1, url: 'https://renew.example/' + ASLUG, site_name: 'Renew', image: 'https://renew.example/media/north-wall.jpg', image_alt: 'A tiled bathroom wall during renovation', locale: 'en' }, twitter: { card: 'summary_large_image', title: ATT + ' | Renew', description: AD1, image: 'https://renew.example/media/north-wall.jpg', image_alt: 'A tiled bathroom wall during renovation' }, image: { url: 'https://renew.example/media/north-wall.jpg', alt: 'A tiled bathroom wall during renovation', source: 'cover_image' }, authors: [{ name: 'Inès Duarte', url: 'https://ines-duarte.example', same_as: ['https://www.linkedin.com/in/ines-duarte'] }], published_at: '2026-03-12T09:14:00Z', modified_at: '2026-10-15T11:02:00Z', fallbacks: { title: 'article_title', description: 'body_first_sentence', image: 'cover_image', canonical: 'site_base_url_and_slug' }, json_ld: { '@context': 'https://schema.org', '@type': 'Article', headline: ATT, description: AD1, image: ['https://renew.example/media/north-wall.jpg'], author: [{ '@type': 'Person', name: 'Inès Duarte', url: 'https://ines-duarte.example', sameAs: ['https://www.linkedin.com/in/ines-duarte'] }], datePublished: '2026-03-12T09:14:00Z', dateModified: '2026-10-15T11:02:00Z', mainEntityOfPage: 'https://renew.example/' + ASLUG } }
|
||||
@@ -397,12 +399,13 @@ class Component extends DCLogic {
|
||||
const x = s.seo.api, raw = x.view === 'raw', st = x.state || 'ok'
|
||||
const bar = D('display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap', D('display:flex;border:1px solid var(--ui-border-accented);width:200px', ...[['formatted', 'Formatted'], ['raw', 'Raw JSON']].map(([k, t]) => S('flex:1;height:' + bh + 'px;display:flex;align-items:center;justify-content:center;font-weight:' + ((raw ? 'raw' : 'formatted') === k ? 500 : 400) + ';background:' + ((raw ? 'raw' : 'formatted') === k ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + ((raw ? 'raw' : 'formatted') === k ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)'), t))), B(x.copied ? 'Copied' : 'Copy JSON', { icon: x.copied ? 'check' : 'copy', dis: st !== 'ok' }))
|
||||
const KV = (k, v, fbk) => D('display:grid;grid-template-columns:' + (m ? '1fr' : '130px minmax(0,1fr)') + ';gap:' + (m ? 2 : 12) + 'px;padding:6px 0;border-bottom:1px solid var(--ui-border-muted)', S('color:var(--ui-text-muted);font:400 12px/20px \'IBM Plex Mono\',monospace', k), D('display:flex;gap:8px;flex-wrap:wrap;align-items:center;min-width:0', S('color:var(--ui-text-highlighted);word-break:break-all;' + (m ? '' : ''), v), fbk && Chip(fbk)))
|
||||
const cf = x.copyFail && D('display:flex;gap:8px;padding:10px 12px;background:var(--rc-warning-soft, var(--ui-bg-elevated));color:var(--ui-warning)', I('triangle-alert', 14, 'margin-top:3px'), S('color:var(--ui-text-toned);text-wrap:pretty', 'The browser blocked copying. The JSON is selected below: press Ctrl+C or Cmd+C to copy it.'))
|
||||
let body
|
||||
if (st === 'loading') body = D('display:flex;flex-direction:column;gap:12px', ...[40, 80, 60, 70, 50, 75].map(w => S('display:block;height:14px;width:' + w + '%;background:var(--ui-bg-accented)')))
|
||||
else if (st === 'error') body = Alert('error', 'circle-x', 'Couldn\'t load the SEO data', 'GET /v1/article/12/seo failed. Your article is unchanged. Try again in a moment.', 'Try again')
|
||||
else if (raw) body = S('display:block;max-height:' + (m ? 440 : 400) + 'px;overflow:hidden;padding:12px;background: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 if (raw) body = S('display:block;max-height:' + (m ? 440 : 400) + 'px;overflow:hidden;padding:12px;background:' + (x.copyFail ? 'var(--ui-info-soft, var(--rc-info-soft))' : 'var(--ui-bg-elevated)') + ';' + (x.copyFail ? 'outline:2px solid var(--ui-info);' : '') + 'white-space:pre;font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-highlighted)', JSON.stringify(API, null, 2))
|
||||
else body = D('display:flex;flex-direction:column', KV('title', API.title, 'article title + template'), KV('description', API.description, 'first sentence of the body'), KV('canonical_url', API.canonical_url, 'site address + slug'), KV('robots', API.robots, 'site default'), KV('og.type', 'article'), KV('og.image', API.image.url, 'cover image'), KV('og.image_alt', API.image.alt), KV('twitter.card', 'summary_large_image'), KV('authors', 'Inès Duarte · ines-duarte.example · 1 social link'), KV('published_at', '12 Mar 2026, 09:14'), KV('modified_at', '15 Oct 2026, 11:02'), KV('json_ld', 'Article · headline, image, author, datePublished, dateModified, mainEntityOfPage'))
|
||||
return Shell({ title: 'What the frontend gets', desc: 'The SEO data returned by GET /v1/article/12/seo, read only. RenewCMS doesn\'t render pages or add meta tags: your frontend does.', close: true, w: 720, top: 40, z: 60, cancel: 'Close' }, D('display:flex;flex-direction:column;gap:12px', 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))
|
||||
return Shell({ title: 'What the frontend gets', desc: 'The SEO data returned by GET /v1/article/12/seo, read only. RenewCMS doesn\'t render pages or add meta tags: your frontend does.', close: true, w: 720, top: 40, z: 60, cancel: 'Close' }, D('display:flex;flex-direction:column;gap:12px', cf, x.draft && D('display:flex;gap:8px;padding:10px 12px;background:var(--rc-info-soft);color:var(--ui-info)', I('info', 14, 'margin-top:3px'), S('color:var(--ui-text-toned)', 'This article is a draft. The API returns this data only to signed-in callers until it is published.')), bar, body))
|
||||
}
|
||||
// ---------- compose
|
||||
const v = s.v
|
||||
@@ -424,6 +427,7 @@ class Component extends DCLogic {
|
||||
if (s.seo && s.seo.panel) ov.push(seoPanel())
|
||||
if (s.seo && s.seo.pick) ov.push(Picker({ title: 'Choose a cover image', desc: 'The cover for this article.', sel: 1, z: 70 }))
|
||||
if (s.seo && s.seo.api) ov.push(apiDialog())
|
||||
if (s.modal && m) { ov.push(Panel({ title: 'Details', z: 55, ro: true, confirm: '' }, metaUI())); edMeta = null; if (shell && shell.ed) shell = { ...shell, ed: { ...shell.ed, sheet: false } } }
|
||||
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') {
|
||||
|
||||
@@ -104,7 +104,7 @@ class Component extends DCLogic {
|
||||
const dims = cur[1].split('×')
|
||||
const ratio = dims.length === 2 && !missing ? dims[0] + ' / ' + dims[1] : '4 / 3'
|
||||
const rows = list.map((v, i) => ({ n: v[0], dim: v[1], fmt: v[2], url: '/media/' + img + '?v=' + v[0], bg: sel === v[0] ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)' }))
|
||||
const resp = [['/media/photo.png?v=thumb', '200', 'The variant image. Generated on first request.'], ['/media/photo.png?v=unknown', '400', 'Unknown variant', '{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "unknown" }'], ['/media/photo.png?width=400', '400', 'Unsupported parameter', '{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" }'], ['/media/missing.png?v=thumb', '404', 'Not found', '']].map((r, i) => ({ req: r[0], st: r[1], res: r[2], body: r[3], bt: i ? '1px solid var(--ui-border)' : '0' }))
|
||||
const resp = [['/static/uploadedImages/photo.png?v=thumb', '200', 'The variant image. Generated on first request.'], ['/media/photo.png?v=unknown', '400', 'Unknown variant', '{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "unknown" }'], ['/media/photo.png?width=400', '400', 'Unsupported parameter', '{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" }'], ['/media/missing.png?v=thumb', '404', 'Not found', '']].map((r, i) => ({ req: r[0], st: r[1], res: r[2], body: r[3], bt: i ? '1px solid var(--ui-border)' : '0' }))
|
||||
const hasVariants = list.length > 0
|
||||
return {
|
||||
gap: m ? '20px' : '24px', btn: m ? '44px' : '32px', inH: m ? '44px' : '32px', pad: m ? '16px' : '20px', bCols: m ? 'minmax(0,1fr)' : 'minmax(0,1fr) minmax(0,1fr)',
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:10px;min-width:0">
|
||||
<div style="display:flex;align-items:center;gap:8px"><span style="font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API key</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:var(--rc-success-soft);color:var(--ui-success);font-size:12px;font-weight:500">Full access</span></div>
|
||||
<span style="color:var(--ui-text-toned);text-wrap:pretty">Send <code>Authorization: Bearer rcms_…</code>. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator.</span>
|
||||
<span style="color:var(--ui-text-toned);text-wrap:pretty">Send <code>Authorization: Bearer rcms_…</code>. Requests act as the user who created the key, with that user's role as it is now. A key never has more rights than its creator. A route that needs a permission answers 403 "missing_permission" to a key whose creator's role lacks it.</span>
|
||||
<dc-import name="CodeSnippet" tabs="{{ authSnips }}" hint-size="100%,160px"></dc-import>
|
||||
<span style="color:var(--ui-text-muted);text-wrap:pretty">Keys start with <code>rcms_</code> and are shown in full once, when you create them.</span>
|
||||
<a href="#" style="align-self:flex-start;display:inline-flex;align-items:center;gap:6px;min-height:{{ linkH }};font-weight:500;color:var(--ui-text-highlighted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">Manage API keys<span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/arrow-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/arrow-right.svg') center / contain no-repeat"></span></a>
|
||||
@@ -113,7 +113,7 @@
|
||||
<span style="color:var(--ui-text-toned);text-wrap:pretty">Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain.</span>
|
||||
<div style="border:1px solid var(--ui-border);background:var(--ui-bg);overflow:hidden">
|
||||
<div style="display:grid;grid-template-columns:{{ errCols }};gap:12px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Meaning</span><sc-if value="{{ desk }}"><span>Typical cause</span></sc-if></div>
|
||||
<sc-for list="{{ errRows }}" as="r" hint-placeholder-count="8"><div style="display:grid;grid-template-columns:{{ errCols }};gap:2px 12px;padding:10px 12px;border-top:1px solid var(--ui-border)"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.c }}</span><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ r.m }}</span><span style="grid-column:{{ errCause }};color:var(--ui-text-toned);text-wrap:pretty"><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>
|
||||
<sc-for list="{{ errRows }}" as="r" hint-placeholder-count="8"><div style="display:grid;grid-template-columns:{{ errCols }};gap:2px 12px;padding:10px 12px;border-top:1px solid var(--ui-border)"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.c }}</span><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ r.m }}</span><span style="grid-column:{{ errCause }};color:var(--ui-text-toned);text-wrap:pretty"><sc-for list="{{ r.parts }}" as="p" hint-placeholder-count="2"><sc-if value="{{ p.c }}"><code>{{ p.t }}</code></sc-if><sc-if value="{{ p.p }}">{{ p.t }}</sc-if></sc-for></span><sc-if value="{{ r.n }}"><span style="grid-column:1 / -1;padding-top:6px;color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty"><sc-for list="{{ r.nparts }}" as="p" hint-placeholder-count="2"><sc-if value="{{ p.c }}"><code>{{ p.t }}</code></sc-if><sc-if value="{{ p.p }}">{{ p.t }}</sc-if></sc-for></span></sc-if></div></sc-for>
|
||||
</div>
|
||||
<dc-import name="CodeSnippet" tabs="{{ errSnips }}" hint-size="100%,120px"></dc-import>
|
||||
</section>
|
||||
@@ -191,7 +191,7 @@ class Component extends DCLogic {
|
||||
const i404 = [404, 'Invite not found']
|
||||
const mm = 'The request cannot be processed due to a mismatch in the format of the body.'
|
||||
const j = o => JSON.stringify(o, null, 2)
|
||||
const eAd = [403, 'Only owners and admins can do this.'], ctnf = [404, 'content_type_not_found', true], enf = [404, 'entry_not_found', true]
|
||||
const eAd = [403, 'missing_permission', true], ctnf = [404, 'content_type_not_found', true], enf = [404, 'entry_not_found', true]
|
||||
const Pp = s => s.split('`').map((x, i) => ({ t: x, c: i % 2 === 1, p: i % 2 === 0 })).filter(x => x.t)
|
||||
const F = (key, label, type, o = {}) => ({ key, label, type, required: false, unique: false, default: null, min: null, max: null, options: null, relation: null, ...o })
|
||||
const evF = [F('title', 'Title', 'text', { required: true, min: 3, max: 120 }), F('starts_on', 'Starts on', 'date', { required: true }), F('capacity', 'Capacity', 'number', { required: true, min: 1, max: 5000 }), F('online', 'Online', 'boolean'), F('category', 'Category', 'select', { options: ['Workshop', 'Talk', 'Open day'] }), F('cover', 'Cover', 'media')]
|
||||
@@ -203,7 +203,7 @@ class Component extends DCLogic {
|
||||
const nameRule = 'Up to 100 characters.', fieldsRule = '1 to 50 fields, in order. See Field types.'
|
||||
const G = {
|
||||
articles: { title: 'Articles', desc: 'Read, write and publish articles. Titles can\'t be changed after creation.', eps: [
|
||||
A('art-list', 'GET', '/v1/article/', 'List articles, one page at a time. Anonymous callers get online articles only, and the total counts only those. The total is in the X-Total-Count response header; the body stays a JSON array.', 'Public', 1, [[400, 'Invalid page or size.'], e401], [S('page', 'integer', 'No', '1 or higher. Default 1. A page past the end returns an empty array.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.')], { qp: 1, pub: 1, r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }),
|
||||
A('art-list', 'GET', '/v1/article/', 'List articles, one page at a time. Anonymous callers get online articles only, and the total counts only those. The body is an object: {items, total, page, size, counts}. counts holds the number of articles per tab and for suggestions.', 'Public', 1, [[400, mm], e401], [S('page', 'integer', 'No', '1 or higher. Default 1. A page past the end returns empty items.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.')], { qp: 1, pub: 1, r: j({ items: [{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }], total: 12, page: 1, size: 25, counts: { all: 12, online: 8, draft: 4, suggestions: 3 } }) }),
|
||||
A('art-get', 'GET', '/v1/article/{id}', 'Get one article. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ id: 12, title: 'How we price a bathroom renovation', status: 'online', body: 'A bathroom renovation is priced by room size, plumbing changes and finish level.', body_format: 'markdown' }) }),
|
||||
A('art-create', 'POST', '/v1/article/', 'Create a draft article.', 'Verified user', 1, [[400, mm], e401], [S('title', 'string', 'Yes', '3 to 200 characters. Can\'t be changed later.'), S('body', 'string', 'No', 'Optional. Any length.')], { body: { title: 'Choosing tiles for a small bathroom' }, r: j({ id: 13, status: 'draft' }) }),
|
||||
A('art-update', 'PATCH', '/v1/article/{id}', 'Update the body of an article.', 'Verified user', 1, [[400, mm], e401, e404], [S('body', 'string', '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' }) }),
|
||||
@@ -215,14 +215,14 @@ class Component extends DCLogic {
|
||||
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: [
|
||||
ctypes: { title: 'Content types', desc: 'Define your own kind of content, like events or recipes. A user whose role can create and edit content types defines a type made of 1 to 50 ordered fields; the type then has its own entry routes.', note: 'A content type has a `key` (2 to 40 lowercase letters, digits or hyphens, chosen at creation and never changed) and fields. Each field has a `key` (letters, digits and underscores, starting with a letter), a `label`, a `type` and rules.', ft: 1, snip: [{ k: 'field', label: 'Field object', code: j(evF[2]) }], eps: [
|
||||
A('ct-list', 'GET', '/v1/content-type/', 'List all custom content types.', 'Verified user', 1, [e401], [], { r: j([{ ...evT, fields: evF.slice(0, 3) }]) }),
|
||||
A('ct-get', 'GET', '/v1/content-type/{key}', 'Get one content type with its fields.', 'Verified user', 1, [e401, ctnf], [], { r: j(evT) }),
|
||||
A('ct-create', 'POST', '/v1/content-type/', 'Create a content type.', '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' })
|
||||
A('ct-create', 'POST', '/v1/content-type/', 'Create a content type. Needs the permission "Create and edit content types".', 'Needs permission', 1, [[400, mm], e401, eAd, [409, 'content_type_key_taken', true]], [S('key', 'string', 'Yes', '2 to 40 characters. Lowercase letters, digits and hyphens. Can\'t be changed later.'), S('name', 'string', 'Yes', 'Up to 100 characters.'), S('icon', 'string', 'No', 'Optional.'), S('fields', 'object[]', 'Yes', fieldsRule)], { pretty: 1, body: { key: 'events', name: 'Events', icon: 'calendar', fields: evF.slice(0, 3) }, r: j(evT) }),
|
||||
A('ct-update', 'PUT', '/v1/content-type/{key}', 'Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can\'t change. Needs the permission "Create and edit content types".', 'Needs permission', 1, [[400, mm], e401, eAd, ctnf, [409, 'schema_incompatible', true, 'Some existing entries don\'t fit the new fields. Nothing was changed.']], [S('name', 'string', 'Yes', nameRule), S('icon', 'string', 'No', 'Optional.'), S('fields', 'object[]', 'Yes', fieldsRule)], { pretty: 1, body: { name: 'Events', icon: 'calendar', fields: evF.slice(0, 3) }, r: j(evT) }),
|
||||
A('ct-delete', 'DELETE', '/v1/content-type/{key}', 'Delete a content type. Only possible when it has no entries and no relation field points at it. Needs the permission "Delete content types".', 'Needs permission', 1, [e401, eAd, ctnf, [409, 'content_type_has_entries', true], [409, 'content_type_referenced', true]], [], { r: '204 No Content' })
|
||||
] },
|
||||
entries: { title: 'Entries', desc: 'Read and write the content of a custom type. 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: [
|
||||
entries: { title: 'Entries', desc: 'Read and write the content of a custom type. Writing entries needs the entry permissions for that type.', note: 'An entry\'s `data` is an object keyed by field key, validated against the type. Responses add `media` (the resolved images for media fields: id, filename, width, height, size and thumbnail_url, or { id, missing: true } if the image was deleted) and `relations` (the resolved first field of related entries).', snip: [{ k: 'entry', label: 'Entry', code: j(ent) }], eps: [
|
||||
A('en-list', 'GET', '/v1/content-type/{key}/entry', 'List entries of a type, one page at a time. The response is an object with items, total, page and size, not a bare array like articles.', 'Verified user', 1, [[400, 'Invalid page, size or sort.'], e401, ctnf], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.'), S('sort', 'string', 'No', 'id, created_at or updated_at, then :asc or :desc. For example updated_at:desc.')], { qp: 1, qs: '?page=1&size=25&sort=updated_at:desc', r: j({ items: [ent], total: 1, page: 1, size: 25 }) }),
|
||||
A('en-create', 'POST', '/v1/content-type/{key}/entry', 'Create an entry.', 'Verified user', ['data', 'title', 'starts_on', 'capacity', 'online', 'category', 'cover'], [[400, mm], e401, ctnf, [409, 'invalid_entry: title must be unique', true], [422, 'invalid_entry: capacity must be at most 5000', true]], [S('data', 'object', 'Yes', 'Keys are the type\'s field keys. For events: title, starts_on, capacity, online, category and cover.')], { body: { data: evD }, r: j(ent) }),
|
||||
A('en-get', 'GET', '/v1/content-type/{key}/entry/{id}', 'Get one entry.', 'Verified user', 1, [[400, 'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).'], e401, enf, ctnf], [], { r: j(ent) }),
|
||||
@@ -230,24 +230,24 @@ class Component extends DCLogic {
|
||||
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 }) })
|
||||
A('bt-list', 'GET', '/v1/builtin-type/', 'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user. Needs the permission "View content types".', 'Needs permission', 1, [e401, eAd], [], { r: j([{ key: 'article', name: 'Articles', fields: btA }, { key: 'author', name: 'Authors', fields: btU }]) }),
|
||||
A('bt-get', 'GET', '/v1/builtin-type/{key}', 'Get one built-in type. Keys are article, author, site, media and user. Needs the permission "View content types".', 'Needs permission', 1, [e401, eAd, [404, 'builtin_type_not_found', true]], [], { r: j({ key: 'author', name: 'Authors', fields: btU }) })
|
||||
] },
|
||||
media: { title: 'Media', desc: 'The image library. One image can be used in many articles.', eps: [
|
||||
A('med-list', 'GET', '/v1/media/', 'List images, 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-list', 'GET', '/v1/media/', 'List images, one page at a time. The total is in the X-Total-Count response header; the body stays a JSON array.', 'Verified user', 1, [[400, mm], e401], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.')], { qp: 1, r: j([{ id: 4, filename: 'north-wall.jpg', size: 3900000 }]) }),
|
||||
A('med-up', 'POST', '/v1/media/', 'Upload an image (multipart).', 'Verified user', 1, [[400, mm], e401, [413, 'The file is over 10 MB.'], [415, 'Not a PNG, JPEG, WEBP or SVG image.']], [S('file', 'file', 'Yes', 'PNG, JPEG, WEBP or SVG, up to 10 MB.')], { multi: true, form: [['file', '@north-wall.jpg']], r: j({ id: 4, filename: 'north-wall.jpg', url: '/static/uploadedImages/3f9c.jpg' }) }),
|
||||
A('med-ren', 'PATCH', '/v1/media/{id}', 'Rename an image. The URL stays the same.', 'Verified user', 1, [[400, mm], e401, m404], [S('filename', 'string', 'Yes', 'The new filename.')], { body: { filename: 'north-wall-01.jpg' }, r: j({ id: 4, filename: 'north-wall-01.jpg' }) }),
|
||||
A('med-del', 'DELETE', '/v1/media/{id}', 'Delete an image. Articles that use it show a broken image.', 'Verified user', 1, [e401, m404], [], { r: '204 No Content' }),
|
||||
A('med-merge', 'POST', '/v1/media/merge', 'Merge identical copies into one image.', 'Verified user', 1, [[400, mm], e401, [409, 'Some of the images to merge changed. Refresh and try again.']], [S('keep_id', 'number', 'Yes', 'Greater than 0. The image to keep.'), S('merge_ids', 'number[]', 'Yes', 'At least 1, each greater than 0. Identical copies to delete. Articles switch to the kept image.')], { body: { keep_id: 4, merge_ids: [9, 11] }, r: j({ merged: 2, articlesUpdated: 5 }) }),
|
||||
A('med-use', 'GET', '/v1/media/{id}/usage', 'List the articles that use an image.', 'Verified user', 1, [e401, m404], [], { r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }),
|
||||
A('med-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 }' })
|
||||
A('med-purge-settings-get', 'GET', '/v1/media/purge-settings', 'Get the settings for purging unused images. Needs the permission "Delete files".', 'Needs permission', 1, [e401, eAd], [], { r: j({ auto_purge_enabled: true, auto_purge_interval_days: 30, last_purge_at: '2026-10-20T03:00:00Z', last_purge_merged: 4 }) }),
|
||||
A('med-purge-settings-patch', 'PATCH', '/v1/media/purge-settings', 'Update the settings for purging unused images. Needs the permission "Delete files".', 'Needs permission', 1, [[400, mm], e401, eAd], [S('auto_purge_enabled', 'boolean', 'No', 'Turns the automatic purge on or off.'), S('auto_purge_interval_days', 'integer', 'No', '1 to 365. Days between automatic purges.')], { body: { auto_purge_enabled: true, auto_purge_interval_days: 14 }, r: j({ auto_purge_enabled: true, auto_purge_interval_days: 14, last_purge_at: '2026-10-20T03:00:00Z', last_purge_merged: 4 }) }),
|
||||
A('med-purge-preview', 'GET', '/v1/media/purge-preview', 'Preview the images a purge would delete. Needs the permission "Delete files".', 'Needs permission', 1, [e401, eAd], [], { r: j({ fingerprint: 'c41f9a7e', count: 2, items: [{ id: 9, filename: 'old-wall.jpg' }, { id: 11, filename: 'old-wall-2.jpg' }] }) }),
|
||||
A('med-purge', 'POST', '/v1/media/purge', 'Delete the images a purge would remove. failed_groups counts groups that could not be purged; the others were still purged. Needs the permission "Delete files".', 'Needs permission', 1, [e401, eAd], [S('fingerprint', 'string', 'No', 'Optional. Taken from purge-preview.')], { body: { fingerprint: 'c41f9a7e' }, r: '{ "groups_total": 3, "groups_purged": 2, "merged": 2, "articles_updated": 5, "sites_updated": 0, "authors_updated": 1, "failed_groups": 1 }' })
|
||||
] },
|
||||
sites: { title: 'Sites and authors', desc: 'Read the sites and authors your content belongs to. Creating and changing them needs a session.', eps: [
|
||||
A('site-list', 'GET', '/v1/site/', 'List sites', 'Verified user', 1, [e401], [], { r: j([{ id: 1, key: 'renew', name: 'Renew', default: true }]) }),
|
||||
A('author-list', 'GET', '/v1/author/', 'List authors, one page at a time', 'Verified user', 1, [[400, '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 }) })
|
||||
A('author-list', 'GET', '/v1/author/', 'List authors, one page at a time', 'Verified user', 1, [[400, mm], e401], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.')], { qp: 1, r: j({ items: [{ id: 3, name: 'In\u00e8s Duarte', slug: 'ines-duarte' }], total: 1, page: 1, size: 25 }) })
|
||||
] },
|
||||
users: { title: 'Users and invites', desc: 'People who can sign in, and pending invitations.', eps: [
|
||||
A('usr-list', 'GET', '/v1/user/', 'List users and invites.', 'Verified user', 1, [e401], [], { r: j([{ email: 'marta@renew.example', role: 'owner', status: 'accepted' }]) }),
|
||||
@@ -255,6 +255,7 @@ class Component extends DCLogic {
|
||||
A('usr-inv', 'POST', '/v1/user/invite', 'Invite a user by email.', 'Verified user', 1, [[400, mm], e401, [409, 'This address already has an account.']], [S('email', 'string', 'Yes', 'A valid email address. The link expires after 3 days.')], { body: { email: 'lea.martin@renew.example' }, r: j({ id: 7, status: 'pending' }) }),
|
||||
A('usr-cancel', 'DELETE', '/v1/user/invite/{id}', 'Cancel a pending invite.', 'Verified user', 1, [e401, i404], [], { r: '204 No Content' }),
|
||||
A('usr-renew', 'POST', '/v1/user/invite/{id}/renew', 'Renew an expired invite.', 'Verified user', 1, [e401, i404], [], { r: j({ id: 7, status: 'pending' }) }),
|
||||
A('usr-del', 'DELETE', '/v1/user/{id}?expected_role_id=N', 'Remove a user. expected_role_id is the role the user had when you loaded them; if it changed since, nothing is removed.', 'Needs permission', 1, [e401, eAd, [409, 'target_role_not_found']], [S('expected_role_id', 'integer', 'Yes', 'Query parameter. The id of the role you saw.')], { qp: 1, r: '204 No Content' }),
|
||||
A('auth-me', 'GET', '/v1/auth/me', 'Get the current user. With a key, the key\'s owner.', 'Verified user', 1, [e401], [], { r: j({ username: 'marta', email: 'marta@renew.example', role: 'owner' }) })
|
||||
] },
|
||||
settings: { title: 'Settings', desc: 'Read the site settings.', eps: [
|
||||
@@ -283,7 +284,7 @@ class Component extends DCLogic {
|
||||
const lang = tab === 'curl' ? 'Shell' : tab === 'fetch' ? 'JavaScript' : 'JSON'
|
||||
const cp = this.state.copied === e.id
|
||||
const chip = (t, o) => ({ t, bg: 'transparent', fg: 'var(--ui-text-toned)', bd: 'var(--ui-border-accented)', bs: 'solid', fw: 400, ...o })
|
||||
const chips = [chip(e.acc, e.acc === 'Owner only' || 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 })]
|
||||
const chips = [chip(e.acc, e.acc === 'Owner only' || e.acc === 'Needs permission' ? { bg: 'var(--ui-bg-elevated)', fg: 'var(--ui-text-highlighted)', fw: 500 } : {}), chip('API key OK', { icon: 'check', bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', bd: 'transparent', fw: 500 })]
|
||||
return { anchor: 'ep-' + e.id, open, m: e.m, p: e.p, d: e.d, mBg: MB[e.m][0], mFg: MB[e.m][1], mBd: MB[e.m][2], chev: open ? 'chevron-up' : 'chevron-down', rowBg: this.state.flash === e.id ? 'var(--rc-sand)' : open ? 'var(--ui-bg-muted)' : 'transparent', ring: this.state.flash === e.id ? '2px solid var(--ui-primary)' : 'none', label: e.m + ' ' + e.p + '. ' + e.d, chips,
|
||||
toggle: () => this.setState(s => ({ open: { ...s.open, [e.id]: !s.open[e.id] } })),
|
||||
bodyTitle: e.qp ? 'Query parameters' : e.multi ? 'Request body · multipart/form-data, 1 file field' : e.body ? 'Request body · JSON' : 'Request body', hasFields: e.fields.length > 0, noFields: e.fields.length === 0, fields: e.fields,
|
||||
@@ -323,13 +324,13 @@ class Component extends DCLogic {
|
||||
errRows: [
|
||||
{ c: 400, m: 'Bad request', w: '"The request cannot be processed due to a mismatch in the format of the body." or "The expiry must be a date in the future." Image requests answer with a JSON body whose error is "unknown_variant" or "unsupported_parameter".' },
|
||||
{ c: 401, m: 'Unauthorized', w: '"You must be logged in to access this resource." Sent for an invalid, unknown, expired or revoked key, even on public routes, and for a key whose user was deleted or unverified.' },
|
||||
{ c: 403, m: 'Forbidden', 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', 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: 403, m: 'Forbidden', n: '`missing_permission` is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article.', w: '"This action needs a real session." (a session-only route called with a key), `missing_permission` (the role of the user, or of the key\'s creator, lacks the permission the route needs) or "You must verify your account to access this resource."' },
|
||||
{ c: 404, m: 'Not found', n: 'Text in code style is the literal body of the response, not a sentence.', w: '"Article not found", "Image not found", "Invite not found" or "API key not found". Content types and entries answer `content_type_not_found` or `entry_not_found`. An unknown site key on the sitemap answers `site_not_found`. Drafts answer 404 to anonymous callers.' },
|
||||
{ c: 409, m: 'Conflict', w: 'Content types: `content_type_key_taken`, `content_type_has_entries`, `content_type_referenced` or `schema_incompatible`. Entries: \`invalid_entry: <reason>\`, only when a unique field clashes. Media and users: "Some of the images to merge changed. Refresh and try again." or "This address already has an account." Articles: `slug_taken`. Removing a user whose role changed first: `target_role_not_found`.' },
|
||||
{ c: 422, m: 'Unprocessable', w: 'An entry doesn\'t fit its content type. The body is `invalid_entry`, then ": " and the reason, for example `invalid_entry: capacity must be at most 5000`.' },
|
||||
{ c: 429, m: 'Too many requests', w: '"Too many requests. Please try again later." A per-key limit of 120 requests per minute also sends a Retry-After header.' },
|
||||
{ c: 500, m: 'Server error', w: '"Internal server error"' }
|
||||
].map(r => ({ ...r, parts: r.w.split('`').map((x, i) => ({ t: x, c: i % 2 === 1, p: i % 2 === 0 })).filter(x => x.t) })),
|
||||
].map(r => ({ ...r, parts: r.w.split('`').map((x, i) => ({ t: x, c: i % 2 === 1, p: i % 2 === 0 })).filter(x => x.t), nparts: r.n ? r.n.split('`').map((x, i) => ({ t: x, c: i % 2 === 1, p: i % 2 === 0 })).filter(x => x.t) : [] })),
|
||||
errBody: 'HTTP/1.1 403 Forbidden\nContent-Type: text/plain\n\nThis action needs a real session.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,10 +57,10 @@ class Component extends DCLogic {
|
||||
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 a404 = [404, 'Article not found'], i404 = [404, 'Image not found'], eAd = [403, 'Your role doesn\'t include this action. Ask an owner or admin.'], ctnf = [404, 'content_type_not_found'], enf = [404, 'entry_not_found']
|
||||
const uq = [409, 'invalid_entry: title must be unique'], ie = [422, 'invalid_entry: capacity must be at most 5000'], evf = ['data', 'title', 'starts_on', 'capacity', 'online', 'category', 'cover']
|
||||
const eps = [
|
||||
E('art-list', 'GET', '/v1/article/', 'List articles, one page at a time.', 'Public', ['page', 'size'], [[400, 'Invalid page or size.'], [401, L]], { qp: 1 }),
|
||||
E('art-list', 'GET', '/v1/article/', 'List articles, one page at a time.', 'Public', ['page', 'size'], [[400, MM], [401, L]], { qp: 1 }),
|
||||
E('art-get', 'GET', '/v1/article/{id}', 'Get one article.', 'Public', [], [[401, L], a404]),
|
||||
E('art-create', 'POST', '/v1/article/', 'Create a draft article.', 'Verified user', ['title', 'body'], [[400, MM], [401, L]]),
|
||||
E('art-update', 'PATCH', '/v1/article/{id}', 'Update the body of an article.', 'Verified user', ['body', 'slug', 'excerpt', 'seo_title', 'seo_description', 'cover_image_id', 'cover_image_alt', 'social_title', 'social_description', 'social_image_id', 'canonical_url', 'noindex', 'seo_dismissed'], [[400, MM], [401, L], a404, [409, 'slug_taken']]),
|
||||
@@ -71,28 +71,28 @@ class Component extends DCLogic {
|
||||
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('ct-create', 'POST', '/v1/content-type/', 'Create a content type.', 'schema.edit', ['key', 'name', 'icon', 'fields'], [[400, MM], [401, L], eAd, [409, 'content_type_key_taken']]),
|
||||
E('ct-update', 'PUT', '/v1/content-type/{key}', 'Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can\'t change.', 'schema.edit', ['name', 'icon', 'fields'], [[400, MM], [401, L], eAd, ctnf, [409, 'schema_incompatible Some existing entries don\'t fit the new fields. Nothing was changed.']]),
|
||||
E('ct-delete', 'DELETE', '/v1/content-type/{key}', 'Delete a content type. Only possible when it has no entries and no relation field points at it.', 'schema.delete', [], [[401, L], eAd, ctnf, [409, 'content_type_has_entries'], [409, 'content_type_referenced']]),
|
||||
E('en-list', 'GET', '/v1/content-type/{key}/entry', 'List entries of a type, one page at a time. The response is an object with items, total, page and size, not a bare array like articles.', 'Verified user', ['page', 'size', 'sort'], [[400, 'Invalid page, size or sort.'], [401, L], ctnf], { qp: 1 }),
|
||||
E('en-create', 'POST', '/v1/content-type/{key}/entry', 'Create an entry.', 'Verified user', evf, [[400, MM], [401, L], ctnf, uq, ie]),
|
||||
E('en-get', 'GET', '/v1/content-type/{key}/entry/{id}', 'Get one entry.', 'Verified user', [], [[400, 'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).'], [401, L], enf, ctnf]),
|
||||
E('en-update', 'PUT', '/v1/content-type/{key}/entry/{id}', 'Replace the data of an entry.', 'Verified user', evf, [[400, MM], [401, L], enf, ctnf, uq, ie]),
|
||||
E('en-delete', 'DELETE', '/v1/content-type/{key}/entry/{id}', 'Delete an entry.', 'Verified user', [], [[401, L], enf, ctnf]),
|
||||
E('bt-list', 'GET', '/v1/builtin-type/', 'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user.', '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('bt-list', 'GET', '/v1/builtin-type/', 'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user.', 'schema.view', [], [[401, L], eAd]),
|
||||
E('bt-get', 'GET', '/v1/builtin-type/{key}', 'Get one built-in type. Keys are article, author, site, media and user.', 'schema.view', [], [[401, L], eAd, [404, 'builtin_type_not_found']]),
|
||||
E('med-list', 'GET', '/v1/media/', 'List images, one page at a time. The total is in the X-Total-Count response header; the body stays a JSON array.', 'Verified user', ['page', 'size'], [[400, MM], [401, L]], { qp: 1 }),
|
||||
E('med-up', 'POST', '/v1/media/', 'Upload an image (multipart).', 'Verified user', ['file'], [[400, MM], [401, L], [413, 'The file is over 10 MB.'], [415, 'Not a PNG, JPEG, WEBP or SVG image.']]),
|
||||
E('med-ren', 'PATCH', '/v1/media/{id}', 'Rename an image. The URL stays the same.', 'Verified user', ['filename'], [[400, MM], [401, L], i404]),
|
||||
E('med-del', 'DELETE', '/v1/media/{id}', 'Delete an image.', 'Verified user', [], [[401, L], i404]),
|
||||
E('med-merge', 'POST', '/v1/media/merge', 'Merge identical copies into one image.', 'Verified user', ['keep_id', 'merge_ids'], [[400, MM], [401, L], [409, 'Some of the images to merge changed. Refresh and try again.']]),
|
||||
E('med-use', 'GET', '/v1/media/{id}/usage', 'List the articles that use an image.', 'Verified user', [], [[401, L], i404]),
|
||||
E('med-purge-settings-get', 'GET', '/v1/media/purge-settings', 'Get the settings for purging unused images.', '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('med-purge-settings-get', 'GET', '/v1/media/purge-settings', 'Get the settings for purging unused images.', 'Needs permission', [], [[401, L], eAd]),
|
||||
E('med-purge-settings-patch', 'PATCH', '/v1/media/purge-settings', 'Update the settings for purging unused images.', 'Needs permission', ['auto_purge_enabled', 'auto_purge_interval_days'], [[400, MM], [401, L], eAd]),
|
||||
E('med-purge-preview', 'GET', '/v1/media/purge-preview', 'Preview the images a purge would delete.', 'Needs permission', [], [[401, L], eAd]),
|
||||
E('med-purge', 'POST', '/v1/media/purge', 'Delete the images a purge would remove. failed_groups counts groups that could not be purged; the others were still purged.', 'Needs permission', ['fingerprint'], [[401, L], eAd]),
|
||||
E('site-list', 'GET', '/v1/site/', 'List sites', 'Verified user', [], [[401, L]]),
|
||||
E('author-list', 'GET', '/v1/author/', 'List authors, one page at a time', 'Verified user', ['page', 'size'], [[400, 'Invalid page or size.'], [401, L]], { qp: 1 }),
|
||||
E('author-list', 'GET', '/v1/author/', 'List authors, one page at a time', 'Verified user', ['page', 'size'], [[400, MM], [401, L]], { qp: 1 }),
|
||||
E('usr-list', 'GET', '/v1/user/', 'List users and invites.', 'Verified user', [], [[401, L]]),
|
||||
E('usr-me', 'PATCH', '/v1/user/me', 'Update my profile.', 'Verified user', ['display_name'], [[400, MM], [401, L]]),
|
||||
E('usr-inv', 'POST', '/v1/user/invite', 'Invite a user by email.', 'Verified user', ['email'], [[400, MM], [401, L], [409, 'This address already has an account.']]),
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
|
||||
<!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 }}" src-pane="{{ srcPane }}" ed-banner="{{ edBanner }}" ed-body-el="{{ edBodyEl }}" ed-header="{{ edHeader }}" ed-mob-bar="{{ edMobBar }}" 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":"boolean","default":false,"tsType":"boolean"}}">
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
const s = this.props.s || {}, m = !!this.props.mobile, v = s.v, x = s.x || ''
|
||||
const P = t => Object.fromEntries(t.split(';').filter(q => q.trim()).map(q => { const i = q.indexOf(':'); let k = q.slice(0, i).trim(); const val = q.slice(i + 1).trim(); if (!k.startsWith('--')) { k = k.replace(/^-webkit-/, 'Webkit-').replace(/-([a-z])/g, (_, c) => c.toUpperCase()) } return [k, val] }))
|
||||
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 A = (tag, pr, t, ...c) => h(tag, { ...pr, style: P(t) }, ...c)
|
||||
const Ic = (n, z = 16) => S('display:inline-block;flex-shrink:0;width:' + z + 'px;height:' + z + 'px;background-color:currentColor;-webkit-mask: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')
|
||||
const HL = 'color:var(--ui-text-highlighted)', MU = 'color:var(--ui-text-muted)', TO = 'color:var(--ui-text-toned)'
|
||||
const mono = "font-family:'IBM Plex Mono',monospace;font-size:12px"
|
||||
const Btn = (l, k = 'secondary', o = {}) => {
|
||||
const hh = m ? 44 : (o.sm ? 28 : 32)
|
||||
const st = { primary: 'background:var(--ui-primary);color:var(--ui-bg)', secondary: 'background:var(--ui-bg);color:var(--ui-text-highlighted);border:1px solid var(--ui-border-accented)', ghost: 'color:var(--ui-text-toned)', danger: 'background:var(--ui-error);color:var(--ui-bg)', dangerOutline: 'background:var(--ui-bg);color:var(--ui-error);border:1px solid var(--ui-error)' }[k]
|
||||
return A('span', { role: 'button', 'aria-label': o.al || (o.ic && !l ? o.ic : undefined), 'aria-disabled': !!o.dis || undefined, 'aria-busy': o.busy || undefined }, 'display:inline-flex;align-items:center;justify-content:center;gap:6px;height:' + hh + 'px;padding:0 ' + (o.ic && !l ? 0 : 12) + 'px;' + (o.ic && !l ? 'width:' + hh + 'px;' : '') + 'font-weight:500;white-space:nowrap;flex-shrink:0;' + st + ';' + (o.dis ? 'opacity:.4;' : '') + (o.w ? 'width:' + o.w + ';' : ''), o.busy && Ic('loader-circle', 14), o.ic && Ic(o.ic, 16), l)
|
||||
}
|
||||
const TN = { neutral: 'background:var(--ui-bg-elevated);color:var(--ui-text-toned)', success: 'background:var(--rc-success-soft);color:var(--ui-success)', info: 'background:var(--rc-info-soft);color:var(--ui-info)', warning: 'background:var(--rc-warning-soft);color:var(--ui-warning)', error: 'background:var(--rc-error-soft);color:var(--ui-error)' }
|
||||
const Bd = (l, tone = 'neutral', ic) => S('display:inline-flex;align-items:center;gap:4px;height:20px;padding:0 6px;font-size:12px;font-weight:500;white-space:nowrap;flex-shrink:0;' + TN[tone], ic && Ic(ic, 12), l)
|
||||
// freshness uses an outline so it never reads as a publication state
|
||||
const Fr = (l, tone = 'warning', ic = 'refresh-cw') => S('display:inline-flex;align-items:center;gap:4px;height:20px;padding:0 6px;font-size:12px;font-weight:500;white-space:nowrap;flex-shrink:0;border:1px solid currentColor;background:var(--ui-bg);color:var(--ui-' + tone + ')', Ic(ic, 12), l)
|
||||
const Pub = st => st === 'pub' ? Bd('Published', 'success', 'circle-check') : Bd('Draft', 'neutral', 'pencil')
|
||||
const Alert = (tone, ic, title, text, act) => D('display:flex;gap:10px;padding:12px 14px;align-items:flex-start;' + TN[tone].split(';')[0] + ';color:var(--ui-text-highlighted)', S('color:var(--ui-' + (tone === 'neutral' ? 'text-toned' : tone) + ')', Ic(ic)), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S('font-weight:600', title), text && S(TO + ';text-wrap:pretty', text), act && D('margin-top:8px;display:flex;gap:8px;flex-wrap:wrap', ...[].concat(act))))
|
||||
const Fd = (label, val, o = {}) => D('display:flex;flex-direction:column;gap:6px;min-width:0', S('font-weight:500;' + HL, label, o.opt && S(MU + ';font-weight:400', ' (optional)')),
|
||||
D('display:flex;align-items:' + (o.area ? 'flex-start' : 'center') + ';gap:8px;min-height:' + (m ? 44 : 32) + 'px;padding:' + (o.area ? 8 : 0) + 'px 10px;border:1px solid ' + (o.err ? 'var(--ui-error)' : o.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (o.ro ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';' + (o.focus ? 'outline:2px solid var(--ui-primary);outline-offset:1px;' : ''), o.pre && S(MU + ';' + mono + ';white-space:nowrap', o.pre), A('span', { dir: o.dir, lang: o.lang }, 'flex:1;min-width:0;' + (o.mono ? mono + ';' : '') + (o.val === false ? MU : HL) + ';overflow:hidden;text-overflow:ellipsis;' + (o.area ? 'white-space:normal' : 'white-space:nowrap'), val), o.sel && Ic('chevrons-up-down', 14), o.lock && Ic('lock', 14)),
|
||||
o.err ? S('display:flex;gap:6px;color:var(--ui-error);font-size:12px;line-height:18px', Ic('circle-x', 14), o.err) : o.help && S(MU + ';font-size:12px;line-height:18px;text-wrap:pretty', o.help))
|
||||
const Radio = (l, d, on) => D('display:flex;gap:10px;padding:10px 12px;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)'), S('width:16px;height:16px;margin-top:2px;flex-shrink:0;border:1px solid var(--ui-border-accented);display:flex;align-items:center;justify-content:center', on && S('width:8px;height:8px;background:var(--ui-primary)')), D('display:flex;flex-direction:column;gap:2px', S('font-weight:500;' + HL, l), d && S(MU + ';font-size:12px;line-height:18px;text-wrap:pretty', d)))
|
||||
const Dlg = (o, body, foot) => 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-top:' + (m ? 0 : o.top ?? 100) + 'px',
|
||||
A('div', { role: 'dialog', 'aria-modal': 'true', 'aria-label': o.title }, 'width:' + (m ? '100%' : (o.w || 460) + 'px') + ';max-height:' + (m ? '92%' : 'calc(100% - ' + ((o.top ?? 100) + 24) + 'px)') + ';background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column;' + (m ? 'border-top:1px solid var(--ui-border)' : ''),
|
||||
m && D('display:flex;justify-content:center;padding:8px 0 0', S('width:36px;height:4px;background:var(--ui-border-accented)')),
|
||||
D('padding:20px 24px 0;display:flex;gap:12px;align-items:flex-start', D('flex:1;display:flex;flex-direction:column;gap:4px;min-width:0', S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + HL, o.title), o.desc && S(TO + ';text-wrap:pretty', o.desc)), Btn('', 'ghost', { ic: 'x', sm: true, al: 'Close' })),
|
||||
D('padding:16px 24px 0;display:flex;flex-direction:column;gap:14px;overflow-y:auto;min-height:0', ...[].concat(body)),
|
||||
D('padding:20px 24px;flex-shrink:0;flex-wrap:wrap;display:flex;gap:8px;' + (m ? 'flex-direction:column-reverse' : 'justify-content:flex-end'), ...[].concat(foot))))
|
||||
const Toast = (tone, ic, t, d) => D('position:absolute;z-index:80;' + (m ? 'left:16px;right:16px;bottom:16px' : 'right:24px;bottom:24px;width:356px') + ';background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:12px;display:flex;gap:10px', S('width:28px;height:28px;display:flex;align-items:center;justify-content:center;flex-shrink:0;color:var(--ui-' + tone + ');' + TN[tone].split(';')[0], Ic(ic)), D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;' + HL, t), d && S(TO, d)))
|
||||
const Sk = n => D('display:flex;flex-direction:column;gap:10px', ...Array.from({ length: n }, (_, i) => D('height:' + (m ? 56 : 44) + 'px;background:var(--ui-bg-elevated);opacity:' + (1 - i * .12))))
|
||||
const Page = (title, sub, act, ...c) => D('flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:16px;padding:' + (m ? '16px 16px 88px' : '24px') + ';position:relative', D('display:flex;gap:12px;align-items:flex-start;' + (m ? 'flex-direction:column' : ''), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;' + HL, title), sub && S(TO + ';text-wrap:pretty', sub)), act), ...c)
|
||||
const Card = (...c) => D('flex-shrink:0;background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:12px', ...c)
|
||||
const ROW = 'display:flex;align-items:center;gap:12px;border-top:1px solid var(--ui-border);padding:10px 16px;min-height:' + (m ? 56 : 48) + 'px'
|
||||
const Av = i => S('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', i)
|
||||
let srcPane = null, edBanner = null, edBodyEl = null, edHeader = null, edMobBar = null
|
||||
let main = null, shell = { kind: 'slot', display: 'Marta Alves', role: s.role }, ov = []
|
||||
// ---- data
|
||||
const FX = window.LOCFX, S1 = FX.STORIES[0], S5 = FX.STORIES[4]
|
||||
const LV = (k, n, c, st) => ({ n, c, lang: k, dir: FX.DIR[k] || 'ltr', t: k === 'en' ? (st || S1).title : (st || S1).tr[k], p: FX.P[k] })
|
||||
const EN = LV('en', 'English', 'EN'), FR = LV('fr', 'French', 'FR'), AR = LV('ar', 'Arabic', 'AR'), DE = LV('de', 'German', 'DE')
|
||||
const CAPS = { full: { e: 1, p: 1, d: 1, c: 1, rv: 1 }, ro: {}, p2: { e: 1, d: 1, c: 1, rv: 1 }, p3: { p: 1 }, p6: { e: 1, p: 1, d: 1, c: 1, rv: 1, part: 1 } }
|
||||
// ---- language menu (menu button + menu; rows are single menu items, no nested controls)
|
||||
const LangBtn = (cur, o = {}) => A('span', { role: 'button', 'aria-haspopup': 'menu', 'aria-expanded': !!o.open, 'aria-label': 'Language: ' + cur + '. Switch version' }, 'display:inline-flex;align-items:center;gap:8px;height:' + (m ? 44 : 32) + 'px;padding:0 10px;border:1px solid ' + (o.open ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;flex-shrink:0;max-width:260px;width:max-content', Ic('languages'), S('white-space:nowrap', cur), o.code && S(mono + ';flex-shrink:0;' + MU, o.code), Ic('chevrons-up-down', 14))
|
||||
const Tag = (l, ic) => S('display:inline-flex;align-items:center;gap:4px;font-size:12px;' + MU, ic && Ic(ic, 12), l)
|
||||
const LRow = r => { const cur = r.cur, add = r.add
|
||||
return A('div', { role: 'menuitem', 'aria-current': cur || undefined, tabIndex: -1 }, 'display:flex;align-items:center;gap:10px;min-height:' + (m ? 52 : 40) + 'px;padding:6px 12px;' + (cur ? 'background:var(--rc-sand);color:var(--rc-on-sand)' : add ? 'color:var(--ui-text-toned)' : ''), S('width:16px', cur && Ic('check')), D('flex:1;min-width:0;display:flex;flex-direction:column', S('overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:' + (cur ? 600 : 500), r.n), S(mono + (cur ? '' : ';' + MU), r.c)),
|
||||
add ? S('display:inline-flex;align-items:center;gap:4px;font-weight:500', Ic('plus', 14), 'Add translation') : D('display:flex;gap:4px;flex-wrap:wrap;justify-content:flex-end', r.tag && Tag(r.tag, r.tagIc), Pub(r.st), r.fr && Fr('Needs review'), r.dis && Bd('Disabled', 'neutral', 'eye-off'))) }
|
||||
const LangPop = (rows, o = {}) => D('position:absolute;z-index:40;' + (m ? 'left:0;right:0;bottom:0;max-height:70%;border-top:1px solid var(--ui-border)' : 'left:' + (o.left ?? 292) + 'px;top:' + (o.top ?? 150) + 'px;width:400px;max-height:' + (o.max ?? 420) + 'px;border:1px solid var(--ui-border)') + ';background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);display:flex;flex-direction:column',
|
||||
m && D('display:flex;justify-content:center;padding:8px 0 0;flex-shrink:0', S('width:36px;height:4px;background:var(--ui-border-accented)')),
|
||||
D('padding:10px 12px 6px;display:flex;flex-direction:column;gap:6px;flex-shrink:0', S('font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;' + MU, 'Versions you can open'), o.find && Fd('', 'Find a language', { val: false })),
|
||||
A('div', { role: 'menu', 'aria-label': 'Versions of this article' }, 'display:flex;flex-direction:column;overflow-y:auto;min-height:0', ...rows.map(LRow)),
|
||||
D('padding:10px 12px;border-top:1px solid var(--ui-border);display:flex;gap:8px;flex-shrink:0;' + MU + ';font-size:12px;line-height:18px', Ic('info', 14), o.note || 'Switching opens that version. The interface language stays the same.'))
|
||||
const MENUS = {
|
||||
all: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }, { n: 'Arabic', c: 'AR', st: 'draft', fr: 1 }],
|
||||
two: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }],
|
||||
many: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'English (United Kingdom)', c: 'EN-GB', st: 'draft' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }, { n: 'Italian', c: 'IT', st: 'draft' }, { n: 'Japanese', c: 'JA', st: 'pub' }, { n: 'Portuguese (Brazil)', c: 'PT-BR', add: 1 }, { n: 'Spanish', c: 'ES', add: 1 }, { n: 'Chinese (Simplified)', c: 'ZH-HANS', add: 1 }, { n: 'Arabic', c: 'AR', add: 1 }, { n: 'Dutch', c: 'NL', add: 1 }, { n: 'Polish', c: 'PL', add: 1 }],
|
||||
ro: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }],
|
||||
dis: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'Spanish', c: 'ES', st: 'draft', dis: 1 }],
|
||||
p4: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }, { n: 'Arabic', c: 'AR', st: 'draft', fr: 1, tag: 'Read only', tagIc: 'lock' }],
|
||||
p5: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }],
|
||||
noAdd: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }],
|
||||
withAdd: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }, { n: 'Arabic', c: 'AR', add: 1 }]
|
||||
}
|
||||
// ---- editor pieces
|
||||
const nr = !!s.narrow && !m
|
||||
const J = FX.SITES.journal, LNm = FX.LANG_NAMES
|
||||
const Body = (L, o = {}) => A('div', { dir: L.dir, lang: L.lang, role: 'textbox', 'aria-multiline': 'true', 'aria-readonly': o.ro || undefined, 'aria-label': (o.label || 'Body') + ' (' + L.n + ')', tabIndex: 0 }, 'display:flex;flex-direction:column;gap:12px;padding:16px;border:1px solid var(--ui-border);background:' + (o.ro ? 'var(--ui-bg-muted)' : 'var(--ui-bg)') + ';flex:1;min-height:0;overflow:auto;font:400 ' + (L.dir === 'rtl' ? '17px/30px' : '15px/26px') + ' \'Source Serif 4\',serif;color:var(--ui-text)', ...(o.long ? L.p.concat(L.p, L.p) : L.p).map(t => S('text-wrap:pretty', t)))
|
||||
const LONGT = 'Comment nous chiffrons une rénovation de salle de bain, poste par poste, avec les coûts cachés derrière les carreaux, sous la baignoire et autour des canalisations anciennes, expliqués avant la commande du premier carreau et de la première vanne'
|
||||
const TitleIn = (L, o) => {
|
||||
const st = o.tst || 'pristine', cap = o.cap, lab = 'Title (' + L.n + ')'
|
||||
if (!cap.e) return D('display:flex;flex-direction:column;gap:2px', D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 44 : 36) + 'px', A('div', { dir: L.dir, lang: L.lang, role: 'textbox', 'aria-readonly': 'true', 'aria-label': lab, tabIndex: 0 }, 'flex:1;min-width:0;font:600 ' + (m ? 18 : 20) + 'px/28px \'IBM Plex Sans\',sans-serif;' + HL, L.t), Ic('lock', 14)))
|
||||
const val = { invalid: '', short: 'Sa', long: LONGT.slice(0, 203), edit: L.t + ' en 2026', unsaved: L.t + ' en 2026' }[st] ?? (o.titleVal ?? L.t)
|
||||
const err = ['invalid', 'short', 'long', 'failed'].includes(st), on = ['focus', 'edit'].includes(st), cnt = ['focus', 'edit', 'short', 'long'].includes(st)
|
||||
const msg = { invalid: 'Enter a title', short: 'Use at least 3 characters', long: 'Use 200 characters or fewer' }[st]
|
||||
return D('display:flex;flex-direction:column;gap:2px',
|
||||
D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 44 : 40) + 'px;padding:2px 4px;border:0;border-bottom:' + (on || err ? 2 : 1) + 'px solid ' + (err ? 'var(--ui-error)' : on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--ui-bg)' : 'transparent') + ';' + (st === 'focus' ? 'box-shadow:0 2px 0 0 var(--ui-primary)' : ''),
|
||||
A('div', { dir: L.dir, lang: L.lang, role: 'textbox', 'aria-label': lab, 'aria-invalid': err || undefined, 'aria-describedby': 'title-help', tabIndex: 0 }, 'flex:1;min-width:0;font:600 ' + (m ? 18 : 20) + 'px/28px \'IBM Plex Sans\',sans-serif;' + (st === 'invalid' ? MU : HL) + ';text-wrap:pretty;' + (m ? '' : 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap'), st === 'invalid' ? 'Title' : val),
|
||||
cnt && S(mono + ';flex-shrink:0;color:' + (err ? 'var(--ui-error)' : 'var(--ui-text-muted)'), val.length + ' / 200'), st === 'saving' ? Ic('loader-circle', 14) : st === 'failed' ? Ic('circle-x', 14) : Ic('pencil', 14)),
|
||||
A('div', { id: 'title-help', role: err ? 'alert' : undefined }, 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:0 4px;font-size:12px;line-height:18px;' + (err ? 'color:var(--ui-error)' : MU),
|
||||
err && Ic('circle-x', 14), msg || (st === 'failed' ? 'Couldn\'t save the title. Your text is kept.' : st === 'saving' ? 'Saving title…' : (on || st === 'unsaved') ? 'Changing the title doesn\'t change the address.' : null), st === 'failed' && Btn('Try again', 'secondary', { sm: true })))
|
||||
}
|
||||
const Acts = (L, o) => {
|
||||
const cap = o.cap, on = o.pub === 'pub', d = !!o.dirty || !!o.saveFail, a = { prim: null, sec: [], un: null }
|
||||
if (o.pubFail) a.prim = { l: 'Retry publish', ic: 'rotate-cw' }
|
||||
else if (o.saving) a.prim = { l: 'Saving…', busy: 1, dis: 1 }
|
||||
else if (cap.e && d && cap.p && !on && !o.saveFail) a.prim = { l: 'Save and publish ' + L.n, dis: !!o.saveBlock }
|
||||
else if (cap.e && d) a.prim = { l: o.saveFail ? 'Try saving again' : 'Save', dis: !!o.saveBlock }
|
||||
else if (cap.p && !on && !o.pubDis) a.prim = { l: 'Publish ' + L.n }
|
||||
const plain = a.prim && (a.prim.busy || a.prim.l === 'Save' || a.prim.l === 'Try saving again')
|
||||
if (cap.e && !plain) a.sec.push({ l: 'Save', dis: !d })
|
||||
if (cap.p && on && !o.saving) a.un = { l: 'Unpublish ' + L.n }
|
||||
if (o.pubDis) a.prim = { l: 'Publish ' + L.n, dis: 1 }
|
||||
return a
|
||||
}
|
||||
const OvItems = (L, o, k = {}) => {
|
||||
const cap = o.cap, a = Acts(L, o), it = []
|
||||
if (m && !k.skipRef) it.push({ l: o.src ? 'Hide source reference' : 'Show source reference', ic: 'columns-2' })
|
||||
if (a.un && (m || nr)) it.push({ l: a.un.l, ic: 'eye-off' })
|
||||
if (cap.e) it.push({ l: 'Edit slug', ic: 'link', why: 'Editing the title never changes the slug.' })
|
||||
if (cap.e) it.push(cap.part ? { l: 'Move article', ic: 'arrow-right-left', dis: 1, why: 'You can\'t move this article.' } : { l: 'Move article', ic: 'arrow-right-left' })
|
||||
if (cap.d) it.push({ l: 'Delete ' + L.n + ' version', ic: 'trash-2', danger: 1 })
|
||||
if (cap.d) it.push(cap.part ? { l: 'Delete article', ic: 'trash-2', dis: 1, why: 'You can\'t delete this article.' } : { l: 'Delete article', ic: 'trash-2', danger: 1 })
|
||||
return it
|
||||
}
|
||||
const OvMenu = (items, top0) => D('position:absolute;z-index:45;' + (m ? 'left:0;right:0;bottom:0;border-top:1px solid var(--ui-border)' : 'right:24px;top:' + (top0 ?? (nr ? 150 : 140)) + 'px;width:320px;border:1px solid var(--ui-border)') + ';background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);display:flex;flex-direction:column',
|
||||
m && D('display:flex;justify-content:center;padding:8px 0 0', S('width:36px;height:4px;background:var(--ui-border-accented)')),
|
||||
A('div', { role: 'menu', 'aria-label': 'More actions' }, 'display:flex;flex-direction:column;padding:4px 0', ...items.map(i => A('div', { role: 'menuitem', 'aria-disabled': i.dis || undefined, tabIndex: -1 }, 'flex-shrink:0;display:flex;align-items:center;gap:10px;min-height:' + (m ? 52 : 40) + 'px;padding:8px 12px;' + (i.dis ? 'opacity:.6;' : '') + (i.danger ? 'color:var(--ui-error)' : HL), Ic(i.ic, 16), D('display:flex;flex-direction:column;gap:2px', S('font-weight:500', i.l), i.why && S(MU + ';font-size:12px;font-weight:400;line-height:16px', i.why))))))
|
||||
const Hdr = (L, o) => {
|
||||
const cap = o.cap, a = Acts(L, o), items = OvItems(L, o, { skipRef: m && !a.prim && !a.sec.length && !a.un })
|
||||
const refL = o.src ? 'Hide source reference' : 'Show source reference'
|
||||
const pb = x2 => x2 && Btn(x2.l, 'primary', { busy: x2.busy, dis: x2.dis, ic: x2.ic })
|
||||
const status = o.saving ? Bd('Saving…', 'neutral', 'loader-circle') : o.saveFail ? Bd('Save failed', 'error', 'circle-x') : o.pubFail || o.saved ? Bd('Saved', 'success', 'check') : (o.dirty || o.tst === 'unsaved') ? Bd('Unsaved changes', 'neutral', 'circle-dot') : null
|
||||
const fresh = o.fresh && (nr ? A('span', { 'aria-label': o.fresh, title: o.fresh }, 'display:inline-flex;align-items:center;justify-content:center;width:24px;height:20px;flex-shrink:0;border:1px solid currentColor;background:var(--ui-bg);color:var(--ui-warning)', Ic('refresh-cw', 12)) : Fr(o.fresh))
|
||||
const left = D('flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:8px;flex-wrap:wrap', LangBtn(L.n, { code: L.c, open: !!o.open }), Pub(o.pub), status, fresh, !cap.e && Bd('Read only', 'neutral', 'lock'))
|
||||
const right = !m && D('display:flex;align-items:center;gap:8px;flex-shrink:0;margin-left:auto;height:40px', Btn(nr ? '' : refL, 'secondary', { ic: 'columns-2', al: refL }), a.un && !nr && Btn(a.un.l), ...a.sec.map(x2 => Btn(x2.l, 'secondary', { dis: x2.dis })), pb(a.prim), items.length ? Btn('', 'secondary', { ic: 'ellipsis', al: 'More actions' }) : null)
|
||||
if (!m) return D('flex:1 1 100%;min-width:0;display:flex;align-items:flex-start;gap:12px;flex-wrap:' + (nr ? 'wrap' : 'nowrap'), D('height:40px;display:flex;align-items:center;' + MU, Ic('arrow-left', 16)), D('display:flex;align-items:center;gap:8px;height:40px;flex-shrink:0;flex-wrap:nowrap', ...[LangBtn(L.n, { code: L.c, open: !!o.open }), Pub(o.pub), status, fresh, !cap.e && Bd('Read only', 'neutral', 'lock')].filter(Boolean)), D('flex:1 1 ' + (nr ? '100%' : '240px') + ';min-width:0;order:' + (nr ? 3 : 0), TitleIn(L, o)), right)
|
||||
return D('flex:1 1 100%;min-width:0;display:flex;flex-direction:column;gap:10px', D('display:flex;align-items:flex-start;gap:8px', !m && D('height:60px;display:flex;align-items:flex-end;' + MU, Ic('arrow-left', 16)), D('flex:1;min-width:0', TitleIn(L, o))), D('display:flex;align-items:center;gap:12px;flex-wrap:wrap', left, right))
|
||||
}
|
||||
const MBar = (L, o) => {
|
||||
if (!m) return null
|
||||
const a = Acts(L, o), items = OvItems(L, o, { skipRef: !a.prim && !a.sec.length && !a.un }), bs = []
|
||||
if (a.prim) bs.push(D('flex:1 1 0;min-width:0;display:flex', Btn(a.prim.l, 'primary', { w: '100%', busy: a.prim.busy, dis: a.prim.dis, ic: a.prim.ic })))
|
||||
a.sec.forEach(x2 => bs.push(Btn(x2.l, 'secondary', { dis: x2.dis })))
|
||||
if (!a.prim && !a.sec.length) bs.push(D('flex:1 1 0;display:flex', Btn(a.un ? a.un.l : (o.src ? 'Hide source reference' : 'Show source reference'), 'secondary', { w: '100%', ic: a.un ? 'eye-off' : 'columns-2' })))
|
||||
if (items.length) bs.push(Btn('', 'secondary', { ic: 'ellipsis', al: 'More actions' }))
|
||||
return D('flex-shrink:0;background:var(--ui-bg);border-top:1px solid var(--ui-border);padding:12px 16px;display:flex;gap:8px;align-items:center', ...bs)
|
||||
}
|
||||
const Seg = (on, L, S0) => A('div', { role: 'radiogroup', 'aria-label': 'Show' }, 'display:flex;border:1px solid var(--ui-border-accented);flex-shrink:0', ...['Source (' + S0.n + ')', 'Edit (' + L.n + ')'].map((t, i) => A('span', { role: 'radio', 'aria-checked': (on ? i === 0 : i === 1) }, 'flex:1;height:44px;display:flex;align-items:center;justify-content:center;font-weight:500;background:' + ((on ? i === 0 : i === 1) ? 'var(--ui-primary)' : 'var(--ui-bg)') + ';color:' + ((on ? i === 0 : i === 1) ? 'var(--ui-bg)' : 'var(--ui-text-toned)'), t)))
|
||||
const TempRef = S('white-space:nowrap;display:inline-flex;align-items:center;gap:4px;height:20px;padding:0 6px;font-size:12px;font-weight:500;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);background:var(--ui-bg)', Ic('book-open', 12), 'Temporary reference')
|
||||
const Tracked = S0n => Bd('Tracked source: ' + S0n, 'info', 'link')
|
||||
const Src = (S0, o = {}) => D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;gap:10px',
|
||||
D('flex-shrink:0;display:flex;flex-direction:column;gap:8px;padding:8px 12px;border:1px dashed var(--ui-border-accented);background:var(--ui-bg)', D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', TempRef, S('font-weight:600;' + HL, S0.n), S(mono + ';' + MU, S0.c), Pub('pub')), D('display:flex;align-items:center;gap:8px', S('white-space:nowrap;display:inline-flex;gap:4px;' + MU + ';font-size:12px', Ic('lock', 14), 'Read only'), D('flex:1'), Btn('Change reference', 'secondary', { sm: true }))),
|
||||
S('flex-shrink:0;' + MU + ';font-size:12px;line-height:18px', 'For comparing only. Review is tracked against English. Changing the reference doesn\'t change the tracked source.'),
|
||||
o.gone ? Alert('warning', 'triangle-alert', 'This reference isn\'t available', 'It was deleted or you can no longer read it. Your version isn\'t affected.', Btn('Choose another reference', 'secondary', { sm: true })) : [
|
||||
A('div', { dir: S0.dir, lang: S0.lang, role: 'heading', 'aria-level': '2', tabIndex: 0 }, 'font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + HL, S0.t),
|
||||
Body(S0, { ro: true, label: 'Reference', long: o.long })])
|
||||
// ---- site shells
|
||||
const setS = { nav: 'settings', crumbs: ['Settings', 'Sites', 'Journal', 'Languages'], mTitle: 'Journal · Languages', mLead: 'arrow-left' }
|
||||
const edS = { nav: 'articles', crumbs: ['Articles', 'Editor'], mTitle: 'Editor', mLead: 'arrow-left' }
|
||||
const liS = { nav: 'articles', crumbs: ['Articles'], mTitle: 'Articles' }
|
||||
// ---- site languages
|
||||
const SOC = (site, code) => { const q = (FX.SOCIAL[site] || {})[code] || ['none']; return q[0] === 'derived' ? 'Derived: ' + q[1] : q[0] === 'set' ? 'Set: ' + q[1] : 'Not set' }
|
||||
const SiteRows = (siteKey, ro) => { const si = FX.SITES[siteKey], all = si.langs.map(k => [k, 1]).concat(si.disabled.map(k => [k, 0])); return all.map(([k, en]) => { const c = FX.count(k, si.stories), def = k === si.def, soc = SOC(siteKey, k)
|
||||
return D(ROW + ';flex-wrap:wrap;opacity:' + (en ? 1 : .85), D('flex:1 1 160px;min-width:0;display:flex;flex-direction:column', S('font-weight:500;' + HL + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', LNm[k] || k), S(mono + ';' + MU, k)),
|
||||
def ? Bd('Default', 'info', 'star') : !en ? Bd('Disabled', 'neutral', 'eye-off') : null, S((m ? 'flex:1 1 100%;font-size:12px;' : 'width:170px;') + MU, FX.pl(c[0], 'version') + ' · ' + c[1] + ' published'),
|
||||
D('display:flex;align-items:center;gap:6px;' + (m ? 'flex:1 1 100%' : 'width:270px;white-space:nowrap'), S('font-size:12px;white-space:nowrap;' + MU, 'Social locale'), S(mono + ';' + (soc === 'Not set' ? MU : HL), soc), !ro && Btn('Edit', 'ghost', { sm: true, al: 'Edit social locale for ' + (LNm[k] || k) })),
|
||||
!ro && !en ? Btn('Re-enable', 'secondary', { sm: true }) : null, !ro && Btn('', 'ghost', { ic: 'ellipsis', sm: true, al: 'Actions for ' + (LNm[k] || k) })) }) }
|
||||
const slPage = () => {
|
||||
const ro = x === 'readonly', single = x === 'single', skey = single ? 'archive' : x === 'social' || x === 'socialEdit' || x === 'socialInvalid' ? 'atelier' : 'journal', site = FX.SITES[skey].name, T = 'Languages', SUB = 'Content languages for ' + site + '. Each article version is written in one language.'
|
||||
if (x === 'loading') return Page(T, SUB, null, Sk(4))
|
||||
if (x === 'error') return Page(T, SUB, null, Alert('error', 'circle-x', 'Couldn\'t load languages', 'Check your connection and try again.', Btn('Try again', 'secondary', { sm: true })))
|
||||
const banner = x === 'disableDefault' ? Alert('warning', 'triangle-alert', 'English is the default', 'Choose another default first, then disable English.') : x === 'disableLast' ? Alert('warning', 'triangle-alert', 'At least one language must stay enabled', 'Journal needs a language for new articles.') : x === 'defaultFail' ? Alert('error', 'circle-x', 'Couldn\'t change the default language', 'Nothing changed. English is still the default. Try again.', Btn('Try again', 'secondary', { sm: true })) : x === 'removeFail' ? Alert('error', 'circle-x', 'Couldn\'t remove Italian', 'Nothing changed. Italian is still listed. Try again.', Btn('Try again', 'secondary', { sm: true })) : x === 'stale' ? Alert('error', 'circle-x', 'Languages changed. Reload.', 'Another owner changed the languages for Journal while this page was open. Reload to see the current list. Your change wasn\'t applied.', Btn('Reload', 'secondary', { sm: true })) : null
|
||||
return Page(T, SUB, !ro && Btn('Add language', 'primary', { ic: 'plus' }),
|
||||
ro && Alert('neutral', 'lock', 'Only owners can change languages', 'You can see which languages are enabled, their social locale and how many versions each has. Ask an owner to change them.'), banner,
|
||||
single ? Card(S('font-weight:600;' + HL, 'Archive uses one language'), S(TO, 'New articles are written in Portuguese (Brazil). Add a language to write translations and publish them separately.'), D('display:flex;gap:10px;align-items:center;flex-wrap:wrap', Bd('Default', 'info', 'star'), S('font-weight:500;' + HL, 'Portuguese (Brazil)'), S(mono + ';' + MU, 'pt-BR'), D('display:flex;gap:6px;align-items:center;' + (m ? 'flex:1 1 100%' : ''), S(MU + ';font-size:12px', 'Social locale'), S(mono + ';' + HL, 'Derived: pt_BR'))))
|
||||
: D('background:var(--ui-bg);border:1px solid var(--ui-border)', D('padding:8px 16px;background:var(--ui-bg-elevated);' + MU + ';font-weight:500', 'Language'), ...SiteRows(skey, ro)),
|
||||
single && Alert('info', 'info', 'Regional language kept: pt-BR', 'Setup kept the full site locale, so the default content language is Portuguese (Brazil), not plain Portuguese. The site locale still formats dates and numbers. Social locale: Derived: pt_BR, taken from the language code.'),
|
||||
!single && !ro && S(MU + ';font-size:12px;text-wrap:pretty', 'Disabling a language keeps its versions readable and editable, and published ones stay online. Drafts can be published again after you re-enable it. Social locale: Derived comes from the language code, Set is chosen by an owner, Not set leaves og:locale out.'))
|
||||
}
|
||||
const slOv = () => {
|
||||
const addBody = (err) => [err && Alert('error', 'circle-x', 'Couldn\'t save languages', 'Nothing changed. Your selection is kept. Try again.'), Fd('Language', 'Portuguese (Brazil)', { sel: true, focus: !err }), Fd('Language code', 'pt-BR', { mono: true, ro: true, lock: true, help: 'Used in the API and in page language metadata.' }), Fd('Social locale', 'Derived: pt_BR', { mono: true, ro: true, lock: true, help: 'Taken from the language code. For a code without a region, such as fr, an owner sets one or leaves it empty and og:locale is left out.' })]
|
||||
const soc = (val, err, name, code) => Dlg({ title: 'Social locale for ' + name, desc: 'Used for og:locale on ' + name + ' pages. Leave it empty to omit the tag.' }, [Fd('Social locale', val, { mono: true, focus: !err, err, help: err ? null : 'Format: language_REGION, for example ' + code + '. Letters only, one underscore.' }), code === 'pt_BR' && Alert('info', 'info', 'Derived from the language code', 'pt-BR gives pt_BR. Setting a value replaces the derived one.')], [Btn('Cancel'), Btn('Save social locale', 'primary', { dis: !!err })])
|
||||
const dlgs = {
|
||||
add: () => Dlg({ title: 'Add a language', desc: 'Choose a language for Journal. Regional variants such as English (United Kingdom) are separate entries.' }, addBody(), [Btn('Cancel'), Btn('Add language', 'primary')]),
|
||||
saveFail: () => Dlg({ title: 'Add a language', desc: 'Choose a language for Journal.' }, addBody(true), [Btn('Cancel'), Btn('Try again', 'primary')]),
|
||||
adddup: () => Dlg({ title: 'Add a language', desc: 'Choose a language for Journal.' }, [Fd('Language', 'French', { sel: true, err: 'French is already enabled for Journal.' })], [Btn('Cancel'), Btn('Add language', 'primary', { dis: true })]),
|
||||
broader: () => Dlg({ title: 'Add a language', desc: 'Choose a language for Journal.' }, [Fd('Language', 'Portuguese (Portugal)', { sel: true }), Alert('warning', 'triangle-alert', 'Portuguese (Portugal) isn\'t available', 'Only the broader language Portuguese (pt) is supported. Pages and the API will say pt, not pt-PT. Articles written in this language can\'t be told apart from other Portuguese.'), D('display:flex;gap:10px;align-items:center;min-height:' + (m ? 44 : 24) + 'px', S('width:16px;height:16px;border:1px solid var(--ui-primary);background:var(--ui-primary);color:var(--ui-bg);display:flex;align-items:center;justify-content:center', Ic('check', 12)), S('font-weight:500;' + HL, 'Add Portuguese (pt) instead'))], [Btn('Cancel'), Btn('Add Portuguese', 'primary')]),
|
||||
default: () => Dlg({ title: 'Make French the default language?', desc: 'New articles start in the default language, and it is the first choice for each article\'s title in the list.' }, [Alert('info', 'info', 'No content changes', 'Existing versions keep their language, status and links. Public requests that name no language will resolve French where a published French version exists, and return not found where one doesn\'t. Requests that name a language aren\'t affected.')], [Btn('Cancel'), Btn('Make French default', 'primary')]),
|
||||
defaultFail: () => Dlg({ title: 'Make French the default language?', desc: 'New articles start in the default language.' }, [Alert('error', 'circle-x', 'Couldn\'t change the default language', 'Nothing changed. Try again.')], [Btn('Cancel'), Btn('Try again', 'primary')]),
|
||||
disable: () => Dlg({ title: 'Disable German?', desc: 'German is used by 4 versions: 1 draft and 3 published.' }, [Alert('warning', 'triangle-alert', 'Nothing is deleted or unpublished', 'The 3 published German versions stay online. All 4 versions stay readable and editable. The draft can\'t be published until you re-enable German. Nobody can create new German versions.')], [Btn('Cancel'), Btn('Disable German', 'primary')]),
|
||||
stale: () => Dlg({ title: 'Disable German?', desc: 'German is used by 4 versions.' }, [Alert('error', 'circle-x', 'Languages changed. Reload.', 'Your change wasn\'t applied.')], [Btn('Cancel'), Btn('Reload', 'primary')]),
|
||||
reenable: () => Dlg({ title: 'Re-enable Spanish?', desc: 'Writers can create Spanish versions again. The 1 existing Spanish version stays as it is. Nothing else changes.' }, [], [Btn('Cancel'), Btn('Re-enable Spanish', 'primary')]),
|
||||
reenableFail: () => Dlg({ title: 'Re-enable Spanish?', desc: 'Writers can create Spanish versions again.' }, [Alert('error', 'circle-x', 'Couldn\'t re-enable Spanish', 'Nothing changed. Spanish is still disabled. Try again.')], [Btn('Cancel'), Btn('Try again', 'primary')]),
|
||||
remove: () => Dlg({ title: 'Remove Italian?', desc: 'No article uses Italian. You can add it again later.' }, [], [Btn('Cancel'), Btn('Remove Italian', 'danger')]),
|
||||
removeFail: () => Dlg({ title: 'Remove Italian?', desc: 'No article uses Italian.' }, [Alert('error', 'circle-x', 'Couldn\'t remove Italian', 'Nothing changed. Italian is still listed. Try again.')], [Btn('Cancel'), Btn('Try again', 'danger')]),
|
||||
removeBlocked: () => Dlg({ title: 'Spanish can\'t be removed', desc: 'Spanish is used by 1 version. Disable the language to keep it, or delete it first.' }, [], [Btn('Close'), Btn('Disable Spanish', 'primary')]),
|
||||
socialEdit: () => soc('fr_FR', null, 'French', 'fr_FR'),
|
||||
socialInvalid: () => soc('fr-FR', 'Use letters and one underscore, for example fr_FR.', 'French', 'fr_FR')
|
||||
}
|
||||
if (x === 'saved') return [Toast('success', 'circle-check', 'French added', 'Writers can now create French versions.')]
|
||||
if (x === 'reenableDone') return [Toast('success', 'circle-check', 'Spanish re-enabled', 'Writers can create Spanish versions again. Drafts can be published.')]
|
||||
return dlgs[x] ? dlgs[x]() : null
|
||||
}
|
||||
// ---- public URL pattern
|
||||
const puPage = () => {
|
||||
const bad = { unknown: ['https://journal.example.com/{language}/{slug}', 'Unknown placeholder {language}. Use {lang} and {slug}.'], scheme: ['ftp://journal.example.com/{lang}/{slug}', 'Start with https:// or http://.'], encoded: ['https://journal.example.com/my pages/{lang}/{slug}', 'Remove spaces and characters that need encoding. Use letters, digits, - and _.'], invalidHost: ['https://journal..example.com/{lang}/{slug}', 'Enter a valid host name, such as journal.example.com.'], noSlug: ['https://journal.example.com/{lang}', 'Add {slug} so each article has its own address.'] }[x]
|
||||
const ro = x === 'viewOnly', unset = x === 'unset', val = bad ? bad[0] : unset ? '' : x === 'editing' ? 'https://journal.example.com/{lang}/articles/{slug}' : 'https://journal.example.com/{lang}/{slug}'
|
||||
const prev = bad ? null : unset ? null : x === 'editing' ? 'https://journal.example.com/en/articles/how-we-price-a-bathroom-renovation' : 'https://journal.example.com/en/how-we-price-a-bathroom-renovation'
|
||||
return Page('Public URL', 'Where Journal articles appear on your website. Used for canonical and alternate links.', null,
|
||||
ro && Alert('neutral', 'lock', 'Only owners can change this', 'You can read the pattern. Ask an owner to change it.'),
|
||||
unset && Alert('info', 'info', 'Public URL not configured', 'Page, canonical and alternate addresses stay hidden until you add a pattern. Links by article ID still work.'),
|
||||
Card(Fd('Public URL pattern', val || 'https://example.com/{lang}/{slug}', { mono: true, val: !!val, ro, focus: !!bad || x === 'editing', err: bad && bad[1], help: bad ? null : 'Placeholders: {lang} is the language code, such as fr or pt-BR. {slug} is the article address in that language.' }),
|
||||
prev && D('display:flex;flex-direction:column;gap:4px', S('font-size:12px;' + MU, 'Preview with "How we price a bathroom renovation" in English, the default language'), S(mono + ';' + HL + ';overflow-wrap:anywhere', prev), S('font-size:12px;' + MU, 'The preview uses the most recently updated article that has a version in the default language.')),
|
||||
!ro && D('display:flex;gap:8px', Btn('Save', 'primary', { dis: !!bad || unset }), Btn('Cancel'))))
|
||||
}
|
||||
// ---- setup
|
||||
const SUV = FX.SETUP, SUN = SUV.reduce((a, r) => a + r.n, 0)
|
||||
const suVal = (r, chosen) => r.name ? r.name + ' · ' + r.tag : chosen ? 'English · en' : 'Choose a language'
|
||||
const suBody = () => {
|
||||
if (x === 'empty') return Page('Languages', 'Content languages for Gallery.', null, Card(S('font-weight:600;' + HL, 'Confirm the default language'), S(TO, 'Gallery has no articles yet. New articles will be written in this language.'), Fd('Default content language', 'English (United Kingdom) · en-GB', { sel: true, help: 'Seeded from site locale en-GB. The full locale is kept. Confirm or change it.' }), D('display:flex', Btn('Save', 'primary'))))
|
||||
const done = x === 'done', busy = x === 'applying', fail = x === 'fail', prog = x === 'inProgress', nol = x === 'noLocale', chosen = x !== 'ready' && !nol, rowsSrc = nol ? SUV.filter(r => !r.name) : SUV, N = rowsSrc.reduce((a, r) => a + r.n, 0)
|
||||
return Page('Set article languages', 'Existing articles don\'t have a language yet. Confirm or change the language for each site.', null,
|
||||
done ? Alert('success', 'circle-check', 'Languages assigned', SUN + ' articles now have a language. Nothing was translated, and no articles were grouped.', Btn('Open Articles', 'secondary', { sm: true })) :
|
||||
fail ? Alert('error', 'circle-x', 'Nothing was changed', 'The update failed, so no article has a language yet. Your choices are kept. Try again. Running it twice is safe.', Btn('Try again', 'secondary', { sm: true })) :
|
||||
prog ? Alert('info', 'loader-circle', 'Setup is already running', 'Another owner started it. Languages appear here when it finishes. Articles created meanwhile get each site\'s default language. Starting it again is safe: sites that already have a language are skipped.', Btn('Check again', 'secondary', { sm: true })) :
|
||||
busy ? Alert('info', 'loader-circle', 'Assigning languages', 'Articles created while this runs get the site\'s default language.') :
|
||||
nol ? Alert('error', 'circle-x', 'Choose a language for Notes', 'Notes has no site locale, so there is nothing to start from. Choose the language its articles are written in.') :
|
||||
Alert('info', 'info', 'This labels articles. It doesn\'t translate them.', 'Each existing article becomes the only version of its own article group. Each site\'s language starts from its locale with the region kept: pt-BR stays Portuguese (Brazil). Nothing is applied until you confirm.'),
|
||||
D('background:var(--ui-bg);border:1px solid var(--ui-border)', ...rowsSrc.map(r => D(ROW + ';' + (m ? 'flex-wrap:wrap' : ''), D('flex:1;min-width:140px;display:flex;flex-direction:column', S('font-weight:600;' + HL, r.site), S(MU + ';font-size:12px', r.n + ' articles · ' + r.note)), D('width:' + (m ? '100%' : '300px'), Fd('', suVal(r, chosen), { sel: !done && !prog, val: !!(r.name || chosen), err: nol && !r.name ? 'Required before you can apply.' : null, ro: prog, help: done || nol ? null : r.name ? 'Seeded from site locale ' + r.locale + '. Confirm or change.' : chosen ? null : 'Required before you can apply.' }))))),
|
||||
!done && D('display:flex;gap:8px;align-items:center;flex-wrap:wrap', Btn(busy ? 'Assigning…' : 'Confirm and assign to ' + N + ' articles', 'primary', { busy, dis: busy || prog || x === 'ready' || nol }), S(MU + ';font-size:12px', 'Counts are articles in ' + rowsSrc.length + (rowsSrc.length === 1 ? ' site.' : ' sites.'))))
|
||||
}
|
||||
// ---- article list
|
||||
const LN = LNm
|
||||
const Pager = (V) => {
|
||||
const pb = m ? 44 : 32
|
||||
const edge = (n, al, dis) => A('span', { role: 'button', 'aria-label': al, 'aria-disabled': dis || undefined }, 'width:' + pb + 'px;height:' + pb + '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:' + (dis ? 0.4 : 1), Ic(n))
|
||||
const first = V.page === 1, last = V.page === V.pages
|
||||
const set = new Set([1, V.pages, V.page - 1, V.page, V.page + 1].filter(n => n >= 1 && n <= V.pages))
|
||||
const arr = V.pages <= 7 ? Array.from({ length: V.pages }, (_, i) => i + 1) : [...set].sort((p, q) => p - q)
|
||||
const nums = []
|
||||
arr.forEach((n, i) => { if (i && n - arr[i - 1] > 1) nums.push(S('min-width:' + pb + 'px;height:' + pb + 'px;display:flex;align-items:center;justify-content:center;' + MU, '…')); const cur = n === V.page; nums.push(A('span', { role: 'button', 'aria-label': 'Page ' + n, 'aria-current': cur ? 'page' : undefined }, 'min-width:' + pb + 'px;height:' + pb + '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 summary = A('span', { 'aria-live': 'polite' }, (m ? 'flex:0 1 auto;' : 'flex:1;') + MU, 'Showing ' + V.from + '–' + V.to + ' of ' + V.total)
|
||||
const size = A('span', { role: 'combobox', 'aria-label': 'Rows per page', 'aria-expanded': false }, 'height:' + pb + 'px;padding:0 8px 0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);flex-shrink:0;' + HL, V.size + ' per page', Ic('chevron-down', 14))
|
||||
if (!m) return D('flex-shrink:0;display:flex;align-items:center;gap:16px;padding:12px 0 0', summary, size, V.pages > 1 && A('nav', { 'aria-label': 'Pagination' }, 'display:flex;align-items:center;gap:4px;justify-content:flex-end', edge('chevrons-left', 'First page', first), edge('chevron-left', 'Previous page', first), ...nums, edge('chevron-right', 'Next page', last), edge('chevrons-right', 'Last page', last)))
|
||||
const mb = (lab, ico, al, dis, right) => A('span', { role: 'button', 'aria-label': al, 'aria-disabled': dis || undefined }, '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:' + (dis ? 0.4 : 1), !right && Ic(ico), lab, right && Ic(ico))
|
||||
return D('flex-shrink:0;display:flex;flex-direction:column;gap:12px;padding:12px 0 16px', D('display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:space-between', summary, size), V.pages > 1 && A('nav', { 'aria-label': 'Pagination' }, 'display:flex;align-items:center;gap:8px', mb('Previous', 'chevron-left', 'Previous page', first), S('flex:0 0 auto;padding:0 4px;white-space:nowrap;font-weight:500;' + HL, 'Page ' + V.page + ' of ' + V.pages), mb('Next', 'chevron-right', 'Next page', last, true)))
|
||||
}
|
||||
const Chip = l => D('white-space:nowrap;width:max-content;flex-shrink:0;display:inline-flex;align-items:center;gap:6px;height:' + (m ? 36 : 24) + 'px;padding:0 8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);' + HL + ';font-size:12px', l, A('span', { role: 'button', 'aria-label': 'Remove filter ' + l }, 'display:inline-flex', Ic('x', 14)))
|
||||
const ChipC = (c, k) => A('span', { 'aria-label': (LN[c.k] || c.k) + ': ' + (c.st === 'pub' ? 'published' : c.st === 'draft' ? 'draft' : 'no version') + (c.nr ? ', needs review' : '') + (c.off ? ', language disabled' : '') }, 'white-space:nowrap;display:inline-flex;align-items:center;gap:4px;height:22px;padding:0 6px;font:500 12px \'IBM Plex Mono\',monospace;' + (c.st ? 'border:1px solid var(--ui-border-accented);' + HL : 'border:1px dashed var(--ui-border-accented);' + MU) + (c.off ? ';opacity:.75' : ''), c.k.toUpperCase(), c.st === 'pub' ? Ic('circle-check', 12) : c.st === 'draft' ? Ic('pencil', 12) : Ic('plus', 12), !!c.nr && Ic('refresh-cw', 12))
|
||||
const Cov = (cs) => { const Lc = m || nr ? 4 : 5, vis = cs.slice(0, Lc), more = cs.slice(Lc); return D('display:flex;gap:6px;align-items:center;flex-wrap:' + (m ? 'wrap' : 'nowrap'), ...vis.map(ChipC), more.length ? (m ? (x0 => D('display:inline-flex;align-items:center;height:44px;margin:-11px 0', x0)) : (x0 => x0))(A('span', { role: 'button', 'aria-label': more.length + ' more languages: ' + more.map(c => LN[c.k] || c.k).join(', ') }, 'display:inline-flex;align-items:center;height:22px;padding:0 6px;font:500 12px \'IBM Plex Mono\',monospace;background:var(--ui-bg-elevated);' + HL, '+' + more.length)) : null) }
|
||||
const covW = m ? '' : (nr ? 'width:330px;' : 'width:430px;')
|
||||
const RowMenuBtn = () => Btn('', 'ghost', { ic: 'ellipsis', sm: true, al: 'Row actions' })
|
||||
const liPage = () => {
|
||||
const C = { page2: { page: 2 }, fr: { lang: 'fr' }, frMissing: { lang: 'fr', tab: 'missing' }, emptyMissing: { site: 'press', lang: 'fr', tab: 'missing' }, noResults: { q: 'zinc' }, frLoading: { lang: 'fr', load: 1 }, listFail: { fail: 1 }, limited: { lim: 1 }, filters: { lang: 'fr', status: 'draft' }, tabsScroll: { lang: 'fr', tab: 'missing', shift: 1 }, long: { site: 'atelier', status: 'draft', seo: 1 }, author: { author: 'Marta Alves' }, review: { tab: 'review' }, frReview: { lang: 'fr', tab: 'review' }, langFilter: { menu: 1 }, rowMenu: { row: 1 }, menuLang: { lang: 'fr' }, apiCopy: { lang: 'fr' }, single: { site: 'archive' }, singleNoResults: { site: 'archive', q: 'zinc' }, singleLoading: { site: 'archive', load: 1 }, singleFail: { site: 'archive', fail: 1 }, singleEmpty: { site: 'gallery' }, singleMenu: { site: 'archive' }, singleDraft: { site: 'archive', status: 'draft' }, ...(x === 'home' ? {} : {}) }[x] || {}
|
||||
const site = FX.SITES[C.site || 'journal'], lim = !!C.lim, single = site.langs.length <= 1
|
||||
const V = FX.view({ site, lang: C.lang, tab: C.tab, status: C.status, author: C.author, seo: C.seo, q: C.q, page: C.page, read: lim ? ['en'] : null, create: lim ? [] : null, scope: lim ? (s0 => ['Camille Roche', 'Luis Ferrer'].includes(s0.by)) : null })
|
||||
const tabs = (C.load || C.fail) ? V.tabs.map(q => [q[0], '–', q[2]]) : V.tabs, tabOn = Math.max(0, V.tabs.findIndex(q => q[2] === V.tab))
|
||||
const act = [C.status && 'Status: Draft', C.author && 'Author: ' + C.author, C.seo && 'SEO: Needs attention'].filter(Boolean)
|
||||
const chips = act.length ? ['Site: ' + site.name].concat(C.lang ? ['Language: ' + LN[C.lang]] : [], act) : []
|
||||
const nF = chips.length
|
||||
const filt = () => D('display:flex;gap:8px;flex-wrap:wrap;align-items:center', D('flex:1;min-width:' + (m ? '0' : '200px'), Fd('', C.q ? C.q : C.lang ? 'Search ' + LN[C.lang] + ' titles' : 'Search titles in every language', { val: !!C.q })),
|
||||
m ? Btn('Filters' + (nF ? ' · ' + nF : ''), 'secondary', { ic: 'sliders-horizontal', al: nF ? 'Filters, ' + nF + ' active' : 'Filters' }) : [Fd('', site.name, { sel: true }), single ? null : Fd('', C.lang ? LN[C.lang] : 'All languages', { sel: true }), Fd('', C.status ? 'Draft' : 'Any status', { sel: true }), Fd('', C.author || 'Any author', { sel: true, focus: x === 'author' }), Fd('', C.seo ? 'Needs attention' : 'Any SEO state', { sel: true })].filter(Boolean).map(e => D('flex:0 0 auto;width:' + (nr ? 128 : 150) + 'px', e)))
|
||||
const chipRow = chips.length ? D('display:flex;gap:6px;flex-wrap:wrap;align-items:center', ...chips.map(Chip), Btn('Clear all', 'ghost', { sm: !m })) : null
|
||||
const shift = C.shift ? -96 : 0
|
||||
const tabRow = D('position:relative;border-bottom:1px solid var(--ui-border);display:flex;align-items:center;gap:4px', m && Btn('', 'ghost', { ic: 'chevron-left', sm: true, al: 'Scroll tabs left', dis: !shift }),
|
||||
A('div', { role: 'tablist', 'aria-label': 'Article views' }, 'flex:1;min-width:0;overflow:hidden;display:flex;position:relative', D('display:flex;gap:' + (m ? 16 : 24) + 'px;flex-shrink:0;transform:translateX(' + (m ? shift : 0) + 'px)', ...tabs.map((q, i) => A('div', { role: 'tab', 'aria-selected': i === tabOn }, 'padding:' + (m ? '12px 0' : '8px 0') + ';white-space:nowrap;font-weight:' + (i === tabOn ? 600 : 500) + ';color:' + (i === tabOn ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)') + ';border-bottom:2px solid ' + (i === tabOn ? 'var(--ui-primary)' : 'transparent'), q[0] + ' ', S(MU + ';font-weight:400', q[1])))), m && D('position:absolute;right:0;top:0;bottom:0;width:28px;background:linear-gradient(90deg,transparent,var(--ui-bg))'), m && !!shift && D('position:absolute;left:0;top:0;bottom:0;width:28px;background:linear-gradient(270deg,transparent,var(--ui-bg))')), m && Btn('', 'ghost', { ic: 'chevron-right', sm: true, al: 'Scroll tabs right' }))
|
||||
const card = 'background:var(--ui-bg);border:1px solid var(--ui-border)', hd = !m && D('display:flex;gap:12px;padding:8px 16px;background:var(--ui-bg-elevated);' + MU + ';font-weight:500', S('flex:1', 'Article'), S(covW + 'flex-shrink:0', 'Languages'), S('width:110px', 'Author'), S('width:80px', 'Updated'), S('width:28px'))
|
||||
const hdS = !m && D('display:flex;gap:12px;padding:8px 16px;background:var(--ui-bg-elevated);' + MU + ';font-weight:500', S('flex:1', 'Article'), S('width:110px', 'Status'), S('width:110px', 'Author'), S('width:80px', 'Updated'), S('width:28px'))
|
||||
const SingleRow = (r) => D(ROW + ';' + (m ? 'flex-wrap:wrap;gap:6px 12px' : ''), D('flex:1 1 ' + (m ? '70%' : '0') + ';min-width:0;display:flex;flex-direction:column;gap:2px', A('span', { lang: r.rep }, HL + ';font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', FX.tt(r.s, r.rep)), m && S(MU + ';font-size:12px', r.s.by + ' · ' + r.s.upd)), D((m ? 'order:3' : 'width:110px'), Pub(r.s.v[r.rep][0])), !m && [S(MU + ';width:110px', r.s.by), S(MU + ';width:80px', r.s.upd)], D(m ? 'order:1' : '', RowMenuBtn()))
|
||||
const creat = lim ? [] : site.langs
|
||||
const StoryRow = (r) => { const s0 = r.s, cs = FX.chips(s0, site, creat), t = FX.tt(s0, r.rep)
|
||||
return D(ROW + ';' + (m ? 'flex-wrap:wrap;gap:6px 12px' : ''), D('flex:1 1 ' + (m ? '70%' : '0') + ';min-width:0;display:flex;flex-direction:column;gap:2px', A('span', { lang: r.rep }, HL + ';font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', t), r.rep !== site.def && S(MU + ';font-size:12px', 'Shown in ' + (LN[r.rep] || r.rep) + (site.disabled.includes(r.rep) ? ' · language disabled' : ''))), m && D('order:2;flex:1 1 100%', Cov(cs)), !m && D(covW + 'flex-shrink:0', Cov(cs)), m ? S('order:3;' + MU + ';font-size:12px;flex:1', s0.by + ' · ' + s0.upd) : [S(MU + ';width:110px', s0.by), S(MU + ';width:80px', s0.upd)], D(m ? 'order:1' : '', RowMenuBtn())) }
|
||||
const VerRow = (r) => { const q = r.s.v[C.lang]; return D(ROW + ';' + (m ? 'flex-wrap:wrap' : ''), D('flex:1 1 ' + (m ? '70%' : '0') + ';min-width:0;display:flex;flex-direction:column;gap:2px', A('span', { lang: C.lang }, HL + ';font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', FX.tt(r.s, C.lang)), m && S(MU + ';font-size:12px', r.s.by + ' · ' + r.s.upd)), D('display:flex;gap:6px', Pub(q[0]), q[1] ? Fr('Needs review') : null), !m && [S(MU + ';width:110px;margin-left:12px', r.s.by), S(MU + ';width:80px', r.s.upd)], RowMenuBtn()) }
|
||||
const MissRow = (r) => D(ROW + ';' + (m ? 'flex-wrap:wrap' : ''), D('flex:1 1 0;min-width:0;display:flex;flex-direction:column;gap:2px', S(HL + ';font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', FX.tt(r.s, r.rep)), S(MU + ';font-size:12px', 'Opens ' + LN[r.rep] + ' · ' + (r.s.v[r.rep][0] === 'pub' ? 'published' : 'draft') + (site.disabled.includes(r.rep) ? ' · language disabled' : ''))), Btn(m ? '' : 'Add ' + LN[C.lang] + ' translation', 'secondary', { sm: true, ic: 'plus', al: 'Add ' + LN[C.lang] + ' translation to ' + FX.tt(r.s, r.rep) }), RowMenuBtn())
|
||||
let body
|
||||
if (C.fail) body = Alert('error', 'circle-x', 'Couldn\'t load articles', 'Your filters and page are kept. Try again.', Btn('Try again', 'secondary', { sm: true }))
|
||||
else if (C.load) body = Sk(5)
|
||||
else if (!V.total && C.lang && V.tab === 'missing') body = D('padding:48px 16px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;' + card, Ic('circle-check', 28), S('font-weight:600;' + HL, 'Every article you can see has a ' + LN[C.lang] + ' version'), S(TO + ';max-width:360px', 'Articles without a ' + LN[C.lang] + ' version will appear here.'))
|
||||
else if (!V.total && single && !C.q) body = D('padding:48px 16px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;' + card, Ic('file-text', 28), S('font-weight:600;' + HL, 'No articles yet'), S(TO + ';max-width:360px', 'Write your first one. It stays a draft until you publish it.'), Btn('New article', 'primary', { ic: 'plus' }))
|
||||
else if (!V.total) body = D('padding:48px 16px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;' + card, Ic('search', 28), S('font-weight:600;' + HL, 'No articles match "' + (C.q || '') + '"'), S(TO, 'Search covers titles in every language you can read.'), Btn('Clear search', 'secondary', { sm: true }))
|
||||
else body = D(card + ';min-height:0;overflow:auto;flex:0 1 auto', C.lang && V.tab === 'missing' ? null : (C.lang ? null : single ? hdS : hd), ...V.rows.map(C.lang ? (V.tab === 'missing' ? MissRow : VerRow) : single ? SingleRow : StoryRow))
|
||||
const unit = C.fail || C.load ? '' : V.unit
|
||||
return D('flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;gap:' + (m ? 12 : 16) + 'px;padding:' + (m ? '16px' : '24px') + ';position:relative', D('display:flex;align-items:baseline;gap:12px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;' + HL, 'Articles'), A('span', { 'aria-live': 'polite' }, 'flex:1;' + MU + ';font-weight:500', unit), Btn(m ? '' : 'New article', 'primary', { ic: 'plus', al: 'New article' })),
|
||||
lim && Alert('neutral', 'lock', 'Showing only what you can read', 'Languages, counts and missing-translation results reflect the versions you have permission to see. You can\'t create translations on this site.'), filt(), chipRow, single ? null : tabRow, body, (V.total && !C.fail && !C.load) ? Pager(V) : null)
|
||||
}
|
||||
const sheet = (o) => D('position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'center') + ';justify-content:center', A('div', { role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Filters' }, 'width:100%;max-width:' + (m ? '100%' : '420px') + ';max-height:92%;background:var(--ui-bg);display:flex;flex-direction:column', D('display:flex;justify-content:center;padding:8px 0 0', S('width:36px;height:4px;background:var(--ui-border-accented)')),
|
||||
D('padding:16px 16px 0;display:flex;align-items:center', S('flex:1;font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + HL, 'Filters'), Btn('', 'ghost', { ic: 'x', al: 'Close filters' })),
|
||||
D('padding:16px;display:flex;flex-direction:column;gap:14px;overflow-y:auto;min-height:0', Fd('Site', 'Journal', { sel: true }), Fd('Language', o.lang || 'All languages', { sel: true }), Fd('Status', o.status || 'Any status', { sel: true }), Fd('Author', o.author || 'Any author', { sel: true, focus: !!o.author }), Fd('SEO', 'Any SEO state', { sel: true })),
|
||||
D('padding:12px 16px;border-top:1px solid var(--ui-border);display:flex;gap:8px', Btn('Clear all', 'secondary'), D('flex:1;display:flex', Btn('Show ' + o.n + ' results', 'primary', { w: '100%' })))))
|
||||
const listOv = () => {
|
||||
const V1 = FX.view({ site: J, lang: 'fr', status: 'draft' }), V2 = FX.view({ site: J, author: 'Marta Alves' })
|
||||
if (x === 'filters' && m) return [sheet({ lang: 'French', status: 'Draft', n: V1.total })]
|
||||
if (x === 'author' && m) return [sheet({ author: 'Marta Alves', n: V2.total })]
|
||||
if (x === 'author') return [D('position:absolute;z-index:40;left:' + (nr ? 500 : 1018) + 'px;top:170px;width:240px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop)', A('div', { role: 'listbox', 'aria-label': 'Author' }, 'display:flex;flex-direction:column;padding:4px 0', ...[['Any author', J.stories.length]].concat(FX.AUTHORS).map((a, i) => A('div', { role: 'option', 'aria-selected': a[0] === 'Marta Alves' }, 'display:flex;align-items:center;gap:8px;min-height:36px;padding:6px 12px;' + (a[0] === 'Marta Alves' ? 'background:var(--rc-sand);color:var(--rc-on-sand);font-weight:600' : HL), S('width:16px', a[0] === 'Marta Alves' && Ic('check')), S('flex:1', a[0]), S(mono + ';' + (a[0] === 'Marta Alves' ? '' : MU), a[1])))))]
|
||||
const langRows = (grp) => (grp ? J.disabled : J.langs).map(k => D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 48 : 36) + 'px;padding:6px 12px;' + (grp ? MU : HL), S('width:16px'), S('flex:1', LN[k]), S(mono + ';' + MU, FX.count(k, J.stories)[0] + ' versions')))
|
||||
if (x === 'langFilter') return [D('position:absolute;z-index:40;' + (m ? 'left:0;right:0;bottom:0;border-top:1px solid var(--ui-border)' : 'left:' + (nr ? 330 : 792) + 'px;top:170px;width:280px;border:1px solid var(--ui-border)') + ';background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);display:flex;flex-direction:column;max-height:70%', A('div', { role: 'listbox', 'aria-label': 'Language' }, 'display:flex;flex-direction:column;overflow-y:auto;padding:4px 0', D('padding:4px 12px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;' + MU, 'Enabled'), D('display:flex;align-items:center;min-height:' + (m ? 48 : 36) + 'px;padding:6px 12px;' + HL, S('width:16px'), 'All languages'), ...langRows(0), D('padding:8px 12px 4px;border-top:1px solid var(--ui-border);font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;' + MU, 'Disabled'), ...langRows(1), D('padding:6px 12px 8px;font-size:12px;' + MU, 'Disabled languages keep their versions. They can\'t get new ones.')))]
|
||||
if (x === 'singleMenu') return [OvMenu([{ l: 'Open', ic: 'file-pen' }, { l: 'Unpublish', ic: 'eye-off' }, { l: 'Copy API URL', ic: 'copy' }, { l: 'Delete article', ic: 'trash-2', danger: 1 }], 272)]
|
||||
if (x === 'menuLang') return [OvMenu([{ l: 'Open French', ic: 'file-pen' }, { l: 'Open another language…', ic: 'languages' }, { l: 'Publish French', ic: 'globe' }, { l: 'Copy API URL', ic: 'copy' }, { l: 'Delete French version', ic: 'trash-2', danger: 1 }], 272)]
|
||||
if (x === 'apiCopy') return [Toast('success', 'circle-check', 'API URL copied', '/v1/article/14?lang=fr')]
|
||||
if (x === 'deleteStoryDone') return [Toast('success', 'circle-check', 'Article deleted', 'The article and all of its translations were deleted.')]
|
||||
if (x === 'rowMenu') return [OvMenu([{ l: 'Open English', ic: 'file-pen' }, { l: 'Open another language…', ic: 'languages' }, { l: 'Add translation…', ic: 'plus' }, { l: 'Unpublish English', ic: 'eye-off' }, { l: 'Copy API URL', ic: 'copy' }, { l: 'Delete English version', ic: 'trash-2', danger: 1 }])]
|
||||
return []
|
||||
}
|
||||
// ---- new article
|
||||
const naOv = () => {
|
||||
const multi = x !== 'single'
|
||||
return Dlg({ title: 'New article', desc: x === 'translation' ? 'Create a translation of an article you can read.' : 'Give the article a title. You can write the rest in the editor.' }, [
|
||||
x === 'translation' ? [D('display:flex;flex-direction:column;gap:8px', Radio('New article', 'Starts an article in this language.', false), Radio('Translation of an existing article', 'Uses the same rules as Add translation.', true)), Fd('Article', 'Roof inspection checklist', { sel: true, help: 'No French version yet.' }), Fd('Language', 'French', { sel: true, help: 'Only enabled languages this article doesn\'t have yet.' }), Fd('Source version', 'English · published', { sel: true, help: 'You choose which version to copy from. It is also the source tracked for review.' }), Fd('Title in French', 'Liste de contrôle pour l\'inspection de la toiture', { focus: true, lang: 'fr' }), D('display:flex;flex-direction:column;gap:8px', S('font-weight:500;' + HL, 'Starting content'), Radio('Start empty', 'Only the title. Freshness shows No source.', false), Radio('Copy from English', 'Copies body, excerpt, search and social text, image alternative text and authors. The version starts as Needs review.', true))] :
|
||||
[Fd('Title', x === 'sitechange' ? 'Spring newsletter' : 'Title', { val: x === 'sitechange', focus: x !== 'sitechange', help: '0 / 200' }), Fd('Site', x === 'sitechange' ? 'Notes' : x === 'single' ? 'Archive' : 'Journal', { sel: true }),
|
||||
multi && Fd('Language', 'English', { sel: true, help: x === 'sitechange' ? 'Notes only offers English, so the language was reset from French.' : 'Default language for Journal.' }),
|
||||
multi && D('display:flex;flex-direction:column;gap:8px', Radio('New article', 'Starts an article in this language.', true), Radio('Translation of an existing article', 'Uses the same rules as Add translation.', false))]
|
||||
], [Btn('Cancel'), Btn(x === 'translation' ? 'Create French draft' : 'Create article', 'primary')])
|
||||
}
|
||||
// ---- add translation
|
||||
const FRT = S1.tr.fr, FRT5 = 'Liste de contrôle pour l\'inspection de la toiture'
|
||||
const LS = st => LV('en', 'English', 'EN', st)
|
||||
const atOv = () => {
|
||||
const empty = x === 'empty', long = x === 'long', dupCase = x === 'dup' || x === 'dupHidden', st = dupCase ? S1 : S5, ft = dupCase ? FRT : FRT5
|
||||
const dis = ['gone', 'unreadable', 'langOff', 'dup', 'dupHidden', 'saving'].includes(x)
|
||||
const f = [Fd('Language', 'French', { sel: true, ro: x === 'langOff', err: x === 'langOff' ? 'French was disabled on this site.' : null }), Fd('Source version', empty ? 'No source' : (x === 'unreadable' || x === 'gone') ? 'Choose a version' : 'English · published', { sel: true, val: !empty && x !== 'unreadable' && x !== 'gone', err: (x === 'unreadable' || x === 'gone') ? 'Choose a version you can read.' : null, help: (x === 'unreadable' || x === 'gone') ? null : empty ? 'Optional. Freshness shows No source until you pick a tracked source in the editor.' : 'You choose which version to copy from. It is also the source tracked for review. Access is checked again when you create the draft.' }), Fd('Title in French', long ? ft + ' avec les coûts cachés derrière les carreaux, sous la baignoire et autour des canalisations anciennes' : ft, { focus: x === 'form', lang: 'fr', help: 'Copying text is a starting point: it stays in English until you translate it.' }),
|
||||
D('display:flex;flex-direction:column;gap:8px', S('font-weight:500;' + HL, 'Starting content'), Radio('Start empty', 'Only the title. Freshness shows No source until you pick a tracked source.', empty), Radio('Copy from English', 'Copies body, excerpt, search and social text, image alternative text and authors. Images are reused, files aren\'t duplicated. The version starts as Needs review.', !empty))]
|
||||
if (long) f.push(Alert('neutral', 'info', 'Authors', 'Marta Alves, Camille Roche, Luis Ferrer, Ana Pereira, Jonas Weber and 4 more will be copied. You can change them in the editor.'), Alert('neutral', 'info', 'Images', 'Cover image and 7 inline images are reused. Alternative text is copied and stays in English until you translate it.'))
|
||||
const T = 'Add a translation'
|
||||
if (x === 'dup') return Dlg({ title: T }, [Alert('error', 'circle-x', 'French already exists for this article', 'Open it to continue. Your entries are kept here.', Btn('Open French version', 'secondary', { sm: true })), ...f], [Btn('Cancel'), Btn('Create French draft', 'primary', { dis: true })])
|
||||
if (x === 'dupHidden') return Dlg({ title: T }, [Alert('error', 'circle-x', 'French can\'t be created for this article', 'Try a different language, or ask an owner. Your entries are kept.'), ...f], [Btn('Cancel'), Btn('Create French draft', 'primary', { dis: true })])
|
||||
if (x === 'fail') return Dlg({ title: T }, [Alert('error', 'circle-x', 'Couldn\'t create the translation', 'Your entries are kept. Try again.'), ...f], [Btn('Cancel'), Btn('Try again', 'primary')])
|
||||
if (x === 'gone') return Dlg({ title: T }, [Alert('warning', 'triangle-alert', 'The source isn\'t available to you', 'Choose another source you can read, or start empty. Your title is kept.'), ...f.slice(0, 1), f[1], f[2]], [Btn('Cancel'), Btn('Create French draft', 'primary', { dis: true })])
|
||||
if (x === 'unreadable') return Dlg({ title: T }, [Alert('warning', 'triangle-alert', 'The source isn\'t available to you', 'Choose another source you can read, or start empty. Your title is kept.'), ...f.slice(0, 1), f[1], f[2]], [Btn('Cancel'), Btn('Create French draft', 'primary', { dis: true })])
|
||||
if (x === 'langOff') return Dlg({ title: T }, [Alert('warning', 'triangle-alert', 'French was disabled', 'French was disabled on this site while you were filling this in. Ask an owner to re-enable it, or choose another language. Your entries are kept.'), ...f], [Btn('Cancel'), Btn('Create French draft', 'primary', { dis: true })])
|
||||
if (x === 'saving') return Dlg({ title: T }, f, [Btn('Cancel', 'secondary', { dis: true }), Btn('Creating…', 'primary', { busy: true })])
|
||||
return Dlg({ title: T, desc: 'Creates a French draft of "' + st.title + '". It isn\'t published.', top: long ? 24 : 60 }, f, [Btn('Cancel'), Btn('Create French draft', 'primary')])
|
||||
}
|
||||
// ---- publish / delete
|
||||
const pdOv = () => ({
|
||||
publish: () => Dlg({ title: 'Publish French?', desc: 'Only the French version goes online. English and German aren\'t affected.' }, [], [Btn('Cancel'), Btn('Publish French', 'primary')]),
|
||||
publishDisabled: () => [],
|
||||
unpublish: () => Dlg({ title: 'Unpublish French?', desc: 'The French version goes back to draft and stops being public. English stays online.' }, [], [Btn('Cancel'), Btn('Unpublish French', 'primary')]),
|
||||
unpublishDirty: () => Dlg({ title: 'Unpublish French?', desc: 'The saved French version goes offline and becomes a draft. English stays online.' }, [Alert('neutral', 'info', 'Your edits stay in the editor', 'Your unsaved changes are kept here. Nothing is saved when you unpublish. Save them yourself when you are ready.')], [Btn('Cancel'), Btn('Unpublish French', 'primary')]),
|
||||
delete: () => Dlg({ title: 'Delete the French version?', desc: '"' + FRT + '" will be deleted. Other versions stay.' }, [], [Btn('Cancel'), Btn('Delete French version', 'danger')]),
|
||||
deleteSource: () => Dlg({ title: 'Delete the English version?', desc: 'English is the tracked source for versions you can read.' }, [Alert('warning', 'triangle-alert', 'Review tracking will pause', 'French, German and Arabic track English. They stay as they are and keep their review history. Each one asks you to choose a new source.')], [Btn('Cancel'), Btn('Delete English version', 'danger')]),
|
||||
deleteStoryOk: () => Dlg({ title: 'Delete English, French, German and Arabic?', desc: 'The article and all of its translations will be deleted. This can\'t be undone.' }, [], [Btn('Cancel'), Btn('Delete article', 'danger')]),
|
||||
deleteLast: () => Dlg({ title: 'Delete the only version?', desc: 'English is the only version of this article. Deleting it deletes the article.' }, [], [Btn('Cancel'), Btn('Delete article', 'danger')]),
|
||||
saveAndPublish: () => Dlg({ title: 'Save and publish French?', desc: 'Your unsaved changes to the French version are saved first, then only the French version goes online. English and German aren\'t affected.' }, [], [Btn('Cancel'), Btn('Save and publish French', 'primary')]),
|
||||
saveFailPublish: () => [Toast('error', 'circle-x', 'Couldn\'t save French', 'Nothing was published. Your changes are kept. Try again.')],
|
||||
deleteStory: () => Dlg({ title: 'You can\'t delete this article', desc: 'Deleting an article needs delete permission on every version. You can still delete the version you are editing.' }, [], [Btn('Close'), Btn('Delete French version', 'danger')]),
|
||||
fail: () => [Toast('error', 'circle-x', 'Couldn\'t publish French', 'Nothing changed. Try again.')],
|
||||
done: () => [Toast('success', 'circle-check', 'French published', 'Other versions weren\'t changed.')],
|
||||
deleteDone: () => [Toast('success', 'circle-check', 'French version deleted', '"' + FRT + '" was deleted. English and German weren\'t changed.')],
|
||||
deleteStoryFail: () => Dlg({ title: 'Delete English, French, German and Arabic?', desc: 'The article and all of its translations will be deleted. This can\'t be undone.' }, [Alert('error', 'circle-x', 'Couldn\'t delete the article', 'Nothing was deleted. Every version is still there. Try again in a moment.')], [Btn('Cancel'), Btn('Try again', 'danger')])
|
||||
}[x]?.())
|
||||
// ---- freshness and review (release 2)
|
||||
const frOv = () => ({
|
||||
review: () => Dlg({ title: 'Mark French as reviewed?', desc: 'This records that the French version matches the English source. It doesn\'t change publication.' }, [Alert('neutral', 'info', 'Changed fields: Title, Body', 'Authors and the cover image don\'t count.'), D('display:grid;grid-template-columns:auto 1fr;gap:6px 16px;font-size:13px', S(MU, 'Source'), S(HL, 'English version saved 14 Oct 2026, 09:12'), S(MU, 'Translation'), S(HL, 'French version saved 15 Oct 2026, 16:40'))], [Btn('Cancel'), Btn('Mark as reviewed', 'primary')]),
|
||||
changed: () => Dlg({ title: 'Review not recorded' }, [Alert('warning', 'triangle-alert', 'The English source changed while you were reviewing. Review again.', 'English was saved again at 14 Oct 2026, 11:20. Check the latest changes first. Nothing was marked.')], [Btn('Cancel'), Btn('Show source reference', 'secondary'), Btn('Review again', 'primary')]),
|
||||
changedTr: () => Dlg({ title: 'Review not recorded' }, [Alert('warning', 'triangle-alert', 'French was changed by someone else while you were reviewing. Review again.', 'Someone saved the French version at 15 Oct 2026, 16:52. Nothing was marked.')], [Btn('Cancel'), Btn('Reload French', 'primary')]),
|
||||
replace: () => Dlg({ title: 'Choose a tracked source', desc: 'Pick a version of this article you can read. French will show Needs review until you review it against the new source.' }, [Radio('English', 'Published', false), Radio('German', 'Published', true), Alert('error', 'circle-x', 'German can\'t be the source', 'German already tracks French as its source. A source can\'t track back to this version.')], [Btn('Cancel'), Btn('Use German as source', 'primary', { dis: true })]),
|
||||
done: () => [Toast('success', 'circle-check', 'French marked as reviewed', 'Reviewed against the English version saved 14 Oct 2026.')],
|
||||
fail: () => [Toast('error', 'circle-x', 'Couldn\'t mark as reviewed', 'Nothing was recorded. Try again.')]
|
||||
}[x]?.())
|
||||
// ---- conflicts, moves, permissions
|
||||
const CfOpt = (t, d, b, k = 'secondary', top, ic) => D((top ? '' : 'border-top:1px solid var(--ui-border);padding-top:14px;') + 'display:flex;' + (m ? 'flex-direction:column;gap:8px' : 'align-items:center;gap:16px'), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', D('display:flex;align-items:center;gap:6px', ic && S('color:var(--ui-error)', Ic(ic)), S('font-weight:600;' + HL, t)), S(MU + ';font-size:13px;line-height:19px;text-wrap:pretty', d)), D((m ? '' : 'width:200px;flex-shrink:0;') + 'display:flex', Btn(b, k, { w: '100%', ic })))
|
||||
const Cf = (o) => Dlg({ title: o.title || 'Someone else saved this version', desc: o.desc || 'Someone else saved this version at ' + (o.t || '14:32') + '. Your changes aren\'t saved. They stay here until you choose.', w: 560, top: 60 }, [o.alert, CfOpt('Keep my changes', 'Nothing is sent. Your changes stay in the editor.', 'Keep my changes', 'primary', 1), ...(o.rc ? [CfOpt('Reload theirs', 'This discards your unsaved changes.', 'Reload and discard my changes')] : [CfOpt('Download my version', 'Saves your title and body as a text file. Nothing is sent.', 'Download my version'), CfOpt('Reload theirs', 'Shows their version. You confirm first.', 'Reload theirs'), CfOpt('Save over theirs', 'Replaces the title, body and search fields of this version only. Other languages are untouched.', 'Save over theirs', 'dangerOutline', 0, 'triangle-alert')])], [])
|
||||
const VList = [['English', 'Published'], ['French', 'Draft'], ['German', 'Published'], ['Arabic', 'Draft · Read only']]
|
||||
const moveNote = (hidden) => D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;' + HL, 'Moves all language versions of this article.'), ...VList.filter(r => !(hidden && r[0] === 'Arabic')).map(r => S(TO, r[0] + ' · ' + r[1])), hidden && S(MU + ';font-size:12px', 'Versions you can\'t read aren\'t listed or counted.'))
|
||||
const mvPanel = (hidden) => D('position:absolute;z-index:40;' + (m ? 'left:0;right:0;bottom:0;border-top:1px solid var(--ui-border)' : 'right:24px;top:215px;width:340px;border:1px solid var(--ui-border)') + ';background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);padding:16px;display:flex;flex-direction:column;gap:12px', S('font-weight:600;' + HL, 'Site'), D('display:flex;flex-direction:column;gap:0;border:1px solid var(--ui-border)', ...['Journal', 'Atelier', 'Archive', 'Notes'].map(k => D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 48 : 36) + 'px;padding:6px 12px;' + (k === 'Journal' ? 'background:var(--rc-sand);color:var(--rc-on-sand);font-weight:600' : HL), S('width:16px', k === 'Journal' && Ic('check')), k))), moveNote(hidden), D('display:flex;gap:8px', Btn('Cancel'), D('flex:1;display:flex', Btn('Move article…', 'primary', { w: '100%' }))))
|
||||
const mvDlg = (kind) => {
|
||||
const base = { desc: 'This moves every version together. Nothing is split.' }
|
||||
return {
|
||||
moveConfirm: () => Dlg({ title: 'Move to Atelier?', ...base }, [moveNote(false), Alert('neutral', 'info', 'Addresses and links', 'Each version keeps its address. Links from the old site to it will stop working. RenewCMS does not create redirects.')], [Btn('Cancel'), Btn('Move article', 'primary')]),
|
||||
moveBusy: () => Dlg({ title: 'Moving to Atelier…', desc: 'All 4 versions move together. Keep this page open.' }, [Alert('info', 'loader-circle', 'Moving', 'This takes a few seconds.')], [Btn('Cancel', 'secondary', { dis: true }), Btn('Moving…', 'primary', { busy: true })]),
|
||||
move: () => Dlg({ title: 'Move to Notes?', ...base }, [Alert('warning', 'triangle-alert', 'Notes doesn\'t enable every language in this article', 'Notes enables English only. Enable French, German and Arabic on Notes first. Languages disabled on Journal count too. Nothing was moved.')], [Btn('Cancel'), Btn('Move article', 'primary', { dis: true })]),
|
||||
moveSlug: () => Dlg({ title: 'Move to Atelier?', ...base }, [Alert('error', 'circle-x', 'An address is already used on Atelier', 'An address must be unique within its language on Atelier. Change the address below, then move again. Nothing was moved.'), D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;' + HL, 'Conflicting version'), D('display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 12px;border:1px solid var(--ui-border)', S('font-weight:500;' + HL, 'French'), S(mono + ';' + TO + ';flex:1 1 160px;min-width:0;overflow-wrap:anywhere', 'comment-nous-chiffrons-une-renovation'), Btn('Edit slug', 'secondary', { sm: true })), S(MU + ';font-size:12px;line-height:18px', 'Only versions you can read are named.'))], [Btn('Cancel'), Btn('Move article', 'primary', { dis: true })]),
|
||||
moveFail: () => Dlg({ title: 'Move to Atelier?', ...base }, [Alert('error', 'circle-x', 'Couldn\'t move the article', 'Nothing was moved. All 4 versions are still on Journal. Try again.')], [Btn('Cancel'), Btn('Try again', 'primary')]),
|
||||
movePerm: () => Dlg({ title: 'You can\'t move this article' }, [Alert('error', 'circle-x', 'Moving needs edit permission on every version', 'Ask an owner to move it. Nothing was changed.')], [Btn('Close')]),
|
||||
moveDone: () => [Toast('success', 'circle-check', 'Moved to Atelier', 'All 4 versions moved together.')]
|
||||
}[kind]
|
||||
}
|
||||
const pmOv = () => {
|
||||
const mv = mvDlg(x)
|
||||
if (mv) return mv()
|
||||
if (x === 'moveSelect' || x === 'moveSelectHidden') return [mvPanel(x === 'moveSelectHidden')]
|
||||
const dl = { session: () => Dlg({ title: 'Your session expired', desc: 'Log in to continue. Your title, body and search fields for this French version are kept in this browser and restored after you log in, with a notice.' }, [], [Btn('Log in', 'primary')]),
|
||||
conflict: () => Cf({}),
|
||||
conflictRestored: () => Cf({ alert: Alert('info', 'info', 'Restored unsaved changes from 14:18', 'Your session ended. These changes were kept in this browser.') }),
|
||||
reloadConfirm: () => Cf({ title: 'Reload theirs?', desc: 'This discards your unsaved changes.', rc: 1 }),
|
||||
conflict2: () => Cf({ t: '14:41', alert: Alert('error', 'circle-x', 'Their version changed again', 'Someone else saved this version again at 14:41, before your save went through. Nothing was overwritten. Your changes are still here.') }),
|
||||
download: () => [Cf({}), Toast('success', 'circle-check', 'Download started', 'Your French title and body are saved as a text file. Your changes are still in the editor.')],
|
||||
restored: () => [] }
|
||||
return dl[x]?.()
|
||||
}
|
||||
// ---- existing surfaces
|
||||
const sfPage = () => {
|
||||
const mk2 = (t, d, ...c) => Card(S('font-weight:600;' + HL, t), d && S(TO, d), ...c)
|
||||
const RowS = (a, b, c) => D(ROW + ';padding-left:0;padding-right:0', D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S(HL + ';font-weight:500', a), b && S(MU + ';font-size:12px', b)), c)
|
||||
if (x === 'home' || x === 'homeFr') { const fr = x === 'homeFr', V = FX.view({ site: J, read: fr ? ['fr'] : null }); return Page('Home', fr ? 'You can read French versions only.' : 'Welcome back, Marta.', null, mk2('Recently updated', null, ...V.rows.slice(0, 5).map(r => RowS(FX.tt(r.s, r.rep), r.s.by + ' · ' + r.s.upd, r.rep !== J.def ? S(mono + ';border:1px solid var(--ui-border-accented);padding:0 6px;' + HL, r.rep.toUpperCase()) : null))), S(MU + ';font-size:12px', 'Each article appears once, with its default-language title. A language code shows only when the title isn\'t in the default language.')) }
|
||||
if (x === 'activity') return Page('Activity', 'Recent changes in Journal and Archive.', null, mk2('Today and this week', null, ...[['Marta Alves', 'Added French to Journal', 'Just now'], ['Marta Alves', 'Made French the default language of Journal', '20 min ago'], ['Marta Alves', 'Changed the public URL pattern of Journal', '1 h ago'], ['Camille Roche', 'Published French version of "Guide de l\'enduit à la chaux"', '2 h ago'], ['Luis Ferrer', 'Published French version of "Liste d\'hiver pour les fenêtres en bois"', 'Yesterday'], ['Marta Alves', 'Published German version of "So kalkulieren wir eine Badsanierung"', '3 days ago'], ['Luis Ferrer', 'Created German version of "Checkliste für die Dachprüfung"', '14 Oct'], ['Camille Roche', 'Published "Guia de cal e areia"', '14 Oct']].map(r => RowS(r[1], r[0] + ' · ' + r[2]))), S(MU + ';font-size:12px', 'Site settings events name the site and the language. The last event is on Archive, which uses one language, so it names none.'))
|
||||
if (x === 'media' || x === 'mediaElsewhere') return Page('Media', 'Images and files for every site.', Btn('Upload', 'primary', { ic: 'upload' }), D('display:grid;grid-template-columns:repeat(auto-fill,minmax(' + (m ? 140 : 180) + 'px,1fr));gap:12px', ...['bathroom-plan.jpg', 'window-frame.jpg', 'lime-plaster.jpg', 'roof-detail.jpg', 'floorboards.jpg', 'tile-samples.jpg'].map((n, i) => D('display:flex;flex-direction:column;gap:6px;padding:8px;background:var(--ui-bg);border:1px solid ' + (i === (x === 'mediaElsewhere' ? 1 : 0) ? 'var(--ui-primary)' : 'var(--ui-border)') + ';' + (i === (x === 'mediaElsewhere' ? 1 : 0) ? 'background:var(--rc-sand);color:var(--rc-on-sand)' : ''), D('height:' + (m ? 80 : 110) + 'px;border:1px dashed var(--ui-border-accented);display:flex;align-items:center;justify-content:center;font:12px \'IBM Plex Mono\',monospace;' + (i === (x === 'mediaElsewhere' ? 1 : 0) ? '' : MU), 'image'), S('font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' + (i === (x === 'mediaElsewhere' ? 1 : 0) ? '' : HL), n)))))
|
||||
return null
|
||||
}
|
||||
const PanelShell = (title, sub, body, o = {}) => D('position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;justify-content:' + (m ? 'center' : 'flex-end') + ';align-items:' + (m ? 'flex-end' : 'stretch'),
|
||||
A('div', { role: 'dialog', 'aria-modal': 'true', 'aria-label': title }, 'width:' + (m ? '100%' : 'min(' + (o.w || 960) + 'px,100%)') + ';' + (m ? 'height:92%;border-top:1px solid var(--ui-border)' : 'height:100%;border-left:1px solid var(--ui-border)') + ';background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column',
|
||||
m && D('display:flex;justify-content:center;padding:8px 0 0;flex-shrink:0', S('width:36px;height:4px;background:var(--ui-border-accented)')),
|
||||
D('padding:' + (m ? '12px 16px' : '16px 24px') + ';border-bottom:1px solid var(--ui-border);display:flex;gap:12px;align-items:flex-start;flex-shrink:0', D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + HL, title), sub && S(TO + ';text-wrap:pretty', sub)), Btn('', 'ghost', { ic: 'x', sm: true, al: 'Close' })),
|
||||
D('flex:1;min-height:0;overflow-y:auto;padding:' + (m ? '16px' : '16px 24px') + ';display:flex;flex-direction:column;gap:16px', ...body)))
|
||||
const seoPanel = () => {
|
||||
const unset = x === 'unset', slugD = x === 'editSlug'
|
||||
const rows = [['Page title', FRT + ' | Journal', 'Fallback: French title', 1], ['Description', 'Empty', 'No fallback. Empty. English site default not used. The tag is left out.', 0], ['Image alternative text', 'Un plan de salle de bain avec les mesures', 'Written per language.', 1]]
|
||||
let body
|
||||
if (x === 'preview') body = [Card(S('font-weight:600;' + HL, 'How it can appear in search'), D('display:flex;flex-direction:column;gap:2px;padding:12px;background:var(--ui-bg-elevated)', S(mono + ';' + MU + ';overflow-wrap:anywhere', 'journal.example.com › fr › comment-nous-chiffrons-une-renovation'), S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + HL, FRT + ' | Journal'), S(TO, 'La plupart des devis échouent parce qu\'ils cachent les trois mêmes coûts.')), S(MU + ';font-size:12px', 'Shows the localized address and canonical. Description: Fallback: French excerpt.'))]
|
||||
else if (x === 'siteFallback') body = [S(TO, 'Site-level text is used as a fallback only in its own language.'), ...[['French version', 'Description', 'Fallback: French site description', 'Used: "Rénovation et conseils pour la maison" (site text in French)', 1], ['Arabic version', 'Description', 'No fallback', 'Empty. English site default not used.', 0]].map(r => Card(S('font-weight:600;' + HL, r[0]), Fd(r[1], 'Empty', { val: false, help: r[2] + '. ' + r[3] }), r[4] ? Bd('Uses French site text', 'info', 'languages') : Bd('Tag left out', 'neutral', 'minus')))]
|
||||
else body = [unset ? Alert('info', 'info', 'Public URL not configured', 'Add a public URL pattern for Journal to show page and canonical addresses. API links by article ID still work.', Btn('Open Public URL settings', 'secondary', { sm: true })) : Card(D('display:flex;gap:8px;align-items:center;flex-wrap:wrap', Bd('Canonical', 'neutral', 'link'), S(mono + ';' + HL + ';overflow-wrap:anywhere', 'https://journal.example.com/fr/comment-nous-chiffrons-une-renovation')), S(MU + ';font-size:12px', 'Built from the Public URL pattern https://journal.example.com/{lang}/{slug}. Each language points to itself unless you set a canonical override.')),
|
||||
!unset && Card(S('font-weight:600;' + HL, 'Alternate languages'), S(TO, 'Published versions in enabled languages only.'), D('display:flex;gap:10px;align-items:center', S(mono, 'EN'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' + TO, 'https://journal.example.com/en/how-we-price-a-bathroom-renovation'), Bd('Published', 'success', 'circle-check')), D('display:flex;gap:10px;align-items:center', S(mono, 'DE'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' + TO, 'https://journal.example.com/de/so-kalkulieren-wir-eine-badsanierung'), Bd('Published', 'success', 'circle-check')), S(MU + ';font-size:12px', 'Drafts and the disabled Spanish language are left out.')),
|
||||
Card(S('font-weight:600;' + HL, 'Social locale'), D('display:flex;gap:8px;align-items:center;flex-wrap:wrap', Bd('og:locale', 'neutral', 'share-2'), x === 'noSocial' ? S(MU, 'Social locale not set') : S(mono + ';' + HL, 'Set: fr_FR')), S(MU + ';font-size:12px;line-height:18px;text-wrap:pretty', x === 'noSocial' ? 'French has no region in its code and no social locale is set for it, so og:locale and og:locale:alternate are left out. An owner can set one in Journal languages.' : 'Set by an owner for French in Journal languages. A region is never guessed from the language.')),
|
||||
Card(Fd('Address (slug)', 'comment-nous-chiffrons-une-renovation', { mono: true, ro: true, lock: true, help: 'Unique within Journal and French. Editing the title never changes the address.' }), D('display:flex;flex-direction:column;gap:6px;align-items:flex-start', Btn('Edit slug', 'secondary', { ic: 'pencil' }), S(MU + ';font-size:12px;line-height:18px', 'Editing the title never changes the slug. Only Edit slug does.'), S(HL + ';font-size:12px;line-height:18px', 'Links to the old address will stop working. RenewCMS does not create redirects.'))),
|
||||
...rows.map(r => Fd(r[0], r[1], { help: r[2], val: r[3] === 1 }))].filter(Boolean)
|
||||
const p = PanelShell('Search and sharing · French', 'Applies only to the French version. Page language: fr.', body)
|
||||
return slugD ? [p, Dlg({ z: 60, title: 'Edit slug for the French version', desc: 'The address changes for French only.' }, [Fd('Address (slug)', 'comment-nous-calculons-une-renovation', { mono: true, focus: true }), Alert('warning', 'triangle-alert', 'This version is published', 'Links to the old address will stop working. RenewCMS does not create redirects.'), S(MU + ';font-size:12px', 'Editing the title never changes the slug.')], [Btn('Cancel'), Btn('Change address', 'primary')])] : [p]
|
||||
}
|
||||
const mediaPanel = () => {
|
||||
const el = x === 'mediaElsewhere'
|
||||
const UR = (t, b2, link) => D('border-top:1px solid var(--ui-border);padding:10px 0;display:flex;align-items:center;gap:8px;min-height:' + (m ? 48 : 40) + 'px', D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S('font-weight:500;text-decoration:underline;text-underline-offset:3px;' + HL, t), b2 && S(MU + ';font-size:12px', b2)), link && S(mono + ';' + MU, link), Ic('arrow-up-right', 14))
|
||||
const SH = t => S('font:600 14px/22px \'IBM Plex Sans\',sans-serif;' + HL, t)
|
||||
const body = [D('height:160px;flex-shrink:0;border:1px dashed var(--ui-border-accented);display:flex;align-items:center;justify-content:center;' + MU + ';font:12px \'IBM Plex Mono\',monospace', 'image preview'), D('display:flex', Bd(el ? 'Used in 1' : 'Used in 2', 'neutral'))]
|
||||
if (el) body.push(D('display:flex;flex-direction:column;gap:6px', SH('Articles'), S(MU, 'No article shows it.')), D('display:flex;flex-direction:column;gap:6px', SH('Used elsewhere'), D('border-bottom:1px solid var(--ui-border)', UR('Author avatar', null, '/authors'))))
|
||||
else body.push(S('font-weight:500;' + HL, 'Used in 2 articles · 4 versions · 1 file'), S(MU + ';font-size:12px', 'A shared image is one file. It is counted once per article, and the versions that use it are listed.'), D('display:flex;flex-direction:column', UR('How we price a bathroom renovation', 'English, French, German'), UR('A guide to lime plaster', 'English')))
|
||||
return PanelShell(el ? 'window-frame.jpg' : 'bathroom-plan.jpg', 'Image details', body, { w: 420 })
|
||||
}
|
||||
const apiPage = () => Page('Integration · Languages', 'Proposed. Not available in the current API.', null, Alert('warning', 'triangle-alert', 'Proposed capability', 'These requests don\'t exist yet. Existing article-ID requests are unchanged.'),
|
||||
Card(S('font-weight:600;' + HL, '1. Exact lookup: site + language + slug'), S(mono + ';' + HL + ';overflow-wrap:anywhere', 'GET /v1/public/site/{key}/lang/{lang}/article/{slug}'), S(TO, 'Matches the published version with that slug in that language, or returns article_not_found. It never falls back, because a slug belongs to one language.'), S(mono + ';' + MU + ';overflow-wrap:anywhere', '{ "story_id": "st_01", "lang": "fr", "slug": "…", "alternates": [{ "lang": "en", "slug": "…" }] }')),
|
||||
Card(S('font-weight:600;' + HL, '2. Resolve by article group, with fallback'), S(mono + ';' + HL + ';overflow-wrap:anywhere', 'GET /v1/public/site/{key}/story/{story_id}?lang=fr&fallback=default'), S(TO, 'Use story_id from any response or from alternates[]. French published: lang "fr". French missing or unpublished with fallback=default: the published default, with "lang": "en", "requested_lang": "fr", "fallback": true. Without fallback=default, a missing French is language_not_available. No other language is tried, and drafts are never returned.')),
|
||||
Card(S('font-weight:600;' + HL, '3. Errors'), S(mono + ';' + HL, 'article_not_found · language_not_available · language_disabled'), S(TO, 'article_not_found: no published version has that slug in that language. language_not_available: the requested language and the allowed fallback are both unavailable. language_disabled: publishing in a disabled language.')),
|
||||
Card(S('font-weight:600;' + HL, 'Existing, unchanged'), S(mono + ';' + HL, 'GET /v1/article/{id}'), S(mono + ';' + HL, 'PATCH /v1/article/{id}/publish'), S(mono + ';' + HL, 'Editor: /article/{id}/edit'), S(TO, 'Each article ID is one language version. Article-ID links keep working.')))
|
||||
// ---- compose
|
||||
if (v === 'sl') { Object.assign(shell, setS, x === 'single' ? { crumbs: ['Settings', 'Sites', 'Archive', 'Languages'], mTitle: 'Archive · Languages' } : x === 'social' || x === 'socialEdit' || x === 'socialInvalid' ? { crumbs: ['Settings', 'Sites', 'Atelier', 'Languages'], mTitle: 'Atelier · Languages' } : {}); main = D('flex:1;min-height:0;display:flex;flex-direction:column;position:relative', slPage()); ov = [].concat(slOv() || []) }
|
||||
else if (v === 'pu') { Object.assign(shell, setS, { crumbs: ['Settings', 'Sites', 'Journal', 'Public URL'], mTitle: 'Journal · Public URL' }); main = D('flex:1;min-height:0;display:flex;flex-direction:column;position:relative', puPage()); if (x === 'saved') ov = [Toast('success', 'circle-check', 'Public URL saved', 'Journal pages now use this pattern.')] }
|
||||
else if (v === 'su') { Object.assign(shell, setS, { crumbs: ['Settings', 'Sites', 'Journal', 'Languages', 'Setup'], mTitle: 'Set article languages' }); main = D('flex:1;min-height:0;display:flex;flex-direction:column', suBody()) }
|
||||
else if (v === 'li') { Object.assign(shell, liS); main = D('flex:1;min-height:0;display:flex;flex-direction:column;position:relative', liPage()); ov = listOv() }
|
||||
else if (v === 'na') { Object.assign(shell, liS); main = D('flex:1;min-height:0;display:flex;flex-direction:column;position:relative', liPage()); ov = [naOv()] }
|
||||
else if (v === 'sf') { Object.assign(shell, { nav: x === 'home' || x === 'homeFr' ? 'home' : x === 'activity' ? 'activity' : x === 'media' || x === 'mediaElsewhere' ? 'media' : 'articles', crumbs: [x === 'home' || x === 'homeFr' ? 'Home' : x === 'activity' ? 'Activity' : x === 'media' || x === 'mediaElsewhere' ? 'Media' : 'Articles'], mTitle: 'Journal' }); main = D('flex:1;min-height:0;display:flex;flex-direction:column;position:relative', sfPage()); if (x === 'media' || x === 'mediaElsewhere') ov = [mediaPanel()] }
|
||||
else if (v === 'api') {
|
||||
Object.assign(shell, { nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', mLead: 'menu' })
|
||||
main = D('flex:1;min-height:0;display:flex;flex-direction:column;position:relative', apiPage())
|
||||
} else {
|
||||
Object.assign(shell, edS)
|
||||
const rtl = x === 'rtl', en = x === 'navFail' || (v === 'pd' && x === 'deleteDone') || (v === 'ed' && ['cleanOnline', 'onlineDirty', 'unpublishDirty'].includes(x))
|
||||
let L = rtl ? AR : FR
|
||||
const o = { cap: CAPS.full, pub: 'draft' }
|
||||
const bn = []
|
||||
let ovl = []
|
||||
const pop = (rows, q = {}) => ovl.push(LangPop(rows, { left: 248, top: 176, ...q }))
|
||||
const lifeOnline = 'This version is online. Saving updates the live page right away.'
|
||||
if (v === 'ed') {
|
||||
if (x === 'menu') { o.open = 1; pop(MENUS.all) } else if (x === 'two') { o.open = 1; pop(MENUS.two) } else if (x === 'many') { o.open = 1; pop(MENUS.many, { find: 1, max: 520 }) } else if (x === 'menuDisabled') { o.open = 1; pop(MENUS.dis, { note: 'Spanish is disabled on this site. Its version stays readable and editable. It can\'t be published until an owner re-enables Spanish.' }) }
|
||||
else if (x === 'dirty') { o.dirty = 1; o.cap = CAPS.p2 }
|
||||
else if (x === 'dirtyPublish') o.dirty = 1
|
||||
else if (x === 'cleanOnline') { o.pub = 'pub'; bn.push(Alert('neutral', 'info', lifeOnline)) }
|
||||
else if (x === 'onlineDirty') { o.dirty = 1; o.pub = 'pub'; bn.push(Alert('neutral', 'info', lifeOnline)) }
|
||||
else if (x === 'saveFail') { o.dirty = 1; o.saveFail = 1; bn.push(Alert('error', 'circle-x', 'Couldn\'t save French', 'Your changes are kept here. Nothing was published. Try saving again.')) }
|
||||
else if (x === 'pubFail') { o.pubFail = 1; bn.push(Alert('error', 'circle-x', 'Saved as a draft. Publishing failed. Try publishing again.', 'Your edits are saved. Only the publishing step failed.', Btn('Retry publish', 'secondary', { sm: true }))) }
|
||||
else if (x === 'unsaved') { o.dirty = 1; ovl.push(Dlg({ title: 'Leave without saving?', desc: 'Your changes to the French version aren\'t saved.' }, [], [Btn('Keep editing'), Btn('Discard and open English', 'danger'), Btn('Save and open English', 'primary')])) }
|
||||
else if (x === 'saving') { o.dirty = 1; o.saving = 1 }
|
||||
else if (x === 'saved') { o.saved = 1; ovl.push(Toast('success', 'circle-check', 'French saved', 'English wasn\'t changed.')) }
|
||||
else if (x === 'ovf') { o.dirty = 1; ovl.push(OvMenu(OvItems(L, { ...o }))) }
|
||||
else if (['focus', 'titleEdit', 'titleUnsaved', 'invalid', 'titleShort', 'titleLong', 'titleSaving', 'failed'].includes(x)) { o.tst = { titleEdit: 'edit', titleUnsaved: 'unsaved', titleShort: 'short', titleLong: 'long', titleSaving: 'saving' }[x] || x; o.dirty = !['focus', 'saving', 'failed'].includes(o.tst) || o.tst === 'failed'; if (o.tst === 'failed') o.saveFail = 1; if (['invalid', 'short', 'long'].includes(o.tst)) o.saveBlock = 1; if (o.tst === 'saving') { o.saving = 1; o.dirty = 1 } }
|
||||
else if (x === 'loading') o.loading = 1
|
||||
else if (x === 'gone') { main = Page('This version isn\'t available', 'It may have been deleted, or you may not have access.', null, Btn('Back to Articles', 'secondary')) }
|
||||
else if (x === 'navFail') ovl.push(Toast('error', 'circle-x', 'Couldn\'t open the French version', 'You\'re still editing English. Try again.'))
|
||||
}
|
||||
else if (v === 'pm') {
|
||||
if (x === 'restored') { o.dirty = 1; bn.push(Alert('info', 'info', 'Restored unsaved changes from 14:18', 'You logged in again. Your French title and body were kept in this browser.')) }
|
||||
else if (x === 'session' || x === 'conflict' || x === 'conflict2' || x === 'download' || x === 'conflictRestored' || x === 'reloadConfirm') o.dirty = 1
|
||||
else if (x.startsWith('move') && x !== 'moveSelect' && x !== 'moveSelectHidden') { o.cap = { ...CAPS.full } }
|
||||
}
|
||||
else if (v === 'src') {
|
||||
o.src = x !== 'closed' && x !== 'mobileClosed'; o.narrow = x === 'narrow'; o.long = x === 'long'; o.srcMob = x === 'mobileSrc' || (x === 'unavail' && m); o.gone = x === 'unavail'
|
||||
if (x === 'rtl') { o.S0 = EN }
|
||||
if (x === 'arRef') o.S0 = AR
|
||||
if (x === 'reopen') ovl.push(Toast('info', 'info', 'Reference: English', 'Restored. Your place in both panes is kept.'))
|
||||
if (x === 'changeRef') ovl.push(D('position:absolute;z-index:40;' + (m ? 'left:0;right:0;bottom:0' : 'left:260px;top:220px;width:340px') + ';background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);display:flex;flex-direction:column', A('div', { role: 'menu', 'aria-label': 'Reference version' }, 'display:flex;flex-direction:column', LRow({ n: 'English', c: 'EN', st: 'pub', cur: 1 }), LRow({ n: 'German', c: 'DE', st: 'pub' })), D('padding:10px 12px;border-top:1px solid var(--ui-border);' + MU + ';font-size:12px;line-height:18px', 'This only changes what you compare against. The tracked source for review stays English. Use Change tracked source in the review banner to change that.')))
|
||||
} else if (v === 'fr') {
|
||||
o.fresh = 'Needs review'
|
||||
const buttons = [Btn('Show source reference', 'secondary', { sm: true }), Btn('Mark as reviewed', 'primary', { sm: true }), Btn('Change tracked source', 'ghost', { sm: true })]
|
||||
if (x === 'gone') { o.fresh = 'No source'; bn.push(Alert('neutral', 'info', 'Source deleted', 'The tracked English version was deleted. Review history is kept. Choose a new source to track changes again.', Btn('Choose a new source', 'secondary', { sm: true }))) }
|
||||
else if (x === 'inaccessible') { o.fresh = 'Source unavailable'; bn.push(Alert('warning', 'lock', 'The source isn\'t available to you right now', 'It may be restricted. Review history is kept. Mark as reviewed is off until you can read it again.', Btn('Choose a new source', 'ghost', { sm: true }))) }
|
||||
else { if (x === 'pub') o.pub = 'pub'; bn.push(D('display:flex;flex-direction:column;gap:8px', D('display:flex;gap:8px;align-items:center', Tracked('English')), Alert('warning', 'triangle-alert', 'English changed after French was reviewed', 'Changed fields: Title, Body. English was saved 14 Oct 2026, 09:12. French was last reviewed 12 Oct 2026.' + (x === 'pub' ? ' French is still published. Reviewing doesn\'t change publication.' : ''), buttons))) }
|
||||
ovl = ovl.concat(frOv() || [])
|
||||
} else if (v === 'pd') { o.pub = ['unpublish', 'unpublishDirty', 'done', 'deleteSource', 'deleteLast', 'deleteStoryOk', 'deleteStoryFail', 'deleteDone', 'deleteStory'].includes(x) ? 'pub' : 'draft'; if (['saveAndPublish', 'saveFailPublish', 'unpublishDirty'].includes(x)) o.dirty = 1; if (x === 'saveFailPublish') { o.saveFail = 1; bn.push(Alert('error', 'circle-x', 'Couldn\'t save French', 'Your changes are kept here. Nothing was published. Try saving again.')) } if (x === 'done') { o.saved = 1; bn.push(Alert('neutral', 'info', lifeOnline)) } if (x === 'publishDisabled') { o.pubDis = 1; bn.push(Alert('warning', 'triangle-alert', 'French is disabled on this site. Re-enable it to publish.', 'You can still edit and save this version. Ask an owner to re-enable French.')) } ovl = ovl.concat(pdOv() || []) }
|
||||
else if (v === 'at') { o.pub = 'pub'; L = LS(x === 'dup' || x === 'dupHidden' ? S1 : S5); ovl = ovl.concat(atOv()) }
|
||||
else if (v === 'seo') ovl = ovl.concat(seoPanel())
|
||||
if (v === 'pm') ovl = ovl.concat(pmOv() || [])
|
||||
if (v === 'perm') {
|
||||
const cp = { ro: CAPS.ro, roMenu: CAPS.ro, p2: CAPS.p2, p3: CAPS.p3, p4: CAPS.full, p5: CAPS.full, p6: CAPS.p6, noAdd: CAPS.full, withAdd: CAPS.full }[x] || CAPS.full
|
||||
o.cap = cp
|
||||
if (x === 'noAdd' || x === 'withAdd') L = LV('fr', 'French', 'FR', FX.STORIES[1])
|
||||
if (x === 'ro' || x === 'roMenu') bn.push(Alert('neutral', 'lock', 'You can read this version. You can\'t edit it.', 'The source reference and the versions you can read stay available.'))
|
||||
if (x === 'p2') bn.push(Alert('neutral', 'info', 'Publishing needs the publish permission', 'Saving keeps it as a draft.'))
|
||||
if (x === 'p3') bn.push(Alert('neutral', 'lock', 'You can publish this version but not edit it', 'Fields are read-only. Publish stays available.'))
|
||||
if (x === 'roMenu') { o.open = 1; pop(MENUS.ro) }
|
||||
if (x === 'p4') { o.open = 1; pop(MENUS.p4) }
|
||||
if (x === 'p5') { o.open = 1; pop(MENUS.p5) }
|
||||
if (x === 'noAdd') { o.open = 1; pop(MENUS.noAdd) }
|
||||
if (x === 'withAdd') { o.open = 1; pop(MENUS.withAdd) }
|
||||
if (x === 'p6') ovl.push(OvMenu(OvItems(L, o)))
|
||||
}
|
||||
ov = ovl
|
||||
if (!main) {
|
||||
const cap = o.cap, S0 = o.S0 || EN
|
||||
if (en) { L = EN }
|
||||
if (m && o.src) bn.push(Seg(!!o.srcMob, L, S0))
|
||||
if (o.src && (!m || o.srcMob)) srcPane = D((m ? 'flex:1 0 100%;padding:16px' : 'width:' + (o.narrow || nr ? '42%' : '50%') + ';padding:24px') + ';flex-shrink:0;min-width:0;border-right:1px solid var(--ui-border);overflow:hidden;display:flex;flex-direction:column;background:var(--ui-bg-muted)', Src(S0, o))
|
||||
if (bn.length) edBanner = D('flex-shrink:0;display:flex;flex-direction:column;gap:8px;padding:12px ' + (m ? 16 : 24) + 'px;border-bottom:1px solid var(--ui-border)', ...bn)
|
||||
const ps = o.long ? L.p.concat(L.p, L.p) : L.p
|
||||
edBodyEl = A('div', { dir: L.dir, lang: L.lang, 'aria-readonly': !cap.e || undefined }, !cap.e ? 'color:var(--ui-text-toned)' : '', ...ps.map(q => A('p', { dir: L.dir }, 'margin:0 0 20px;font-size:' + (m ? 17 : 18) + 'px;line-height:1.66', q)))
|
||||
const sv = o.saving ? 'saving' : o.saveFail ? 'error' : o.dirty ? 'dirty' : 'clean'
|
||||
edHeader = Hdr(L, o); edMobBar = MBar(L, o)
|
||||
Object.assign(shell, { kind: 'editor', crumbs: ['Articles', L.t], mTitle: 'Edit article', mLead: 'arrow-left', ...(nr ? { w: 1024, h: 768 } : {}), ed: { readonly: !cap.e, status: o.pub === 'pub' ? 'online' : 'draft', save: sv, title: L.t, loading: !!o.loading, len: 3200, seoSummary: v === 'ed' ? '1 suggestion' : 'Ready', lang: { name: L.n, code: L.c, open: !!o.open, ro: !cap.e, fresh: o.fresh || '' } } })
|
||||
}
|
||||
}
|
||||
if (nr && shell.kind !== 'editor') Object.assign(shell, { w: 1024, h: 768 })
|
||||
return { srcPane, edBanner, edBodyEl, edHeader, edMobBar, shell, mob: m, main, ov: ov.length ? D('position:absolute;inset:0;pointer-events:none', ...ov.map(e => D('pointer-events:none', e))) : null }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -29,9 +29,9 @@ class Component extends DCLogic {
|
||||
const TONES = ['var(--rc-sand)', 'var(--ui-bg-accented)', 'color-mix(in srgb, var(--ui-border-accented) 35%, var(--ui-bg))', 'var(--ui-bg-elevated)']
|
||||
const mark = h('svg', { width: 28, height: 28, viewBox: '0 0 16 16', shapeRendering: 'crispEdges' }, h('rect', { width: 16, height: 16, fill: '#111' }), h('path', { fill: '#fff', fillRule: 'evenodd', d: 'M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z' }))
|
||||
const Th = (im, t = '', z = 24) => D('position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);background:' + (im.alpha ? CHECK : 'repeating-linear-gradient(135deg, var(--ui-border) 0 1px, transparent 1px 12px), ' + TONES[im.tone % 4]) + ';' + t, im.svg ? mark : I('image', z, 'opacity:0.55'))
|
||||
const BADGE = { unused: ['background:var(--rc-warning-soft);color:var(--ui-warning)', 'unlink'], dup: ['background:var(--rc-info-soft);color:var(--ui-info)', 'copy'], used: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)'], on: ['background:var(--ui-bg-inverted);color:var(--ui-text-inverted)', 'globe'], draft: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', 'pencil-line'], vars: ['background:var(--rc-info-soft);color:var(--ui-info)', 'layers'] }
|
||||
const varsBadge = im => im.vc > 0 && Bd(im.vc + (im.vc === 1 ? ' variant' : ' variants'), 'vars')
|
||||
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;' + BADGE[k][0], BADGE[k][1] && I(BADGE[k][1], 12), t)
|
||||
const BADGE = { unused: ['background:var(--rc-warning-soft);color:var(--ui-warning)', 'unlink'], dup: ['background:var(--rc-info-soft);color:var(--ui-info)', 'copy'], used: ['border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-toned)'], on: ['background:var(--ui-bg-inverted);color:var(--ui-text-inverted)', 'globe'], draft: ['border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-toned)', 'pencil-line'], vars: ['background:var(--rc-info-soft);color:var(--ui-info)', 'layers'] }
|
||||
const varsBadge = (im, on) => im.vc > 0 && Bd(im.vc + (im.vc === 1 ? ' variant' : ' variants'), 'vars', 20, on)
|
||||
const Bd = (t, k, sz = 20, on) => S('height:' + sz + 'px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:500;white-space:nowrap;' + BADGE[k][0] + (on && k !== 'used' && k !== 'draft' ? ';background:var(--ui-bg);border:1px solid currentColor' : ''), BADGE[k][1] && I(BADGE[k][1], 12), t)
|
||||
const Cb = (on, z = 20, ind) => S('width:' + z + 'px;height:' + z + 'px;flex-shrink:0;display:flex;align-items:center;justify-content:center;border:1px solid ' + (on || ind ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on || ind ? 'var(--ui-primary)' : 'var(--ui-bg)') + ';color:var(--ui-bg)', (on || ind) && I(ind ? 'minus' : 'check', z - 6))
|
||||
const Alert = (tone, icon, title, text, action, actionEl) => 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), S('color:var(--ui-text-toned)', text)), action && B(action, { icon: 'rotate-cw', h: m ? 44 : 28, fs: 12, z: 14, ...(actionEl || {}) })))
|
||||
const Empty = (icon, title, text, action, primary, pad = 56) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + pad + 'px 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:320px', text), action && B(action, { k: primary ? 'primary' : 'outline', icon: primary ? 'upload' : null, px: 16 }))
|
||||
@@ -54,16 +54,17 @@ 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 }
|
||||
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 })
|
||||
IM.forEach(x => { x.ent = s.ent ? (ENT[x.id] || 0) : 0; x.ex = 0 })
|
||||
const byId = id => IM.find(x => x.id === id)
|
||||
if (s.panel && s.panel.ext) byId(s.panel.id).ex = s.panel.ext === 'both' ? 2 : s.panel.ext === 'none' ? 0 : 1
|
||||
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 ids = s.ids || IM.map(x => x.id)
|
||||
const imgs = ids.map(byId)
|
||||
const sel = s.sel || []
|
||||
const pl = n => n + (n === 1 ? ' article' : ' articles')
|
||||
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 => { 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 }
|
||||
const label = im => im.n + ', ' + im.sz + ', ' + (im.used + im.ent + (im.ex || 0) ? 'used in ' + [im.used && pl(im.used), im.ent && (im.ent + (im.ent === 1 ? ' entry' : ' entries')), im.ex && (im.ex + ' other ' + (im.ex === 1 ? 'place' : 'places'))].filter(Boolean).join(' and ') : 'unused')
|
||||
const usedBadge = im => { const n = im.used + im.ent + (im.ex || 0), 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
|
||||
const Card = im => {
|
||||
@@ -73,7 +74,7 @@ class Component extends DCLogic {
|
||||
Th(im, 'aspect-ratio:4/3;border-bottom:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border)')),
|
||||
showCb && S('position:absolute;top:0;left:0;width:' + (m ? 44 : 36) + 'px;height:' + (m ? 44 : 36) + 'px;display:flex;align-items:center;justify-content:center', Cb(on, m ? 22 : 20)),
|
||||
D('padding:10px 12px;display:flex;flex-direction:column;gap:6px;min-width:0', D('display:flex;flex-direction:column;gap:2px;min-width:0', S('font-weight:500;color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', im.n), S('font-size:12px;color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-muted)') + ';white-space:nowrap;overflow:hidden;text-overflow:ellipsis', im.sz + ' · ' + im.dim)),
|
||||
D('display:flex;flex-wrap:wrap;gap:4px', im.dup && Bd('Duplicate', 'dup'), usedBadge(im), varsBadge(im))))
|
||||
D('display:flex;flex-wrap:wrap;gap:4px', im.dup && Bd('Duplicate', 'dup', 20, on), usedBadge(im, on), varsBadge(im, on))))
|
||||
}
|
||||
const Grid = () => D('display:grid;grid-template-columns:repeat(' + (m ? 2 : 5) + ',minmax(0,1fr));gap:' + (m ? 10 : 16) + 'px', ...imgs.map(Card))
|
||||
const Skel = () => D('display:grid;grid-template-columns:repeat(' + (m ? 2 : 5) + ',minmax(0,1fr));gap:' + (m ? 10 : 16) + 'px', ...Array.from({ length: m ? 6 : 10 }, () => D('background:var(--ui-bg);border:1px solid var(--ui-border)', S('display:block;aspect-ratio:4/3;background:var(--ui-bg-accented)'), D('padding:10px 12px;display:flex;flex-direction:column;gap:8px', S('display:block;width:70%;height:13px;background:var(--ui-bg-accented)'), S('display:block;width:45%;height:11px;background:var(--ui-bg-accented)'), S('display:block;width:60px;height:20px;background:var(--ui-bg-accented)')))))
|
||||
@@ -85,7 +86,7 @@ class Component extends DCLogic {
|
||||
const on = sel.includes(im.id), hov = s.hover === im.id
|
||||
return D('display:grid;grid-template-columns:' + cols + ';height:56px;align-items:center;padding:0 12px 0 0;border-top:1px solid var(--ui-border);background:' + (on ? 'var(--rc-sand)' : hov ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-muted)'),
|
||||
S('display:flex;justify-content:center', Cb(on, 18)),
|
||||
D('display:flex;align-items:center;gap:12px;min-width:0;padding-right:12px', Th(im, 'width:40px;height:40px;flex-shrink:0;border:1px solid var(--ui-border)', 16), S('font-weight:500;color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', im.n), im.dup && Bd('Duplicate', 'dup'), varsBadge(im)),
|
||||
D('display:flex;align-items:center;gap:12px;min-width:0;padding-right:12px', Th(im, 'width:40px;height:40px;flex-shrink:0;border:1px solid var(--ui-border)', 16), S('font-weight:500;color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', im.n), im.dup && Bd('Duplicate', 'dup', 20, on), varsBadge(im, on)),
|
||||
S('', im.dim), S('', im.sz), S('', im.ty), S('', usedLink(im)), S('overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:8px', im.by), S('', im.d))
|
||||
}))
|
||||
}
|
||||
@@ -94,7 +95,7 @@ class Component extends DCLogic {
|
||||
return D('display:flex;align-items:center;gap:0;min-height:80px;padding:8px 12px 8px 0;border-bottom:1px solid var(--ui-border);background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-muted)'),
|
||||
S('width:44px;height:44px;flex-shrink:0;display:flex;align-items:center;justify-content:center', Cb(on, 22)),
|
||||
Th(im, 'width:56px;height:56px;flex-shrink:0;border:1px solid var(--ui-border)', 20),
|
||||
D('flex:1;min-width:0;display:flex;flex-direction:column;gap:3px;padding-left:12px', S('font-weight:500;font-size:15px;color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', im.n), S('font-size:12px', im.sz + ' · ' + im.dim + ' · ' + im.ty), D('display:flex;flex-wrap:wrap;gap:4px;align-items:center;font-size:12px', im.dup && Bd('Duplicate', 'dup'), usedBadge(im), varsBadge(im), S('', im.by + ' · ' + im.d))),
|
||||
D('flex:1;min-width:0;display:flex;flex-direction:column;gap:3px;padding-left:12px', S('font-weight:500;font-size:15px;color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', im.n), S('font-size:12px', im.sz + ' · ' + im.dim + ' · ' + im.ty), D('display:flex;flex-wrap:wrap;gap:4px;align-items:center;font-size:12px', im.dup && Bd('Duplicate', 'dup', 20, on), usedBadge(im, on), varsBadge(im, on), S('', im.by + ' · ' + im.d))),
|
||||
I('chevron-right', 16, 'margin-left:8px'))
|
||||
}))
|
||||
|
||||
@@ -200,6 +201,7 @@ class Component extends DCLogic {
|
||||
// ---------- 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 ExtBox = ext => D('display:flex;flex-direction:column;gap:8px', S('font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Used elsewhere'), D('border:1px solid var(--ui-border)', ...[['avatar', 'Author avatar', '/authors'], ['site', 'Site default social image', '/settings/sites']].filter(r => ext === 'both' || ext === r[0]).map(([k, t, href], 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), S('font:400 12px \'IBM Plex Mono\',monospace;color:var(--ui-text-muted)', href), I('arrow-up-right', 14, 'color:var(--ui-text-muted)')))))
|
||||
const Panel = () => {
|
||||
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]]
|
||||
@@ -209,13 +211,14 @@ class Component extends DCLogic {
|
||||
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',
|
||||
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', usedBadge(im)),
|
||||
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' }),
|
||||
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) : (im.ent || im.ex) ? 'Articles' : '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)'))))
|
||||
: 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.ent > 0 && EntBox(im))))
|
||||
: S('color:var(--ui-text-muted)', im.ent || im.ex ? '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.ent > 0 && EntBox(im), im.ex > 0 && ExtBox(s.panel.ext))))
|
||||
}
|
||||
|
||||
// ---------- modal
|
||||
|
||||
@@ -38,9 +38,10 @@ class Component extends DCLogic {
|
||||
fetch('emails/verification.html').then(x => x.text()).then(v => this.setState({ v }))
|
||||
fetch('emails/password-reset.html').then(x => x.text()).then(r => this.setState({ r }))
|
||||
fetch('emails/invitation.html').then(x => x.text()).then(i => this.setState({ i }))
|
||||
;['verify-new-email','email-change-notice','account-deleted'].forEach(n => fetch('emails/' + n + '.html').then(x => x.text()).then(t => this.setState({ ['x_' + n]: t })))
|
||||
}
|
||||
fill(t) {
|
||||
return t.split('{{.InvitedBy}}').join('marta').split('{{.Email}} It').join('lea.martin@renew.example. It').split('sent to {{.Email}}').join('sent to lea.martin@renew.example').split('{{.Host}}').join('https://cms.renew.example').split('{{.VerificationCode}}').join('7f3c9a2e4b1d').split('{{.Email}}').join('marta%40renew.example').replace('uses marta%40renew.example', 'uses marta@renew.example')
|
||||
return t.split('{{.InvitedBy}}').join('marta').split('{{.Email}} It').join('lea.martin@renew.example. It').split('sent to {{.Email}}').join('sent to lea.martin@renew.example').split('{{.Username}}').join('marta').split('{{.NewEmail}}').join('marta.alves@atelier-renew.example').split('{{.DeletedAt}}').join('29 Oct 2026').split('{{.Host}}').join('https://cms.renew.example').split('{{.VerificationCode}}').join('7f3c9a2e4b1d').split('{{.Email}}').join('marta%40renew.example').replace('uses marta%40renew.example', 'uses marta@renew.example')
|
||||
}
|
||||
frames(t) {
|
||||
if (!t) return []
|
||||
@@ -52,7 +53,10 @@ class Component extends DCLogic {
|
||||
return { rows: [
|
||||
{ title: 'Verification email', vars: '{{.Host}} · {{.VerificationCode}} → /register/validate?c=', frames: this.frames(this.state.v) },
|
||||
{ title: 'Password reset email', vars: '{{.Host}} · {{.VerificationCode}} · {{.Email}} → /register/reset/validate?c=&email=', frames: this.frames(this.state.r) },
|
||||
{ title: 'Invitation email', vars: '{{.Host}} · {{.VerificationCode}} · {{.Email}} · {{.InvitedBy}} → /invite/accept?c=', frames: this.frames(this.state.i) }
|
||||
{ title: 'Invitation email', vars: '{{.Host}} · {{.VerificationCode}} · {{.Email}} · {{.InvitedBy}} → /invite/accept?c=', frames: this.frames(this.state.i) },
|
||||
{ title: 'Verify new email', vars: '{{.Host}} · {{.Username}} · {{.VerificationCode}} → /account/email/confirm?c=', frames: this.frames(this.state['x_verify-new-email']) },
|
||||
{ title: 'Email change notice (to the old address)', vars: '{{.Host}} · {{.Username}} · {{.NewEmail}}', frames: this.frames(this.state['x_email-change-notice']) },
|
||||
{ title: 'Account deleted', vars: '{{.Host}} · {{.Username}} · {{.DeletedAt}}', frames: this.frames(this.state['x_account-deleted']) },
|
||||
] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<script src="./account-copy.js"></script>
|
||||
<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&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&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="Back-office screens" style="--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-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;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: account (email, data, delete)</h1>
|
||||
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">Extends Profile with change email, export my data and delete account. 1440 px at 50%, 390 px at 75%. The theme switch shows every state in dark mode. </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="7">
|
||||
<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></div>
|
||||
<sc-if value="{{ g.note }}"><p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">{{ g.note }}</p></sc-if>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap: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">
|
||||
<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;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></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></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
<sc-if value="{{ g.table }}"><div style="display:flex;flex-direction:column;gap:8px;max-width:1240px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.tableTitle }}</span><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><div style="display:grid;grid-template-columns:90px 240px minmax(0,1fr);padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Where</span><span>Copy</span></div><sc-for list="{{ g.table }}" as="r" hint-placeholder-count="3"><div style="display:grid;grid-template-columns:90px 240px minmax(0,1fr);gap:0;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-toned);padding-right:16px">{{ r.b }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.c }}</span></div></sc-for></div></div></sc-if>
|
||||
</section>
|
||||
</sc-for>
|
||||
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"theme":{"editor":"enum","options":["system","light","dark"],"default":"system","tsType":"'system' | 'light' | 'dark'"}}">
|
||||
class Component extends DCLogic {
|
||||
state = { mode: this.props.theme ?? 'light' }
|
||||
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-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-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() }
|
||||
componentDidUpdate(p) {
|
||||
if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme })
|
||||
this.apply()
|
||||
}
|
||||
groups() {
|
||||
const C = (window.ACCOUNT_COPY || {}).en || {}
|
||||
const nw = 'marta.alves@atelier-renew.example', old = 'marta@renew.example'
|
||||
const fmt = t => (t || '').split('{new}').join(nw).split('{old}').join(old)
|
||||
const toast = (tone, icon, t, b) => ({ tone, icon, title: C[t], text: fmt(C[b]) })
|
||||
const S = (name, trigger, ac, extra = {}) => { const x = { kind: 'profile', nav: 'profile', crumbs: ['Profile'], mTitle: 'Profile', copy: C, name, trigger, ...extra, pf: { ac: { old, new: nw, ...ac }, shift: extra.shift || ES } }; return { ...x, m: { ...x } } }
|
||||
const form = (o = {}) => ({ form: { email: nw, pw: 'limewash2026', focus: '', ...o } })
|
||||
const pend = (o = {}) => ({ pending: { resend: 'idle', ...o } })
|
||||
const D = (mode, state) => ({ data: { mode, state } })
|
||||
const ES = [300, 330], DS = [780, 1100], ZS = [1000, 1500]
|
||||
const del = (o = {}) => ({ title: C.delTitle, desc: C.delDesc, list: C.delList, warn: { tone: 'info', icon: 'info', title: C.delKeepT, text: C.delKeepB }, link: C.delExport, cancel: C.delKeep, confirm: C.delConfirm, tone: 'danger', disabled: true, close: true, ...o })
|
||||
const pw = '••••••••••'
|
||||
const dm = (fields, o = {}) => ({ modal: del({ fields, ...o }), shift: ZS })
|
||||
const ff = (p, w, e = {}) => [{ label: C.delPw, value: p, ...(e.p || {}) }, { label: C.delWordLabel, value: w, ph: C.delWord, ...(e.w || {}) }]
|
||||
const blk = (t, b, go) => ({ modal: { title: C[t], desc: C[b], cancel: C.blockBack, confirm: go ? C.blockGo : '' }, shift: ZS })
|
||||
return this.finish([
|
||||
{ label: 'Change email', title: 'Account: change email', route: '/profile', api: 'assumed: POST /v1/auth/email/change, resend, cancel', note: 'Extends the Account card on Profile. The email stays read-only until verified. The old email keeps working for login until the link is opened. A notice goes to the old address. A request for an address already in use looks like success, so nothing leaks.', states: [
|
||||
S('Default', 'The Email field gains a Change email link.', {}),
|
||||
S('Form open', 'Change email opens an inline form in the Account card.', form({ email: '', pw: '', focus: 'email' })),
|
||||
S('Invalid format', 'On blur or submit. Nothing is sent.', form({ email: 'marta.alves@atelier', errEmail: C.errFormat })),
|
||||
S('Same as current', 'Checked on submit, before any request.', form({ email: old, errEmail: C.errSame })),
|
||||
S('Password missing', 'Submit with an empty password.', form({ pw: '', errPw: C.errPwEmpty, focus: 'pw' })),
|
||||
S('Wrong password (403)', 'The password is cleared and gets focus.', form({ pw: '', errPw: C.errPwWrong, focus: 'pw' })),
|
||||
S('Sending', 'Fields and button are disabled.', form({ sending: true })),
|
||||
S('Pending', '200. The form closes; the old email stays active.', pend(), { toasts: [toast('success', 'circle-check', 'sentT', 'sentB')] }),
|
||||
S('Pending: resending', 'Resend link in flight.', pend({ resend: 'sending' })),
|
||||
S('Pending: resent', 'A new link replaces the old one.', pend(), { toasts: [toast('info', 'info', 'resentT', 'resentB')] }),
|
||||
S('Pending: resend rate limited (429)', 'Resend is disabled with a countdown.', pend({ resend: 'rate' })),
|
||||
S('Cancelled', 'Cancel change clears the pending state. No confirmation, it only discards a request.', {}, { toasts: [toast('info', 'info', 'cancelledT', 'cancelledB')] }),
|
||||
S('Rate limited (429)', 'Submit is disabled.', form({ disabled: true, notice: C.rate })),
|
||||
S('Server or network error', 'Values are kept.', form({ notice: C.server }))
|
||||
] },
|
||||
{ label: 'Your data', title: 'Account: export my data', route: '/profile', api: 'assumed: GET /v1/user/me/export (direct) or POST (async, emailed link)', note: 'Recommended: direct download, since one user\'s data in a small self-hosted CMS prepares in seconds. Alternative: a background export with an emailed link, for larger sites.', states: [
|
||||
S('Direct (recommended): default', 'Your data card.', D('direct', 'idle'), { shift: DS }),
|
||||
S('Direct: preparing', 'Button disabled, spinner.', D('direct', 'preparing'), { shift: DS }),
|
||||
S('Direct: started', 'Browser download starts, then a toast.', D('direct', 'idle'), { shift: DS, toasts: [toast('success', 'circle-check', 'dlT', 'dlB')] }),
|
||||
S('Direct: rate limited (429)', 'One export every 15 minutes.', D('direct', 'rate'), { shift: DS }),
|
||||
S('Direct: error', '500 or network failure.', D('direct', 'error'), { shift: DS }),
|
||||
S('Alternative, emailed link: default', 'Same card, request button.', D('async', 'idle'), { shift: DS }),
|
||||
S('Alternative: requested', 'Export queued.', D('async', 'queued'), { shift: DS, toasts: [toast('success', 'circle-check', 'queuedToastT', 'queuedToastB')] }),
|
||||
S('Alternative: already in progress', 'A second request while one is queued.', D('async', 'inprogress'), { shift: DS }),
|
||||
S('Alternative: error', 'The request failed.', D('async', 'error'), { shift: DS })
|
||||
] },
|
||||
{ label: 'Delete account', title: 'Account: delete', route: '/profile', api: 'assumed: DELETE /v1/user/me {password}', note: 'Danger zone card with the existing destructive styling. Default for authored content: anonymize (credited to Deleted user). Open question. Confirm stays disabled until the password is filled and the word matches. Blocked states have no close button, no Escape and no backdrop dismissal.', states: [
|
||||
S('Danger zone', 'Bottom of Profile.', {}, { shift: ZS }),
|
||||
S('Confirm: empty', 'Confirm is disabled.', {}, dm(ff('', ''))),
|
||||
S('Confirm: typing', 'Password entered, word partial. Still disabled.', {}, dm(ff(pw, 'DEL', { w: { focus: true } }))),
|
||||
S('Confirm: ready', 'Both valid. Confirm is enabled.', {}, dm(ff(pw, 'DELETE'), { disabled: false })),
|
||||
S('Wrong password (403)', 'The password field is cleared and marked.', {}, dm(ff('', 'DELETE', { p: { err: C.errPwWrong, focus: true } }), { disabled: false })),
|
||||
S('Deleting', 'Fields and Cancel are disabled. Then sign out and redirect to log in.', {}, dm(ff(pw, 'DELETE'), { disabled: false, loading: true, confirm: C.deleting, close: false })),
|
||||
S('Rate limited (429)', 'Confirm stays enabled.', {}, dm(ff(pw, 'DELETE'), { disabled: false, notice: C.rate })),
|
||||
S('Server error', 'Nothing was deleted.', {}, dm(ff(pw, 'DELETE'), { disabled: false, notice: C.delServer })),
|
||||
S('Blocked: last administrator', 'Not dismissable. Next step: Users.', {}, blk('blockT', 'blockB', true)),
|
||||
S('Blocked: cannot be deleted', 'Generic server refusal. Not dismissable.', {}, blk('protT', 'protB', false))
|
||||
] }
|
||||
])
|
||||
}
|
||||
finish(gs) { return gs.map(g => ({ ...g, states: g.states.map(st => ({ notMedia: !st.isMedia, ...st, m: { ...st.m } })) })) }
|
||||
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 }) }
|
||||
})
|
||||
return { rootRef: this.rootRef, modes, groups: this.groups() }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -156,6 +156,19 @@ class Component extends DCLogic {
|
||||
{ name: 'No session, retrying', trigger: 'Logged in inline. Now verifying.', spinner: 'Verifying your account…' },
|
||||
{ name: 'Missing code', trigger: 'The link has no ?c= parameter. No request is made.', result: { tone: 'error', icon: 'link-2-off', title: 'This link is incomplete', text: 'The verification code is missing. Open the link from your email again, or copy the full address into your browser.', actions: [{ t: 'Go to log in', secondary: true }] } }
|
||||
] },
|
||||
{ label: 'Verify new email', title: 'Verify new email', route: '/account/email/confirm?c=CODE', api: 'assumed: POST /v1/auth/email/confirm. Needs a session; without one, inline log in', note: 'Opened from the verification email sent to the new address. Submits on load. The unavailable state says the same thing whether the address is in use or rejected, so it never reveals whether an account exists.', states: [
|
||||
{ name: 'Confirming', trigger: 'On load, with a code in the URL.', spinner: 'Confirming your new email…' },
|
||||
{ name: 'Success', trigger: '200 → /profile after 800 ms, with a toast.', result: { tone: 'success', icon: 'mail-check', title: 'Your email address has changed', text: 'Your account now uses marta.alves@atelier-renew.example. Use it the next time you log in.', actions: [{ t: 'Go to profile' }] }, toast: toast('success', 'circle-check', 'Email address changed', 'Your account now uses marta.alves@atelier-renew.example.') },
|
||||
{ name: 'Invalid or expired (403)', trigger: 'The code is wrong, used, cancelled or older than 2 hours.', result: { tone: 'error', icon: 'link-2-off', title: 'This link doesn\'t work', text: 'It may have expired (links last 2 hours), been cancelled or already been used.', note: 'Request a new link from your profile.', actions: [{ t: 'Go to profile' }] } },
|
||||
{ name: 'Address unavailable (409)', trigger: 'The address is taken or rejected. One message for both.', result: { tone: 'error', icon: 'mail-x', title: 'We couldn\'t change your email address', text: 'This address can\'t be used for your account. Go back to your profile and try a different one.', actions: [{ t: 'Go to profile' }] } },
|
||||
{ name: 'No session (401)', trigger: 'Opened in another browser. Log in inline, then the change is confirmed with the same code.', title: 'Log in to confirm your new email', desc: 'You opened this link somewhere you\'re not logged in. Log in here and we\'ll confirm the change right after.', fields: [U(), P()], submit: { t: 'Log in and confirm' } },
|
||||
{ name: 'No session, retrying', trigger: 'Logged in inline. Now confirming.', spinner: 'Confirming your new email…' },
|
||||
{ name: 'Missing code', trigger: 'The link has no ?c= parameter. No request is made.', result: { tone: 'error', icon: 'link-2-off', title: 'This link is incomplete', text: 'The confirmation code is missing. Open the link from your email again, or copy the full address into your browser.', actions: [{ t: 'Go to log in', secondary: true }] } },
|
||||
{ name: 'Server error', trigger: '500 or network failure. Nothing was changed.', result: { tone: 'error', icon: 'circle-x', title: 'Couldn\'t confirm the change', text: 'Something went wrong on our side. Nothing was changed. Try again in a moment.', actions: [{ t: 'Try again' }] } }
|
||||
] },
|
||||
{ label: 'Account deleted', title: 'Log in: after account deletion', route: '/login?reason=account-deleted', api: 'none', note: 'After a successful deletion the user is signed out and sent here. The message shows once and is gone on reload.', states: [
|
||||
{ ...login, name: 'Account deleted', trigger: 'Redirected from the delete dialog.', notice: { tone: 'success', icon: 'circle-check', title: 'Your account has been deleted', text: 'Your articles and media were kept without your name.' }, fields: loginF({}, {}) }
|
||||
] },
|
||||
{ label: 'Accept invite', title: 'Accept invite', route: '/invite/accept?c=CODE', api: 'GET /v1/auth/invite?c= (pending) · POST /v1/auth/invite/accept (pending)', note: 'Opened from the invitation email. The link carries only the code, so the invited email is looked up on load and shown read-only. Username and password follow the Register rules, including PasswordStrengthHint. On success the person is logged in and sent to /home with a toast; they are already verified because the invite went to their email.', states: [
|
||||
{ name: 'Checking the link', trigger: 'On load, while the invite is looked up.', spinner: 'Checking your invite…' },
|
||||
{ ...inv, name: 'Default', trigger: 'The code is valid and unused.', fields: invF({}, {}) },
|
||||
|
||||
@@ -229,6 +229,8 @@ class Component extends DCLogic {
|
||||
{ label: 'Editor', title: 'Editor', route: '/article/:id/edit', api: 'GET /v1/article/{id} · PATCH /v1/article/{id} · PATCH …/publish|unpublish · POST …/image · DELETE', note: 'On mobile, Save and Publish sit in a sticky bottom bar and the details panel opens as a slideover. A session expiry mid-edit keeps the draft in sessionStorage and goes to /login?redirect=/article/12/edit. After logging in, the editor restores the draft and shows "Unsaved changes".', states: [
|
||||
E({ loading: true }, { name: 'Loading', trigger: 'GET in flight. Skeletons for the title, content and details.' }),
|
||||
E({}, { name: 'Saved', trigger: 'Just opened or just saved. Save is disabled.' }),
|
||||
E({ title: 'Comment nous chiffrons une rénovation de salle de bain', lang: { name: 'French', code: 'FR' } }, { name: 'Multilingual site: French draft', trigger: 'Sites with more than one language show the language control after the status badge. Publish, Unpublish and Delete name the language. Single-language sites are unchanged. Menu, source reference and review states: Localization board.' }),
|
||||
E({ status: 'online', title: 'Comment nous chiffrons une rénovation de salle de bain', lang: { name: 'French', code: 'FR', fresh: 'Needs review' } }, { name: 'Multilingual site: published, needs review', trigger: 'Publication (Online) and freshness (outlined Needs review) are separate badges.' }),
|
||||
E({ save: 'dirty' }, { name: 'Unsaved changes', trigger: 'Any edit. Publish becomes "Save and publish". Leaving asks for confirmation.' }),
|
||||
E({ save: 'saving' }, { name: 'Saving', trigger: 'Save, or Ctrl/⌘ S.' }),
|
||||
E({ save: 'error' }, { name: 'Save failed', trigger: '500 or network failure. The changes are kept and Retry resends them.' }),
|
||||
@@ -286,7 +288,7 @@ class Component extends DCLogic {
|
||||
Mp({ empty: 'first', name: 'Empty library, first run', trigger: 'Zero images. Toolbar and tabs are hidden; one call to action. Copy: "No images yet" / "Upload your first image. You can use it in any article." / "Upload an image".' }),
|
||||
Mp({ tab: 'unused', q: 'wall', empty: 'search', name: 'No results', trigger: 'Search or filter matched nothing. "Clear filters" resets the search and goes back to All.' }),
|
||||
Mp({ hover: 2, focus: 3, ann: 'aria-label: kitchen-before.webp, 1.1 MB, used in 1 article', name: 'Hover, focus and keyboard', trigger: 'Hover (second item) darkens the border, adds the pop shadow and reveals the checkbox. Keyboard focus (third item) shows the 2 px primary ring with a 2 px offset and reveals the checkbox. Tab moves between items, Enter opens the detail panel, Space toggles selection, and arrow keys move focus across the grid.' }),
|
||||
Mp({ sel: [1, 2, 8], name: 'Selection and bulk bar', trigger: 'Checkbox on any item, or Space. Selected items take the sand fill with a primary outline, and Select all shows a partial state. The bar is sticky at the bottom (above the bottom edge on mobile, replacing the Upload button). Announced: "3 selected". Escape or Clear empties it.' }),
|
||||
Mp({ sel: [1, 2, 8], name: 'Selection and bulk bar', trigger: 'Checkbox on any item, or Space. Selected items take the sand fill with a primary outline, and Select all shows a partial state. Outline badges ("Used in N", "Draft") keep an opaque --ui-bg fill over the sand tint with the text colour unchanged (--ui-text-toned on --ui-bg: 9.7:1 light, 11.7:1 dark; both above 4.5:1). The bar is sticky at the bottom (above the bottom edge on mobile, replacing the Upload button). Announced: "3 selected". Escape or Clear empties it.' }),
|
||||
Mp({ sel: [1, 2, 8], modal: { k: 'bulk' }, name: 'Bulk delete: confirm', trigger: 'Delete in the bulk bar. The dialog says how many of the selection are in use and lists them. The confirm button carries the count.' }),
|
||||
Mp({ sel: [1, 2, 8], modal: { k: 'bulk', loading: true }, name: 'Bulk delete: in progress', trigger: 'Requests in flight. The dialog can\'t be dismissed.' }),
|
||||
Mp({ ids: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12], sel: [8], toasts: [TT('error', 'circle-x', 'Couldn\'t delete 1 image', '2 images were deleted. window-frame.jpg is still in your library. Try again.', 'Retry')], name: 'Bulk delete: partly failed', trigger: 'Some requests failed. Deleted images leave the grid, failed ones stay selected, and the toast stays until retried or dismissed.' }),
|
||||
@@ -294,6 +296,10 @@ class Component extends DCLogic {
|
||||
Mp({ panel: { id: 1 }, name: 'Detail panel: in use', trigger: 'Click or Enter on an item. Right-hand slideover on desktop, full screen on mobile with a back arrow. Actions: Copy URL, Rename, Delete. "Used in 2 articles" lists title, Online or Draft, and links to the editor. Focus returns to the item on close.' }),
|
||||
Mp({ panel: { id: 8 }, name: 'Detail panel: unused', trigger: 'Used in nothing. The section reads "Not used in any article" and says the image is safe to delete.' }),
|
||||
Mp({ panel: { id: 12 }, name: 'Detail panel: duplicate', trigger: 'The image has identical copies. An info alert links to the duplicates view.' }),
|
||||
Mp({ panel: { id: 8, ext: 'avatar' }, name: 'Detail panel: used elsewhere, avatar only', trigger: 'window-frame.jpg has no article or entry, and is the avatar of one author. "Used elsewhere" lists "Author avatar" linking to /authors. The "Used in 1" badge counts it, so "Not used in any article" is replaced by "Articles" with "No article shows it."' }),
|
||||
Mp({ panel: { id: 8, ext: 'site' }, name: 'Detail panel: used elsewhere, site image only', trigger: 'The image is the site default social image. The row "Site default social image" links to /settings/sites. Badge reads "Used in 1".' }),
|
||||
Mp({ panel: { id: 8, ext: 'both' }, name: 'Detail panel: used elsewhere, both', trigger: 'Both rows, avatar first. Badge reads "Used in 2".' }),
|
||||
Mp({ panel: { id: 8, ext: 'none' }, name: 'Detail panel: used elsewhere, none', trigger: 'No section. Badge reads "Unused" and "Not used in any article" with "This image is safe to delete." appears, never next to a badge of 1 or more.' }),
|
||||
Mp({ panel: { id: 1 }, toasts: [TT('success', 'circle-check', 'URL copied', 'Paste it wherever you need the image address.')], name: 'Copy URL', trigger: 'Copy URL. Clipboard write succeeded. Success toasts last 5 s.' }),
|
||||
Mp({ panel: { id: 1 }, modal: { k: 'rename', val: 'north-wall' }, name: 'Rename: dialog', trigger: 'Rename in the panel. The field holds the name without its extension, and the .jpg ending is fixed. The description says the URL stays the same.' }),
|
||||
Mp({ panel: { id: 1 }, modal: { k: 'rename', val: 'north-wall-01', loading: true }, name: 'Rename: saving', trigger: 'PATCH in flight. Field and buttons disabled, dialog can\'t be dismissed.' }),
|
||||
@@ -792,7 +798,7 @@ class Component extends DCLogic {
|
||||
const T = (cols, tpl, rows, o = {}) => ({ cols, tpl, rows: rows.map(r => r.map((t, i) => this.cell(t, i, { mono: o.mono && o.mono.includes(i), muted: t === '–' }))) })
|
||||
return [
|
||||
{ label: 'Request contract', title: '7. Public request contract', note: 'Shown as the "How requests are handled" card in section 1. Only variants that exist and are enabled are served. There is no arbitrary-size mode.', tables: [T(['Request', 'Status', 'Body or result'], '320px 80px minmax(0,1fr)', [
|
||||
['/media/photo.png?v=thumb', '200', 'The cached image. Generated on first request.'],
|
||||
['/static/uploadedImages/photo.png?v=thumb', '200', 'The cached image. Generated on first request.'],
|
||||
['/media/photo.png?v=unknown', '400', 'Unknown variant'],
|
||||
['/media/photo.png?v=hero (disabled)', '400', 'Unknown variant'],
|
||||
['/media/photo.png?width=400', '400', 'Unsupported parameter'],
|
||||
@@ -952,14 +958,23 @@ class Component extends DCLogic {
|
||||
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 } })
|
||||
S('Create author: failed', '500 or network failure. On mobile the dialog sits above the Details sheet; the sheet stays behind it.', '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 } }),
|
||||
S('Collapsed summary: authors', 'The summary under Details when the section is closed. Three authors show the first name and a count.', 'Inès Duarte +2', { v: 'editor', ed: { metaSummary: 'Inès Duarte +2' } }),
|
||||
S('Collapsed summary: none', 'No authors.', 'No authors', { v: 'editor', ed: { metaSummary: 'No authors' } }),
|
||||
S('Collapsed summary: site and author', 'Several sites: site name, then the first author.', 'Journal · Inès Duarte', { v: 'editor', ed: { metaSummary: 'Journal · Inès Duarte' } }),
|
||||
S('Collapsed summary: site, no authors', 'Several sites and no author.', 'Journal · No authors', { v: 'editor', ed: { metaSummary: 'Journal · No authors' } }),
|
||||
S('Reorder: dropped', 'Space on the grabbed row drops it. The live region (polite) announces the result.', 'Jonas Pereira dropped. Position 2 of 3.', { v: 'editor', metaOpen: true, meta: { ...{"authors":["Inès Duarte","Jonas Pereira","Camille Roche"]}, live: 'Jonas Pereira dropped. Position 2 of 3.' } }),
|
||||
S('Reorder: cancelled', 'Escape returns the row to where it was.', 'Reorder cancelled. Jonas Pereira is back at position 3 of 3.', { v: 'editor', metaOpen: true, meta: { ...{"authors":["Inès Duarte","Jonas Pereira","Camille Roche"]}, live: 'Reorder cancelled. Jonas Pereira is back at position 3 of 3.' } })
|
||||
]),
|
||||
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('Author profile created: toast', 'Hint-success toast after Create my author profile from the Details hint. The author is added to the article.', 'Author profile created · Marta Alves is now your byline.', { v: 'editor', metaOpen: true, meta: { authors: ['Marta Alves'] }, toasts: [['success', 'circle-check', 'Author profile created', 'Marta Alves is now your byline.']] }),
|
||||
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('No author profile, no display name', 'The user has no linked author and no display name.', 'Uses your username, marta. You can change it afterwards.', { v: 'profile', pa: { k: 'none', user: true } }),
|
||||
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' } } })
|
||||
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' } } }),
|
||||
S('Edit own author: owner or admin', 'Edit author on the card, by an owner or admin. The form opens with the linked user and Unlink is allowed.', 'Edit Marta Alves · Linked user · marta@renew.example · Unlink · Save changes', { v: 'profile', role: 'owner', pa: { k: 'has' }, panel: { k: 'author', mode: 'edit', own: false, 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 } }),
|
||||
@@ -969,7 +984,14 @@ class Component extends DCLogic {
|
||||
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' } } })
|
||||
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' } } }),
|
||||
S('Count chip: 0', 'Zero suggestions: the toggle shows a 0 chip.', 'Needs attention · 0', { v: 'app', list: { needsN: 0 } }),
|
||||
S('Count chip: absent', 'The count is not known yet, so the chip is not drawn.', 'Needs attention', { v: 'app', list: { naState: 'absent' } }),
|
||||
S('Count chip: pending', 'The count request is in flight.', 'Needs attention · …', { v: 'app', list: { naState: 'pending' } }),
|
||||
S('Count chip: failure', 'The count request failed. The toggle still works and the chip shows !.', 'Needs attention · !', { v: 'app', list: { naState: 'fail' } }),
|
||||
S('Empty: suggestions + search', 'Precedence: search wins. The empty state names the search; Needs attention stays on.', 'No articles match "kitchen" · Try another search or clear it. · Clear search', { v: 'app', list: { needs: true, counts: [0, 0, 0], empty: { icon: 'search', title: 'No articles match "kitchen"', text: 'Try another search or clear it.', action: 'Clear search' } } }),
|
||||
S('Empty: suggestions + tab', 'A tab other than All with Needs attention on and no match: the tab wins.', 'No drafts need attention · Switch to All to see the rest. · Show all articles', { v: 'app', list: { needs: true, counts: [0, 0, 0], empty: { icon: 'file-text', title: 'No drafts need attention', text: 'Switch to All to see the rest.', action: 'Show all articles' } } }),
|
||||
S('Empty: suggestions + site', 'A site filter with Needs attention on and no match: the site wins.', 'No articles on "Archive" need attention · Show all sites', { v: 'app', list: { multi: true, site: 'Archive', needs: true, counts: [0, 0, 0], empty: { icon: 'file-text', title: 'No articles on "Archive" need attention', text: 'Choose another site or clear the filter.', action: 'Show all sites' } } })
|
||||
]),
|
||||
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' } }),
|
||||
@@ -995,12 +1017,20 @@ class Component extends DCLogic {
|
||||
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('Check failed', 'The suggestion check returned an error. The rest of the section works.', 'Couldn\'t check this article. · Try again', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { state: 'error' } } }),
|
||||
S('Panel: check failed', 'Same state inside the panel.', 'Couldn\'t check this article. · Try again', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { sugState: 'error' } } }),
|
||||
S('Panel: dismiss busy', 'The × shows a spinner and the row dims while the request runs.', 'Dismiss (busy)', { v: 'editor', ed: { seoSummary: '2 suggestions' }, seo: { panel: { codes: ['title_long', 'cover_alt'], busy: 0 } } }),
|
||||
S('Panel: dismiss failed', 'The row stays. An inline error with Try again appears under it.', 'Couldn\'t dismiss the suggestion · Try again in a moment.', { v: 'editor', ed: { seoSummary: '2 suggestions' }, seo: { panel: { codes: ['title_long', 'cover_alt'], fail: 0 } } }),
|
||||
S('Panel: show dismissed', 'All suggestions dismissed. The line says how many; Show dismissed brings them back.', 'All good · 2 dismissed suggestions are hidden. · Show dismissed', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { codes: [], dismissed: 2 } } }),
|
||||
S('Panel: excerpt over 300', 'Inline error under the excerpt; the counter turns red. Save changes is disabled while it shows, and also while nothing changed.', 'The excerpt is 342 characters. Keep it to 300 or fewer. · 342 / 300', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { ex: 'x'.repeat(342).replace(/x/g, 'a'), exErr: 'The excerpt is 342 characters. Keep it to 300 or fewer.', noChange: true, codes: [] } } }),
|
||||
S('Panel: nothing changed', 'Save changes is disabled until a field differs from the saved value.', 'Save changes (disabled)', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { noChange: true, 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('Copy refused', 'navigator.clipboard was refused. The Raw JSON view opens, the text is selected and a note explains how to copy by hand. The button keeps reading Copy JSON.', 'The browser blocked copying. The JSON is selected below: press Ctrl+C or Cmd+C to copy it.', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { view: 'raw', copyFail: 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' } } })
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<script src="./locale-fixtures.js"></script>
|
||||
<script src="./locale-spec.js"></script>
|
||||
<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&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&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="Back-office screens" style="--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-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;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: article localization</h1>
|
||||
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">One story, several language versions. Proposed feature: every API change is labelled as proposed. 1440 px at 50%, 1024 px at 50% for layout-dependent states, 390 px at 75%. The theme switch shows every state in dark mode. Each state shows its ID and fixture. 183 states in 16 groups. L7s is the pixel-gate reference for a single-language article list. Groups L6 and L9 are release 2. Repository code was unavailable, so existing behaviour comes from the design project only.</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="7">
|
||||
<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)">{{ g.count }}{{ g.rel }}</span></div>
|
||||
<sc-if value="{{ g.note }}"><p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">{{ g.note }}</p></sc-if>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap: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">
|
||||
<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><span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ st.id }} · frame {{ st.sel }} · fixture {{ st.fx }}</span></div>
|
||||
<div style="display:flex;gap:24px;align-items:flex-start">
|
||||
<div style="width:{{ st.dw }};height:{{ st.dh }};overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:{{ st.fw }};height:{{ st.fh }};zoom:{{ st.z }}"><dc-import name="LocaleFrame" s="{{ st.s }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></div></div>
|
||||
<sc-if value="{{ st.tri }}"><div style="width:512px;height:384px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1024px;height:768px;zoom:0.5"><dc-import name="LocaleFrame" s="{{ st.s2 }}" mobile="{{ false }}" hint-size="1024px,768px"></dc-import></div></div></sc-if>
|
||||
<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"><dc-import name="LocaleFrame" s="{{ st.s }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></div></div>
|
||||
</div>
|
||||
</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:1400px"><h2 style="margin:0;font:600 28px/36px '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.w }};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="3"><div style="display:grid;grid-template-columns:{{ t.w }};gap:16px;padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><sc-for list="{{ r }}" as="c" hint-placeholder-count="3"><span style="color:var(--ui-text-highlighted);text-wrap:pretty;min-width:0">{{ 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","options":["system","light","dark"],"default":"system","tsType":"'system' | 'light' | 'dark'"}}">
|
||||
class Component extends DCLogic {
|
||||
state = { mode: this.props.theme ?? 'light' }
|
||||
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-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-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() }
|
||||
componentDidUpdate(p) {
|
||||
if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme })
|
||||
this.apply()
|
||||
}
|
||||
groups() { return window.LOC.groups.map(g => ({ ...g, count: g.states.length + ' states · ', rel: g.release === 2 ? 'Release 2' : 'Release 1', label: g.id + ' ' + g.title, states: g.states.map(a => { const nar = a[4] === 'narrow', tri = a[4] === 'tri'; return { name: a[0], trigger: a[1], id: a[5], fx: a[6], sel: 'LocaleFrame{v:' + a[2] + ',x:' + (a[3] || "''") + '}', tri, dw: nar ? '717px' : '720px', dh: nar ? '538px' : '450px', fw: nar ? '1024px' : '1440px', fh: nar ? '768px' : '900px', z: nar ? 0.7 : 0.5, s: { v: a[2], x: a[3], narrow: nar }, s2: { v: a[2], x: a[3], narrow: true } } }) })) }
|
||||
ratio(a, b2) { const h = c => { c = c.replace('#', ''); return [0, 2, 4].map(i => parseInt(c.slice(i, i + 2), 16)) }, L = rgb => { const f = v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4) }; return 0.2126 * f(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2]) }, x = L(h(a)), y = L(h(b2)); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05) }
|
||||
mix(fg, bg, p) { const h = c => [1, 3, 5].map(i => parseInt(c.slice(i, i + 2), 16)), f = h(fg), g = h(bg); return '#' + f.map((v, i) => Math.round(v * p + g[i] * (1 - p)).toString(16).padStart(2, '0')).join('') }
|
||||
computed() {
|
||||
const FX = window.LOCFX, LOC = window.LOC, J = FX.SITES.journal
|
||||
const jv = FX.view({ site: J }), p2 = FX.view({ site: J, page: 2 }), fr = FX.view({ site: J, lang: 'fr' }), frm = FX.view({ site: J, lang: 'fr', tab: 'missing' }), rv = FX.view({ site: J, tab: 'review' }), au = FX.view({ site: J, author: 'Marta Alves' })
|
||||
const cnt = k => FX.count(k, J.stories), ver = J.stories.reduce((a, s) => a + Object.keys(s.v).length, 0), eq = (a, b2) => a === b2 ? 'Matches' : 'MISMATCH'
|
||||
const checks = [['Articles', 8, J.stories.length], ['Versions', 17, ver], ['Page size', 5, jv.size], ['Page 1 rows', 5, jv.rows.length], ['Page 2 rows', 3, p2.rows.length], ['Pages', 2, jv.pages], ['en versions / published', '7 / 6', cnt('en').join(' / ')], ['fr versions / published', '4 / 2', cnt('fr').join(' / ')], ['fr needing review', 2, fr.tabs[3][1]], ['de versions / published', '4 / 3', cnt('de').join(' / ')], ['ar versions', 1, cnt('ar')[0]], ['es versions', 1, cnt('es')[0]], ['it versions', 0, cnt('it')[0]], ['Articles without French', 4, frm.total], ['Needs translation tab', 7, jv.tabs[1][1]], ['Needs review tab', 2, rv.total], ['Author Marta Alves', 3, au.total], ['Setup articles (Journal + Notes + Archive)', 16, FX.SETUP.reduce((a, r) => a + r.n, 0)], ['Press: not in French', 0, FX.view({ site: FX.SITES.press, lang: 'fr', tab: 'missing' }).total]]
|
||||
const checkRows = checks.map(c => [c[0], String(c[1]), String(c[2]), eq(String(c[1]), String(c[2]))])
|
||||
const byFx = {}; LOC.groups.forEach(g => g.states.forEach(a => { (byFx[a[6]] = byFx[a[6]] || []).push(a[5]) }))
|
||||
const fxRows = Object.entries(byFx).map(([k, v]) => [k, FX.FIXTURE_NOTES[k.split(',')[0].replace(' (limited scope)', '').trim()] || '', v.length + ' states', v.length > 12 ? v.slice(0, 8).join(', ') + ' and ' + (v.length - 8) + ' more (every state not listed under another fixture)' : v.join(', ')])
|
||||
const cl = ['--ui-success', '--rc-success-soft'], ct = (m2) => { const t = m2 === 'dark' ? this.dark : this.light; const pr = [['Published badge', t['--ui-success'], t['--rc-success-soft'], 4.5], ['Info badge (Default, Tracked source)', t['--ui-info'], t['--rc-info-soft'], 4.5], ['Warning badge', t['--ui-warning'], t['--rc-warning-soft'], 4.5], ['Error badge', t['--ui-error'], t['--rc-error-soft'], 4.5], ['Neutral badge (Draft, Read only)', t['--ui-text-toned'], t['--ui-bg-elevated'], 4.5], ['Freshness badge (outlined)', t['--ui-warning'], t['--ui-bg'], 4.5], ['Selected row and menu item', t['--rc-on-sand'], t['--rc-sand'], 4.5], ['Coverage chip', t['--ui-text-highlighted'], t['--ui-bg'], 4.5], ['Dashed missing chip text', t['--ui-text-muted'], t['--ui-bg'], 4.5], ['Disabled-language chip (75%)', this.mix(t['--ui-text-highlighted'], t['--ui-bg'], .75), t['--ui-bg'], 4.5], ['Primary button', t['--ui-bg'], t['--ui-primary'], 4.5], ['Disabled menu item (60%, exempt)', this.mix(t['--ui-text-highlighted'], t['--ui-bg'], .6), t['--ui-bg'], 4.5], ['Disabled button (40%, exempt)', this.mix(t['--ui-text-highlighted'], t['--ui-bg'], .4), t['--ui-bg'], 4.5], ['Dashed and input borders (non-text)', t['--ui-border-accented'], t['--ui-bg'], 3]]; return pr.map(r => [r[0], this.ratio(r[1], r[2]).toFixed(2), r[3]]) }
|
||||
const L = ct('light'), D = ct('dark'), conRows = L.map((r, i) => [r[0], r[1], D[i][1], r[2] + ':1', (+r[1] >= r[2] && +D[i][1] >= r[2]) ? 'Pass' : (/exempt/.test(r[0]) ? 'Exempt (disabled)' : 'FAIL')])
|
||||
const cntRows = LOC.groups.map(g => [g.id + ' ' + g.title, String(g.states.length), String(g.states.filter(a => a[4] === 'tri').length), g.release === 2 ? 'Release 2' : 'Release 1']).concat([['Total', String(LOC.groups.reduce((a, g) => a + g.states.length, 0)), String(LOC.groups.reduce((a, g) => a + g.states.filter(a2 => a2[4] === 'tri').length, 0)), '']])
|
||||
return [{ title: 'Fixture index', cols: ['Fixture', 'Contents', 'Used by', 'State IDs'], w: '1.2fr 2fr .8fr 3fr', rows: fxRows }, { title: 'Fixture totals check (computed from locale-fixtures.js now)', cols: ['Figure', 'Decided', 'Computed', 'Result'], w: '2fr 1fr 1fr 1fr', rows: checkRows }, { title: 'State counts per group', cols: ['Group', 'States', 'Also at 1024', 'Release'], w: '3fr 1fr 1fr 1fr', rows: cntRows }, { title: 'Contrast of badges, chips and variants (computed from the tokens)', cols: ['Element', 'Light', 'Dark', 'Needs', 'Result'], w: '3fr 1fr 1fr 1fr 1.4fr', rows: conRows }]
|
||||
}
|
||||
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 }) }
|
||||
})
|
||||
return { rootRef: this.rootRef, modes, groups: this.groups(), tables: window.LOC.tables.concat(this.computed()) }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -81,14 +81,15 @@ class Component extends DCLogic {
|
||||
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') },
|
||||
junior: { name: 'Junior writer', desc: 'Writes articles and reads online ones and their own drafts.', members: 2, g: { schema: '100', media: '11n', authors: '10o0', activity: 'n0', users: '0000', settings: '000n000', roles: '000', system: '000' }, all: 'o1onn', t: mkT('o1onn') },
|
||||
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 phr = (c, art) => { const f = (ch, vb) => ch === 'a' ? vb + ' any entry' : ch === 'o' ? vb + ' only their own' : null; const can = [c[0] === 'o' ? (art ? 'read online articles and their own drafts' : 'read only entries they created') : 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 groups = {}; TY.forEach(t => { const p = phr(R.t[t], t === 'Articles'); (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) })
|
||||
@@ -119,17 +120,19 @@ class Component extends DCLogic {
|
||||
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 rdNote = TY.some(t => R.t[t][0] === 'o') && Alert('info', 'info', 'Read: Own', 'Articles show online articles plus the person\'s own drafts. Custom content types show only entries the person created. Status counts follow the same rule.')
|
||||
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)))
|
||||
return D('display:flex;flex-direction:column;gap:10px', presets, card('', base, false, true), ...(o.noSchema ? ['Articles'] : TY).map(t => card(t, R.t[t], R.t[t] !== base)), o.noSchema && Alert('info', 'info', 'Custom content types aren\'t listed', 'Your role doesn\'t include Schema: view, so only Articles is shown. The permissions of custom types stay as they are.'), rdNote)
|
||||
}
|
||||
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 TYs = o.noSchema ? ['Articles'] : TY
|
||||
return D('display:flex;flex-direction:column;gap:12px', !o.ro && presets, D('border:1px solid var(--ui-border);position:relative;overflow-x:auto', D('min-width:720px', hd, row('', allv, false, true), ...TYs.map(t => row(t, R.t[t], R.t[t] !== base, false)), o.tip && Tip2(o.tip))), o.noSchema && Alert('info', 'info', 'Custom content types aren\'t listed', 'Your role doesn\'t include Schema: view, so only Articles is shown. The permissions of custom types stay as they are.'), rdNote)
|
||||
}
|
||||
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
|
||||
@@ -167,7 +170,7 @@ class Component extends DCLogic {
|
||||
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
|
||||
crumbs('Settings', 'Roles'); const cols = '200px minmax(0,1fr) 90px 100px 40px', noEdit = k === 'noedit', rows = k === 'empty' ? RL.slice(0, 3) : k === 'tgone' ? RL.filter(r => r !== 'contrib') : 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)) })),
|
||||
@@ -177,9 +180,11 @@ class Component extends DCLogic {
|
||||
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.'),
|
||||
noEdit && Banner('info', 'lock', 'You can see roles, but only people who manage roles can create or change them.'),
|
||||
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'))
|
||||
if (k === 'tedit') ov.push(Toast('warning', 'triangle-alert', 'Role changed elsewhere', 'Jonas Weber edited Contributor, so nothing was saved. Reload and try again.', 'Reload'))
|
||||
if (k === 'tgone') ov.push(Toast('info', 'info', 'That role no longer exists', 'It was deleted somewhere else, so we removed it from the list.'))
|
||||
} else if (v === 'editor') {
|
||||
const flow = s.flow
|
||||
let R = ROLES.contrib, o = {}
|
||||
@@ -200,6 +205,17 @@ class Component extends DCLogic {
|
||||
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 (k === 'readown') { R = ROLES.junior; crumbs('Settings', 'Roles', 'Junior writer'); o = { open: [], ring: 'Articles.read' } }
|
||||
else if (k === 'review') { R = { ...R, t: { ...R.t, Events: 'a1aan' } }; o = { open: [], chg: ['Events.update', 'Events.delete'], 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' }), over: Shell({ title: 'How the saved role differs from your edits', desc: 'Jonas Weber saved changes to Contributor at 11:02.', close: true, cancel: 'Use the saved role', confirm: 'Keep my edits', top: 100, w: 520 }, D('border:1px solid var(--ui-border)', D('display:grid;grid-template-columns:minmax(0,1fr) 72px 72px;gap:8px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', S('', 'Setting'), S('', 'Saved'), S('', 'Yours')), ...[['Events · Update', 'Own', 'All'], ['Events · Delete', 'None', 'Own']].map(r => D('display:grid;grid-template-columns:minmax(0,1fr) 72px 72px;gap:8px;padding:10px 12px;border-top:1px solid var(--ui-border);color:var(--ui-text-highlighted)', S('font-weight:500', r[0]), S('', r[1]), S('font-weight:600', r[2]))))) } }
|
||||
else if (k === 'noschema') { shell.display = 'Elena Rossi'; shell.role = 'admin'; o = { open: [], noSchema: true } }
|
||||
else if (k === 'reset-confirm' || k === 'reset-toast') { R = ROLES.writer; shell.mTitle = 'Writer'; crumbs('Settings', '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.')], over: k === 'reset-confirm' ? Shell({ title: 'Reset Writer to default?', desc: 'Writer goes back to its built-in permissions. 5 members and the API keys they created are affected as soon as you save. This can\'t be undone.', close: true, cancel: 'Cancel', confirm: 'Reset to default', tone: 'danger', top: 140 }, [Mu('Your unsaved changes to Writer are discarded.')]) : Toast('success', 'circle-check', 'Role reset', 'Writer is back to its defaults. Save to apply it to 5 members.') } }
|
||||
else if (k === 'review-name') { o = { open: [], chg: ['Events.update'], 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(3, { sdis: true, msg: '3 unsaved changes. Save is off until you reload' }), over: Shell({ title: 'How the saved role differs from your edits', desc: 'Jonas Weber saved changes to Contributor at 11:02.', close: true, cancel: 'Use the saved role', confirm: 'Keep my edits', top: 100, w: 520 }, D('border:1px solid var(--ui-border)', D('display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) minmax(0,1fr);gap:8px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', S('', 'Setting'), S('', 'Saved'), S('', 'Yours')), ...[['Name', 'Contributor', 'Event writer'], ['Description', 'Writes posts', 'Writes events'], ['Events · Update', 'Own', 'All']].map(r => D('display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) minmax(0,1fr);gap:8px;padding:10px 12px;border-top:1px solid var(--ui-border);color:var(--ui-text-highlighted)', S('font-weight:500', r[0]), S('', r[1]), S('font-weight:600', r[2]))))) } }
|
||||
else if (k === 'dup') { R = { ...ROLES.contrib, name: 'Contributor copy', members: 0 }; crumbs('Settings', 'Roles', 'New role'); shell.mTitle = 'New role'; o = { isNew: true, open: [], alerts: [Banner('info', 'copy', 'Copied from Contributor', 'The permissions are the same. Give the copy a name that fits, then save.')], bar: SaveBar(0, { sdis: false, msg: 'Copy not saved yet' }) } }
|
||||
else if (k === 'invname') { R = { ...ROLES.blank, name: '' }; o = { isNew: true, nameErr: 'Give the role a name.', bar: SaveBar(0, { sdis: false, msg: '1 problem to fix' }) }; shell.mTitle = 'New role' }
|
||||
else if (k === 'e400') { R = { ...R, t: { ...R.t, Events: 'a1aan' } }; o = { open: [], chg: ['Events.update', 'Events.delete'], bar: SaveBar(2), over: Toast('error', 'circle-x', 'Couldn\'t save the role', 'Something in the form isn\'t valid. Check the highlighted fields.') } }
|
||||
else if (k === 'nf') { crumbs('Settings', 'Roles'); main = Center('lock', 'That role no longer exists', 'It was deleted somewhere else, so we removed it from the list.', B('Back to Roles', { k: 'primary', icon: 'arrow-left', px: 14 })); R = null }
|
||||
else if (k === 'kept') { R = { ...R, t: { ...R.t, Events: 'a1aan' } }; o = { open: [], chg: ['Events.update', 'Events.delete'], pvChg: true, bar: SaveBar(2), over: Toast('info', 'info', 'Role reloaded', 'Your 2 changes are still here. Review them, then save.') } }
|
||||
else if (k === 'saveerr') { R = { ...R, t: { ...R.t, Events: 'a1aan' } }; o = { open: [], chg: ['Events.update', 'Events.delete'], alerts: [Banner('error', 'circle-x', 'Couldn\'t save the role', 'Your changes are still here. Try again in a moment.', 'Try again')], bar: SaveBar(2) } }
|
||||
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' }
|
||||
@@ -208,8 +224,8 @@ class Component extends DCLogic {
|
||||
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 (R) { 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')
|
||||
@@ -229,25 +245,51 @@ class Component extends DCLogic {
|
||||
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')]))
|
||||
const pk = (R2, o2) => pick(R2, o2)
|
||||
if (k === 'gone') 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: 100 }, [Alert('error', 'circle-x', 'Writer no longer exists', 'It was deleted somewhere else. Choose another role to move the members to.'), pk(null, {})]))
|
||||
if (k === 'newmem') ov.push(Shell({ title: 'Delete Intern?', close: true, cancel: 'Cancel', confirm: 'Move 1 member and delete role', cdis: true, tone: 'danger', top: 100 }, [Alert('warning', 'triangle-alert', 'Role still has members', 'Intern now has 1 member. Move them to another role first.'), pk(null, {})]))
|
||||
if (k === 'moveerr') 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: false, tone: 'danger', top: 100 }, [Alert('error', 'circle-x', 'Couldn\'t move the members', 'Nothing was changed. Try again in a moment.'), pk(null, { val: 'Writer' })]))
|
||||
} 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']]
|
||||
if (k === 'rolechanged') U[2][2] = 'Content lead'
|
||||
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))),
|
||||
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 === 'selfmenu' && i === 1))),
|
||||
k === 'menu' && Menu(24, 190, [['user-cog', 'Change role', false, true], ['mail', 'Send password reset'], ['user-x', 'Remove user', false, false, true]]),
|
||||
k === 'selfmenu' && Menu(24, 143, [['user-cog', 'Change role', true], ['user-x', 'Remove user', true]]), k === 'selfmenu' && D('position:absolute;z-index:31;right:260px;top:150px;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)', 'You can\'t change your own role'), k === 'selfmenu' && D('position:absolute;z-index:31;right:260px;top:196px;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)', 'You can\'t remove yourself. Ask another owner or admin.'),
|
||||
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 radio = (n, sum, on, dis, why, sel) => D('display:flex;gap:10px;padding:10px 12px;border:1px solid ' + (on || sel ? '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 || sel) && 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 === 'change') ov.push(Shell({ title: 'Change role for Ines Duarte', desc: 'Their 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: 'Their 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.')]))
|
||||
const chOpts = [radio('Admin', SUM.Admin, true), radio('Writer', SUM.Writer), radio('Contributor', SUM.Contributor), radio('Content lead', SUM['Content lead'], false, false, '', true)]
|
||||
if (k === 'changesame') ov.push(Shell({ title: 'Change role for Ines Duarte', desc: 'Their API keys follow the new role.', close: true, cancel: 'Cancel', confirm: 'Change role', cdis: true, top: 60 }, D('display:flex;flex-direction:column;gap:8px', Alert('info', 'info', 'Ines Duarte is now Admin', 'Someone changed their role while this dialog was open. Your choice is already their role, so there is nothing to change.'), radio('Admin', SUM.Admin, true), radio('Writer', SUM.Writer), radio('Contributor', SUM.Contributor))))
|
||||
if (k === 'changestale') ov.push(Shell({ title: 'Change role for Ines Duarte', desc: 'Their API keys follow the new role.', close: true, cancel: 'Cancel', confirm: 'Change role', top: 60 }, D('display:flex;flex-direction:column;gap:8px', Alert('warning', 'triangle-alert', 'Role changed in the meantime', 'Ines Duarte is now Admin. Your choice is kept. Check it and try again.'), ...chOpts)))
|
||||
if (k === 'changeerr') ov.push(Shell({ title: 'Change role for Ines Duarte', desc: 'Their API keys follow the new role.', close: true, cancel: 'Cancel', confirm: 'Change role', top: 60 }, D('display:flex;flex-direction:column;gap:8px', Alert('error', 'circle-x', 'Couldn\'t change the role', 'Nothing was changed. Try again in a moment.'), radio('Admin', SUM.Admin), radio('Writer', SUM.Writer, true), radio('Contributor', SUM.Contributor), radio('Content lead', SUM['Content lead'], false, false, '', true))))
|
||||
const inv = (email, eerr, role, rerr, alert) => Shell({ title: 'Invite user', desc: 'They get an email with a link to set a password.', close: true, cancel: 'Cancel', confirm: 'Send invite', top: 80 }, [alert, F('Email', email, { err: eerr }), F('Role', role, { sel: true, err: rerr, help: rerr ? null : SUM[role] }), Mu('Writer is the default. You can change a role later.')].filter(Boolean))
|
||||
if (k === 'invbad') ov.push(inv('sofia@renewbuild', 'Enter a valid email address.', 'Writer'))
|
||||
if (k === 'invdup') ov.push(inv('ines@renewbuild.example', 'This address already has an account.', 'Writer'))
|
||||
if (k === 'invesc') { shell.role = 'admin'; shell.display = 'Jonas Weber'; ov.push(inv('sofia@renewbuild.example', null, 'Content lead', 'Your role can\'t assign a role with more power than yours.')) }
|
||||
if (k === 'inverr') ov.push(inv('sofia@renewbuild.example', null, 'Writer', null, Alert('error', 'circle-x', 'Couldn\'t send the invite', 'Try again in a moment.')))
|
||||
if (k === 'tr2stale') ov.push(Shell({ title: 'Transfer ownership to Jonas Weber', desc: 'This takes effect straight away.', close: true, cancel: 'Back', confirm: 'Transfer ownership', cdis: true, tone: 'danger', top: 40 }, [Alert('warning', 'triangle-alert', 'Role changed in the meantime', 'Jonas Weber is now Writer. What you typed is kept. Check the change, then try again.', 'Reload member'), D('display:flex;flex-direction:column;gap:6px', ...['Jonas Weber becomes the owner.', 'You become an Admin. You can\'t take ownership back unless they transfer it.'].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.example', { focus: true, help: 'The email must match exactly.' })]))
|
||||
if (k === 'rolechanged') ov.push(Toast('success', 'circle-check', 'Role changed', 'Ines Duarte is now Content lead, and their API keys follow the new role.'))
|
||||
if (k === 'tstale') ov.push(Toast('warning', 'triangle-alert', 'Role not changed', 'Ines Duarte\'s role changed in the meantime, so nothing was saved. Reload and try again.', 'Reload'))
|
||||
if (k === 'ttr') ov.push(Toast('warning', 'triangle-alert', 'Ownership not transferred', 'Jonas Weber\'s role changed in the meantime, so nothing was transferred. Check it and try again.', 'Reload'))
|
||||
if (k === 'selfmenu') { shell.role = 'admin'; shell.display = 'Jonas Weber' }
|
||||
const rem = (o, alert) => Shell({ title: 'Remove Ines Duarte?', desc: 'They lose access straight away, and the API keys they created stop working. Their articles, entries and media stay.', close: true, cancel: 'Cancel', confirm: 'Remove user', tone: 'danger', top: 100, ...o }, [alert].filter(Boolean))
|
||||
if (k === 'remdlg') ov.push(rem({}))
|
||||
if (k === 'remsend') ov.push(rem({ confirm: 'Removing…', loading: true }))
|
||||
if (k === 'remerr') ov.push(rem({}, Alert('error', 'circle-x', 'Couldn\'t remove the user', 'Nothing was changed. Try again in a moment.')))
|
||||
if (k === 'remok') ov.push(Toast('success', 'circle-check', 'User removed', 'Ines Duarte no longer has access.'))
|
||||
if (k === 'trem') ov.push(Toast('warning', 'triangle-alert', 'User not removed', 'Ines Duarte\'s role changed in the meantime, so nothing was removed. Check it and try again.', 'Reload'))
|
||||
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 === '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 they transfer 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') {
|
||||
@@ -281,6 +323,26 @@ class Component extends DCLogic {
|
||||
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 === 'rdown') {
|
||||
shell.role = 'user'; shell.display = 'Pedro Lima'; shell.sideOpen = false
|
||||
const ent = k === 'ent' || k === 'ent0'
|
||||
shell.nav = ent ? 'content' : 'articles'; if (ent) shell.contentOn = 'events'
|
||||
crumbs(...(ent ? ['Content', 'Events'] : ['Articles']))
|
||||
const Tabs = (items, on) => D('display:flex;gap:' + (m ? 16 : 24) + 'px;border-bottom:1px solid var(--ui-border);flex-shrink:0', ...items.map(([tt, n]) => D('display:flex;align-items:center;gap:6px;height:' + (m ? 44 : 36) + 'px;margin-bottom:-1px;border-bottom:2px solid ' + (on === tt ? 'var(--ui-primary)' : 'transparent') + ';color:' + (on === tt ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + ';font-weight:' + (on === tt ? 600 : 500), tt, S('font-size:12px;font-weight:400;color:var(--ui-text-muted)', String(n)))))
|
||||
if (!ent) {
|
||||
const cols = 'minmax(0,1fr) 140px 110px 120px 40px'
|
||||
const A = [['How we price a bathroom renovation', 'Ines Duarte', 'Online'], ['Retrofit grants in 2027', 'Jonas Weber', 'Online'], ['Winter checklist for timber windows', 'Marta Alves', 'Online'], ['Choosing lime plaster', 'Pedro Lima', 'Draft'], ['Site safety checklist', 'Pedro Lima', 'Draft']]
|
||||
const d0 = k === 'draft0'
|
||||
const data = d0 ? [] : A
|
||||
const body = d0 ? Empty('file-text', 'You have no drafts', 'Drafts you create appear here. Your role can\'t see drafts by other people.', 'New article') : m ? D('display:flex;flex-direction:column;gap:8px', ...data.map(r => Cd({}, Hi(r[0]), D('display:flex;gap:8px;align-items:center', Bd(r[2], r[2] === 'Online' ? 'ok' : 'req'), Mu(r[1]))))) : D('background:var(--ui-bg);border:1px solid var(--ui-border)', Th(cols, 'Title', 'Created by', 'Status', 'Updated', ''), ...data.map(r => Tr(cols, {}, Hi(r[0]), Mu(r[1]), Bd(r[2], r[2] === 'Online' ? 'ok' : 'req'), Mu('Oct 14'), Dots())))
|
||||
main = Page(m ? '16px 16px 88px' : '24px', Title('Articles', 'Online articles and the drafts you created.', B('New article', { k: 'primary', icon: 'plus', px: 14 })), Banner('info', 'eye-off', 'Drafts by others are hidden', 'Your role reads online articles and the drafts you created. The counts follow the same rule.'), Tabs(d0 ? [['All', 3], ['Online', 3], ['Draft', 0]] : [['All', 5], ['Online', 3], ['Draft', 2]], d0 ? 'Draft' : 'All'), body, m && Fab('New article'))
|
||||
} else {
|
||||
const cols = 'minmax(0,1fr) 140px 120px 40px'
|
||||
const En = [['Lime plaster workshop', '21 Nov 2026', 'Oct 14'], ['Tool library evening', '5 Dec 2026', 'Oct 12'], ['Site safety talk', '18 Dec 2026', 'Oct 9']]
|
||||
const e0 = k === 'ent0'
|
||||
const body = e0 ? Empty('files', 'You haven\'t created an event yet', 'Your role shows only entries you created, so other people\'s events aren\'t listed here. Use New entry to add one.', 'New entry') : D('display:flex;flex-direction:column;gap:8px', Mu('3 entries'), m ? D('display:flex;flex-direction:column;gap:8px', ...En.map(r => Cd({}, Hi(r[0]), Mu(r[1] + ' · updated ' + r[2])))) : D('background:var(--ui-bg);border:1px solid var(--ui-border)', Th(cols, 'Title', 'Starts on', 'Updated', ''), ...En.map(r => Tr(cols, {}, Hi(r[0]), Mu(r[1]), Mu(r[2]), Dots()))))
|
||||
main = Page(m ? '16px 16px 88px' : '24px', Title('Events', 'Your role shows only entries you created.', B('New entry', { k: 'primary', icon: 'plus', px: 14 })), Banner('info', 'eye-off', 'Entries by others aren\'t shown', 'Your role reads only entries you created. Ask an owner or admin if you need to see more.'), body, m && Fab('New entry'))
|
||||
}
|
||||
} 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' }))) }
|
||||
@@ -292,15 +354,22 @@ class Component extends DCLogic {
|
||||
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)))) }
|
||||
const OKR = [['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'], ['users', 'Moved 3 members from Intern to Writer', 'Marta Alves', 'Oct 15, 09:29'], ['trash-2', 'Role Intern deleted', 'Marta Alves', 'Oct 15, 09:30']]
|
||||
const FLR = [['shield', 'Failed to create the role Contributor', 'Marta Alves', 'Oct 16, 09:02', 1], ['pencil', 'Failed to edit the role Contributor', 'Marta Alves', 'Oct 16, 09:20', 1], ['user-cog', 'Failed to change the role of Ines Duarte', 'Jonas Weber', 'Oct 16, 10:05', 1], ['crown', 'Failed to transfer ownership to Jonas Weber', 'Marta Alves', 'Oct 16, 10:30', 1], ['users', 'Failed to move 3 members from Intern to Writer', 'Marta Alves', 'Oct 16, 11:12', 1], ['trash-2', 'Failed to delete the role Intern', 'Marta Alves', 'Oct 16, 11:13', 1]]
|
||||
const E = k === 'failed' || k === 'faildetail' ? FLR : OKR
|
||||
const hot = (k === 'detail' || k === 'faildetail') ? 1 : -1
|
||||
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: i === hot }, I(e[0], 16, 'color:var(--ui-text-toned)'), D('display:flex;align-items:center;gap:8px;min-width:0', Hi(e[1]), e[4] && Bd('Failed', 'err', 'circle-x')), 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])), D('display:flex;gap:8px;align-items:center', e[4] && Bd('Failed', 'err', 'circle-x'), Mu(e[2] + ' · ' + e[3])))))
|
||||
const CATS = ['Articles', 'Media', 'Users and invites', 'Roles', 'API keys', 'Authentication', 'System']
|
||||
const fbar = k === 'cats' && D('position:relative;flex-shrink:0;height:' + (m ? 44 : 32) + 'px;width:' + (m ? '100%' : '240px'), F('', 'Roles', { sel: true, ico: 'layout-list', focus: true }), D('position:absolute;z-index:30;left:0;right:0;top:' + (m ? 48 : 36) + 'px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px', D('min-height:' + (m ? 44 : 32) + 'px;padding:6px 8px;display:flex;align-items:center;color:var(--ui-text-muted)', 'All categories'), ...CATS.map(c => D('min-height:' + (m ? 44 : 32) + 'px;padding:6px 8px;display:flex;align-items:center;gap:8px;color:var(--ui-text-highlighted);background:' + (c === 'Roles' ? 'var(--ui-bg-elevated)' : 'transparent'), S('flex:1', c), c === 'Roles' && I('check', 16)))))
|
||||
main = Page(m ? '16px' : '24px', Title('Activity', 'Everything that happened on this site.'), fbar, m ? cards : tbl)
|
||||
if (k === 'detail' || k === 'faildetail') { const fd = k === 'faildetail'; const body = D('display:flex;flex-direction:column;gap:12px', D('display:flex;flex-direction:column;gap:2px', Hi(fd ? 'Failed to edit the role Contributor' : 'Role Contributor edited'), Mu(fd ? 'Marta Alves · Oct 16, 09:20' : 'Marta Alves · Oct 14, 10:52')), fd ? Alert('error', 'circle-x', 'Why it failed', 'Jonas Weber saved changes to Contributor first, so this edit was refused.') : 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(fd ? 'The role was not changed.' : '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.')))
|
||||
shell.nav = 'profile'; shell.role = k === 'owner' ? 'owner' : 'user'; shell.display = k === 'owner' ? 'Marta Alves' : 'Pedro Lima'; shell.sideOpen = false; crumbs('Profile')
|
||||
const R = k === 'owner' ? ROLES.owner : ROLES.contrib
|
||||
main = Page(m ? '16px' : '24px', Title('Profile', 'Your account on this RenewCMS workspace.'), Card('Display name', 'Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username.', null, F('Display name', k === 'dn-long' ? 'Marta Alves, Head of Renovations, Brussels and Wallonia office' : k === 'owner' || k === 'dn-removed' ? '' : shell.display, { opt: true, phText: 'e.g. Marta Alves', err: k === 'dn-long' ? 'Display name must be 60 characters or fewer.' : undefined })), Card('Account', 'Set when your account was created. You log in with your username, even after adding a display name.', null, F('Username', k === 'owner' ? 'marta' : 'pedro', { lock: true }), F('Email', k === 'owner' ? 'marta@renewbuild.example' : 'pedro@renewbuild.example', { lock: true })), Card('Your role', null, null, D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', S('display:inline-flex;align-items:center;height:22px;padding:0 8px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font:400 12px \'IBM Plex Mono\',monospace', R.name.toLowerCase()), R.lock && Bd('Locked', 'lock', 'lock'), R.built ? Bd('Built-in', 'built') : Bd('Custom', 'own')), 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.')), Card('Author profile', 'Your public byline on articles. It\'s separate from your account.', null, Mu('You don\'t have an author profile yet. Create one to be credited on your own articles.'), D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';align-items:' + (m ? 'stretch' : 'center') + ';gap:12px', B('Create my author profile', { k: 'primary', icon: 'feather', px: 12, full: m }), Mu(k === 'owner' ? 'Uses your username, marta. You can change it afterwards.' : 'Uses your display name, ' + shell.display + '. You can change it afterwards.'))))
|
||||
if (k === 'dn-saved') ov.push(Toast('success', 'circle-check', 'Display name saved', 'You now appear as ' + shell.display + ' across RenewCMS and in emails.'))
|
||||
if (k === 'dn-removed') ov.push(Toast('success', 'circle-check', 'Display name removed', 'You now appear as your username, pedro.'))
|
||||
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'
|
||||
|
||||
@@ -209,7 +209,7 @@ class Component extends DCLogic {
|
||||
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.'),
|
||||
cr ? Alert('info', 'lock', 'You can read these fields here', 'Your role doesn\'t include Edit content types. Ask an owner or admin.') : 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 {
|
||||
@@ -234,6 +234,7 @@ class Component extends DCLogic {
|
||||
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 === 'saving') info = false
|
||||
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 }
|
||||
@@ -264,8 +265,9 @@ class Component extends DCLogic {
|
||||
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 && f && (s.sheet || (v === 'builder' && ['edit-sheet', 'choices', 'media', 'mreq', 'mnew'].includes(k)))) ov.push(Sheet({ title: 'Field: ' + (f.label || 'Untitled field') }, Insp(f), [D('display:flex;gap:8px;width:100%', B('Move up', { icon: 'arrow-up', h: 44, full: true, dis: sel === 0 }), B('Move down', { icon: 'arrow-down', h: 44, full: true, dis: sel === fl.length - 1 }), B('Remove', { icon: 'trash-2', k: 'del', h: 44, full: true })), 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 (v === 'builder' && k === 'saving') 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 }, [D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;font-size:15px;color:var(--ui-text-highlighted)', 'Add 1 field, remove 1'), S('color:var(--ui-text-muted)', 'Events has no entries yet, so nothing is checked.'))]))
|
||||
if (toast) ov.push(toast)
|
||||
}
|
||||
if (v === 'builder') { bld() }
|
||||
@@ -400,12 +402,19 @@ class Component extends DCLogic {
|
||||
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') {
|
||||
} else if (k === 'form' || k === 'more') {
|
||||
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 NM = ['Rue Verte hall', 'Rue Verte yard', 'Rue du Port studio', 'Rue Verte annex', 'Rue de la Gare depot', 'Rue Verte workshop', 'Rue du Port loft', 'Rue des Halles hall', 'Rue Verte garden room', 'Rue du Moulin barn', 'Rue de Nantes studio', 'Rue Verte gallery', 'Rue du Port quay', 'Rue des Tanneurs hall', 'Rue Verte cellar', 'Rue du Four kitchen', 'Rue Verte terrace', 'Rue du Port warehouse', 'Rue des Écoles hall', 'Rue Verte pavilion', 'Rue du Lac studio', 'Rue Verte shed', 'Rue de la Paix room', 'Rue Verte forge', 'Rue du Port atelier']
|
||||
const VN = NM.map((n, i) => [n, ['Lyon', 'Nantes', 'Lille'][i % 3] + ' · ' + (24 + (i * 17) % 120) + ' seats'])
|
||||
const more = k === 'more', merr = k === 'moreerr', rh = m ? 48 : 40
|
||||
const rw = ([x, y], i) => D('min-height:' + rh + 'px;padding:4px 8px;display:flex;flex-direction:column;justify-content:center;background:' + (i === 0 && !more ? 'var(--ui-bg-elevated)' : 'transparent'), S('font-weight:500', x), S('font-size:12px;color:var(--ui-text-muted)', y))
|
||||
const sk = w => D('min-height:' + rh + 'px;padding:4px 8px;display:flex;flex-direction:column;justify-content:center;gap:6px', S('display:block;height:12px;width:' + w + ';background:var(--ui-bg-accented)'), S('display:block;height:10px;width:40%;background:var(--ui-bg-accented)'))
|
||||
const rows = more ? [...VN.slice(22).map(rw), sk('55%'), sk('70%'), sk('45%')] : merr ? VN.slice(18).map(rw) : VN.slice(0, 5).map(rw)
|
||||
const lm = S('height:' + (m ? 44 : 28) + 'px;padding:0 10px;display:inline-flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-weight:500;font-size:12px;white-space:nowrap;opacity:' + (more ? 0.4 : 1), more && I('loader-circle', 14), more ? 'Loading…' : merr ? 'Try again' : 'Load more')
|
||||
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)', D('position:relative;padding:4px', ...rows, !more && !merr && S('position:absolute;right:3px;top:6px;width:4px;height:' + rh * 1.5 + 'px;background:var(--ui-bg-accented)')), D('display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 8px;border-top:1px solid var(--ui-border)', merr ? S('font-size:12px;line-height:16px;color:var(--ui-error);min-width:0;text-wrap:pretty', 'Couldn\'t load more results. The 25 shown are still here.') : S('font-size:12px;color:var(--ui-text-muted)', 'Showing 25 of 62'), lm))
|
||||
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;position:relative', S('font-weight:500;color:var(--ui-text-highlighted)', 'Venue'), F('', 'rue', { focus: true, ico: 'search' }), res, S('height:' + (m ? 300 : 280) + 'px')),
|
||||
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'
|
||||
|
||||
@@ -32,7 +32,7 @@ class Component extends DCLogic {
|
||||
const BADGE = {
|
||||
ok: ['background:var(--rc-success-soft);color:var(--ui-success)', 'circle-check'], none: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', 'circle-dashed'],
|
||||
busy: ['background:var(--rc-info-soft);color:var(--ui-info)', 'loader-circle'], fail: ['background:var(--rc-error-soft);color:var(--ui-error)', 'circle-x'],
|
||||
used: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)'], unused: ['background:var(--rc-warning-soft);color:var(--ui-warning)', 'unlink'],
|
||||
used: ['border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-toned)'], dup: ['background:var(--rc-info-soft);color:var(--ui-info)', 'copy'], unused: ['background:var(--rc-warning-soft);color:var(--ui-warning)', 'unlink'],
|
||||
vars: ['background:var(--rc-info-soft);color:var(--ui-info)', 'layers'], off: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', 'circle-off'], warn: ['background:var(--rc-warning-soft);color:var(--ui-warning)', 'triangle-alert']
|
||||
}
|
||||
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;' + BADGE[k][0], BADGE[k][1] && I(BADGE[k][1], 12), t)
|
||||
@@ -107,7 +107,7 @@ class Component extends DCLogic {
|
||||
S('color:var(--ui-text-toned);' + MONO + ';font-size:12px', dim(v) + ' · ' + v.fit + ' · ' + v.fmt + (v.q ? ' · q' + v.q : '')),
|
||||
D('display:flex;align-items:center', S('flex:1;color:var(--ui-text-muted)', v.files.toLocaleString('en-US') + ' generated · ' + v.size), act(i)))), S('color:var(--ui-text-muted);padding:4px 0', '6 variants · 1,284 generated files · 212 MB'))
|
||||
const RefCard = () => {
|
||||
const rows = [['/media/photo.png?v=thumb', '200', 'Image', 'ok'], ['Unknown or disabled variant', '400', 'Unknown variant', 'err'], ['width, height, format or quality', '400', 'Unsupported parameter', 'err'], ['Missing source image', '404', 'Not found', 'err']]
|
||||
const rows = [['/static/uploadedImages/photo.png?v=thumb', '200', 'Image', 'ok'], ['Unknown or disabled variant', '400', 'Unknown variant', 'err'], ['width, height, format or quality', '400', 'Unsupported parameter', 'err'], ['Missing source image', '404', 'Not found', 'err']]
|
||||
const json = '{\n "status": 400,\n "error": "unknown_variant",\n "message": "Unknown variant",\n "detail": "banner"\n}'
|
||||
return D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:12px', S('font-weight:600;color:var(--ui-text-highlighted)', 'How requests are handled'),
|
||||
D('display:grid;grid-template-columns:' + (m ? 'minmax(0,1fr)' : 'minmax(0,1.3fr) minmax(0,1fr)') + ';gap:16px', D('display:flex;flex-direction:column;border:1px solid var(--ui-border)', ...rows.map(([a, b, c, k], i) => D('display:flex;align-items:center;gap:10px;min-height:' + (m ? 44 : 36) + 'px;padding:6px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('flex:1;min-width:0;' + (i === 0 ? MONO + ';font-size:12px' : '') + ';color:var(--ui-text-highlighted)', a), Bd(b, k === 'ok' ? 'ok' : 'fail'), S('width:' + (m ? 120 : 130) + 'px;color:var(--ui-text-toned)', c)))),
|
||||
@@ -177,19 +177,20 @@ class Component extends DCLogic {
|
||||
const sortLbl = ML.sort === 'total' ? (m ? 'Largest total size' : 'Largest total size (original + variants)') : 'Newest first'
|
||||
const sortBox = D('position:relative;' + (m ? 'flex:1' : ''), D('height:' + (m ? 44 : 30) + 'px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;', I('arrow-up-down', 14), S('flex:1', sortLbl), I('chevron-down', 14)),
|
||||
ML.menu && D('position:absolute;z-index:20;top:' + (m ? 48 : 34) + 'px;right:0;width:' + (m ? '100%' : '340px') + ';background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px', ...['Newest first', 'Oldest first', 'Filename', 'Largest total size (original + variants)'].map((t, i) => D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 44 : 32) + 'px;padding:0 8px;' + (i === 3 ? 'background:var(--ui-bg-elevated);font-weight:500;' : '') + 'color:var(--ui-text-highlighted)', S('width:16px;display:flex', i === 3 && I('check', 14)), t, i === 3 && S('margin-left:auto;font-size:11px;color:var(--ui-text-muted);' + MONO, 'new')))))
|
||||
return D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';gap:8px;align-items:' + (m ? 'stretch' : 'center') + ';flex-shrink:0', D('height:' + (m ? 44 : 30) + 'px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-muted);' + (m ? '' : 'width:240px'), I('search', 14), 'Search by filename'), !m && D('flex:1'), m ? D('display:flex;gap:8px', seg) : seg, sortBox)
|
||||
return D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';gap:8px;align-items:' + (m ? 'stretch' : 'center') + ';flex-shrink:0', D('height:' + (m ? 44 : 30) + 'px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-muted);' + (m ? '' : 'width:240px'), I('search', 14), 'Search by filename'), !m && D('flex:1'), m ? D('display:flex;gap:8px', seg) : seg, D('display:flex;gap:8px', sortBox, D('display:flex;border:1px solid var(--ui-border-accented);flex-shrink:0', ...[['layout-grid', 'var(--rc-sand)', 'var(--rc-on-sand)'], ['list', 'var(--ui-bg)', 'var(--ui-text-toned)']].map(([ic, bg, fg]) => S('width:' + (m ? 44 : 30) + 'px;height:' + (m ? 42 : 28) + 'px;display:flex;align-items:center;justify-content:center;background:' + bg + ';color:' + fg, I(ic))))))
|
||||
}
|
||||
const TabsRow = () => D('display:flex;gap:20px;border-bottom:1px solid var(--ui-border);flex-shrink:0', ...[['All', '12', 1], ['Unused', '2', 0], ['Duplicates', '2', 0]].map(([t, n, on]) => S('height:' + (m ? 44 : 36) + 'px;display:flex;align-items:center;gap:6px;border-bottom:2px solid ' + (on ? 'var(--ui-primary)' : 'transparent') + ';margin-bottom:-1px;font-weight:' + (on ? 500 : 400) + ';color:' + (on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)'), t, S('font:500 11px \'IBM Plex Mono\',monospace;color:var(--ui-text-muted)', n))))
|
||||
const MCard = im => {
|
||||
const hover = ML.sort === 'total'
|
||||
return D('display:flex;flex-direction:column;background:var(--ui-bg);border:1px solid var(--ui-border);min-width:0', Th(im.tone, 'aspect-ratio:4/3;border-bottom:1px solid var(--ui-border)'),
|
||||
D('padding:10px 12px;display:flex;flex-direction:column;gap:6px;min-width:0', 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', im.n), S('font-size:12px;color:var(--ui-text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis', hover ? im.tot + ' total' + (im.vc ? ' · ' + im.sz + ' + ' + im.vs : '') : im.sz + ' · ' + im.dim)),
|
||||
D('display:flex;flex-wrap:wrap;gap:4px', im.used ? Bd('Used in ' + im.used, 'used') : Bd('Unused', 'unused'), im.vc > 0 && Bd(im.vc + (im.vc === 1 ? ' variant' : ' variants'), 'vars'))))
|
||||
D('display:flex;flex-wrap:wrap;gap:4px', [5, 6, 7, 9, 12].includes(im.id) && Bd('Duplicate', 'dup'), im.used ? Bd('Used in ' + im.used, 'used') : Bd('Unused', 'unused'), im.vc > 0 && Bd(im.vc + (im.vc === 1 ? ' variant' : ' variants'), 'vars'))))
|
||||
}
|
||||
const MediaPage = () => {
|
||||
let ids = ML.ids || IM.map(x => x.id)
|
||||
const body = [PH('Media', 'Images you upload are kept as they are. Variants are generated from them.', B('Upload', { k: 'primary', icon: 'upload' })), Tb()]
|
||||
const body = [PH('Media', 'Images you upload are kept as they are. Variants are generated from them.', B('Upload', { k: 'primary', icon: 'upload' })), TabsRow(), Tb()]
|
||||
if (ML.empty) body.push(Empty('layers', 'No images with variants', 'Variants are generated when an image is requested with ?v=<name>, or from its Variants tab.', 56, B('Show all images')))
|
||||
else body.push(D('display:grid;grid-template-columns:repeat(' + (m ? 2 : 5) + ',minmax(0,1fr));gap:' + (m ? 10 : 16) + 'px', ...ids.map(byId).map(MCard)))
|
||||
else body.push(D('display:flex;align-items:center;gap:10px;color:var(--ui-text-muted)', S('width:18px;height:18px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);flex-shrink:0'), S('color:var(--ui-text-highlighted);font-weight:500', 'Select all'), S('', ids.length + ' images')), D('display:grid;grid-template-columns:repeat(' + (m ? 2 : 5) + ',minmax(0,1fr));gap:' + (m ? 10 : 16) + 'px', ...ids.map(byId).map(MCard)))
|
||||
return D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column', topbar, Pg(...body), ML.ann && D('position:absolute;z-index:20;' + (m ? 'left:16px;bottom:16px' : 'left:248px;bottom:20px'), Note(ML.ann)))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
window.ACCOUNT_COPY = {
|
||||
en: {
|
||||
emailLabel: 'Email',
|
||||
changeEmail: 'Change email',
|
||||
newEmail: 'New email address',
|
||||
newEmailPh: 'name@example.com',
|
||||
password: 'Current password',
|
||||
formHelp: "We'll send a verification link to the new address. Your email stays the same until you open it.",
|
||||
cancel: 'Cancel',
|
||||
send: 'Send verification link',
|
||||
sending: 'Sending…',
|
||||
errFormat: 'Enter a full email address, like name@example.com.',
|
||||
errSame: "That's already your email address.",
|
||||
errPwEmpty: 'Enter your current password.',
|
||||
errPwWrong: "That password isn't correct. Try again.",
|
||||
rate: 'Too many requests. Try again in a few minutes.',
|
||||
server: 'Something went wrong on our side. Nothing was changed. Try again in a moment.',
|
||||
pendTitle: 'Waiting for verification',
|
||||
pendText: 'We sent a link to {new}. Until you open it, you keep logging in with {old}.',
|
||||
resend: 'Resend link',
|
||||
resendRate: 'Resend in 4:30',
|
||||
cancelChange: 'Cancel change',
|
||||
sentT: 'Verification link sent',
|
||||
sentB: 'Check {new}. The link works once, within 2 hours.',
|
||||
resentT: 'Link sent again',
|
||||
resentB: 'The previous link no longer works.',
|
||||
cancelledT: 'Email change cancelled',
|
||||
cancelledB: 'You keep logging in with {old}.',
|
||||
dataTitle: 'Your data',
|
||||
dataDesc: 'Download a copy of the personal data RenewCMS holds about you, as JSON files in a ZIP.',
|
||||
dataItems: [
|
||||
['user', 'Profile', 'Username, display name, email, role and author profile.'],
|
||||
['file-text', 'Authored content', 'Your articles and entries.'],
|
||||
['image', 'Uploaded media', 'References to the files you uploaded.'],
|
||||
['history', 'Account activity', 'Your log-ins and the actions recorded under your name.']
|
||||
],
|
||||
download: 'Download my data',
|
||||
preparing: 'Preparing download…',
|
||||
dlHint: 'Ready in a few seconds.',
|
||||
dlT: 'Download started',
|
||||
dlB: 'renewcms-export-marta-2026-10-29.zip is in your downloads.',
|
||||
exRateT: 'You can export once every 15 minutes',
|
||||
exRateB: 'Your last export was at 09:41. Try again in 12 minutes.',
|
||||
exRateBtn: 'Available in 12:00',
|
||||
exErrT: "Couldn't prepare your data",
|
||||
exErrB: 'Nothing was changed. Try again in a moment.',
|
||||
request: 'Request my data',
|
||||
asyncHint: "Larger exports are prepared in the background. We'll email a download link to {old} when it's ready. You'll need to be logged in to download it.",
|
||||
queuedT: "We're preparing your export",
|
||||
queuedB: "This can take a few minutes. We'll email a link to {old} when it's ready.",
|
||||
queuedBtn: 'Export requested',
|
||||
inprogT: 'An export is already being prepared',
|
||||
inprogB: "We'll email a link to {old} when it's ready.",
|
||||
queuedToastT: 'Export requested',
|
||||
queuedToastB: 'Check {old} in a few minutes.',
|
||||
dzTitle: 'Danger zone',
|
||||
dzDesc: "Delete your account and your personal data. This can't be undone.",
|
||||
dzBtn: 'Delete my account',
|
||||
delTitle: 'Delete your account?',
|
||||
delDesc: "This permanently deletes your account. It can't be undone.",
|
||||
delList: ['Your username, display name and email address', 'Your author profile and API keys', "Your sessions: you'll be logged out everywhere"],
|
||||
delKeepT: 'Your articles and media stay, without your name',
|
||||
delKeepB: 'They stay on the site and are credited to "Deleted user". Your name is removed from them and this cannot be reversed.',
|
||||
delExport: 'Export my data first',
|
||||
delPw: 'Current password',
|
||||
delWordLabel: 'Type DELETE to confirm',
|
||||
delWord: 'DELETE',
|
||||
delKeep: 'Keep my account',
|
||||
delConfirm: 'Delete my account',
|
||||
deleting: 'Deleting…',
|
||||
delServer: "Couldn't delete your account. Nothing was deleted. Try again in a moment.",
|
||||
blockT: "You can't delete your account yet",
|
||||
blockB: "You are the last Owner. RenewCMS needs at least one. Transfer ownership or promote another user first, then come back to delete your account.",
|
||||
blockBack: 'Back to profile',
|
||||
blockGo: 'Go to Users',
|
||||
protT: "This account can't be deleted",
|
||||
protB: "RenewCMS can't delete this account right now. Ask an administrator to help, then try again.",
|
||||
vVerifying: 'Confirming your new email…',
|
||||
vOkT: 'Your email address has changed',
|
||||
vOkB: 'Your account now uses {new}. Use it the next time you log in.',
|
||||
vOkBtn: 'Go to profile',
|
||||
vOkToastT: 'Email address changed',
|
||||
vOkToastB: 'Your account now uses {new}.',
|
||||
vBadT: "This link doesn't work",
|
||||
vBadB: 'It may have expired (links last 2 hours), been cancelled or already been used.',
|
||||
vBadN: 'Request a new link from your profile.',
|
||||
vNoT: "We couldn't change your email address",
|
||||
vNoB: "This address can't be used for your account. Go back to your profile and try a different one.",
|
||||
vLoginT: 'Log in to confirm your new email',
|
||||
vLoginB: "You opened this link somewhere you're not logged in. Log in here and we'll confirm the change right after.",
|
||||
vLoginBtn: 'Log in and confirm',
|
||||
vMissT: 'This link is incomplete',
|
||||
vMissB: 'The confirmation code is missing. Open the link from your email again, or copy the full address into your browser.',
|
||||
vMissBtn: 'Go to log in',
|
||||
vErrT: "Couldn't confirm the change",
|
||||
vErrB: 'Something went wrong on our side. Nothing was changed. Try again in a moment.',
|
||||
vErrBtn: 'Try again',
|
||||
goneT: 'Your account has been deleted',
|
||||
goneB: 'Your account has been deleted. Your articles and media were kept without your name.'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<title>Your account has been deleted</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
|
||||
table { border-collapse: collapse; }
|
||||
a { color: #111111; }
|
||||
@media (max-width: 620px) {
|
||||
.rc-card { padding: 28px 20px !important; }
|
||||
.rc-btn a { display: block !important; }
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.rc-bg { background-color: #0a0a0a !important; }
|
||||
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
|
||||
.rc-h1, .rc-word { color: #fafafa !important; }
|
||||
.rc-text { color: #e6e4e0 !important; }
|
||||
.rc-muted { color: #999999 !important; }
|
||||
.rc-mark { border: 1px solid #444444 !important; }
|
||||
.rc-btn td { background-color: #fafafa !important; }
|
||||
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
|
||||
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
|
||||
.rc-rule { border-color: #333333 !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">Your RenewCMS account was deleted and your personal data removed.</div>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
|
||||
<tr>
|
||||
<td align="center" style="padding:40px 12px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
|
||||
<tr>
|
||||
<td style="padding:0 0 20px 0;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
|
||||
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
|
||||
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">Your account has been deleted</h1>
|
||||
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">Your RenewCMS account at {{.Host}} has been deleted.</p>
|
||||
<p class="rc-text rc-rule" style="margin:0;padding-top:0;border-top:0;font-size:14px;line-height:22px;color:#222222;">If you didn't ask for this, contact an administrator of {{.Host}}.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,69 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<title>Your email address change was requested</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
|
||||
table { border-collapse: collapse; }
|
||||
a { color: #111111; }
|
||||
@media (max-width: 620px) {
|
||||
.rc-card { padding: 28px 20px !important; }
|
||||
.rc-btn a { display: block !important; }
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.rc-bg { background-color: #0a0a0a !important; }
|
||||
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
|
||||
.rc-h1, .rc-word { color: #fafafa !important; }
|
||||
.rc-text { color: #e6e4e0 !important; }
|
||||
.rc-muted { color: #999999 !important; }
|
||||
.rc-mark { border: 1px solid #444444 !important; }
|
||||
.rc-btn td { background-color: #fafafa !important; }
|
||||
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
|
||||
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
|
||||
.rc-rule { border-color: #333333 !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">A change of email address was requested for your RenewCMS account. Nothing has changed yet.</div>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
|
||||
<tr>
|
||||
<td align="center" style="padding:40px 12px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
|
||||
<tr>
|
||||
<td style="padding:0 0 20px 0;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
|
||||
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
|
||||
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">Your email address change was requested</h1>
|
||||
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">A request was made to change the email address of the RenewCMS account {{.Username}} at {{.Host}} from this address to {{.NewEmail}}. Nothing has changed yet: you keep logging in with this address until the new one is confirmed.</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="rc-btn">
|
||||
<tr>
|
||||
<td bgcolor="#111111" style="background-color:#111111;">
|
||||
<a href="{{.Host}}/profile" target="_blank" style="display:inline-block;padding:14px 24px;border:1px solid #111111;background-color:#111111;color:#ffffff;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:16px;font-weight:600;line-height:20px;text-decoration:none;text-align:center;">Review my profile</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p class="rc-text rc-rule" style="margin:28px 0 0 0;padding-top:20px;border-top:1px solid #e6e4e0;font-size:14px;line-height:22px;color:#222222;">If this was you, no action is needed. If it wasn't, log in, cancel the change from your profile and change your password.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<title>Confirm your new email address</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
|
||||
table { border-collapse: collapse; }
|
||||
a { color: #111111; }
|
||||
@media (max-width: 620px) {
|
||||
.rc-card { padding: 28px 20px !important; }
|
||||
.rc-btn a { display: block !important; }
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.rc-bg { background-color: #0a0a0a !important; }
|
||||
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
|
||||
.rc-h1, .rc-word { color: #fafafa !important; }
|
||||
.rc-text { color: #e6e4e0 !important; }
|
||||
.rc-muted { color: #999999 !important; }
|
||||
.rc-mark { border: 1px solid #444444 !important; }
|
||||
.rc-btn td { background-color: #fafafa !important; }
|
||||
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
|
||||
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
|
||||
.rc-rule { border-color: #333333 !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">Confirm this address to finish changing your RenewCMS email. The link expires in 2 hours.</div>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
|
||||
<tr>
|
||||
<td align="center" style="padding:40px 12px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
|
||||
<tr>
|
||||
<td style="padding:0 0 20px 0;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
|
||||
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
|
||||
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">Confirm your new email address</h1>
|
||||
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">Someone, hopefully you, asked to change the email address of the RenewCMS account {{.Username}} at {{.Host}} to this address. Confirm it to finish the change.</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="rc-btn">
|
||||
<tr>
|
||||
<td bgcolor="#111111" style="background-color:#111111;">
|
||||
<a href="{{.Host}}/account/email/confirm?c={{.VerificationCode}}" target="_blank" style="display:inline-block;padding:14px 24px;border:1px solid #111111;background-color:#111111;color:#ffffff;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:16px;font-weight:600;line-height:20px;text-decoration:none;text-align:center;">Confirm new email address</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p class="rc-muted" style="margin:16px 0 0 0;font-size:14px;line-height:22px;color:#666666;">This link expires in 2 hours.</p>
|
||||
<p class="rc-muted" style="margin:28px 0 8px 0;font-size:14px;line-height:22px;color:#666666;">If the button doesn't work, copy this address into your browser:</p>
|
||||
<p class="rc-url" style="margin:0;padding:10px 12px;background-color:#f3f2f0;font-family:'IBM Plex Mono',Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#222222;word-break:break-all;">{{.Host}}/account/email/confirm?c={{.VerificationCode}}</p>
|
||||
<p class="rc-text rc-rule" style="margin:28px 0 0 0;padding-top:20px;border-top:1px solid #e6e4e0;font-size:14px;line-height:22px;color:#222222;">If this wasn't you, ignore this email; nothing will change.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,101 @@
|
||||
// Shared localization fixtures. One source for the Localization board (LocaleFrame), the Prototype (LocaleLive) and the Handoff.
|
||||
// Dataset "Journal": 8 stories, 17 versions. Every count shown anywhere is derived from STORIES or LANGS.
|
||||
// Separate fixtures (marked): PRESS (all stories translated, for the nothing-missing state), JOURNAL_PRE_FR (prototype start: French not enabled yet).
|
||||
(function () {
|
||||
const P = {
|
||||
en: ['Most quotes fail because they hide the same three costs. Here is how we itemise them before the first tile is ordered.', 'Start with the room, not the catalogue. Measure the walls, note where the pipes run and photograph the existing fixtures.', 'Next, separate what is fixed from what is chosen. Plumbing and waterproofing are fixed. Tiles and taps are chosen.', 'Price the fixed items first and ask for a written range. A range with a stated assumption is more useful than a single figure.', 'Allow ten percent for what the walls reveal. Old bathrooms almost always hide one surprise behind the tiles.', 'Check the floor falls toward the drain before any membrane goes down. Correcting a fall after tiling means lifting everything.', 'Choose the tiles after the layout is drawn. A tile that needs many cuts costs more in labour than it saves in material.', 'Ask the tiler to confirm the adhesive and grout for the wet area in writing, and keep the data sheets with the quote.', 'Agree who protects the hallway floor and who removes the old bath. These small jobs are the ones that start disputes.', 'Finally, agree the order of work with the tiler and the plumber before anything is ordered.'],
|
||||
fr: ['La plupart des devis échouent parce qu\'ils cachent les trois mêmes coûts. Voici comment nous les détaillons avant de commander le premier carreau.', 'Commencez par la pièce, pas par le catalogue. Mesurez les murs, repérez le passage des canalisations et photographiez les équipements existants.', 'Séparez ensuite ce qui est fixe de ce qui est choisi. La plomberie et l\'étanchéité sont fixes. Les carreaux et la robinetterie sont choisis.', 'Chiffrez d\'abord les postes fixes et demandez une fourchette écrite.'],
|
||||
de: ['Die meisten Angebote scheitern, weil sie dieselben drei Kosten verschweigen. So listen wir sie auf, bevor die erste Fliese bestellt wird.', 'Beginnen Sie mit dem Raum, nicht mit dem Katalog. Messen Sie die Wände und halten Sie fest, wo die Rohre verlaufen.'],
|
||||
ar: ['تفشل معظم عروض الأسعار لأنها تُخفي التكاليف الثلاثة نفسها. إليك كيف نفصّلها قبل طلب أول بلاطة.', 'ابدأ بالغرفة لا بالكتالوج. قِس الجدران، وحدّد مسار الأنابيب، وصوّر التركيبات الحالية.', 'افصل بعد ذلك ما هو ثابت عمّا هو مختار. السباكة والعزل ثابتان، أما البلاط والصنابير فمختارة.']
|
||||
}
|
||||
const LANG_NAMES = { en: 'English', fr: 'French', de: 'German', ar: 'Arabic', es: 'Spanish', it: 'Italian', 'pt-BR': 'Portuguese (Brazil)', 'en-GB': 'English (United Kingdom)' }
|
||||
const DIR = { ar: 'rtl' }
|
||||
// v: lang -> [status, needsReview, revision]
|
||||
const mk = (id, title, v, tr, by, upd) => ({ id, title, v, tr: tr || {}, by, upd })
|
||||
const STORIES = [
|
||||
mk('s1', 'How we price a bathroom renovation', { en: ['pub', 0, 8], fr: ['draft', 1, 5], de: ['pub', 0, 3], ar: ['draft', 1, 2] }, { fr: 'Comment nous chiffrons une rénovation de salle de bain', de: 'So kalkulieren wir eine Badsanierung', ar: 'كيف نسعّر تجديد الحمّام' }, 'Marta Alves', '2 h ago'),
|
||||
mk('s2', 'A guide to lime plaster', { en: ['pub', 0, 4], fr: ['pub', 0, 4], de: ['pub', 0, 2] }, { fr: 'Guide de l\'enduit à la chaux', de: 'Ein Leitfaden zu Kalkputz' }, 'Camille Roche', 'Yesterday'),
|
||||
mk('s3', 'Winter checklist for timber windows', { en: ['pub', 0, 6], fr: ['pub', 1, 3] }, { fr: 'Liste d\'hiver pour les fenêtres en bois' }, 'Luis Ferrer', '3 days ago'),
|
||||
mk('s4', 'Spring newsletter', { en: ['draft', 0, 1] }, {}, 'Marta Alves', '3 days ago'),
|
||||
mk('s5', 'Roof inspection checklist', { en: ['pub', 0, 3], de: ['draft', 0, 1] }, { de: 'Checkliste für die Dachprüfung' }, 'Luis Ferrer', '14 Oct'),
|
||||
mk('s6', 'Why we reuse original floorboards', { en: ['pub', 0, 2], fr: ['draft', 0, 1] }, { fr: 'Pourquoi nous réutilisons les lames de parquet d\'origine' }, 'Camille Roche', '13 Oct'),
|
||||
mk('s7', 'Our first passive-house retrofit', { en: ['pub', 0, 5], de: ['pub', 0, 2] }, { de: 'Unsere erste Passivhaus-Sanierung' }, 'Marta Alves', '12 Oct'),
|
||||
mk('s8', 'Cómo elegir un contratista', { es: ['pub', 0, 2] }, {}, 'Luis Ferrer', '10 Oct')
|
||||
]
|
||||
const ENABLED = ['en', 'fr', 'de', 'ar']
|
||||
const LANGS = [
|
||||
{ name: 'English', code: 'en', def: true, enabled: true, social: 'Not set' },
|
||||
{ name: 'French', code: 'fr', enabled: true, social: 'fr_FR' },
|
||||
{ name: 'German', code: 'de', enabled: true, social: 'Not set' },
|
||||
{ name: 'Arabic', code: 'ar', enabled: true, social: 'Not set' },
|
||||
{ name: 'Spanish', code: 'es', enabled: false, social: 'Not set' },
|
||||
{ name: 'Italian', code: 'it', enabled: false, social: 'Not set' }
|
||||
]
|
||||
const count = (code, stories) => { let n = 0, pub = 0; stories.forEach(s => { const x = s.v[code]; if (x) { n++; if (x[0] === 'pub') pub++ } }); return [n, pub] }
|
||||
const PRESS = [
|
||||
mk('p1', 'Opening hours over the holidays', { en: ['pub', 0, 2], fr: ['pub', 0, 2] }, { fr: 'Horaires pendant les fêtes' }, 'Marta Alves', 'Yesterday'),
|
||||
mk('p2', 'Meet the new site manager', { en: ['pub', 0, 3], fr: ['draft', 1, 1] }, { fr: 'Rencontrez la nouvelle cheffe de chantier' }, 'Luis Ferrer', '3 days ago'),
|
||||
mk('p3', 'Annual report', { en: ['draft', 0, 1], fr: ['pub', 0, 1] }, { fr: 'Rapport annuel' }, 'Marta Alves', '14 Oct')
|
||||
]
|
||||
// Prototype start: French is not enabled, so no French versions exist yet.
|
||||
const preFr = () => STORIES.map(s => { const v = { ...s.v }; delete v.fr; const tr = { ...s.tr }; delete tr.fr; return { ...s, v, tr } })
|
||||
// Setup fixture (before languages exist): articles per site, site locale as stored, the language entry it maps to.
|
||||
const SETUP = [
|
||||
{ site: 'Journal', n: 8, locale: 'en', tag: 'en', name: 'English', note: 'Site locale en' },
|
||||
{ site: 'Notes', n: 3, locale: null, tag: null, name: null, note: 'No site locale' },
|
||||
{ site: 'Archive', n: 5, locale: 'pt-BR', tag: 'pt-BR', name: 'Portuguese (Brazil)', note: 'Site locale pt-BR' }
|
||||
]
|
||||
|
||||
STORIES[3].seo = 1; STORIES[4].seo = 1
|
||||
Object.assign(LANG_NAMES, { 'zh-Hans': 'Chinese (Simplified)', ja: 'Japanese' })
|
||||
const ATELIER = [
|
||||
mk('a1', 'A complete guide to renovating a ground-floor kitchen and bathroom in a listed building, with every cost itemised for owners and tenants', { en: ['pub', 0, 4], 'en-GB': ['draft', 0, 1], fr: ['draft', 1, 2], de: ['pub', 0, 2], 'pt-BR': ['draft', 0, 1], 'zh-Hans': ['pub', 0, 2], ja: ['pub', 0, 2], ar: ['draft', 0, 1] }, {}, 'Marta Alves', '2 h ago'),
|
||||
mk('a2', 'Heat pumps for stone farmhouses', { en: ['pub', 0, 2], 'en-GB': ['pub', 0, 1], fr: ['draft', 0, 1], de: ['pub', 0, 1], ja: ['draft', 0, 1] }, {}, 'Luis Ferrer', 'Yesterday'),
|
||||
mk('a3', 'Sourcing reclaimed oak beams', { en: ['draft', 0, 1], 'en-GB': ['draft', 0, 1] }, {}, 'Camille Roche', '14 Oct')
|
||||
]
|
||||
ATELIER[0].seo = 1; ATELIER[2].seo = 1
|
||||
const ARCHIVE = [['Como restaurar azulejos antigos', 'pub', 'Marta Alves', '3 days ago'], ['Guia de cal e areia', 'pub', 'Camille Roche', '14 Oct'], ['Pintura para fachadas húmidas', 'draft', 'Luis Ferrer', '13 Oct'], ['Orçamento de uma reabilitação', 'pub', 'Marta Alves', '12 Oct'], ['Telhados de telha cerâmica', 'pub', 'Camille Roche', '10 Oct']].map((r, i) => mk('r' + (i + 1), r[0], { 'pt-BR': [r[1], 0, 1] }, {}, r[2], r[3]))
|
||||
const NOTES = [['Site visit notes', 'Luis Ferrer', '3 days ago'], ['Supplier call summary', 'Marta Alves', '14 Oct'], ['Tile samples list', 'Camille Roche', '10 Oct']].map((r, i) => mk('n' + (i + 1), r[0], {}, {}, r[1], r[2]))
|
||||
const SITES = {
|
||||
journal: { name: 'Journal', stories: STORIES, langs: ['en', 'fr', 'de', 'ar'], disabled: ['es', 'it'], def: 'en', size: 5, locale: 'en' },
|
||||
press: { name: 'Press', stories: PRESS, langs: ['en', 'fr'], disabled: [], def: 'en', size: 5, locale: 'en' },
|
||||
atelier: { name: 'Atelier', stories: ATELIER, langs: ['en', 'en-GB', 'fr', 'de', 'pt-BR', 'zh-Hans', 'ja', 'ar'], disabled: [], def: 'en', size: 5, locale: 'en' },
|
||||
archive: { name: 'Archive', stories: ARCHIVE, langs: ['pt-BR'], disabled: [], def: 'pt-BR', size: 5, locale: 'pt-BR' },
|
||||
notes: { name: 'Notes', stories: NOTES, langs: [], disabled: [], def: null, size: 5, locale: null },
|
||||
gallery: { name: 'Gallery', stories: [], langs: ['en-GB'], disabled: [], def: 'en-GB', size: 5, locale: 'en-GB' }
|
||||
}
|
||||
const pl = (n, w) => n + ' ' + w + (n === 1 ? '' : 's')
|
||||
const tt = (s, k) => s.tr[k] || s.title
|
||||
const repKey = (s, def) => s.v[def] ? def : Object.keys(s.v).sort()[0]
|
||||
const view = o => {
|
||||
const site = o.site, en = site.langs, lang = o.lang || null, create = o.create || en, st = o.status, au = o.author, q = (o.q || '').toLowerCase(), seo = o.seo, size = o.size || site.size
|
||||
const ss = site.stories.map(s => { const v = {}; Object.keys(s.v).forEach(k => { if (!o.read || o.read.includes(k)) v[k] = s.v[k] }); return { ...s, v } }).filter(s => Object.keys(s.v).length && (!o.scope || o.scope(s)))
|
||||
const pass = (s, vs, ks) => (!st || vs.some(x => x[0] === st)) && (!au || s.by === au) && (!seo || s.seo) && (!q || ks.some(k => tt(s, k).toLowerCase().includes(q)))
|
||||
const all = s => pass(s, Object.values(s.v), Object.keys(s.v))
|
||||
let tabs, rowsBy, LN = lang ? LANG_NAMES[lang] : ''
|
||||
if (lang) {
|
||||
const inL = ss.filter(s => s.v[lang] && pass(s, [s.v[lang]], [lang])), miss = ss.filter(s => !s.v[lang] && all(s))
|
||||
rowsBy = { all: inL, pub: inL.filter(s => s.v[lang][0] === 'pub'), draft: inL.filter(s => s.v[lang][0] === 'draft'), review: inL.filter(s => s.v[lang][1]), missing: miss }
|
||||
tabs = [['All', 'all'], ['Published', 'pub'], ['Drafts', 'draft'], ['Needs review', 'review'], ['Not in ' + LN, 'missing']]
|
||||
} else {
|
||||
const b = ss.filter(all)
|
||||
rowsBy = { all: b, missing: b.filter(s => create.some(k => en.includes(k) && !s.v[k])), review: b.filter(s => Object.values(s.v).some(x => x[1])) }
|
||||
tabs = [['All articles', 'all'], ['Needs translation', 'missing'], ['Needs review', 'review']]
|
||||
}
|
||||
const tab = o.tab || 'all', rows = rowsBy[tab], total = rows.length, pages = Math.max(1, Math.ceil(total / size)), page = Math.min(o.page || 1, pages), from = total ? (page - 1) * size + 1 : 0, to = Math.min(total, page * size)
|
||||
const unit = lang ? ({ all: pl(total, LN + ' version'), pub: 'published ' + LN + ' version' + (total === 1 ? '' : 's'), draft: pl(total, LN + ' draft'), review: pl(total, LN + ' version') + ' ' + (total === 1 ? 'needs' : 'need') + ' review', missing: pl(total, 'article') + ' not in ' + LN })[tab] : ({ all: pl(total, 'article'), missing: pl(total, 'article') + ' ' + (total === 1 ? 'needs' : 'need') + ' translation', review: pl(total, 'article') + ' ' + (total === 1 ? 'needs' : 'need') + ' review' })[tab]
|
||||
return { tabs: tabs.map(t => [t[0], rowsBy[t[1]].length, t[1]]), tab, total, pages, page, size, from, to, unit, rows: rows.slice((page - 1) * size, page * size).map(s => ({ s, rep: repKey(s, site.def) })) }
|
||||
}
|
||||
const chips = (s, site, create) => {
|
||||
const en = site.langs, out = [], has = k => !!s.v[k]
|
||||
const order = [site.def].concat(en.filter(k => k !== site.def))
|
||||
order.forEach(k => { if (has(k)) out.push({ k, st: s.v[k][0], nr: s.v[k][1] }) })
|
||||
Object.keys(s.v).filter(k => !en.includes(k)).forEach(k => out.push({ k, st: s.v[k][0], nr: s.v[k][1], off: 1 }))
|
||||
order.forEach(k => { if (!has(k) && (create || en).includes(k)) out.push({ k, st: null }) })
|
||||
return out
|
||||
}
|
||||
const SOCIAL = { journal: { en: ['none'], fr: ['set', 'fr_FR'], de: ['none'], ar: ['none'], es: ['none'], it: ['none'] }, atelier: { en: ['none'], 'en-GB': ['derived', 'en_GB'], fr: ['set', 'fr_FR'], de: ['none'], 'pt-BR': ['derived', 'pt_BR'], 'zh-Hans': ['none'], ja: ['none'], ar: ['none'] } }
|
||||
const AUTHORS = Object.entries(STORIES.reduce((a, s) => (a[s.by] = (a[s.by] || 0) + 1, a), {}))
|
||||
const FIXTURE_NOTES = { Journal: '8 articles, 17 versions, page size 5; en (default), fr, de, ar enabled; es, it disabled', Press: '3 articles, en + fr, all translated', Atelier: '3 articles, 8 languages, long labels', Archive: '5 articles, one language (pt-BR kept)', Notes: '3 articles, no site locale', Gallery: 'No articles, site locale en-GB' }
|
||||
window.LOCFX = { P, STORIES, PRESS, ATELIER, ARCHIVE, NOTES, SITES, ENABLED, LANGS, LANG_NAMES, DIR, SETUP, preFr, count, view, chips, pl, tt, repKey, SOCIAL, AUTHORS, FIXTURE_NOTES, TOTALS: { stories: 8, versions: 17 } }
|
||||
})()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -10,7 +10,7 @@ export const groups = [
|
||||
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('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' }, 'ReadOnlyBanner: 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: [
|
||||
@@ -30,6 +30,17 @@ export const groups = [
|
||||
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: '2b Role editor: app sync', title: '2b Role editor: app sync', route: '/settings/roles/new?from=<id>, /settings/roles/:id', api: 'POST /v1/role, PUT /v1/role/{id}, GET /v1/role/{id}', auth: 'Roles: manage', note: 'Presets: Read only sets Read: All and everything else None for every type; Write own sets Read: All, Create: On, Update: Own, Delete and Publish: None; Full content sets Read, Update, Delete and Publish: All and Create: On. A preset overwrites every type row, including custom rows, and counts every cell it changes as an unsaved change. "N unsaved changes" counts cells in the content grid plus general toggles whose value differs from the loaded role; name and description each count as one. Changing a cell back to its loaded value removes it from the count. The footer bar is pinned to the bottom. The grid scrolls sideways inside its card below 720 px. There is no leave guard when deleting a role: that dialog holds no unsaved edits.', states: [
|
||||
E('No Schema: view', 'noschema', 'The editor lacks Schema: view, so custom content types can\'t be listed.', 'Custom content types aren\'t listed · Your role doesn\'t include Schema: view, so only Articles is shown. The permissions of custom types stay as they are.', {}),
|
||||
E('Reset to default: confirm', 'reset-confirm', 'Reset to default on a built-in role (owner only).', 'Reset Writer to default? · Writer goes back to its built-in permissions. 5 members and the API keys they created are affected as soon as you save. This can\'t be undone. · Your unsaved changes to Writer are discarded. · Cancel · Reset to default', {}),
|
||||
E('Reset to default: done', 'reset-toast', 'The grid shows the defaults; Save applies them.', 'Role reset · Writer is back to its defaults. Save to apply it to 5 members.', {}),
|
||||
E('Review differences: name and description', 'review-name', 'Name and description differences appear as rows above the permission rows, with the saved value and yours.', 'How the saved role differs from your edits · Name · Contributor · Event writer · Description · Writes posts · Writes events · Events · Update · Own · All', {}),
|
||||
E('Duplicate', 'dup', 'Duplicate on a role opens /settings/roles/new?from=<id>. The name is prefilled with "<role> copy" and nothing is saved until Save changes.', 'Copied from Contributor · The permissions are the same. Give the copy a name that fits, then save.', {}),
|
||||
E('Role not found (404)', 'nf', 'Opening /settings/roles/:id for a role that was deleted.', 'That role no longer exists · It was deleted somewhere else, so we removed it from the list. · Back to Roles', {}),
|
||||
E('invalid_role_name (400)', 'invname', 'The server refuses a blank or invalid name. The field shows the same text as the client check.', 'Give the role a name.', {}),
|
||||
E('Generic 400', 'e400', 'Any other 400 on save.', 'Couldn\'t save the role · Something in the form isn\'t valid. Check the highlighted fields.', {}),
|
||||
st('Change role: choice is now the current role', N, 'After the re-read, the chosen role equals the member\'s current role. Change role is disabled.', { v: 'users', k: 'changesame' }, 'Ines Duarte is now Admin · Someone changed their role while this dialog was open. Your choice is already their role, so there is nothing to change. · Change role (disabled)')
|
||||
] },
|
||||
{ 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.'),
|
||||
@@ -40,20 +51,20 @@ export const groups = [
|
||||
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')
|
||||
st('Built-in role', N, 'Delete is disabled in the menu. If the dialog is reached by link it shows this state. There is no leave guard: closing it needs no confirmation.', { 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: [
|
||||
{ label: '5 Users', title: '5 Users: roles and ownership', route: '/users', api: 'PUT /v1/user/{id}/role, POST /v1/user/invite, POST /v1/site/transfer-ownership', auth: 'Users: view; change role, invite, remove; owner for transfer', 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 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 · Their 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: 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 they transfer it. · Only the owner can apply updates, delete a site and change activity retention. · Your API keys follow your new role. · Type jonas@renewbuild.example to confirm · The email must match exactly. · Back · Transfer ownership'),
|
||||
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: [
|
||||
{ label: '6 Role-aware shell', title: '6 Role-aware shell', route: 'Every page', api: 'GET /v1/auth/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.'),
|
||||
@@ -69,11 +80,18 @@ export const groups = [
|
||||
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.')
|
||||
st('List with role events', N, 'Six role events. Event names: role.create, role.edit, user.change_role, user.transfer_ownership, role.reassign, role.delete.', { 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 · Moved 3 members from Intern to Writer · 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.'),
|
||||
st('Failed role events', N, 'The same six events with outcome Failed. The sentence starts with "Failed to" and the Failed badge follows it.', { v: 'act', k: 'failed' }, 'Failed to create the role Contributor · Failed to edit the role Contributor · Failed to change the role of Ines Duarte · Failed to transfer ownership to Jonas Weber · Failed to move 3 members from Intern to Writer · Failed to delete the role Intern · Failed'),
|
||||
st('Failed event: details', N, 'Open "Failed to edit the role Contributor". The reason comes from the API.', { v: 'act', k: 'faildetail' }, 'Activity details · Why it failed · Jonas Weber saved changes to Contributor first, so this edit was refused. · The role was not changed.'),
|
||||
st('Category filter', N, 'The Category select open. Roles is a new category; Users & invites is renamed Users and invites.', { v: 'act', k: 'cats' }, 'All categories · Articles · Media · Users and invites · Roles · API keys · Authentication · System')
|
||||
] },
|
||||
{ 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.'),
|
||||
{ label: '9 Profile', title: '9 Profile: your role', route: '/profile', api: 'GET /v1/auth/me', auth: 'Any', note: 'The person sees their own role and a read-only summary. Page order: Display name, Account, Your role, Author profile.', states: [
|
||||
st('Role card: custom role', N, 'Any signed-in user with a custom role. The card shows only what access.role carries (id, name, built_in, locked): the name as a role badge and a Custom tag. The one-line summary and Assigned by were removed.', { v: 'prof', k: 'card' }, 'Your role · Contributor · Custom · See what I can do · Your API keys follow this role. If your role changes, they change too.'),
|
||||
st('Role card: Owner', N, 'The owner. Same card; the Owner badge is followed by Locked (locked is true) and Built-in.', { v: 'prof', k: 'owner' }, 'Your role · Owner · Locked · Built-in · See what I can do · Your API keys follow this role. If your role changes, they change too.'),
|
||||
st('Display name too long', N, 'Over 60 characters. Save stays off.', { v: 'prof', k: 'dn-long' }, 'Display name must be 60 characters or fewer.'),
|
||||
st('Display name saved', N, '200.', { v: 'prof', k: 'dn-saved', toastB: 80 }, 'Display name saved · You now appear as Pedro Lima across RenewCMS and in emails.'),
|
||||
st('Display name removed', N, 'Saved empty: back to the username.', { v: 'prof', k: 'dn-removed', toastB: 80 }, 'Display name removed · You now appear as your username, pedro.'),
|
||||
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: [
|
||||
@@ -86,6 +104,47 @@ export const groups = [
|
||||
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 })
|
||||
] },
|
||||
{ label: '12 Toasts', title: '12 Toasts: role changed and stale state', route: 'Any page', api: 'PUT /v1/user/{id}/role, PUT /v1/role/{id}, DELETE /v1/user/{id}, POST /v1/site/transfer-ownership', auth: 'As the action', note: 'Warning toasts say that the thing the person acted on changed first, that nothing was changed, and offer Reload. They stay until dismissed or Reload is pressed. Info toasts (404) close after 6 s. Success toasts close after 6 s. Final EN and FR copy is in the table "Final copy: toasts and errors, EN and FR".', states: [
|
||||
st('Role changed', N, '200 on PUT /v1/user/{id}/role. The row updates in place and a success toast confirms.', { v: 'users', k: 'rolechanged', toastB: 80 }, `Role changed · Ines Duarte is now Content lead, and their API keys follow the new role.`),
|
||||
st('Role edited elsewhere (409)', N, '409 on an action from the roles list (Duplicate, Delete, Move members) because the role version changed after the list loaded. Inside the role editor the Conflict banner is used instead. Reload refetches GET /v1/role.', { v: 'list', k: 'tedit', toastB: 80 }, `Role changed elsewhere · Jonas Weber edited Contributor, so nothing was saved. Reload and try again. · Reload`),
|
||||
st('Role deleted elsewhere', N, 'Any action on a role deleted by someone else. The row is removed from the list. A target role deleted during Move members or Change role shows an inline error instead.', { v: 'list', k: 'tgone', toastB: 80 }, `That role no longer exists · It was deleted somewhere else, so we removed it from the list.`),
|
||||
st('Role not changed: member\'s role changed first (409)', N, 'Change role was confirmed after someone else had changed the same member. The dialog is closed and the list is stale. Reload refetches GET /v1/user/.', { v: 'users', k: 'tstale', toastB: 80 }, `Role not changed · Ines Duarte's role changed in the meantime, so nothing was saved. Reload and try again. · Reload`),
|
||||
st('Ownership not transferred: target\'s role changed first (409)', N, 'Transfer ownership was confirmed after the target\'s role changed, and the dialog was closed in the meantime. Nothing was transferred.', { v: 'users', k: 'ttr', toastB: 80 }, `Ownership not transferred · Jonas Weber's role changed in the meantime, so nothing was transferred. Check it and try again. · Reload`),
|
||||
st('User not removed: member\'s role changed first (409)', N, 'Remove user was confirmed after the member\'s role changed. Nothing was removed.', { v: 'users', k: 'trem', toastB: 80 }, `User not removed · Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again. · Reload`)
|
||||
] },
|
||||
{ label: '12b Remove user', title: '12b Remove user', route: '/settings/users', api: 'DELETE /v1/user/{id}?expected_role_id=N', auth: 'Users and invites: remove users', note: 'Remove user is in the row menu. It is disabled on your own row. The request carries expected_role_id, so a role change made first returns 409 target_role_not_found. The name in the dialog and toast is the display name, falling back to the username, never the email. "Removed {0}" is the activity-log sentence for user.remove, not a toast.', states: [
|
||||
st('Own row: remove and change role disabled', N, 'Row menu on your own row. Both items are disabled, each with its own reason.', { v: 'users', k: 'selfmenu' }, `Change role (disabled) · You can't change your own role · Remove user (disabled) · You can't remove yourself. Ask another owner or admin.`),
|
||||
st('Remove user: confirm', N, 'Remove user in the row menu of another member.', { v: 'users', k: 'remdlg' }, `Remove {0}? · They lose access straight away, and the API keys they created stop working. Their articles, entries and media stay. · Cancel · Remove user`),
|
||||
st('Remove user: removing', N, 'DELETE in flight. Buttons disabled, dialog can\'t be dismissed.', { v: 'users', k: 'remsend' }, `Removing…`),
|
||||
st('Remove user: 500', N, '500 or network failure. Inline error, nothing was removed, the button stays on.', { v: 'users', k: 'remerr' }, `Couldn't remove the user · Nothing was changed. Try again in a moment.`),
|
||||
st('Remove user: target_role_not_found (409)', N, 'The member\'s role changed between loading and confirming. The dialog closes and this warning toast stays until dismissed or Reload is pressed.', { v: 'users', k: 'trem', toastB: 80 }, `User not removed · {0}'s role changed in the meantime, so nothing was removed. Check it and try again. · Reload`),
|
||||
st('Remove user: done', N, '204. The row leaves the list. Toast closes after 6 s.', { v: 'users', k: 'remok', toastB: 80 }, `User removed · {0} no longer has access.`)
|
||||
] },
|
||||
{ label: '13 Inline errors', title: '13 Inline errors: editor and dialogs', route: 'Role editor, Change role, Delete role, Invite', api: 'POST/PUT /v1/role, PUT /v1/user/{id}/role, POST /v1/role/{id}/reassign, POST /v1/user/invite', auth: 'As the action', note: 'Errors stay in the dialog or form that caused them, above the fields or under the field they belong to. The dialog stays open and keeps what the person entered. The primary button stays on so the same action can be tried again, except when the person has to choose again.', states: [
|
||||
st('Role editor: save failed (500)', N, '500 or network failure on POST or PUT /v1/role. Every edit is kept and Save stays on.', { v: 'editor', k: 'saveerr' }, `Couldn't save the role · Your changes are still here. Try again in a moment. · Try again`),
|
||||
st('Change role: failed (500)', N, '500 or network failure. The choice is kept.', { v: 'users', k: 'changeerr' }, `Couldn't change the role · Nothing was changed. Try again in a moment.`),
|
||||
st('Delete role: target role deleted', N, '409 because the chosen target role was deleted meanwhile. The picker is cleared and the action is off until a role is chosen.', { v: 'del', k: 'gone' }, `Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to.`),
|
||||
st('Delete role: role gained members', N, '409 role_has_members when a role that had no members got one after the dialog opened. The dialog turns into the blocked state.', { v: 'del', k: 'newmem' }, `Role still has members · Intern now has 1 member. Move them to another role first. · Move 1 member and delete role (disabled until a role is chosen)`),
|
||||
st('Delete role: move failed (500)', N, '500 or network failure on reassign. The chosen role is kept.', { v: 'del', k: 'moveerr' }, `Couldn't move the members · Nothing was changed. Try again in a moment.`),
|
||||
st('Invite: invalid email', N, '400 or client check. Shown under Email.', { v: 'users', k: 'invbad' }, `Enter a valid email address.`),
|
||||
st('Invite: address already has an account (409)', N, 'The address belongs to a user. Shown under Email. Same text as the API.', { v: 'users', k: 'invdup' }, `This address already has an account.`),
|
||||
st('Invite: role above yours (403)', N, 'An admin picked a role with more power than their own. Shown under Role.', { v: 'users', k: 'invesc', role: 'admin' }, `Your role can't assign a role with more power than yours.`),
|
||||
st('Invite: send failed (500)', N, '500 or network failure. Email and role are kept.', { v: 'users', k: 'inverr' }, `Couldn't send the invite · Try again in a moment.`)
|
||||
] },
|
||||
{ label: '14 Read = Own', title: '14 Read = Own: what the person sees', route: '/articles, /content/:key, /settings/roles/:id', api: 'GET /v1/article/, GET /v1/content-type/{key}/entry', auth: 'As the role', note: 'A role with Read set to Own sees online articles plus its own drafts. For custom content types it sees only entries it created. Counts, totals and pagination follow the same rule, so nothing reveals how many hidden items exist. This is different from Update or Delete set to Own, where other people\'s rows are still listed with a View only mark.', states: [
|
||||
E('Role editor: Read set to Own', 'readown', 'Junior writer has Read: Own on every type. A note under the grid explains what Own means for Read. The preview panel uses type-specific sentences.', `Read: Own · Articles show online articles plus the person's own drafts. Custom content types show only entries the person created. Status counts follow the same rule. · Can read online articles and their own drafts, create entries and edit only their own. · Can read only entries they created, create entries and edit only their own.`),
|
||||
st('Articles list: Read = Own', N, 'Pedro Lima, Junior writer. Three online articles by others and his two own drafts. Counts: All 5, Online 3, Draft 2. Drafts by others are not listed and not counted.', { v: 'rdown', k: 'art' }, `Articles · Online articles and the drafts you created. · Drafts by others are hidden · Your role reads online articles and the drafts you created. The counts follow the same rule. · All 5 · Online 3 · Draft 2 · New article`),
|
||||
st('Articles list: no drafts of their own', N, 'The Draft tab when the person owns nothing. Counts: All 3, Online 3, Draft 0.', { v: 'rdown', k: 'draft0' }, `You have no drafts · Drafts you create appear here. Your role can't see drafts by other people. · New article · All 3 · Online 3 · Draft 0`),
|
||||
st('Entry list: Read = Own', N, 'Events with Read: Own. Only entries the person created, no pager, count line shows only their own entries.', { v: 'rdown', k: 'ent' }, `Events · Your role shows only entries you created. · Entries by others aren't shown · Your role reads only entries you created. Ask an owner or admin if you need to see more. · 3 entries · New entry`),
|
||||
st('Entry list: no entries of their own', N, 'The person has created none. Other people\'s entries exist but are not hinted at.', { v: 'rdown', k: 'ent0' }, `You haven't created an event yet · Your role shows only entries you created, so other people's events aren't listed here. Use New entry to add one. · New entry`)
|
||||
] },
|
||||
{ label: '15 409 conflicts', title: '15 Role 409 conflicts: stale save, change and transfer', route: '/settings/roles/:id, /users', api: 'PUT /v1/role/{id}, PUT /v1/user/{id}/role, POST /v1/site/transfer-ownership', auth: 'As the action', note: 'Roles carry a version. A stale request returns 409 and nothing is changed. The message appears where the person is working (banner in the editor, alert inside a dialog), never as a toast while that surface is open. The form always keeps what the person entered. Recovery: in the editor, review the differences and either keep the edits on top of the saved role or use the saved role; in a dialog, reload the member and try again.', states: [
|
||||
E('Stale save: banner', 'conflict', '409 role_conflict on PUT (version mismatch). Banner above the form. Edits are kept. Save is off until the person reloads or reviews.', `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('Stale save: review differences', 'review', 'Review differences. A dialog lists only the settings where the saved role and the edits disagree. Keep my edits reloads the role and puts the edits back on top (Save turns on). Use the saved role drops the edits. Reload role in the banner is the same as Use the saved role.', `How the saved role differs from your edits · Jonas Weber saved changes to Contributor at 11:02. · Setting · Saved · Yours · Events · Update · Own · All · Events · Delete · None · Own · Use the saved role · Keep my edits`),
|
||||
E('Stale save: edits kept after reload', 'kept', 'After Keep my edits. The form shows the saved role with the edits on top, changed cells marked, Save on.', `Role reloaded · Your 2 changes are still here. Review them, then save. · 2 unsaved changes`),
|
||||
st('Change role: member\'s role changed first (409)', N, 'Change role confirmed after someone else changed the same member. The dialog stays open, the current role now shows the new value, and the choice is kept. Change role tries again against the new version.', { v: 'users', k: 'changestale' }, `Role changed in the meantime · Ines Duarte is now Admin. Your choice is kept. Check it and try again. · Change role`),
|
||||
st('Transfer ownership: target\'s role changed first (409)', N, 'Transfer confirmed after the target\'s role changed. The typed email is kept, the primary action is off until Reload member refreshes the target.', { v: 'users', k: 'tr2stale' }, `Role changed in the meantime · Jonas Weber is now Writer. What you typed is kept. Check the change, then try again. · Reload member · Transfer ownership (disabled until reloaded)`)
|
||||
] }
|
||||
]
|
||||
|
||||
@@ -117,13 +176,17 @@ T('Hidden or disabled', '240px 1fr 1fr', ['Case', 'Treatment', 'Example'], [
|
||||
['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']]),
|
||||
['Owner-only action visible to an admin', 'Disabled, Owner only tag', 'Apply update'],
|
||||
['Item the role may not read (Read = Own)', 'Not listed and not counted. No View only mark, no hint that it exists.', 'Another person\'s draft article, or an entry created by someone else']]),
|
||||
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.'],
|
||||
['role_conflict (change role)', '409', 'Role not changed', 'Dialog alert: {user} is now {role}. Your choice is kept. Check it and try again. Toast if the dialog was closed.'],
|
||||
['role_conflict (remove, transfer)', '409', 'User not removed / Ownership not transferred', '{user}\'s role changed in the meantime, so nothing was removed (transferred). Check it and try again.'],
|
||||
['role_not_found', '404', 'That role no longer exists', 'It was deleted somewhere else, so we removed it from the list.'],
|
||||
['escalation', '403', 'You can\'t grant that', 'You can only grant what your role has.'],
|
||||
['(page level)', '403', 'You don\'t have access to this page', 'Your role, {role}, doesn\'t include it. Ask an owner or admin if you need it.']]),
|
||||
T('Components (new) and Nuxt UI v4 mapping', '200px 1fr 1fr', ['Component', 'Used for', 'Nuxt UI mapping'], [
|
||||
@@ -150,8 +213,8 @@ T('Needs backend', '240px 1fr', ['Item', 'Detail'], [
|
||||
['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.']]),
|
||||
['Version field', 'role.version for the conflict state (409 role_conflict). The same 409 covers a change of role or a removal when the member\'s role changed first, and an ownership transfer when the target\'s role changed first.'],
|
||||
['Activity events', 'role.create, role.edit (with a diff of added and removed permissions), role.delete, role.reassign, user.change_role, user.transfer_ownership. Present tense, as the backend emits them. The Failed outcome is a field on the event, not a separate name.']]),
|
||||
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.'],
|
||||
@@ -165,11 +228,72 @@ T('Existing screens that change', '220px 1fr', ['Screen', 'Change'], [
|
||||
['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('Activity events: sentences, EN and FR', '190px 1fr 1fr', ['Event name','EN','FR'], [
|
||||
['role.create', 'Role Contributor created', 'Rôle Contributor créé'],
|
||||
['role.edit', 'Role Contributor edited', 'Rôle Contributor modifié'],
|
||||
['role.delete', 'Role Intern deleted', 'Rôle Intern supprimé'],
|
||||
['role.reassign', 'Moved 3 members from Intern to Writer (1 member: Moved 1 member from Intern to Writer)', '3 membres déplacés de Intern vers Writer (1 membre : 1 membre déplacé de Intern vers Writer)'],
|
||||
['user.change_role', 'Ines Duarte\'s role changed from Writer to Content lead', 'Le rôle d\'Ines Duarte est passé de Writer à Content lead'],
|
||||
['user.transfer_ownership', 'Ownership transferred from Marta Alves to Jonas Weber', 'Propriété transférée de Marta Alves à Jonas Weber']]),
|
||||
T('Activity events: failed sentences, EN and FR', '190px 1fr 1fr', ['Event name (outcome failed)','EN','FR'], [
|
||||
['Template', 'Failed to {verb} {object}', 'Échec de {action} {objet}'],
|
||||
['role.create', 'Failed to create the role Contributor', 'Échec de la création du rôle Contributor'],
|
||||
['role.edit', 'Failed to edit the role Contributor', 'Échec de la modification du rôle Contributor'],
|
||||
['role.delete', 'Failed to delete the role Intern', 'Échec de la suppression du rôle Intern'],
|
||||
['role.reassign', 'Failed to move 3 members from Intern to Writer', 'Échec du déplacement de 3 membres de Intern vers Writer'],
|
||||
['user.change_role', 'Failed to change the role of Ines Duarte', 'Échec du changement de rôle d\'Ines Duarte'],
|
||||
['user.transfer_ownership', 'Failed to transfer ownership to Jonas Weber', 'Échec du transfert de la propriété à Jonas Weber'],
|
||||
['user.invite', 'Failed to invite sofia@renewbuild.example', 'Échec de l\'invitation de sofia@renewbuild.example'],
|
||||
['user.remove', 'Failed to remove Ines Duarte', 'Échec du retrait d\'Ines Duarte'],
|
||||
['Reason: role_conflict', 'Jonas Weber saved changes to Contributor first, so this edit was refused.', 'Jonas Weber a enregistré des modifications de Contributor avant, donc cette modification a été refusée.'],
|
||||
['Reason: role_locked', 'The Owner role can\'t be changed or deleted.', 'Le rôle Owner ne peut être ni modifié ni supprimé.'],
|
||||
['Reason: missing_permission', 'The role of this person doesn\'t include that permission.', 'Le rôle de cette personne n\'inclut pas cette autorisation.']]),
|
||||
T('Activity filter: category names, EN and FR', '190px 1fr 1fr', ['Value','EN','FR'], [
|
||||
['(none)', 'All categories', 'Toutes les catégories'],
|
||||
['articles', 'Articles', 'Articles'],
|
||||
['media', 'Media', 'Médias'],
|
||||
['users', 'Users and invites (was "Users & invites")', 'Utilisateurs et invitations'],
|
||||
['roles (new, to confirm)', 'Roles', 'Rôles'],
|
||||
['api_keys', 'API keys', 'Clés API'],
|
||||
['auth', 'Authentication', 'Authentification'],
|
||||
['system', 'System', 'Système']]),
|
||||
T('Final copy: toasts and errors, EN and FR', '230px 1fr 1fr', ['State','EN','FR'], [
|
||||
['Toast: role changed', 'Role changed · Ines Duarte is now Content lead, and their API keys follow the new role.', 'Rôle modifié · Ines Duarte a maintenant le rôle Content lead, et ses clés API suivent le nouveau rôle.'],
|
||||
['Toast: role edited elsewhere (409)', 'Role changed elsewhere · Jonas Weber edited Contributor, so nothing was saved. Reload and try again. · Reload', 'Rôle modifié ailleurs · Jonas Weber a modifié Contributor, donc rien n\'a été enregistré. Rechargez et réessayez. · Recharger'],
|
||||
['Toast: role deleted elsewhere', 'That role no longer exists · It was deleted somewhere else, so we removed it from the list.', 'Ce rôle n\'existe plus · Il a été supprimé ailleurs, nous l\'avons donc retiré de la liste.'],
|
||||
['Toast: member\'s role changed before a change of role (409)', 'Role not changed · Ines Duarte\'s role changed in the meantime, so nothing was saved. Reload and try again. · Reload', 'Rôle non modifié · Le rôle d\'Ines Duarte a changé entre-temps, donc rien n\'a été enregistré. Rechargez et réessayez. · Recharger'],
|
||||
['Toast: target\'s role changed before a transfer (409)', 'Ownership not transferred · Jonas Weber\'s role changed in the meantime, so nothing was transferred. Check it and try again. · Reload', 'Propriété non transférée · Le rôle de Jonas Weber a changé entre-temps, donc rien n\'a été transféré. Vérifiez-le et réessayez. · Recharger'],
|
||||
['Toast: member\'s role changed before a removal (409)', 'User not removed · Ines Duarte\'s role changed in the meantime, so nothing was removed. Check it and try again. · Reload', 'Utilisateur non retiré · Le rôle d\'Ines Duarte a changé entre-temps, donc rien n\'a été retiré. Vérifiez-le et réessayez. · Recharger'],
|
||||
['Editor: name required', 'Give the role a name.', 'Donnez un nom au rôle.'],
|
||||
['Editor: name taken (409)', 'A role named "Writer" already exists. Choose a different name.', 'Un rôle nommé « Writer » existe déjà. Choisissez un autre nom.'],
|
||||
['Editor: save failed (500)', 'Couldn\'t save the role · Your changes are still here. Try again in a moment. · Try again', 'Impossible d\'enregistrer le rôle · Vos modifications sont toujours là. Réessayez dans un instant. · Réessayer'],
|
||||
['Editor: stale save banner (409)', 'This role changed while you were editing · Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits. · Reload role · Review differences', 'Ce rôle a changé pendant que vous le modifiiez · Jonas Weber a enregistré des modifications de Contributor à 11:02. Rechargez pour les voir, ou comparez-les à vos modifications. · Recharger le rôle · Voir les différences'],
|
||||
['Editor: review differences', 'How the saved role differs from your edits · Setting · Saved · Yours · Use the saved role · Keep my edits', 'Comment le rôle enregistré diffère de vos modifications · Réglage · Enregistré · Vous · Utiliser le rôle enregistré · Garder mes modifications'],
|
||||
['Editor: edits kept after reload', 'Role reloaded · Your 2 changes are still here. Review them, then save.', 'Rôle rechargé · Vos 2 modifications sont toujours là. Relisez-les, puis enregistrez.'],
|
||||
['Editor: Read = Own note', 'Read: Own · Articles show online articles plus the person\'s own drafts. Custom content types show only entries the person created. Status counts follow the same rule.', 'Lecture : Les siennes · Les articles affichent les articles en ligne et les brouillons de la personne. Les types de contenu personnalisés n\'affichent que les entrées créées par la personne. Les compteurs de statut suivent la même règle.'],
|
||||
['Change role: member\'s role changed (409)', 'Role changed in the meantime · Ines Duarte is now Admin. Your choice is kept. Check it and try again.', 'Rôle modifié entre-temps · Ines Duarte a maintenant le rôle Admin. Votre choix est conservé. Vérifiez-le et réessayez.'],
|
||||
['Change role: failed (500)', 'Couldn\'t change the role · Nothing was changed. Try again in a moment.', 'Impossible de modifier le rôle · Rien n\'a été modifié. Réessayez dans un instant.'],
|
||||
['Change role: role above yours', 'Your role can\'t assign a role with more power than yours.', 'Votre rôle ne peut pas attribuer un rôle plus puissant que le vôtre.'],
|
||||
['Delete role: target deleted', 'Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to.', 'Writer n\'existe plus · Il a été supprimé ailleurs. Choisissez un autre rôle pour y déplacer les membres.'],
|
||||
['Delete role: role gained members (409)', 'Role still has members · Intern now has 1 member. Move them to another role first.', 'Le rôle a encore des membres · Intern a maintenant 1 membre. Déplacez-le d\'abord vers un autre rôle.'],
|
||||
['Delete role: move failed (500)', 'Couldn\'t move the members · Nothing was changed. Try again in a moment.', 'Impossible de déplacer les membres · Rien n\'a été modifié. Réessayez dans un instant.'],
|
||||
['Invite: invalid email', 'Enter a valid email address.', 'Saisissez une adresse e-mail valide.'],
|
||||
['Invite: account exists (409)', 'This address already has an account.', 'Cette adresse a déjà un compte.'],
|
||||
['Invite: role above yours', 'Your role can\'t assign a role with more power than yours.', 'Votre rôle ne peut pas attribuer un rôle plus puissant que le vôtre.'],
|
||||
['Invite: send failed (500)', 'Couldn\'t send the invite · Try again in a moment.', 'Impossible d\'envoyer l\'invitation · Réessayez dans un instant.'],
|
||||
['Transfer: target\'s role changed (409)', 'Role changed in the meantime · Jonas Weber is now Writer. What you typed is kept. Check the change, then try again. · Reload member', 'Rôle modifié entre-temps · Jonas Weber a maintenant le rôle Writer. Ce que vous avez saisi est conservé. Vérifiez le changement, puis réessayez. · Recharger le membre']]),
|
||||
T('Read = Own: what each surface returns', '230px 1fr 1fr', ['Surface','Read = All','Read = Own'], [
|
||||
['Articles list', 'Every article, including drafts by others', 'Online articles plus the person\'s own drafts'],
|
||||
['Status counts (All, Online, Draft)', 'Counted over every article', 'Counted over the visible articles only: Draft counts own drafts, Online counts every online article'],
|
||||
['Custom type entry list', 'Every entry', 'Only entries the person created. The count line and pager use that number.'],
|
||||
['API list calls and X-Total-Count', 'Every item', 'Only the visible items. The total does not reveal hidden ones.'],
|
||||
['Direct link to a hidden item (proposed)', 'Opens', 'The existing 404 page: Entry not found. No hint that the item exists.'],
|
||||
['Empty state', 'Existing empty states', 'You have no drafts · You haven\'t created an event yet. New article or New entry stays available when Create is on.']]),
|
||||
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.'],
|
||||
['4', 'Update or Delete set to Own: rows others created show "View only" with a lock icon, not a hidden row. Read set to Own: those rows are not returned, so they are hidden and not counted.', 'Hide other people\'s entries for Update and Delete too.'],
|
||||
['5', 'Reset to default exists for built-in roles edited by the owner.', 'No reset.'],
|
||||
['6', 'Activity own/all scope uses the same None / Own / All control.', 'Toggle plus a separate scope select.'],
|
||||
['7', 'Transfer ownership takes effect at once, no email to the new owner.', 'The new owner must accept.'],
|
||||
|
||||
@@ -10,7 +10,7 @@ export const groups = [
|
||||
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")
|
||||
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 · Your role doesn't include Edit content types. Ask an owner or admin. (proposal; shown in the ReadOnlyBanner) · 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'),
|
||||
@@ -23,7 +23,7 @@ export const groups = [
|
||||
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('Saving', N, 'Save always opens the review first, even for a type with no entries. Apply is in flight: the dialog shows Applying…, inputs dim, nothing can be dragged. No entries info bar.', { v: 'builder', k: 'saving' }, 'Review changes to Events · Events has no entries yet, so nothing is checked. · Applying…'),
|
||||
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.'),
|
||||
@@ -84,9 +84,11 @@ export const groups = [
|
||||
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: [
|
||||
{ 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: 'The picker is built; 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 entry form', N, 'Search and pick; chips for many. The picker loads 25 results per page; the list scrolls and ends with Load more.', { v: 'rel', k: 'form' }, 'Venue · Speakers · Search to add · An event can have many speakers. · Showing 25 of 62 · Load more'),
|
||||
st('Relation picker: loading more', N, 'Load more pressed. The next page of 25 is requested. Skeleton rows follow the last loaded result, the button shows a loader and is disabled, and the footer count stays until the new rows arrive. Focus stays on the button, then moves to the first new row.', { v: 'rel', k: 'more' }, 'Showing 25 of 62 · Loading… · (skeleton rows)'),
|
||||
st('Relation picker: load more failed', N, 'Load more failed. The loaded rows stay. An inline error replaces the count and the button reads Try again. Focus stays on the button; after a successful retry it moves to the first new row.', { v: 'rel', k: 'moreerr' }, 'Couldn\'t load more results. The 25 shown are still here. · Try again'),
|
||||
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: [
|
||||
@@ -201,7 +203,7 @@ export const tables = [
|
||||
['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.'],
|
||||
['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."). On mobile the field sheet carries Move up, Move down and Remove (44 px) above Done. 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.'],
|
||||
|
||||
+418
-6
@@ -6,6 +6,8 @@ Every state in the three boards is drawn in light and dark (theme switch) at 144
|
||||
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
|
||||
Permission model: access follows the role's permissions (Manage sites, Manage authors). The built-in Owner and Admin roles hold them, so the copy below still says "owner" and "owner or admin", as the app ships it. Moving the copy to permission wording is a separate app change (proposal).
|
||||
|
||||
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
|
||||
@@ -38,8 +40,8 @@ Glossary pass 2, changes made: activity log lines "Removed N expired invites" an
|
||||
| 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 owner_only (page; raised when the role lacks Manage sites) | "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 owner_only (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) |
|
||||
@@ -54,6 +56,7 @@ Glossary pass 2, changes made: activity log lines "Removed N expired invites" an
|
||||
| 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." |
|
||||
| 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) |
|
||||
| 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." |
|
||||
@@ -67,7 +70,6 @@ Glossary pass 2, changes made: activity log lines "Removed N expired invites" an
|
||||
| 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
|
||||
`{ "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.
|
||||
@@ -95,7 +97,7 @@ Frames: Editor: search and sharing (24), Editor: what the frontend gets (6), Sit
|
||||
|
||||
|
||||
## 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.
|
||||
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 (4), 8 Permissions (2), 9 Export and import (3). 66 states, 56 available now and 10 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".
|
||||
|
||||
@@ -106,11 +108,11 @@ Schema changes with entries: Save opens the review ("Add 2 fields, change 1, rem
|
||||
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).
|
||||
Copy and states are in `media-field.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).
|
||||
90 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 5, Profile 3, API keys 1, Create-a-role flow 6, Toasts 6, Inline errors 9, Read = Own 5, 409 conflicts 5. None is clock-sensitive (times are fixed strings).
|
||||
|
||||
Hidden or disabled:
|
||||
| Case | Treatment | Example |
|
||||
@@ -141,3 +143,413 @@ HTTP status to copy (additions):
|
||||
| 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. |
|
||||
|
||||
## Roles sync (pass 6)
|
||||
|
||||
### Wording
|
||||
People are referred to as "they" everywhere: "Their API keys follow the new role." "You can't take ownership back unless they transfer it." No "he", "she", "his" or "her" in any frame or copy table. French uses "vous"; possessives agree with the noun, so they stay neutral ("ses clés API"). Built-in role names stay in English in the French copy until the translation of Owner, Admin and Writer is decided; custom names are user data and are never translated.
|
||||
|
||||
### Activity event names
|
||||
Present tense, as the backend emits them. The outcome (succeeded or failed) is a field, so a failed event keeps its name.
|
||||
| Event | Sentence | Failed sentence |
|
||||
|---|---|---|
|
||||
| role.create | Role Contributor created | Failed to create the role Contributor |
|
||||
| role.edit | Role Contributor edited | Failed to edit the role Contributor |
|
||||
| role.delete | Role Intern deleted | Failed to delete the role Intern |
|
||||
| role.reassign | Moved 3 members from Intern to Writer | Failed to move 3 members from Intern to Writer |
|
||||
| user.change_role | Ines Duarte's role changed from Writer to Content lead | Failed to change the role of Ines Duarte |
|
||||
| user.transfer_ownership | Ownership transferred from Marta Alves to Jonas Weber | Failed to transfer ownership to Jonas Weber |
|
||||
Failed template: "Failed to {verb} {object}". Category names in the filter: Articles, Media, Users and invites, Roles, API keys, Authentication, System. French for every string is in the board table "Final copy: toasts and errors, EN and FR" and in screens/roles.md.
|
||||
|
||||
### Stale state
|
||||
Nothing is changed when a request is stale. The message names who or what changed, says nothing was changed, and ends with the recovery. Where it shows: banner in the role editor, alert inside a dialog, toast only when the surface is no longer open. The form keeps what the person entered. Recovery: Reload (toast), Reload role or Review differences (editor), Reload member (transfer), retry (change role).
|
||||
| Case | Status | Where | Copy |
|
||||
|---|---|---|---|
|
||||
| Stale role save | 409 role_conflict | Banner in the editor | 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. |
|
||||
| Change of role, member's role changed first | 409 | Alert in the dialog, or toast if it closed | Role changed in the meantime · Ines Duarte is now Admin. Your choice is kept. Check it and try again. |
|
||||
| Transfer, target's role changed first | 409 | Alert in the dialog, or toast if it closed | Role changed in the meantime · Jonas Weber is now Writer. What you typed is kept. Check the change, then try again. |
|
||||
| Removal, member's role changed first | 409 | Toast | User not removed · Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again. |
|
||||
| Role deleted by someone else | 404 | Info toast | That role no longer exists · It was deleted somewhere else, so we removed it from the list. |
|
||||
|
||||
### Read = Own
|
||||
A role with Read set to Own sees online articles plus its own drafts. For custom content types it sees only entries it created. Counts, totals and pagination follow the same rule, so hidden items are not hinted at. Update or Delete set to Own still list other people's rows with the View only mark. Frames: group "14 Read = Own" on the Roles board.
|
||||
|
||||
|
||||
## Wording status
|
||||
Strings that match messages.ts are verbatim. "Only the owner can manage sites…" and other board-only wording are proposals. Moving the app to permission-based wording is a separate code change.
|
||||
|
||||
## Account: change email, export, delete
|
||||
States and copy in screens/account.md. Board: RenewCMS Screens Account.dc.html (33 states) and the Auth board groups "Verify new email" (7) and "Account deleted" (1). Direct-download rate limit state is clock-sensitive (frozen clock 09:41).
|
||||
| Status | Copy |
|
||||
|---|---|
|
||||
| 403 wrong_password | "That password isn't correct. Try again." (inline under password) |
|
||||
| 429 | existing 429 copy, inline in the form or the dialog |
|
||||
| 500, network | existing 500 copy, or "Couldn't delete your account. Nothing was deleted. Try again in a moment." |
|
||||
| 409 last_admin | "You can't delete your account yet" (non-dismissable dialog) |
|
||||
| 409 email_unavailable | Landing only: "We couldn't change your email address" (same for in use and rejected) |
|
||||
|
||||
## Localization state IDs (groups L1-L12 on the Localization board; source of truth: locale-spec.js)
|
||||
Every state has a stable ID, a frame selector and a fixture. Legacy IDs from the first pass keep their names and are the canonical ID of the state they map to; the loc.lN.xxx alias is listed below.
|
||||
|
||||
Total: 183 states in 16 groups (was 109 in the first pass, 169 after the corrections pass). L7s (single-language list) and 13 other states were added in pass 3; see localization-changelog.md.
|
||||
|
||||
## L1 Site languages
|
||||
Route: /settings/sites/:key/languages
|
||||
API: proposed: GET/PUT /v1/site/{key}/languages
|
||||
|
||||
Navigation: Settings → Sites → [site] → Languages only. The site name is in the heading and breadcrumb. Site formatting locale, default content language, article language and social locale are four separate values. At least one language stays enabled and the default must be enabled: to retire the default, first Make default on another language, then Disable. Disable and Remove never delete or unpublish content. Disabled languages show a Disabled tag and a Re-enable action; their versions stay readable and editable per permission. Drafts can be published only after Re-enable ("French is disabled on this site. Re-enable it to publish."). The language filter lists them under Disabled. Social locale per language has three states: Derived (from the language code, e.g. pt-BR gives pt_BR), Set (an owner typed it, e.g. fr_FR) and Not set (og:locale left out). Only owners edit it.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l1.single | Single language | Owner opens Languages on Archive (one language, pt-BR kept) | LocaleFrame{s:{v:"sl",x:"single"}} | 1440, 390 | Archive |
|
||||
| loc.l1.multi | Multilingual | Journal: four enabled, two disabled (Spanish, Italian) | LocaleFrame{s:{v:"sl",x:"multi"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l1.loading | Loading | Request in flight | LocaleFrame{s:{v:"sl",x:"loading"}} | 1440, 390 | Journal |
|
||||
| loc.l1.error | Load failed | Network or server error | LocaleFrame{s:{v:"sl",x:"error"}} | 1440, 390 | Journal |
|
||||
| loc.l1.add | Add a language | Add language | LocaleFrame{s:{v:"sl",x:"add"}} | 1440, 390 | Journal |
|
||||
| loc.l1.saveFail | Add failed | Server error, selection kept | LocaleFrame{s:{v:"sl",x:"saveFail"}} | 1440, 390 | Journal |
|
||||
| loc.l1.adddup | Duplicate language | Already enabled | LocaleFrame{s:{v:"sl",x:"adddup"}} | 1440, 390 | Journal |
|
||||
| loc.l1.broader | Only a broader tag supported | Add Portuguese (Portugal): substitution explained, confirmation required | LocaleFrame{s:{v:"sl",x:"broader"}} | 1440, 390 | Journal |
|
||||
| loc.l1.default | Make default | Row menu | LocaleFrame{s:{v:"sl",x:"default"}} | 1440, 390 | Journal |
|
||||
| loc.l1.defaultFail | Set default failed | Server error, nothing changed | LocaleFrame{s:{v:"sl",x:"defaultFail"}} | 1440, 390 | Journal |
|
||||
| loc.l1.disable | Disable with content | Row menu → Disable German | LocaleFrame{s:{v:"sl",x:"disable"}} | 1440, 390 | Journal |
|
||||
| loc.l1.stale | Languages changed | Another owner changed them: "Languages changed. Reload." | LocaleFrame{s:{v:"sl",x:"stale"}} | 1440, 390 | Journal |
|
||||
| loc.lang.disableDefault | Disable default blocked | Disable English | LocaleFrame{s:{v:"sl",x:"disableDefault"}} | 1440, 390 | Journal |
|
||||
| loc.lang.disableLast | Disable last blocked | Only one enabled | LocaleFrame{s:{v:"sl",x:"disableLast"}} | 1440, 390 | Journal |
|
||||
| loc.l1.reenable | Re-enable | Spanish row → Re-enable | LocaleFrame{s:{v:"sl",x:"reenable"}} | 1440, 390 | Journal |
|
||||
| loc.l1.reenableDone | Re-enabled | Toast after success | LocaleFrame{s:{v:"sl",x:"reenableDone"}} | 1440, 390 | Journal |
|
||||
| loc.l1.reenableFail | Re-enable failed | Dialog error, still disabled | LocaleFrame{s:{v:"sl",x:"reenableFail"}} | 1440, 390 | Journal |
|
||||
| loc.l1.remove | Remove (no content) | Italian (0 versions) | LocaleFrame{s:{v:"sl",x:"remove"}} | 1440, 390 | Journal |
|
||||
| loc.l1.removeFail | Remove failed | Dialog error, still listed | LocaleFrame{s:{v:"sl",x:"removeFail"}} | 1440, 390 | Journal |
|
||||
| loc.l1.removeBlocked | Remove blocked | Spanish has versions | LocaleFrame{s:{v:"sl",x:"removeBlocked"}} | 1440, 390 | Journal |
|
||||
| loc.l1.readonly | Read-only view | Non-owner: list and social locale readable; no controls | LocaleFrame{s:{v:"sl",x:"readonly"}} | 1440, 390 | Journal |
|
||||
| loc.l1.saved | Saved | After Add language | LocaleFrame{s:{v:"sl",x:"saved"}} | 1440, 390 | Journal |
|
||||
| loc.l1.social | Social locale: three states | Atelier: Derived en_GB and pt_BR, Set fr_FR, Not set | LocaleFrame{s:{v:"sl",x:"social"}} | 1440, 1024, 390 | Atelier |
|
||||
| loc.l1.socialEdit | Social locale: edit | Edit on French (Set: fr_FR) | LocaleFrame{s:{v:"sl",x:"socialEdit"}} | 1440, 390 | Atelier |
|
||||
| loc.l1.socialInvalid | Social locale: invalid | fr-FR typed with a hyphen | LocaleFrame{s:{v:"sl",x:"socialInvalid"}} | 1440, 390 | Atelier |
|
||||
|
||||
## L1b Public URL pattern (owner setting)
|
||||
Route: /settings/sites/:key/public-url
|
||||
API: proposed: GET/PUT /v1/site/{key} { public_url_pattern }
|
||||
|
||||
Owner-only. One pattern per site, for example https://example.com/{lang}/{slug}. Supported placeholders: {lang} (language code) and {slug} (the article address in that language). {slug} is required: without it every article would share one address. The preview uses a real article of the site. When unset, article panels show "Public URL not configured" and link here. No URL is invented.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l1b.set | Set | Pattern saved, preview shown | LocaleFrame{s:{v:"pu",x:"set"}} | 1440, 390 | Journal |
|
||||
| loc.l1b.editing | Editing | Live preview follows the field | LocaleFrame{s:{v:"pu",x:"editing"}} | 1440, 390 | Journal |
|
||||
| loc.l1b.unknown | Unknown placeholder | {language} typed | LocaleFrame{s:{v:"pu",x:"unknown"}} | 1440, 390 | Journal |
|
||||
| loc.l1b.scheme | Bad scheme | ftp:// typed | LocaleFrame{s:{v:"pu",x:"scheme"}} | 1440, 390 | Journal |
|
||||
| loc.l1b.encoded | Not encoded | A space in the path | LocaleFrame{s:{v:"pu",x:"encoded"}} | 1440, 390 | Journal |
|
||||
| loc.l1b.noSlug | Missing {slug} | Pattern without {slug} | LocaleFrame{s:{v:"pu",x:"noSlug"}} | 1440, 390 | Journal |
|
||||
| loc.l1b.saved | Saved | Toast | LocaleFrame{s:{v:"pu",x:"saved"}} | 1440, 390 | Journal |
|
||||
| loc.l1b.unset | Unset | No pattern; opened from "Public URL not configured" | LocaleFrame{s:{v:"pu",x:"unset"}} | 1440, 390 | Journal |
|
||||
| loc.l1b.viewOnly | Read-only view | Non-owner: pattern readable, no controls | LocaleFrame{s:{v:"pu",x:"viewOnly"}} | 1440, 390 | Journal |
|
||||
| loc.l1b.invalidHost | Invalid host | Pattern with an empty host label | LocaleFrame{s:{v:"pu",x:"invalidHost"}} | 1440, 390 | Journal |
|
||||
|
||||
## L2 First-time setup for existing articles
|
||||
Route: /settings/sites/:key/languages/setup
|
||||
API: proposed: GET /v1/language/setup, POST /v1/language/setup { sites:[{ key, lang }] } (lang is the full tag, e.g. pt-BR)
|
||||
|
||||
Opened from the Languages banner of any site; :key is the site it was opened from, and the page lists every site that still has unlabelled articles. Setup assigns a language per site and applies in one transaction. It never translates, links or moves articles. Each site is seeded from its full Site.Locale when a matching language entry exists: pt-BR becomes Portuguese (Brazil). A site with no locale has no seed and the owner must choose. Nothing applies until the owner confirms every row, including a site with no articles. Retrying is safe.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l2.ready | Ready to assign | Journal (en), Notes (no locale, must choose), Archive (pt-BR kept) | LocaleFrame{s:{v:"su",x:"ready"}} | 1440, 390 | Journal, Notes, Archive |
|
||||
| loc.l2.noLocale | No-locale site | Notes only: the owner must choose | LocaleFrame{s:{v:"su",x:"noLocale"}} | 1440, 390 | Notes |
|
||||
| loc.l2.empty | Site with no articles | Gallery has no articles; en-GB kept, confirm and Save | LocaleFrame{s:{v:"su",x:"empty"}} | 1440, 390 | Gallery |
|
||||
| loc.l2.applying | Applying | Request in flight | LocaleFrame{s:{v:"su",x:"applying"}} | 1440, 390 | Journal, Notes, Archive |
|
||||
| loc.l2.inProgress | Setup already running | Another owner started it; starting again is safe | LocaleFrame{s:{v:"su",x:"inProgress"}} | 1440, 390 | Journal, Notes, Archive |
|
||||
| loc.l2.done | Done | 16 articles labelled | LocaleFrame{s:{v:"su",x:"done"}} | 1440, 390 | Journal, Notes, Archive |
|
||||
| loc.l2.fail | Failed | Nothing changed; choices kept; retry is safe | LocaleFrame{s:{v:"su",x:"fail"}} | 1440, 390 | Journal, Notes, Archive |
|
||||
|
||||
## L3 New article
|
||||
Route: /articles (New article modal)
|
||||
API: existing POST /v1/article/ + proposed lang, story_id
|
||||
|
||||
Single-language sites show no language field. Multilingual sites show Language and a New article / Translation of an existing article choice. The translation path uses the same endpoint and rules as Add translation. The picker lists only articles the user can read; the chosen version is the source.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l3.single | Single language | Title and site only | LocaleFrame{s:{v:"na",x:"single"}} | 1440, 390 | Archive |
|
||||
| loc.l3.multi | Multilingual: new article | Language defaults to the site default | LocaleFrame{s:{v:"na",x:"multi"}} | 1440, 390 | Journal |
|
||||
| loc.l3.translation | Translation of an article | Same fields as Add translation: article, language, source version, title, starting content | LocaleFrame{s:{v:"na",x:"translation"}} | 1440, 390 | Journal |
|
||||
| loc.l3.sitechange | Site changed | Selected language not offered by the new site | LocaleFrame{s:{v:"na",x:"sitechange"}} | 1440, 390 | Journal, Notes |
|
||||
|
||||
## L4 Add a translation
|
||||
Route: /article/{id}/edit?dialog=add-translation&lang=fr
|
||||
API: proposed: POST /v1/article/{id}/translations
|
||||
|
||||
Always creates a draft. Source: the user picks which readable version to copy from (default: the default-language version if readable, else the first readable). The source version is captured in the same transaction as the copy. Copied text stays in the source language until edited; the version starts as Needs review. Start empty: no tracked source, so freshness shows No source. The primary action stays in the sticky footer on mobile; the body scrolls.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l4.form | Copy from source | Editor → language menu → Add translation | LocaleFrame{s:{v:"at",x:"form"}} | 1440, 390 | Journal |
|
||||
| loc.l4.empty | Start empty | Title only; Source version reads No source, Freshness will show No source | LocaleFrame{s:{v:"at",x:"empty"}} | 1440, 390 | Journal |
|
||||
| loc.l4.saving | Creating | Submit in flight | LocaleFrame{s:{v:"at",x:"saving"}} | 1440, 390 | Journal |
|
||||
| loc.dup.readable | Exists, readable | Conflict; Open offered | LocaleFrame{s:{v:"at",x:"dup"}} | 1440, 390 | Journal |
|
||||
| loc.dup.hidden | Exists, not readable | Neutral error, no Open | LocaleFrame{s:{v:"at",x:"dupHidden"}} | 1440, 390 | Journal |
|
||||
| loc.l4.fail | Failed | Server error, entries kept | LocaleFrame{s:{v:"at",x:"fail"}} | 1440, 390 | Journal |
|
||||
| loc.l4.gone | Source deleted | Deleted while the dialog was open: same neutral message as unreadable, no distinction shown | LocaleFrame{s:{v:"at",x:"gone"}} | 1440, 390 | Journal |
|
||||
| loc.l4.unreadable | Source no longer readable | Access lost between opening and submitting: same neutral message as deleted | LocaleFrame{s:{v:"at",x:"unreadable"}} | 1440, 390 | Journal |
|
||||
| loc.l4.langOff | Language disabled mid-flow | An owner disabled French while the dialog was open | LocaleFrame{s:{v:"at",x:"langOff"}} | 1440, 390 | Journal |
|
||||
| loc.l4.long | Long dialog | Long title, many authors and images; body scrolls, footer stays | LocaleFrame{s:{v:"at",x:"long"}} | 1440, 390 | Journal |
|
||||
|
||||
## L5 Editor: header, actions and title
|
||||
Route: /article/{id}/edit (one id per version)
|
||||
API: proposed: GET /v1/article/{id} → translations[], capabilities[]
|
||||
|
||||
Header priority and the action matrix are in the tables below. One filled primary button at most: Save and publish when allowed, dirty and offline; otherwise Save when dirty; otherwise Publish when clean and offline. Save stays separate and secondary. Desktop collapse order as width shrinks (1440 → 1024): reference button loses its label, Unpublish moves to the overflow menu, the freshness badge becomes icon-only. Mobile: title, language, publication and dirty badges stay at the top; Save, primary and an overflow button sit in a fixed bottom bar; reference, Unpublish, Edit slug, Move and Delete go to the overflow sheet. The language control is a menu button with a menu (not a listbox).
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l5.default | Clean draft | Open a draft you can publish; title pristine | LocaleFrame{s:{v:"ed",x:"default"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l5.dirty | Dirty draft | Can edit, can't publish: Save is primary | LocaleFrame{s:{v:"ed",x:"dirty"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l5.dirtyPublish | Dirty draft, publish allowed | Save and publish French is primary; Save secondary | LocaleFrame{s:{v:"ed",x:"dirtyPublish"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l5.cleanOnline | Clean online | Unpublish only; no primary | LocaleFrame{s:{v:"ed",x:"cleanOnline"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l5.onlineDirty | Dirty online | Save updates the live page; Unpublish stays | LocaleFrame{s:{v:"ed",x:"onlineDirty"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l5.saving | Saving | Save in flight | LocaleFrame{s:{v:"ed",x:"saving"}} | 1440, 390 | Journal |
|
||||
| loc.l5.saved | Saved | Toast | LocaleFrame{s:{v:"ed",x:"saved"}} | 1440, 390 | Journal |
|
||||
| loc.l5.saveFail | Save failed | Stays unsaved; Try saving again; nothing published | LocaleFrame{s:{v:"ed",x:"saveFail"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l5.pubFail | Publish failed after save | Saved as a draft; Retry publish; edits shown as saved | LocaleFrame{s:{v:"ed",x:"pubFail"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l5.ovf | Overflow menu | More actions (sheet on mobile) | LocaleFrame{s:{v:"ed",x:"ovf"}} | 1440, 390 | Journal |
|
||||
| loc.l5.menu | Menu: four languages | Open menu | LocaleFrame{s:{v:"ed",x:"menu"}} | 1440, 390 | Journal |
|
||||
| loc.l5.two | Menu: two languages | Two-language site | LocaleFrame{s:{v:"ed",x:"two"}} | 1440, 390 | Journal |
|
||||
| loc.l5.many | Menu: many languages | Scrolls inside the viewport, with filter | LocaleFrame{s:{v:"ed",x:"many"}} | 1440, 390 | Journal |
|
||||
| loc.l5.unsaved | Switch with unsaved changes | Choose English while dirty | LocaleFrame{s:{v:"ed",x:"unsaved"}} | 1440, 390 | Journal |
|
||||
| loc.l5.loading | Loading version | Slow switch | LocaleFrame{s:{v:"ed",x:"loading"}} | 1440, 390 | Journal |
|
||||
| loc.l5.navFail | Switch failed | Stays on current version | LocaleFrame{s:{v:"ed",x:"navFail"}} | 1440, 390 | Journal |
|
||||
| loc.l5.gone | Version unavailable | Deleted or no access (neutral) | LocaleFrame{s:{v:"ed",x:"gone"}} | 1440, 390 | Journal |
|
||||
| loc.l5.focus | Title: focus | Field focused, not changed | LocaleFrame{s:{v:"ed",x:"focus"}} | 1440, 390 | Journal |
|
||||
| loc.l5.titleEdit | Title: editing | Typing; counter shown | LocaleFrame{s:{v:"ed",x:"titleEdit"}} | 1440, 390 | Journal |
|
||||
| loc.l5.titleUnsaved | Title: unsaved | Changed, focus left | LocaleFrame{s:{v:"ed",x:"titleUnsaved"}} | 1440, 390 | Journal |
|
||||
| loc.l5.invalid | Title: invalid | Empty: "Enter a title" | LocaleFrame{s:{v:"ed",x:"invalid"}} | 1440, 390 | Journal |
|
||||
| loc.l5.titleShort | Title: too short | Under 3 characters | LocaleFrame{s:{v:"ed",x:"titleShort"}} | 1440, 390 | Journal |
|
||||
| loc.l5.titleLong | Title: too long | Over 200 characters | LocaleFrame{s:{v:"ed",x:"titleLong"}} | 1440, 390 | Journal |
|
||||
| loc.l5.titleSaving | Title: saving | Save in flight | LocaleFrame{s:{v:"ed",x:"titleSaving"}} | 1440, 390 | Journal |
|
||||
| loc.l5.failed | Title: save failed | Text kept, still unsaved, Try again | LocaleFrame{s:{v:"ed",x:"failed"}} | 1440, 390 | Journal |
|
||||
| loc.l5.menuDisabled | Language menu with a disabled language | Spanish is disabled: Disabled tag, version stays readable | LocaleFrame{s:{v:"ed",x:"menuDisabled"}} | 1440, 390 | Journal |
|
||||
|
||||
## L5b Permission states (per version)
|
||||
Route: /article/{id}/edit
|
||||
API: capabilities[] on GET /v1/article/{id}; every PATCH/DELETE rechecked on the server
|
||||
|
||||
Every action renders from its own capability (read, edit, publish, delete, create translation, review). See the capability matrix. Missing-translation rows appear only for users who can create; unreadable siblings are absent with no placeholder and no count. The formatting toolbar is hidden whenever edit is missing.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.perm.readonly | P1 Read-only version | Banner, read-only fields, no Save, Publish, Delete, toolbar; reference stays | LocaleFrame{s:{v:"perm",x:"ro"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l5b.roMenu | P1 menu | No Add translation row | LocaleFrame{s:{v:"perm",x:"roMenu"}} | 1440, 390 | Journal |
|
||||
| loc.perm.editNoPublish | P2 Edit, no publish | Save only; hint about the publish permission | LocaleFrame{s:{v:"perm",x:"p2"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.perm.publishNoEdit | P3 Publish, no edit | Publish only; fields read-only | LocaleFrame{s:{v:"perm",x:"p3"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.perm.siblingReadOnly | P4 Sibling readable, not editable | Arabic row tagged Read only | LocaleFrame{s:{v:"perm",x:"p4"}} | 1440, 390 | Journal |
|
||||
| loc.perm.siblingHidden | P5 Sibling not readable | German absent, no count, no placeholder | LocaleFrame{s:{v:"perm",x:"p5"}} | 1440, 390 | Journal |
|
||||
| loc.perm.createNotAll | P6 Can create, not modify all | Delete article and Move disabled with the reason; Delete French version available | LocaleFrame{s:{v:"perm",x:"p6"}} | 1440, 390 | Journal |
|
||||
| loc.l5b.noAdd | Menu without Add translation | No create permission; Arabic is missing but offers nothing | LocaleFrame{s:{v:"perm",x:"noAdd"}} | 1440, 390 | Journal |
|
||||
| loc.l5b.withAdd | Menu with Add translation | Same article, user may create | LocaleFrame{s:{v:"perm",x:"withAdd"}} | 1440, 390 | Journal |
|
||||
|
||||
## L6 Source reference (release 2) (release 2)
|
||||
Route: /article/{id}/edit?ref=:sourceId
|
||||
API: proposed: GET /v1/article/{id} (read-only fetch of the reference)
|
||||
|
||||
Release 2. The temporary reference (?ref=, dashed outline, "Temporary reference") and the tracked review source (solid "Tracked source" badge in the review banner) are separate: changing the reference never changes the tracked source. Show source reference is in the editor header (desktop, icon-only at 1024) and the overflow sheet (mobile). Each pane scrolls independently. dir and lang go on the title and body of the editing pane and of the reference, never on the app shell.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l6.split | Side by side | Show source reference | LocaleFrame{s:{v:"src",x:"split"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l6.closed | Reference hidden | Hide source reference; button returns | LocaleFrame{s:{v:"src",x:"closed"}} | 1440, 390 | Journal |
|
||||
| loc.l6.reopen | Reopened | Scroll positions kept | LocaleFrame{s:{v:"src",x:"reopen"}} | 1440, 390 | Journal |
|
||||
| loc.l6.changeRef | Change reference | Menu; tracked source stays English | LocaleFrame{s:{v:"src",x:"changeRef"}} | 1440, 390 | Journal |
|
||||
| loc.l6.narrow | Narrow desktop (1024) | True 1024 frame | LocaleFrame{s:{v:"src",x:"narrow"}} | 1024, 390 | Journal |
|
||||
| loc.l6.long | Long content | Both panes scroll independently | LocaleFrame{s:{v:"src",x:"long"}} | 1440, 390 | Journal |
|
||||
| loc.l6.rtl | Arabic editing, English reference | dir=rtl on the Arabic title and body only | LocaleFrame{s:{v:"src",x:"rtl"}} | 1440, 390 | Journal |
|
||||
| loc.l6.arRef | French editing, Arabic reference | dir=rtl on the reference pane only | LocaleFrame{s:{v:"src",x:"arRef"}} | 1440, 390 | Journal |
|
||||
| loc.l6.mobileEdit | Mobile: editing | Segment | LocaleFrame{s:{v:"src",x:"mobileEdit"}} | 1440, 390 | Journal |
|
||||
| loc.l6.mobileSrc | Mobile: source | Segment | LocaleFrame{s:{v:"src",x:"mobileSrc"}} | 1440, 390 | Journal |
|
||||
| loc.l6.mobileClosed | Mobile: closed | Show source reference in the bottom bar or sheet | LocaleFrame{s:{v:"src",x:"mobileClosed"}} | 1440, 390 | Journal |
|
||||
| loc.l6.unavail | Reference unavailable | Deleted or unreadable | LocaleFrame{s:{v:"src",x:"unavail"}} | 1440, 390 | Journal |
|
||||
|
||||
## L7 Article list and coverage
|
||||
Route: /articles?site&lang&tab=all|missing|review&q&status&author&seo&page&size
|
||||
API: proposed: lang, tab=all|missing|review on GET /v1/article/ (same names as the URL)
|
||||
|
||||
Journal: 8 articles, 17 versions, page size 5. The list header shows the unit: "8 articles" (all languages, one row per article with its translations), "4 French versions" (language selected), "4 articles not in French" (missing tab), "7 articles need translation", "2 articles need review". Coverage chips: default language first, then other existing versions, then disabled-language versions, then dashed missing chips (only for languages the user can create). Chips collapse after 5 at 1440 and 4 at 1024 and 390, leaving a "+N" chip. Row actions are the same on every width: Open, Open another language, Add translation, Publish or Unpublish, Copy API URL, Delete version; every row has the row menu button. All state is in the URL.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l7.all | All languages, page 1 | 5 of 8 articles | LocaleFrame{s:{v:"li",x:"all"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.page2 | All languages, page 2 (last) | 3 of 8 articles | LocaleFrame{s:{v:"li",x:"page2"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.fr | French selected | 4 French versions | LocaleFrame{s:{v:"li",x:"fr"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.frMissing | Not in French | 4 articles not in French | LocaleFrame{s:{v:"li",x:"frMissing"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.emptyMissing | Nothing missing | Press: all articles translated; no summary, no pager | LocaleFrame{s:{v:"li",x:"emptyMissing"}} | 1440, 1024, 390 | Press |
|
||||
| loc.l7.noResults | No results | Search "zinc"; every count 0 | LocaleFrame{s:{v:"li",x:"noResults"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.frLoading | Loading | Counts shown as – | LocaleFrame{s:{v:"li",x:"frLoading"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.listFail | Failed | Filters kept | LocaleFrame{s:{v:"li",x:"listFail"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.limited | Limited access | Reads English only, can't create: no dashed chips | LocaleFrame{s:{v:"li",x:"limited"}} | 1440, 1024, 390 | Journal (limited scope) |
|
||||
| loc.l7.review | Needs review tab | 2 articles need review | LocaleFrame{s:{v:"li",x:"review"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.author | Author filter | Marta Alves: 3 articles, chips and Clear all | LocaleFrame{s:{v:"li",x:"author"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.langFilter | Language filter | Enabled and Disabled groups | LocaleFrame{s:{v:"li",x:"langFilter"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.rowMenu | Row menu | Same actions on desktop and mobile | LocaleFrame{s:{v:"li",x:"rowMenu"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.filters | Mobile filter sheet | Journal, French, Draft: 2 results | LocaleFrame{s:{v:"li",x:"filters"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l7.tabsScroll | Tabs scrolled | Not in French reachable; fade and chevrons | LocaleFrame{s:{v:"li",x:"tabsScroll"}} | 1440, 390 | Journal |
|
||||
| loc.l7.long | Eight languages, long labels, filters | Atelier: chips collapse to +3, +4, +4; Draft and SEO filters | LocaleFrame{s:{v:"li",x:"long"}} | 1440, 1024, 390 | Atelier |
|
||||
|
||||
## L7s Article list, single-language site
|
||||
Route: /articles?site=archive&q&status&page&size
|
||||
API: GET /v1/article/?page=&size=&q=&status=&site= returns { items, total, page, size, counts }
|
||||
|
||||
Replaces the old Articles board frames as the pixel-gate reference for the list. A site with one language has no language filter, no coverage column, no Needs translation or Needs review tabs, and its menu labels name no language. The pager is AppPaginator: summary "Showing 1–5 of 5", rows-per-page select, first, previous, page numbers, next and last (390 px: summary and select, then Previous, "Page N of M", Next). Hidden controls when everything fits on one page; the summary stays.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l7s.default | Default | Archive: 5 articles on one page, no language filter, coverage column or tabs | LocaleFrame{s:{v:"li",x:"single"}} | 1440, 390 | Archive |
|
||||
| loc.l7s.draft | Draft filter | Status: Draft | LocaleFrame{s:{v:"li",x:"singleDraft"}} | 1440, 390 | Archive |
|
||||
| loc.l7s.noResults | No results | Search "zinc" | LocaleFrame{s:{v:"li",x:"singleNoResults"}} | 1440, 390 | Archive |
|
||||
| loc.l7s.empty | Empty | Gallery: no articles | LocaleFrame{s:{v:"li",x:"singleEmpty"}} | 1440, 390 | Gallery |
|
||||
| loc.l7s.loading | Loading | Counts shown as – | LocaleFrame{s:{v:"li",x:"singleLoading"}} | 1440, 390 | Archive |
|
||||
| loc.l7s.fail | Failed | Filters kept | LocaleFrame{s:{v:"li",x:"singleFail"}} | 1440, 390 | Archive |
|
||||
| loc.l7s.menu | Row menu | Open, Unpublish, Copy API URL, Delete article: no language named | LocaleFrame{s:{v:"li",x:"singleMenu"}} | 1440, 390 | Archive |
|
||||
|
||||
## L8 Publishing and deletion
|
||||
Route: same as the editor
|
||||
API: existing PATCH /v1/article/{id}/publish and /unpublish and DELETE per article id
|
||||
|
||||
Every action names its language. No bulk publish. With unsaved edits the primary reads Save and publish and saves first; if the save fails nothing is published and the edits stay unsaved; if the save works and publishing fails the edits show as saved. Unpublish never saves: the saved version goes offline and local edits stay in the editor. Saving an online version updates the live page immediately, and the editor says so. Deletion confirmations name only readable versions.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l8.publish | Publish French | Confirm, English untouched | LocaleFrame{s:{v:"pd",x:"publish"}} | 1440, 390 | Journal |
|
||||
| loc.lang.publishDisabled | Publishing blocked: language disabled | French disabled on the site | LocaleFrame{s:{v:"pd",x:"publishDisabled"}} | 1440, 390 | Journal |
|
||||
| loc.l8.saveAndPublish | Save and publish French | Unsaved edits: saves first, then publishes | LocaleFrame{s:{v:"pd",x:"saveAndPublish"}} | 1440, 390 | Journal |
|
||||
| loc.l8.saveFailPublish | Save failed, nothing published | Error banner above the editor and an error toast; Save failed badge, primary Try saving again, edits kept, nothing published | LocaleFrame{s:{v:"pd",x:"saveFailPublish"}} | 1440, 390 | Journal |
|
||||
| loc.l8.unpublish | Unpublish French | Confirm | LocaleFrame{s:{v:"pd",x:"unpublish"}} | 1440, 390 | Journal |
|
||||
| loc.l8.unpublishDirty | Unpublish with unsaved edits | Saved version goes offline; edits kept; nothing saved | LocaleFrame{s:{v:"pd",x:"unpublishDirty"}} | 1440, 390 | Journal |
|
||||
| loc.l8.delete | Delete French version | Confirm | LocaleFrame{s:{v:"pd",x:"delete"}} | 1440, 390 | Journal |
|
||||
| loc.l8.deleteSource | Delete a source version | Names the readable versions that track the source; warns review tracking pauses | LocaleFrame{s:{v:"pd",x:"deleteSource"}} | 1440, 390 | Journal |
|
||||
| loc.l8.deleteLast | Delete the only version | Deletes the article | LocaleFrame{s:{v:"pd",x:"deleteLast"}} | 1440, 390 | Journal |
|
||||
| loc.l8.deleteStoryOk | Delete article (all readable versions) | Names readable versions only | LocaleFrame{s:{v:"pd",x:"deleteStoryOk"}} | 1440, 390 | Journal |
|
||||
| loc.l8.deleteStory | Delete article blocked | Neutral, no version named | LocaleFrame{s:{v:"pd",x:"deleteStory"}} | 1440, 390 | Journal |
|
||||
| loc.l8.done | Published | Toast "French published" and the online banner; badge Published, status Saved | LocaleFrame{s:{v:"pd",x:"done"}} | 1440, 390 | Journal |
|
||||
| loc.l8.fail | Publish failed | Toast | LocaleFrame{s:{v:"pd",x:"fail"}} | 1440, 390 | Journal |
|
||||
| loc.l8.deleteDone | French version deleted | Toast; editor opens English | LocaleFrame{s:{v:"pd",x:"deleteDone"}} | 1440, 390 | Journal |
|
||||
| loc.l8.deleteStoryFail | Delete article failed | Dialog error; nothing deleted | LocaleFrame{s:{v:"pd",x:"deleteStoryFail"}} | 1440, 390 | Journal |
|
||||
| loc.l8.deleteStoryDone | Article deleted | Toast on the Articles list | LocaleFrame{s:{v:"li",x:"deleteStoryDone"}} | 1440, 390 | Journal |
|
||||
|
||||
## L9 Freshness and review (release 2) (release 2)
|
||||
Route: editor banner · list badge
|
||||
API: proposed: POST /v1/article/{id}/review { source_article_id, source_revision, translation_revision } (internal fields; never shown)
|
||||
|
||||
Release 2. Publication (solid badge) and freshness (outlined badge with refresh icon) are separate. Labels: No version, Draft, Needs review, Reviewed. A copied draft starts as Needs review. The dialog shows "Changed fields: Title, Body", the source version with its date and the translation version with its date. No revision numbers appear in the UI.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l9.warn | Source updated | English changed after review | LocaleFrame{s:{v:"fr",x:"warn"}} | 1440, 390 | Journal |
|
||||
| loc.l9.pub | Published, needs review | Still online | LocaleFrame{s:{v:"fr",x:"pub"}} | 1440, 390 | Journal |
|
||||
| loc.l9.review | Mark reviewed | Confirm with changed fields and dates | LocaleFrame{s:{v:"fr",x:"review"}} | 1440, 390 | Journal |
|
||||
| loc.review.changed | Source changed again | During the review | LocaleFrame{s:{v:"fr",x:"changed"}} | 1440, 390 | Journal |
|
||||
| loc.l9.changedTr | Translation changed | Saved by someone else while confirming | LocaleFrame{s:{v:"fr",x:"changedTr"}} | 1440, 390 | Journal |
|
||||
| loc.l9.replace | Choose tracked source | Cycle prevented | LocaleFrame{s:{v:"fr",x:"replace"}} | 1440, 390 | Journal |
|
||||
| loc.src.inaccessible | Source unavailable (access) | History kept | LocaleFrame{s:{v:"fr",x:"inaccessible"}} | 1440, 390 | Journal |
|
||||
| loc.src.deleted | Source deleted | Choose a new source | LocaleFrame{s:{v:"fr",x:"gone"}} | 1440, 390 | Journal |
|
||||
| loc.l9.done | Reviewed | Toast | LocaleFrame{s:{v:"fr",x:"done"}} | 1440, 390 | Journal |
|
||||
| loc.l9.fail | Review failed | Toast | LocaleFrame{s:{v:"fr",x:"fail"}} | 1440, 390 | Journal |
|
||||
| loc.l9.frReview | Published and needs review in the list | French tab, Needs review: 2 versions | LocaleFrame{s:{v:"li",x:"frReview"}} | 1440, 1024, 390 | Journal |
|
||||
|
||||
## L10 Metadata and public API
|
||||
Route: Search and sharing panel · /integration
|
||||
API: existing slug, SEO fields; proposed alternates[], lang; exact lookup by site + lang + slug; resolution by story_id
|
||||
|
||||
Slug is unique per (site, lang). Exact lookup is by site + language + slug and never falls back. Language resolution and fallback use the stable story_id. Website URLs come from the site's Public URL pattern; without it the panel says "Public URL not configured" and links to the setting. Canonical is the localized page itself unless an override is set. SEO labels: "Fallback: French title", "No fallback", "Empty. English site default not used."
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.seo.noFallback | Search and sharing: French | Search and sharing slide-over on the editor: localized slug, canonical, alternates, fallback labels, Edit slug | LocaleFrame{s:{v:"seo",x:""}} | 1440, 390 | Journal |
|
||||
| loc.slug.published | Edit slug | Edit slug dialog over the Search and sharing slide-over: published version warns about old links | LocaleFrame{s:{v:"seo",x:"editSlug"}} | 1440, 390 | Journal |
|
||||
| loc.url.unset | Public URL not configured | Search and sharing slide-over: no pattern, no fabricated URL | LocaleFrame{s:{v:"seo",x:"unset"}} | 1440, 390 | Journal |
|
||||
| loc.l10.noSocial | Social locale not set | Search and sharing slide-over: fr has no region and none is set, og:locale omitted | LocaleFrame{s:{v:"seo",x:"noSocial"}} | 1440, 390 | Journal |
|
||||
| loc.l10.api | Integration: languages | Integration page: exact lookup, resolve by article group, errors | LocaleFrame{s:{v:"api",x:""}} | 1440, 390 | Journal |
|
||||
|
||||
## L11 Conflicts and sessions
|
||||
Route: editor
|
||||
API: existing 401/403/404/409 patterns
|
||||
|
||||
The conflict dialog gives four choices. Keep my changes is the only filled button; the other three are secondary and equal. "Someone else saved this version at <time>". No revision numbers. On mobile the dialog is a bottom sheet with Keep my changes first, so it needs no scrolling at 390 px.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l11.session | Session expired | Draft kept in this browser; editor behind shows the unsaved changes | LocaleFrame{s:{v:"pm",x:"session"}} | 1440, 390 | Journal |
|
||||
| loc.l11.restored | Restored after login | Editor shows "Restored unsaved changes from 14:18" | LocaleFrame{s:{v:"pm",x:"restored"}} | 1440, 390 | Journal |
|
||||
| loc.conflict | Concurrent update | Four choices, local work kept; Save over theirs outlined in error colour with a warning icon | LocaleFrame{s:{v:"pm",x:"conflict"}} | 1440, 390 | Journal |
|
||||
| loc.l11.conflictRestored | Concurrent update after session expiry | Restored changes notice above the choices | LocaleFrame{s:{v:"pm",x:"conflictRestored"}} | 1440, 390 | Journal |
|
||||
| loc.l11.reloadConfirm | Reload theirs: confirm | "This discards your unsaved changes." | LocaleFrame{s:{v:"pm",x:"reloadConfirm"}} | 1440, 390 | Journal |
|
||||
| loc.l11.conflict2 | Second conflict | Save over theirs failed again; nothing overwritten | LocaleFrame{s:{v:"pm",x:"conflict2"}} | 1440, 390 | Journal |
|
||||
| loc.l11.download | Download started | Toast; changes still in the editor | LocaleFrame{s:{v:"pm",x:"download"}} | 1440, 390 | Journal |
|
||||
|
||||
## L11b Move article to another site
|
||||
Route: editor → site select
|
||||
API: proposed: POST /v1/story/{story_id}/move { site }
|
||||
|
||||
Entry point is the site select in the editor details. Articles move as a whole, only when the destination enables every language they contain (disabled ones included) and the user can edit all versions. The note lists readable versions only; versions the user can't read are not listed or counted. Moves are atomic and revalidate permissions and conflicts in the transaction.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l11b.moveSelect | Site select | Note lists all four versions, one tagged Read only | LocaleFrame{s:{v:"pm",x:"moveSelect"}} | 1440, 390 | Journal |
|
||||
| loc.l11b.moveSelectHidden | Site select, hidden version | Arabic not readable: not listed or counted | LocaleFrame{s:{v:"pm",x:"moveSelectHidden"}} | 1440, 390 | Journal |
|
||||
| loc.l11b.moveConfirm | Confirm | Move to Atelier? | LocaleFrame{s:{v:"pm",x:"moveConfirm"}} | 1440, 390 | Journal |
|
||||
| loc.l11b.moveBusy | In progress | Dialog locked | LocaleFrame{s:{v:"pm",x:"moveBusy"}} | 1440, 390 | Journal |
|
||||
| loc.l11b.moveDone | Moved | Toast | LocaleFrame{s:{v:"pm",x:"moveDone"}} | 1440, 390 | Journal |
|
||||
| loc.l11b.move | Language missing in destination | Notes enables English only | LocaleFrame{s:{v:"pm",x:"move"}} | 1440, 390 | Journal, Notes |
|
||||
| loc.l11b.moveSlug | Address conflict | Names the conflicting readable version and its address; unreadable ones are never named | LocaleFrame{s:{v:"pm",x:"moveSlug"}} | 1440, 390 | Journal, Atelier |
|
||||
| loc.l11b.movePerm | No permission | Neutral | LocaleFrame{s:{v:"pm",x:"movePerm"}} | 1440, 390 | Journal |
|
||||
| loc.l11b.moveFail | Move failed | Server error, nothing moved | LocaleFrame{s:{v:"pm",x:"moveFail"}} | 1440, 390 | Journal |
|
||||
|
||||
## L12 Existing surfaces
|
||||
Route: Home · Activity · Media · editor details
|
||||
API: existing; language added where it applies
|
||||
|
||||
Single-language sites show none of this. Home counts articles, not versions.
|
||||
|
||||
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||
|---|---|---|---|---|---|
|
||||
| loc.l12.home | Home: recent articles | Default-language title; code only when not default | LocaleFrame{s:{v:"sf",x:"home"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l12.homeFr | Home: reader of French only | Titles in French with code FR | LocaleFrame{s:{v:"sf",x:"homeFr"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l12.activity | Activity | Events name the language | LocaleFrame{s:{v:"sf",x:"activity"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l12.media | Media usage | Media page with the image detail panel: shared image counted once, versions listed, "Used in 2" | LocaleFrame{s:{v:"sf",x:"media"}} | 1440, 1024, 390 | Journal |
|
||||
| loc.l12.mediaElsewhere | Media usage: used elsewhere only | Image used only as an author avatar: badge "Used in 1", "Articles" with "No article shows it.", "Used elsewhere" with Author avatar linking to /authors | LocaleFrame{s:{v:"sf",x:"mediaElsewhere"}} | 1440, 390 | Journal |
|
||||
| loc.l12.menu | Article menu labels | Articles list, French selected: row menu names the language (Publish French, Delete French version). Bottom sheet at 390 | LocaleFrame{s:{v:"li",x:"menuLang"}} | 1440, 390 | Journal |
|
||||
| loc.l12.apiCopy | API URL copy | Articles list: toast after Copy API URL with the ?lang= address. Bottom toast at 390 | LocaleFrame{s:{v:"li",x:"apiCopy"}} | 1440, 390 | Journal |
|
||||
| loc.l12.seoPreview | SEO preview | Search and sharing slide-over: localized address and canonical | LocaleFrame{s:{v:"seo",x:"preview"}} | 1440, 390 | Journal |
|
||||
| loc.l12.siteFallback | Site text fallback | Search and sharing slide-over: site text used only in its own language | LocaleFrame{s:{v:"seo",x:"siteFallback"}} | 1440, 390 | Journal |
|
||||
|
||||
### Legacy IDs kept
|
||||
| Canonical ID | Alias |
|
||||
|---|---|
|
||||
| loc.lang.disableDefault | loc.l1.disableDefault |
|
||||
| loc.lang.disableLast | loc.l1.disableLast |
|
||||
| loc.dup.readable | loc.l4.dup |
|
||||
| loc.dup.hidden | loc.l4.dupHidden |
|
||||
| loc.perm.readonly | loc.l5b.ro |
|
||||
| loc.perm.editNoPublish | loc.l5b.p2 |
|
||||
| loc.perm.publishNoEdit | loc.l5b.p3 |
|
||||
| loc.perm.siblingReadOnly | loc.l5b.p4 |
|
||||
| loc.perm.siblingHidden | loc.l5b.p5 |
|
||||
| loc.perm.createNotAll | loc.l5b.p6 |
|
||||
| loc.lang.publishDisabled | loc.l8.publishDisabled |
|
||||
| loc.review.changed | loc.l9.changed |
|
||||
| loc.src.inaccessible | loc.l9.inaccessible |
|
||||
| loc.src.deleted | loc.l9.gone |
|
||||
| loc.seo.noFallback | loc.l10.default |
|
||||
| loc.slug.published | loc.l10.editSlug |
|
||||
| loc.url.unset | loc.l10.unset |
|
||||
| loc.conflict | loc.l11.conflict |
|
||||
|
||||
### Added in the third localization pass
|
||||
New: L5:ed:menuDisabled, L1b:pu:invalidHost, L8:pd:deleteDone, L8:pd:deleteStoryFail, L8:li:deleteStoryDone, L11b:pm:moveFail, L12:sf:mediaElsewhere, L7s:li (default, draft, noResults, empty, loading, fail, menu). Moved selector, same ID: loc.l12.menu, loc.l12.apiCopy, loc.l12.seoPreview, loc.l12.siteFallback.
|
||||
|
||||
### Changes against the previous 109
|
||||
Added (70): L1:sl:broader, L1:sl:defaultFail, L1:sl:stale, L1:sl:reenableDone, L1:sl:reenableFail, L1:sl:removeFail, L1:sl:social, L1:sl:socialEdit, L1:sl:socialInvalid, L1b:pu:set, L1b:pu:editing, L1b:pu:unknown, L1b:pu:scheme, L1b:pu:encoded, L1b:pu:noSlug, L1b:pu:saved, L1b:pu:unset, L1b:pu:viewOnly, L2:su:noLocale, L2:su:inProgress, L4:at:unreadable, L4:at:langOff, L5:ed:cleanOnline, L5:ed:pubFail, L5:ed:ovf, L5:ed:titleEdit, L5:ed:titleUnsaved, L5:ed:titleShort, L5:ed:titleLong, L5b:perm:ro, L5b:perm:roMenu, L5b:perm:p2, L5b:perm:p3, L5b:perm:p4, L5b:perm:p5, L5b:perm:p6, L5b:perm:noAdd, L5b:perm:withAdd, L6:src:arRef, L7:li:page2, L7:li:review, L7:li:author, L7:li:langFilter, L7:li:rowMenu, L8:pd:publishDisabled, L8:pd:unpublishDirty, L8:pd:deleteStoryOk, L9:li:frReview, L10:seo:editSlug, L11:pm:restored, L11:pm:conflictRestored, L11:pm:reloadConfirm, L11:pm:conflict2, L11:pm:download, L11b:pm:moveSelect, L11b:pm:moveSelectHidden, L11b:pm:moveConfirm, L11b:pm:moveBusy, L11b:pm:moveDone, L11b:pm:move, L11b:pm:moveSlug, L11b:pm:movePerm, L12:sf:home, L12:sf:homeFr, L12:sf:activity, L12:sf:media, L12:sf:menu, L12:sf:apiCopy, L12:sf:seoPreview, L12:sf:siteFallback
|
||||
|
||||
Changed (rekeyed to a new group or frame, same meaning): L5b|ed|ro, L5b|ed|roMenu, L5b|ed|p2, L5b|ed|p3, L5b|ed|p4, L5b|ed|p5, L5b|ed|p6, L11|pm|move, L11|pm|moveSlug, L11|pm|movePerm
|
||||
|
||||
Removed without replacement: none.
|
||||
|
||||
Kept unchanged: 99.
|
||||
+1
-1
@@ -75,7 +75,7 @@ task clean # remove bin/, tmp/ and the built SPA
|
||||
- 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`.
|
||||
- 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|TestLocalizationPersistence' -count=1`, or `RENEWCMS_TEST_POSTGRES_DSN='postgres://user:pass@host:5432/db?sslmode=disable' go test -tags nofrontend ./test/... -run 'Postgres|TestLocalizationPersistence' -count=1`. `TestLocalizationPersistence` runs one subtest per backend (`sqlite/…`, `mysql/…`, `postgres/…`), so it is part of both runs.
|
||||
- `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
|
||||
|
||||
+4
-4
@@ -21,7 +21,7 @@ Requirements: Docker with Compose v2. [task](https://taskfile.dev) is optional f
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`docker-compose.yml` uses `image: ${RENEWCMS_IMAGE:-git.floriansylvain.fr/fsylvain/renewcms:beta}`. The default tag is `beta` because `latest` does not exist until the first stable release (the default changes to `latest` then). Set `RENEWCMS_IMAGE` in your shell or in a `.env` file next to the compose file to pin another tag, for example `RENEWCMS_IMAGE=git.floriansylvain.fr/fsylvain/renewcms:v3.2.1-beta`.
|
||||
`docker-compose.yml` uses `image: ${RENEWCMS_IMAGE:-git.floriansylvain.fr/fsylvain/renewcms:beta}`. The default tag is `beta` because `latest` does not exist until the first stable release (the default changes to `latest` then). Set `RENEWCMS_IMAGE` in your shell or in a `.env` file next to the compose file to pin another tag, for example `RENEWCMS_IMAGE=git.floriansylvain.fr/fsylvain/renewcms:v3.7.0-beta`.
|
||||
|
||||
To build from the local sources instead, see [Building from source](#building-from-source).
|
||||
|
||||
@@ -50,7 +50,7 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
| Release | Tags pushed |
|
||||
|---|---|
|
||||
| `vX.Y.Z` (stable) | `vX.Y.Z`, `X.Y.Z`, `X.Y`, `latest` |
|
||||
| `vX.Y.Z-<pre>` (for example `v3.2.1-beta`, `v3.2.1-rc.1`) | `vX.Y.Z-<pre>`, `X.Y.Z-<pre>`, and the moving tag `<pre-name>` (`beta`, `rc`) |
|
||||
| `vX.Y.Z-<pre>` (for example `v3.7.0-beta`, `v3.7.0-rc.1`) | `vX.Y.Z-<pre>`, `X.Y.Z-<pre>`, and the moving tag `<pre-name>` (`beta`, `rc`) |
|
||||
|
||||
A pre-release never moves `latest`. Images are published for `linux/amd64` and `linux/arm64`. `renewcms --version` in the image prints the exact release tag. Images are not signed; the Ed25519 key signs the release binaries only.
|
||||
|
||||
@@ -198,7 +198,7 @@ Publishing is a manual, maintainer-run task, like `task release`. There is no CI
|
||||
1. Tag the release and keep the work tree clean:
|
||||
|
||||
```sh
|
||||
git tag v3.2.1-beta
|
||||
git tag v3.7.0-beta
|
||||
```
|
||||
|
||||
2. Log in once to the registry. The task never reads or stores credentials:
|
||||
@@ -226,7 +226,7 @@ Publishing is a manual, maintainer-run task, like `task release`. There is no CI
|
||||
|
||||
```sh
|
||||
task docker:publish -- --dry-run
|
||||
task docker:publish VERSION=v3.2.1-rc.1 -- --dry-run
|
||||
task docker:publish VERSION=v3.7.0-rc.1 -- --dry-run
|
||||
```
|
||||
|
||||
The dry run builds for the host platform only, loads the image into the local Docker, pushes nothing and needs no login. It prints the tags a real run would push. A `VERSION` override is accepted only with `--dry-run`, and a dirty tree is allowed there. Remove the loaded images afterwards with `docker rmi`.
|
||||
|
||||
Loaded 100 of 804 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user