10 Commits
274 changed files with 32996 additions and 1440 deletions

No files matched your search

+3
View File
@@ -16,6 +16,9 @@ AUTH_RATE_LIMIT_WINDOW_SECONDS=60
TRUSTED_PROXIES=
UPDATE_FEED_URL=https://git.floriansylvain.fr
RENEWCMS_CONTAINER=false
IMAGE_VARIANT_MAX_SOURCE_BYTES=10485760
IMAGE_VARIANT_MAX_SOURCE_PIXELS=50000000
IMAGE_VARIANT_MAX_CONCURRENT_RENDERS=4
DB_DRIVER=sqlite
DATA_DIR=
DB_HOST=
+1 -1
View File
@@ -178,7 +178,7 @@ tasks:
mutate:
desc: Gremlins mutation testing of the use cases → tmp/gremlins.json
cmds:
- bash scripts/mutate.sh
- bash scripts/mutate.sh {{.CLI_ARGS}}
test:web:
desc: Run the SPA test suite (Vitest) in run mode
+165 -104
View File
@@ -13,6 +13,7 @@ import (
"RenewCMS/internal/infrastructure/auditing"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/markdown"
"RenewCMS/internal/infrastructure/persistence/database"
@@ -20,6 +21,7 @@ import (
"RenewCMS/internal/infrastructure/scheduler"
"RenewCMS/internal/infrastructure/storage"
"RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/usage"
"RenewCMS/internal/infrastructure/useCases"
"context"
"crypto/ed25519"
@@ -35,58 +37,73 @@ import (
)
type UseCases struct {
apiKeyAuthOnce sync.Once
apiKeyAuthMiddleware func(http.Handler) http.Handler
Token *jwtauth.JWTAuth
PurgeScheduler *scheduler.Runner
ActivityCleanupScheduler *scheduler.Runner
CreateArticleUseCase *useCases.CreateArticleUseCase
GetArticleUseCase *useCases.GetArticleUseCase
ListArticlesUseCase *useCases.ListArticlesUseCase
UpdateArticleUseCase *useCases.UpdateArticleUseCase
DeleteArticleUseCase *useCases.DeleteArticleUseCase
GetUserUseCase *useCases.GetUserUseCase
CreateUserUseCase *useCases.CreateUserUseCase
AuthenticateUserUseCase *useCases.AuthenticateUserUseCase
VerifyUserUseCase *useCases.VerifyUserUseCase
CancelRegistrationUseCase *useCases.CancelRegistrationUseCase
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
ResetPasswordUseCase *useCases.ResetPasswordUseCase
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
ListUsersUseCase *useCases.ListUsersUseCase
CreateInviteUseCase *useCases.CreateInviteUseCase
CancelInviteUseCase *useCases.CancelInviteUseCase
RenewInviteUseCase *useCases.RenewInviteUseCase
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase
SendMailUseCase *useCases.SendMailUseCase
CreateImageUseCase *useCases.CreateImageUseCase
OpenMediaUseCase *useCases.OpenMediaUseCase
ListMediaUseCase *useCases.ListMediaUseCase
RenameMediaUseCase *useCases.RenameMediaUseCase
DeleteMediaUseCase *useCases.DeleteMediaUseCase
MergeMediaUseCase *useCases.MergeMediaUseCase
GetMediaUsageUseCase *useCases.GetMediaUsageUseCase
GetPurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase
UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase
PreviewPurgeUseCase *useCases.PreviewPurgeUseCase
RunPurgeUseCase *useCases.RunPurgeUseCase
GetSettingsUseCase *useCases.GetSettingsUseCase
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
RequestRestartUseCase *useCases.RequestRestartUseCase
RecordRunningInfoUseCase *useCases.RecordRunningInfoUseCase
CreateAPIKeyUseCase *useCases.CreateAPIKeyUseCase
ListAPIKeysUseCase *useCases.ListAPIKeysUseCase
RevokeAPIKeyUseCase *useCases.RevokeAPIKeyUseCase
AuthenticateAPIKeyUseCase *useCases.AuthenticateAPIKeyUseCase
ListActivityUseCase *useCases.ListActivityUseCase
GetActivitySummaryUseCase *useCases.GetActivitySummaryUseCase
ExportActivityUseCase *useCases.ExportActivityUseCase
GetActivityRetentionUseCase *useCases.GetActivityRetentionUseCase
UpdateActivityRetentionUseCase *useCases.UpdateActivityRetentionUseCase
apiKeyAuthOnce sync.Once
apiKeyAuthMiddleware func(http.Handler) http.Handler
Token *jwtauth.JWTAuth
PurgeScheduler *scheduler.Runner
ActivityCleanupScheduler *scheduler.Runner
CreateArticleUseCase *useCases.CreateArticleUseCase
GetArticleUseCase *useCases.GetArticleUseCase
ListArticlesUseCase *useCases.ListArticlesUseCase
UpdateArticleUseCase *useCases.UpdateArticleUseCase
DeleteArticleUseCase *useCases.DeleteArticleUseCase
GetUserUseCase *useCases.GetUserUseCase
CreateUserUseCase *useCases.CreateUserUseCase
AuthenticateUserUseCase *useCases.AuthenticateUserUseCase
VerifyUserUseCase *useCases.VerifyUserUseCase
CancelRegistrationUseCase *useCases.CancelRegistrationUseCase
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
ResetPasswordUseCase *useCases.ResetPasswordUseCase
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
ListUsersUseCase *useCases.ListUsersUseCase
CreateInviteUseCase *useCases.CreateInviteUseCase
CancelInviteUseCase *useCases.CancelInviteUseCase
RenewInviteUseCase *useCases.RenewInviteUseCase
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase
SendMailUseCase *useCases.SendMailUseCase
CreateImageUseCase *useCases.CreateImageUseCase
OpenMediaUseCase *useCases.OpenMediaUseCase
ListMediaUseCase *useCases.ListMediaUseCase
RenameMediaUseCase *useCases.RenameMediaUseCase
DeleteMediaUseCase *useCases.DeleteMediaUseCase
MergeMediaUseCase *useCases.MergeMediaUseCase
GetMediaUsageUseCase *useCases.GetMediaUsageUseCase
GetPurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase
UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase
PreviewPurgeUseCase *useCases.PreviewPurgeUseCase
RunPurgeUseCase *useCases.RunPurgeUseCase
GetSettingsUseCase *useCases.GetSettingsUseCase
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
RequestRestartUseCase *useCases.RequestRestartUseCase
RecordRunningInfoUseCase *useCases.RecordRunningInfoUseCase
CreateAPIKeyUseCase *useCases.CreateAPIKeyUseCase
ListAPIKeysUseCase *useCases.ListAPIKeysUseCase
RevokeAPIKeyUseCase *useCases.RevokeAPIKeyUseCase
AuthenticateAPIKeyUseCase *useCases.AuthenticateAPIKeyUseCase
ListActivityUseCase *useCases.ListActivityUseCase
GetActivitySummaryUseCase *useCases.GetActivitySummaryUseCase
ExportActivityUseCase *useCases.ExportActivityUseCase
GetActivityRetentionUseCase *useCases.GetActivityRetentionUseCase
UpdateActivityRetentionUseCase *useCases.UpdateActivityRetentionUseCase
ListVariantsUseCase *useCases.ListVariantsUseCase
CreateVariantUseCase *useCases.CreateVariantUseCase
UpdateVariantUseCase *useCases.UpdateVariantUseCase
SetVariantEnabledUseCase *useCases.SetVariantEnabledUseCase
DeleteVariantUseCase *useCases.DeleteVariantUseCase
PurgeVariantUseCase *useCases.PurgeVariantUseCase
ServeVariantUseCase *useCases.ServeVariantUseCase
ListImageVariantsUseCase *useCases.ListImageVariantsUseCase
GenerateImageVariantUseCase *useCases.GenerateImageVariantUseCase
GenerateAllImageVariantsUseCase *useCases.GenerateAllImageVariantsUseCase
PurgeImageVariantUseCase *useCases.PurgeImageVariantUseCase
GenerateVariantsOnUploadUseCase *useCases.GenerateVariantsOnUploadUseCase
GetVariantCacheStatsUseCase *useCases.GetVariantCacheStatsUseCase
PurgeVariantCacheUseCase *useCases.PurgeVariantCacheUseCase
VariantUsageTracker *usage.Tracker
}
func (c *UseCases) RunSchedulers(ctx context.Context) {
@@ -108,6 +125,11 @@ type Repositories struct {
ArticleRepo domainArticle.Repository
UserRepo domainUser.Repository
ImageRepo domainImage.Repository
VariantRepo domainImage.VariantRepository
VariantFileRepo domainImage.VariantFileRepository
VariantCacheRepo domainImage.VariantCacheRepository
VariantUsageRepo domainImage.VariantUsageRepository
VariantRenderer domainImage.VariantRenderer
InviteRepo domainInvite.Repository
APIKeyRepo domainAPIKey.Repository
PurgeSettingsRepo domainPurge.Repository
@@ -236,10 +258,16 @@ func releasePublicKey() ed25519.PublicKey {
func getRepositories(connection *database.Connection, settings config.Settings) *Repositories {
db := connection.DB
dataDir := settings.DataDir()
variantFiles := persistence.NewVariantFileRepository(db)
return &Repositories{
ArticleRepo: persistence.NewArticleRepository(db),
UserRepo: persistence.NewUserRepository(db),
ImageRepo: persistence.NewImageRepository(db),
VariantRepo: persistence.NewVariantRepository(db),
VariantFileRepo: variantFiles,
VariantCacheRepo: variantFiles,
VariantUsageRepo: persistence.NewVariantUsageRepository(db),
VariantRenderer: imaging.NewVariantRenderer(),
InviteRepo: persistence.NewInviteRepository(db),
APIKeyRepo: persistence.NewAPIKeyRepository(db),
PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db),
@@ -265,63 +293,81 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
renderer := markdown.NewConverter()
keyGenerator := hashing.NewSHA256KeyGenerator()
recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default())
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, recorder)
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, repos.VariantFileRepo, recorder)
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder)
purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge, recorder)
activityCleanup := useCases.NewCleanupActivityUseCase(repos.AuditRepo, repos.AuditSettingsRepo, recorder)
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)
return &UseCases{
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder),
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher, recorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder),
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder),
RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo),
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, recorder),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, recorder),
MergeMediaUseCase: mergeMedia,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo),
RunPurgeUseCase: runPurge,
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now, recorder),
StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings, recorder),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now, recorder),
RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now, recorder),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now),
ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now),
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now),
ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, time.Now),
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, recorder, time.Now),
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder),
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher, recorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder),
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder),
RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo),
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, recorder),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo, repos.VariantFileRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder),
MergeMediaUseCase: mergeMedia,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo),
RunPurgeUseCase: runPurge,
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now, recorder),
StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings, recorder),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now, recorder),
RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now, recorder),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now),
ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now),
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now),
ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, time.Now),
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, recorder, time.Now),
ListVariantsUseCase: useCases.NewListVariantsUseCase(repos.VariantRepo, repos.VariantFileRepo),
CreateVariantUseCase: useCases.NewCreateVariantUseCase(repos.VariantRepo, repos.VariantRenderer, time.Now, recorder),
UpdateVariantUseCase: useCases.NewUpdateVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, repos.VariantRenderer, time.Now, recorder),
SetVariantEnabledUseCase: useCases.NewSetVariantEnabledUseCase(repos.VariantRepo, repos.VariantFileRepo, time.Now, recorder),
DeleteVariantUseCase: useCases.NewDeleteVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder),
PurgeVariantUseCase: useCases.NewPurgeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder),
ServeVariantUseCase: serveVariant,
ListImageVariantsUseCase: useCases.NewListImageVariantsUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, repos.VariantRenderer),
GenerateImageVariantUseCase: useCases.NewGenerateImageVariantUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, repos.VariantRenderer, serveVariant),
GenerateAllImageVariantsUseCase: useCases.NewGenerateAllImageVariantsUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, repos.VariantRenderer, serveVariant, recorder),
PurgeImageVariantUseCase: useCases.NewPurgeImageVariantUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder),
GenerateVariantsOnUploadUseCase: useCases.NewGenerateVariantsOnUploadUseCase(repos.VariantRepo, serveVariant, log.Default()),
GetVariantCacheStatsUseCase: useCases.NewGetVariantCacheStatsUseCase(repos.VariantRepo, repos.VariantFileRepo, repos.VariantCacheRepo, repos.ImageRepo, repos.VariantUsageRepo, usageTracker, time.Now),
PurgeVariantCacheUseCase: useCases.NewPurgeVariantCacheUseCase(repos.VariantRepo, repos.ImageRepo, repos.VariantCacheRepo, repos.VariantFileRepo, imageStorage, time.Now, recorder),
VariantUsageTracker: usageTracker,
}
}
@@ -363,7 +409,22 @@ 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())
}
return getUseCases(repos, imageStorage, settings), connection.Close
container := getUseCases(repos, imageStorage, settings)
return container, container.runUsageTracker(connection.Close)
}
func (c *UseCases) runUsageTracker(closeDatabase func() error) func() error {
ctx, stop := context.WithCancel(context.Background())
stopped := make(chan struct{})
go func() {
defer close(stopped)
c.VariantUsageTracker.Run(ctx)
}()
return func() error {
stop()
<-stopped
return closeDatabase()
}
}
type BrowserUpdateCommands struct {
+141
View File
@@ -0,0 +1,141 @@
package imagevariant
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"math"
"net/http"
validatorlib "github.com/go-playground/validator/v10"
)
type cacheUnusedResponse struct {
Files int `json:"files"`
Bytes int64 `json:"bytes"`
Days int `json:"days"`
}
type cacheVariantResponse struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
Files int `json:"files"`
Bytes int64 `json:"bytes"`
}
type cacheImageResponse struct {
ID uint32 `json:"id"`
Filename string `json:"filename"`
URL string `json:"url"`
Files int `json:"files"`
Bytes int64 `json:"bytes"`
}
type cacheResponse struct {
GeneratedFiles int `json:"generated_files"`
DiskUsed int64 `json:"disk_used"`
VariantsDefined int `json:"variants_defined"`
VariantsEnabled int `json:"variants_enabled"`
VariantsDisabled int `json:"variants_disabled"`
RejectedRequests int `json:"rejected_requests"`
RejectedWindowDays int `json:"rejected_window_days"`
Unused cacheUnusedResponse `json:"unused"`
ByVariant []cacheVariantResponse `json:"by_variant"`
TopImages []cacheImageResponse `json:"top_images"`
}
type cachePurgeRequest struct {
Scope string `json:"scope" validate:"required,oneof=all unused variant image"`
Variant string `json:"variant" validate:"required_if=Scope variant"`
ImageID int64 `json:"image_id" validate:"required_if=Scope image,omitempty,gt=0,lte=4294967295"`
}
type cachePurgeResponse struct {
Scope string `json:"scope"`
PurgedFiles int `json:"purged_files"`
PurgedBytes int64 `json:"purged_bytes"`
FailedFiles int `json:"failed_files"`
}
func (r *cachePurgeRequest) ignoreUnusedTargets() {
if r.Scope != string(image.CacheScopeVariant) {
r.Variant = ""
}
if r.Scope != string(image.CacheScopeImage) {
r.ImageID = 0
}
}
func cacheResponseOf(stats useCases.VariantCacheStats) cacheResponse {
response := cacheResponse{
GeneratedFiles: stats.GeneratedFiles,
DiskUsed: stats.DiskUsed,
VariantsDefined: stats.VariantsDefined,
VariantsEnabled: stats.VariantsEnabled,
VariantsDisabled: stats.VariantsDisabled,
RejectedRequests: stats.RejectedRequests,
RejectedWindowDays: stats.RejectedWindowDays,
Unused: cacheUnusedResponse{Files: stats.Unused.Files, Bytes: stats.Unused.Bytes, Days: stats.UnusedDays},
ByVariant: make([]cacheVariantResponse, 0, len(stats.ByVariant)),
TopImages: make([]cacheImageResponse, 0, len(stats.TopImages)),
}
for _, entry := range stats.ByVariant {
response.ByVariant = append(response.ByVariant, cacheVariantResponse{Name: entry.Name, Enabled: entry.Enabled, Files: entry.Files, Bytes: entry.Bytes})
}
for _, top := range stats.TopImages {
response.TopImages = append(response.TopImages, cacheImageResponse{ID: top.ID, Filename: top.Filename, URL: top.URL, Files: top.Files, Bytes: top.Bytes})
}
return response
}
func (h *Handler) CacheStats(w http.ResponseWriter, _ *http.Request) {
stats, err := h.cacheStats.Get()
if err != nil {
h.writeError(w, "", err)
return
}
writeJSON(w, http.StatusOK, cacheResponseOf(stats))
}
func (h *Handler) PurgeCache(w http.ResponseWriter, r *http.Request) {
var request cachePurgeRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
request.ignoreUnusedTargets()
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, cachePurgeValidationMessage(err), http.StatusUnprocessableEntity)
return
}
result, err := h.purgeCache.Purge(r.Context(), useCases.PurgeVariantCacheCommand{
Scope: image.CachePurgeScope(request.Scope),
Variant: request.Variant,
ImageID: uint32(min(request.ImageID, math.MaxUint32)),
})
if err != nil {
h.writePurgeCacheError(w, err)
return
}
writeJSON(w, http.StatusOK, cachePurgeResponse{Scope: string(result.Scope), PurgedFiles: result.Files, PurgedBytes: result.Bytes, FailedFiles: result.Failed})
}
func cachePurgeValidationMessage(err error) string {
var failures validatorlib.ValidationErrors
if errors.As(err, &failures) && len(failures) > 0 && failures[0].StructField() != "Scope" {
return apperr.CachePurgeTargetErrorMessage
}
return apperr.CachePurgeScopeErrorMessage
}
func (h *Handler) writePurgeCacheError(w http.ResponseWriter, err error) {
if errors.Is(err, image.ErrImageNotFound) {
http.Error(w, apperr.ImageNotFoundErrorMessage, http.StatusNotFound)
return
}
h.writeError(w, "", err)
}
@@ -0,0 +1,266 @@
package imagevariant
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
validatorlib "github.com/go-playground/validator/v10"
"github.com/go-chi/chi/v5"
)
type Handler struct {
list *useCases.ListVariantsUseCase
create *useCases.CreateVariantUseCase
update *useCases.UpdateVariantUseCase
setEnabled *useCases.SetVariantEnabledUseCase
remove *useCases.DeleteVariantUseCase
purge *useCases.PurgeVariantUseCase
cacheStats *useCases.GetVariantCacheStatsUseCase
purgeCache *useCases.PurgeVariantCacheUseCase
}
func NewHandler(list *useCases.ListVariantsUseCase, create *useCases.CreateVariantUseCase, update *useCases.UpdateVariantUseCase, setEnabled *useCases.SetVariantEnabledUseCase, remove *useCases.DeleteVariantUseCase, purge *useCases.PurgeVariantUseCase, cacheStats *useCases.GetVariantCacheStatsUseCase, purgeCache *useCases.PurgeVariantCacheUseCase) *Handler {
return &Handler{list, create, update, setEnabled, remove, purge, cacheStats, purgeCache}
}
type definitionRequest struct {
Width *int `json:"width" validate:"required_without=Height,omitempty,min=1,max=8000"`
Height *int `json:"height" validate:"required_without=Width,omitempty,min=1,max=8000"`
Fit string `json:"fit" validate:"omitempty,oneof=cover contain inside"`
Format string `json:"format" validate:"omitempty,oneof=original jpeg png webp"`
Quality *int `json:"quality" validate:"omitempty,min=1,max=100"`
}
type switchesRequest struct {
GenerateOnUpload *bool `json:"generate_on_upload"`
Enabled *bool `json:"enabled"`
}
type createRequest struct {
Name string `json:"name" validate:"required,variantname"`
definitionRequest
switchesRequest
}
type updateRequest struct {
definitionRequest
switchesRequest
}
type enabledRequest struct {
Enabled *bool `json:"enabled" validate:"required"`
}
type variantResponse struct {
Name string `json:"name"`
Width *int `json:"width"`
Height *int `json:"height"`
Fit image.VariantFit `json:"fit"`
Format image.VariantFormat `json:"format"`
Quality *int `json:"quality"`
GenerateOnUpload bool `json:"generate_on_upload"`
Enabled bool `json:"enabled"`
GeneratedFiles int `json:"generated_files"`
CacheSize int64 `json:"cache_size"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type updatedVariantResponse struct {
variantResponse
PurgedFiles int `json:"purged_files"`
}
type listResponse struct {
Items []variantResponse `json:"items"`
}
type purgeResponse struct {
PurgedFiles int `json:"purged_files"`
PurgedBytes int64 `json:"purged_bytes"`
}
func responseOf(summary useCases.VariantSummary) variantResponse {
variant := summary.Variant
return variantResponse{
Name: variant.Name,
Width: variant.Width,
Height: variant.Height,
Fit: variant.Fit,
Format: variant.Format,
Quality: variant.Quality,
GenerateOnUpload: variant.GenerateOnUpload,
Enabled: variant.Enabled,
GeneratedFiles: summary.GeneratedFiles,
CacheSize: summary.CacheSize,
CreatedAt: variant.CreatedAt,
UpdatedAt: variant.UpdatedAt,
}
}
func (h *Handler) List(w http.ResponseWriter, _ *http.Request) {
summaries, err := h.list.List()
if err != nil {
h.writeError(w, "", err)
return
}
items := make([]variantResponse, 0, len(summaries))
for _, summary := range summaries {
items = append(items, responseOf(summary))
}
writeJSON(w, http.StatusOK, listResponse{items})
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var request createRequest
if !h.decode(w, r, &request) {
return
}
summary, err := h.create.Create(r.Context(), useCases.CreateVariantCommand{
Name: request.Name,
VariantDefinitionCommand: request.definitionRequest.command(),
GenerateOnUpload: request.GenerateOnUpload,
Enabled: request.Enabled,
})
if err != nil {
h.writeError(w, request.Name, err)
return
}
writeJSON(w, http.StatusCreated, responseOf(summary))
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
var request updateRequest
if !h.decode(w, r, &request) {
return
}
name := chi.URLParam(r, "name")
result, err := h.update.Update(r.Context(), useCases.UpdateVariantCommand{
Name: name,
VariantDefinitionCommand: request.definitionRequest.command(),
GenerateOnUpload: request.GenerateOnUpload,
Enabled: request.Enabled,
})
if err != nil {
h.writeError(w, name, err)
return
}
writeJSON(w, http.StatusOK, updatedVariantResponse{responseOf(result.VariantSummary), result.PurgedFiles})
}
func (h *Handler) SetEnabled(w http.ResponseWriter, r *http.Request) {
var request enabledRequest
if !h.decode(w, r, &request) {
return
}
name := chi.URLParam(r, "name")
summary, err := h.setEnabled.SetEnabled(r.Context(), name, *request.Enabled)
if err != nil {
h.writeError(w, name, err)
return
}
writeJSON(w, http.StatusOK, responseOf(summary))
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if err := h.remove.Delete(r.Context(), name); err != nil {
h.writeError(w, name, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Purge(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
purged, err := h.purge.Purge(r.Context(), name)
if err != nil {
h.writeError(w, name, err)
return
}
writeJSON(w, http.StatusOK, purgeResponse{purged.Files, purged.Bytes})
}
func (d definitionRequest) command() useCases.VariantDefinitionCommand {
return useCases.VariantDefinitionCommand{Width: d.Width, Height: d.Height, Fit: d.Fit, Format: d.Format, Quality: d.Quality}
}
func (h *Handler) decode(w http.ResponseWriter, r *http.Request, request any) bool {
if err := json.NewDecoder(r.Body).Decode(request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return false
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, h.validationMessage(err), http.StatusUnprocessableEntity)
return false
}
return true
}
func (h *Handler) validationMessage(err error) string {
var failures validatorlib.ValidationErrors
if !errors.As(err, &failures) || len(failures) == 0 {
return apperr.VariantInvalidErrorMessage
}
switch failures[0].StructField() {
case "Name":
return apperr.VariantNameErrorMessage
case "Width", "Height":
return apperr.VariantDimensionsErrorMessage
case "Fit":
return apperr.VariantFitErrorMessage
case "Format":
return h.formatMessage()
case "Quality":
return apperr.VariantQualityErrorMessage
default:
return apperr.VariantInvalidErrorMessage
}
}
func (h *Handler) formatMessage() string {
encodable := h.create.EncodableFormats()
names := make([]string, 0, len(encodable))
for _, format := range encodable {
names = append(names, string(format))
}
last := len(names) - 1
return fmt.Sprintf(apperr.VariantFormatErrorMessageFormat, strings.Join(names[:last], ", ")+" or "+names[last])
}
func (h *Handler) writeError(w http.ResponseWriter, name string, err error) {
switch {
case errors.Is(err, image.ErrVariantNotFound):
http.Error(w, apperr.VariantNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, image.ErrVariantExists):
http.Error(w, fmt.Sprintf(apperr.VariantExistsErrorMessageFormat, name), http.StatusConflict)
case errors.Is(err, image.ErrVariantFormatUnavailable):
http.Error(w, h.formatMessage(), http.StatusUnprocessableEntity)
case errors.Is(err, image.ErrVariantInvalid):
http.Error(w, apperr.VariantInvalidErrorMessage, http.StatusUnprocessableEntity)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func writeJSON(w http.ResponseWriter, status int, value any) {
body, err := json.Marshal(value)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(status)
_, _ = w.Write(body)
}
+24 -1
View File
@@ -29,6 +29,11 @@ type Handler struct {
UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase
PreviewPurgeUseCase *useCases.PreviewPurgeUseCase
RunPurgeUseCase *useCases.RunPurgeUseCase
ListVariantsUseCase *useCases.ListImageVariantsUseCase
GenerateVariantUseCase *useCases.GenerateImageVariantUseCase
GenerateAllUseCase *useCases.GenerateAllImageVariantsUseCase
PurgeVariantUseCase *useCases.PurgeImageVariantUseCase
GenerateOnUploadUseCase *useCases.GenerateVariantsOnUploadUseCase
}
type RenameRequest struct {
@@ -53,6 +58,9 @@ type mediaItemResponse struct {
CreatedAt time.Time `json:"created_at"`
UsedIn int `json:"used_in"`
Duplicate bool `json:"duplicate"`
VariantCount int `json:"variant_count"`
VariantSize int64 `json:"variant_size"`
}
type countsResponse struct {
@@ -78,7 +86,7 @@ type mergeResponse struct {
Merged int `json:"merged"`
}
func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUseCase, rename *useCases.RenameMediaUseCase, deleteUseCase *useCases.DeleteMediaUseCase, merge *useCases.MergeMediaUseCase, usage *useCases.GetMediaUsageUseCase, purgeSettings *useCases.GetPurgeSettingsUseCase, updatePurgeSettings *useCases.UpdatePurgeSettingsUseCase, previewPurge *useCases.PreviewPurgeUseCase, runPurge *useCases.RunPurgeUseCase) *Handler {
func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUseCase, rename *useCases.RenameMediaUseCase, deleteUseCase *useCases.DeleteMediaUseCase, merge *useCases.MergeMediaUseCase, usage *useCases.GetMediaUsageUseCase, purgeSettings *useCases.GetPurgeSettingsUseCase, updatePurgeSettings *useCases.UpdatePurgeSettingsUseCase, previewPurge *useCases.PreviewPurgeUseCase, runPurge *useCases.RunPurgeUseCase, listVariants *useCases.ListImageVariantsUseCase, generateVariant *useCases.GenerateImageVariantUseCase, generateAll *useCases.GenerateAllImageVariantsUseCase, purgeVariant *useCases.PurgeImageVariantUseCase, generateOnUpload *useCases.GenerateVariantsOnUploadUseCase) *Handler {
return &Handler{
CreateUseCase: create,
ListUseCase: list,
@@ -90,6 +98,11 @@ func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUse
UpdatePurgeSettingsUseCase: updatePurgeSettings,
PreviewPurgeUseCase: previewPurge,
RunPurgeUseCase: runPurge,
ListVariantsUseCase: listVariants,
GenerateVariantUseCase: generateVariant,
GenerateAllUseCase: generateAll,
PurgeVariantUseCase: purgeVariant,
GenerateOnUploadUseCase: generateOnUpload,
}
}
@@ -145,6 +158,9 @@ func toResponse(item useCases.MediaItem) mediaItemResponse {
CreatedAt: item.Image.CreatedAt,
UsedIn: item.UsedIn,
Duplicate: item.Duplicate,
VariantCount: item.VariantCount,
VariantSize: item.VariantSize,
}
}
@@ -161,6 +177,12 @@ func parseListQuery(r *http.Request) (domainImage.ListQuery, error) {
if values.Has("filter") {
query.Filter = domainImage.Filter(values.Get("filter"))
}
if values.Has("variants") {
if values.Get("variants") == "" {
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
}
query.Variants = domainImage.VariantsFilter(values.Get("variants"))
}
if values.Has("sort") {
query.Sort = domainImage.Sort(values.Get("sort"))
}
@@ -219,6 +241,7 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
return
}
h.GenerateOnUploadUseCase.Generate(r.Context(), created)
h.writeItem(w, http.StatusCreated, created)
}
+161
View File
@@ -0,0 +1,161 @@
package media
import (
"RenewCMS/api/pkg/apperr"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
)
type imageVariantResponse struct {
Name string `json:"name"`
Width *int `json:"width"`
Height *int `json:"height"`
Fit domainImage.VariantFit `json:"fit"`
Format domainImage.VariantFormat `json:"format"`
OutputWidth int `json:"output_width"`
OutputHeight int `json:"output_height"`
OutputFormat domainImage.VariantFormat `json:"output_format"`
Generated bool `json:"generated"`
Size *int64 `json:"size"`
URL string `json:"url"`
}
type imageVariantListResponse struct {
Unsupported *string `json:"unsupported"`
Items []imageVariantResponse `json:"items"`
}
type variantFailureResponse struct {
Name string `json:"name"`
Cause string `json:"cause"`
}
type generateAllResponse struct {
Generated int `json:"generated"`
Failed int `json:"failed"`
Failures []variantFailureResponse `json:"failures"`
Items []imageVariantResponse `json:"items"`
}
type variantPurgeResponse struct {
PurgedFiles int `json:"purged_files"`
PurgedBytes int64 `json:"purged_bytes"`
}
func toImageVariantResponse(view useCases.ImageVariantView) imageVariantResponse {
response := imageVariantResponse{
Name: view.Variant.Name,
Width: view.Variant.Width,
Height: view.Variant.Height,
Fit: view.Variant.Fit,
Format: view.Variant.Format,
OutputWidth: view.OutputWidth,
OutputHeight: view.OutputHeight,
OutputFormat: view.OutputFormat,
Generated: view.Generated,
URL: view.URL,
}
if view.Generated {
size := view.Size
response.Size = &size
}
return response
}
func toImageVariantResponses(views []useCases.ImageVariantView) []imageVariantResponse {
items := make([]imageVariantResponse, 0, len(views))
for _, view := range views {
items = append(items, toImageVariantResponse(view))
}
return items
}
func writeImageVariantError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domainImage.ErrVariantNotFound):
http.Error(w, apperr.VariantNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domainImage.ErrVariantSourceUnsupported):
http.Error(w, apperr.VariantSourceUnsupportedErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domainImage.ErrSourceTooLarge):
http.Error(w, apperr.VariantSourceTooLargeErrorMessage, http.StatusRequestEntityTooLarge)
case errors.Is(err, domainImage.ErrVariantSourceUnreadable):
http.Error(w, apperr.VariantSourceUnreadableErrorMessage, http.StatusUnsupportedMediaType)
default:
writeImageError(w, err)
}
}
func (h *Handler) ListVariants(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
list, err := h.ListVariantsUseCase.List(id)
if err != nil {
writeImageVariantError(w, err)
return
}
response := imageVariantListResponse{Items: toImageVariantResponses(list.Items)}
if list.Unsupported != "" {
response.Unsupported = &list.Unsupported
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) GenerateVariant(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
view, err := h.GenerateVariantUseCase.Generate(r.Context(), id, chi.URLParam(r, "name"))
if err != nil {
writeImageVariantError(w, err)
return
}
writeJSON(w, http.StatusOK, toImageVariantResponse(view))
}
func (h *Handler) GenerateAllVariants(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
result, err := h.GenerateAllUseCase.Generate(r.Context(), id)
if err != nil {
writeImageVariantError(w, err)
return
}
failures := make([]variantFailureResponse, 0, len(result.Failures))
for _, failure := range result.Failures {
failures = append(failures, variantFailureResponse{Name: failure.Name, Cause: failure.Cause})
}
writeJSON(w, http.StatusOK, generateAllResponse{
Generated: result.Generated,
Failed: result.Failed,
Failures: failures,
Items: toImageVariantResponses(result.Items),
})
}
func (h *Handler) PurgeVariant(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
purged, err := h.PurgeVariantUseCase.Purge(r.Context(), id, chi.URLParam(r, "name"))
if err != nil {
writeImageVariantError(w, err)
return
}
writeJSON(w, http.StatusOK, variantPurgeResponse{PurgedFiles: purged.Files, PurgedBytes: purged.Bytes})
}
+30 -3
View File
@@ -7,6 +7,7 @@ import (
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"io"
"net/http"
"time"
)
@@ -18,12 +19,15 @@ type Handler struct {
}
type UpdateSettingsRequest struct {
ActivityRetentionDays *int `json:"activity_retention_days" validate:"required,oneof=0 30 90 365"`
ActivityRetentionDays *int `json:"activity_retention_days" validate:"omitempty,oneof=0 30 90 365"`
ActivityRetentionMaxGB *int `json:"activity_retention_max_gb" validate:"omitempty,oneof=0 1 5 10 25 50"`
}
type settingsResponse struct {
Version string `json:"version"`
ActivityRetentionDays int `json:"activity_retention_days"`
ActivityRetentionMaxGB int `json:"activity_retention_max_gb"`
ActivityLogSizeBytes int64 `json:"activity_log_size_bytes"`
ActivityLastCleanupAt *time.Time `json:"activity_last_cleanup_at"`
ActivityLastCleanupRemoved int `json:"activity_last_cleanup_removed"`
ActivityNextCleanupAt *time.Time `json:"activity_next_cleanup_at"`
@@ -43,8 +47,13 @@ func (h *Handler) GetSettings(w http.ResponseWriter, _ *http.Request) {
}
func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil || hasNullValue(body) {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
var request UpdateSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
if err := json.Unmarshal(body, &request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
@@ -53,7 +62,10 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
return
}
retention, err := h.UpdateActivityRetentionUseCase.UpdateActivityRetention(r.Context(), *request.ActivityRetentionDays)
retention, err := h.UpdateActivityRetentionUseCase.UpdateActivityRetention(r.Context(), useCases.UpdateActivityRetentionCommand{
Days: request.ActivityRetentionDays,
MaxGB: request.ActivityRetentionMaxGB,
})
if err != nil {
if errors.Is(err, audit.ErrInvalidRetention) {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
@@ -65,10 +77,25 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
h.write(w, retention)
}
func hasNullValue(body []byte) bool {
var fields map[string]json.RawMessage
if err := json.Unmarshal(body, &fields); err != nil {
return false
}
for _, value := range fields {
if string(value) == "null" {
return true
}
}
return false
}
func (h *Handler) write(w http.ResponseWriter, retention audit.Settings) {
body, err := json.Marshal(settingsResponse{
Version: h.GetSettingsUseCase.GetSettings().Version,
ActivityRetentionDays: retention.RetentionDays,
ActivityRetentionMaxGB: retention.RetentionMaxGB,
ActivityLogSizeBytes: retention.LogSizeBytes,
ActivityLastCleanupAt: retention.LastCleanupAt,
ActivityLastCleanupRemoved: retention.LastCleanupRemoved,
ActivityNextCleanupAt: retention.NextCleanupAt,
@@ -1,8 +1,10 @@
package staticimages
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"io"
"net/http"
@@ -17,31 +19,115 @@ import (
const cacheControl = "public, no-cache"
const svgContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
const fallbackContentType = "application/octet-stream"
const unknownVariantCode = "unknown_variant"
const unsupportedParameterCode = "unsupported_parameter"
var contentTypeExtensions = map[string]string{
".png": "image/png",
".jpeg": "image/jpeg",
".webp": "image/webp",
".svg": "image/svg+xml",
".gif": "image/gif",
}
type Handler struct {
openMedia *useCases.OpenMediaUseCase
modTime time.Time
openMedia *useCases.OpenMediaUseCase
serveVariant *useCases.ServeVariantUseCase
modTime time.Time
}
func NewHandler(openMedia *useCases.OpenMediaUseCase) *Handler {
func NewHandler(openMedia *useCases.OpenMediaUseCase, serveVariant *useCases.ServeVariantUseCase) *Handler {
return &Handler{
openMedia: openMedia,
modTime: time.Now(),
openMedia: openMedia,
serveVariant: serveVariant,
modTime: time.Now(),
}
}
type requestErrorBody struct {
Status int `json:"status"`
Error string `json:"error"`
Message string `json:"message"`
Detail string `json:"detail"`
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
cleaned := cleanPath(requestedPath(r))
blob, err := h.openMedia.Open(cleaned)
served, err := h.serveVariant.Serve(variantRequestFrom(r, cleaned))
if err != nil {
writeServeError(w, r, err)
return
}
switch {
case served.NotModified:
writeNotModified(w, served)
case served.Original:
h.serveOriginal(w, r, cleaned)
default:
serveVariantFile(w, r, cleaned, served)
}
}
func variantRequestFrom(r *http.Request, storedName string) useCases.ServeVariantRequest {
query := r.URL.Query()
request := useCases.ServeVariantRequest{Context: r.Context(), StoredName: storedName, IfNoneMatch: r.Header.Get("If-None-Match")}
for parameter := range query {
request.Parameters = append(request.Parameters, parameter)
}
if query.Has(image.VariantQueryParameter) {
name := query.Get(image.VariantQueryParameter)
request.Variant = &name
}
return request
}
func writeServeError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, image.ErrUnknownVariant):
writeRequestError(w, unknownVariantCode, apperr.VariantUnknownMessage, err)
case errors.Is(err, image.ErrUnsupportedParameter):
writeRequestError(w, unsupportedParameterCode, apperr.VariantUnsupportedParameterMessage, err)
case errors.Is(err, image.ErrBlobNotFound), errors.Is(err, image.ErrImageNotFound):
http.NotFound(w, r)
case errors.Is(err, image.ErrSourceTooLarge):
http.Error(w, apperr.VariantSourceTooLargeErrorMessage, http.StatusRequestEntityTooLarge)
default:
http.Error(w, "Unable to read the file", http.StatusInternalServerError)
}
}
func writeRequestError(w http.ResponseWriter, code string, message string, err error) {
body, marshalErr := json.Marshal(requestErrorBody{
Status: http.StatusBadRequest,
Error: code,
Message: message,
Detail: image.VariantRequestDetail(err),
})
if marshalErr != nil {
http.Error(w, "Unable to read the file", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write(body)
}
func writeNotModified(w http.ResponseWriter, served useCases.ServedVariant) {
w.Header().Set("ETag", served.ETag)
w.Header().Set("Cache-Control", cacheControl)
w.WriteHeader(http.StatusNotModified)
}
func serveVariantFile(w http.ResponseWriter, r *http.Request, name string, served useCases.ServedVariant) {
defer served.Content.Close()
w.Header().Set("ETag", served.ETag)
serveContent(w, r, name, served.ContentType, image.Blob{Content: served.Content, Size: served.Size, ModTime: served.ModTime})
}
func (h *Handler) serveOriginal(w http.ResponseWriter, r *http.Request, name string) {
blob, err := h.openMedia.Open(name)
if errors.Is(err, image.ErrBlobNotFound) {
http.NotFound(w, r)
return
@@ -52,7 +138,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
defer blob.Content.Close()
contentType := contentTypeFor(cleaned)
serveContent(w, r, name, contentTypeFor(name), blob)
}
func serveContent(w http.ResponseWriter, r *http.Request, name string, contentType string, blob image.Blob) {
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", cacheControl)
if contentType == "image/svg+xml" {
@@ -60,7 +149,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
if content, ok := blob.Content.(io.ReadSeeker); ok {
http.ServeContent(w, r, cleaned, blob.ModTime, content)
http.ServeContent(w, r, name, blob.ModTime, content)
return
}
w.Header().Set("Content-Length", strconv.FormatInt(blob.Size, 10))
+10
View File
@@ -51,6 +51,16 @@ func RequireSession(next http.Handler) http.Handler {
})
}
func RejectAPIKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, authenticatedByKey := apiKeyIdFrom(r); authenticatedByKey {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func APIKeyAuth(authenticate *useCases.AuthenticateAPIKeyUseCase, maxRequests int, window time.Duration) func(http.Handler) http.Handler {
limiter := newKeyedRateLimiter(maxRequests, window, apiKeyRateLimitKey)
+15
View File
@@ -42,3 +42,18 @@ const OwnerOrAdminRequiredErrorMessage = "Only owners and admins can do this."
const PurgeConflictErrorMessage = "The library changed, so nothing was purged. Check the new summary and try again."
const PurgeBusyErrorMessage = "A purge is already running."
const PurgeIntervalErrorMessage = "Enter a whole number of days between 1 and 365."
const VariantNotFoundErrorMessage = "Variant not found"
const VariantNameErrorMessage = "Use lowercase letters, numbers and dashes, 2 to 32 characters."
const VariantDimensionsErrorMessage = "Width and height must be between 1 and 8000, and one of them is required."
const VariantFitErrorMessage = "Fit must be cover, contain or inside."
const VariantQualityErrorMessage = "Quality must be between 1 and 100."
const VariantInvalidErrorMessage = "The variant is not valid."
const VariantExistsErrorMessageFormat = "A variant named “%s” already exists. Choose another name."
const VariantFormatErrorMessageFormat = "Format must be %s."
const VariantSourceTooLargeErrorMessage = "The source image is too large to generate this variant."
const VariantSourceUnsupportedErrorMessage = "Variants aren't generated for this format."
const VariantSourceUnreadableErrorMessage = "The source image can't be read, so this variant can't be generated."
const CachePurgeScopeErrorMessage = "Scope must be all, unused, variant or image."
const CachePurgeTargetErrorMessage = "A variant name or an image id is required for this scope."
const VariantUnknownMessage = "Unknown variant"
const VariantUnsupportedParameterMessage = "Unsupported parameter"
+7
View File
@@ -1,12 +1,14 @@
package validator
import (
"RenewCMS/internal/domain/image"
"regexp"
"github.com/go-playground/validator/v10"
)
const inviteEmailTag = "inviteemail"
const variantNameTag = "variantname"
var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`)
@@ -15,6 +17,11 @@ var Validate *validator.Validate
func InitValidator() {
Validate = validator.New()
_ = Validate.RegisterValidation(inviteEmailTag, isInviteEmail)
_ = Validate.RegisterValidation(variantNameTag, isVariantName)
}
func isVariantName(field validator.FieldLevel) bool {
return image.IsValidVariantName(field.Field().String())
}
func isInviteEmail(field validator.FieldLevel) bool {
+36 -2
View File
@@ -6,6 +6,7 @@ import (
"RenewCMS/api/handlers/article"
"RenewCMS/api/handlers/auth"
"RenewCMS/api/handlers/frontend"
"RenewCMS/api/handlers/imagevariant"
"RenewCMS/api/handlers/media"
"RenewCMS/api/handlers/settings"
"RenewCMS/api/handlers/staticimages"
@@ -249,11 +250,12 @@ func NewAPIKeyRouter(container *UseCases, trustedProxies []string) http.Handler
func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase, container.GetPurgeSettingsUseCase, container.UpdatePurgeSettingsUseCase, container.PreviewPurgeUseCase, container.RunPurgeUseCase)
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase, container.GetPurgeSettingsUseCase, container.UpdatePurgeSettingsUseCase, container.PreviewPurgeUseCase, container.RunPurgeUseCase, container.ListImageVariantsUseCase, container.GenerateImageVariantUseCase, container.GenerateAllImageVariantsUseCase, container.PurgeImageVariantUseCase, container.GenerateVariantsOnUploadUseCase)
r.Use(jwtauth.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
@@ -264,6 +266,14 @@ func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
r.Get("/purge-preview", mediaHandler.PreviewPurge)
r.Post("/purge", mediaHandler.RunPurge)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RejectAPIKey)
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
r.Get("/{id}/variants", mediaHandler.ListVariants)
r.Post("/{id}/variants", mediaHandler.GenerateAllVariants)
r.Post("/{id}/variants/{name}", mediaHandler.GenerateVariant)
r.Delete("/{id}/variants/{name}", mediaHandler.PurgeVariant)
})
r.Get("/", mediaHandler.List)
r.Post("/", mediaHandler.Upload)
r.Post("/merge", mediaHandler.Merge)
@@ -273,6 +283,29 @@ func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
return r
}
func NewImageVariantRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
variantHandler := imagevariant.NewHandler(container.ListVariantsUseCase, container.CreateVariantUseCase, container.UpdateVariantUseCase, container.SetVariantEnabledUseCase, container.DeleteVariantUseCase, container.PurgeVariantUseCase, container.GetVariantCacheStatsUseCase, container.PurgeVariantCacheUseCase)
r.Use(jwtauth.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
r.Get("/", variantHandler.List)
r.Post("/", variantHandler.Create)
r.Get("/cache", variantHandler.CacheStats)
r.Post("/cache/purge", variantHandler.PurgeCache)
r.Put("/{name}", variantHandler.Update)
r.Patch("/{name}", variantHandler.SetEnabled)
r.Delete("/{name}", variantHandler.Delete)
r.Post("/{name}/purge", variantHandler.Purge)
return r
}
func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r := chi.NewRouter()
@@ -289,6 +322,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
r.Mount("/activity", NewActivityRouter(container, settings.TrustedProxies))
r.Mount("/media", NewMediaRouter(container, settings.TrustedProxies))
r.Mount("/image-variants", NewImageVariantRouter(container, settings.TrustedProxies))
r.Mount("/api-key", NewAPIKeyRouter(container, settings.TrustedProxies))
r.Mount("/system", NewSystemRouter(container, settings.TrustedProxies))
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
@@ -335,7 +369,7 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
apiRouter.Use(chimiddleware.GetHead)
apiRouter.Get("/healthz", GetHealth)
apiRouter.Mount("/v1", backend)
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase))
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase, container.ServeVariantUseCase))
apiRouter.Mount("/", frontend)
return apiRouter
+22 -19
View File
@@ -42,25 +42,28 @@ var requiredEnvVars = []string{
}
var envDefaults = map[string]string{
"ENVIRONMENT": "production",
"PORT": "8080",
"DB_FILE": "./data/renewcms.db",
config.EnvDatabaseDriver: config.DefaultDatabaseDriver,
"DB_TLS": config.DefaultDatabaseTLS,
"DB_SSLMODE": config.DefaultDatabaseSSLMode,
"DB_MAX_OPEN_CONNS": config.DefaultMaxOpenConns,
"DB_MAX_IDLE_CONNS": config.DefaultMaxIdleConns,
"DB_CONN_MAX_LIFETIME_SECONDS": config.DefaultConnMaxLifetimeSeconds,
"UPLOAD_DIR": "./data/uploads",
config.EnvStorageDriver: config.DefaultStorageDriver,
"S3_REGION": config.DefaultS3Region,
"S3_PATH_STYLE": config.DefaultS3PathStyle,
"S3_TLS": config.DefaultS3TLS,
"AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5",
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
"TRUSTED_PROXIES": "",
"UPDATE_FEED_URL": "https://git.floriansylvain.fr",
"RENEWCMS_CONTAINER": "false",
"ENVIRONMENT": "production",
"PORT": "8080",
"DB_FILE": "./data/renewcms.db",
config.EnvDatabaseDriver: config.DefaultDatabaseDriver,
"DB_TLS": config.DefaultDatabaseTLS,
"DB_SSLMODE": config.DefaultDatabaseSSLMode,
"DB_MAX_OPEN_CONNS": config.DefaultMaxOpenConns,
"DB_MAX_IDLE_CONNS": config.DefaultMaxIdleConns,
"DB_CONN_MAX_LIFETIME_SECONDS": config.DefaultConnMaxLifetimeSeconds,
"UPLOAD_DIR": "./data/uploads",
config.EnvStorageDriver: config.DefaultStorageDriver,
"S3_REGION": config.DefaultS3Region,
"S3_PATH_STYLE": config.DefaultS3PathStyle,
"S3_TLS": config.DefaultS3TLS,
"AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5",
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
"TRUSTED_PROXIES": "",
"UPDATE_FEED_URL": "https://git.floriansylvain.fr",
"RENEWCMS_CONTAINER": "false",
config.EnvImageVariantMaxSourceBytes: config.DefaultImageVariantMaxSourceBytes,
config.EnvImageVariantMaxSourcePixels: config.DefaultImageVariantMaxSourcePixels,
config.EnvImageVariantMaxConcurrentRenders: config.DefaultImageVariantMaxConcurrentRenders,
}
const versionFlag = "--version"
+96 -9
View File
@@ -19,11 +19,11 @@ for example `https://cms.example/v1/article/`.
or revoked key answers 401, even on public routes.
- Each key can make 120 requests per minute. Beyond that: 429 with a `Retry-After` header.
## Routes a key can call (20)
## Routes a key can call (24)
| Group | Routes |
| --- | --- |
| Articles | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
| Media | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage |
| Media | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) |
| Users and invites | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me |
| Settings | GET /v1/settings |
@@ -45,9 +45,12 @@ Query: `q`, `actor` (`user:ID`, `key:ID`, `system`), `category` (`articles`, `me
are UTC. The list answers `{ items, total, page, size }`, newest first (`created_at`, then `id`).
`GET /v1/settings` also returns `activity_retention_days` (30, 90, 365 or 0 for forever, default
90), `activity_last_cleanup_at` and `activity_last_cleanup_removed`. `PATCH /v1/settings` with
`{"activity_retention_days": 30}` is session only and owner only (403 "Only the owner can do this.");
any other value answers 400.
90), `activity_retention_max_gb` (0 for no size limit), `activity_log_size_bytes`,
`activity_last_cleanup_at` and `activity_last_cleanup_removed`. `PATCH /v1/settings` with
`{"activity_retention_days": 30}`, `{"activity_retention_max_gb": 5}` or both is session only and
owner only (403 "Only the owner can do this."); any other value answers 400. The daily cleanup
removes events past the age limit, then the oldest events until the log fits the size limit.
Whichever limit is reached first wins.
### What is recorded
Every real action is recorded by its use case, once, after the change was made (outcome `success`)
@@ -59,16 +62,25 @@ never changes the response or the change itself.
| Category | Actions |
| --- | --- |
| `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` |
| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge` |
| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge`, `media.variant_generate_all`, `media.variant_purge_image`, `media.variant_purge_all`, `media.variant_purge_unused` |
| `users` | `user.register`, `user.verify`, `user.update_profile`, `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
| `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` |
Target types: `article`, `image`, `user`, `invite`, `api_key` and `release` (id and name are the tag).
Only the fields `title`, `online`, `body`, `filename` and `display_name` appear in `changes`, with the
Target types: `article`, `image`, `image_variant` (id and name are the variant name), `user`, `invite`,
`api_key` and `release` (id and name are the tag).
Only the fields `title`, `online`, `body`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes`, with the
kinds `text` and `body`; a body edit carries a signed character count delta and never the text. A
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`);
`media.variant_generate_all` carries `generated`, `failed` and `causes` (a list of `too_large`,
`unsupported_format` or `failed`) and is `failed` only when nothing was generated and something failed;
`media.variant_purge_image` carries `purged_files` and `purged_bytes`; both have an `image` target
and are recorded only when files were generated or removed;
`media.variant_purge_all` and `media.variant_purge_unused` (no target) carry `purged_files` and
`purged_bytes`, the latter also `older_than_days`; a partial purge adds `failed_files` and stays
`success`, and a purge that removed nothing while some files failed is `failed`; nothing is recorded
when no file was removed or failed;
`system.update_checked` carries `update_available`; `system.restart_requested` carries `kind` and,
for an update, `tag` (it has no target). `system.update_downloaded` is recorded when the job reaches
`ready` or `failed`, with the origin captured at request time; a refusal at the start is recorded
@@ -84,7 +96,82 @@ try again."; the text of the error never reaches the event, the API or the CSV.
codes, article bodies, file bytes, storage paths and asset urls are never recorded.
Article routes accept `?format=markdown|html|json` (default `markdown`). `GET /v1/media/` accepts
`q`, `hash`, `cursor`, `filter`, `sort` and `limit`.
`q`, `hash`, `cursor`, `filter`, `sort` (`newest`, `oldest`, `name`, `largest` or `total_size`, the
original plus its generated variants), `variants` (`with` or `without` generated variant files) and
`limit`. Every item carries `variant_count` and `variant_size` (the index rows of the image and
their bytes, 0 when none); `counts` stays library-wide. Any other `variants` or `sort` value is 400.
## Image variants
Owners and admins manage named variants of the stored images. These routes are session only (no API
key) and sit behind the verified-user check; any other role gets 403 "Only owners and admins can do
this." Behaviour, caching and the public `?v=<name>` contract are in [image-variants.md](image-variants.md).
| Route | Purpose |
| --- | --- |
| `GET /v1/image-variants` | `{ "items": [...] }`, ordered by name. |
| `POST /v1/image-variants` | Create. 201 with the variant. 409 when the name exists. |
| `PUT /v1/image-variants/{name}` | Replace the definition (the name in the body is ignored). 200 with the variant and `purged_files`. |
| `PATCH /v1/image-variants/{name}` | `{ "enabled": true }` or `{ "enabled": false }`. Keeps the generated files. |
| `DELETE /v1/image-variants/{name}` | 204. Removes the definition and its generated files. |
| `POST /v1/image-variants/{name}/purge` | `{ "purged_files", "purged_bytes" }`. Keeps the definition. |
| `GET /v1/image-variants/cache` | Cache statistics, see below. |
| `POST /v1/image-variants/cache/purge` | Purge generated files by scope, see below. |
A variant is `{ name, width, height, fit, format, quality, generate_on_upload, enabled,
generated_files, cache_size, created_at, updated_at }`. `width` and `height` are 1 to 8000 or `null`
(at least one is required), `fit` is `cover`, `contain` or `inside` (default `cover`), `format` is
`original`, `jpeg`, `png` or `webp` (default `original`), `quality` is 1 to 100 (default 82, `null`
for `png`), `generate_on_upload` defaults to `false`, `enabled` to `true`, and `cache_size` is in
bytes. A `PUT` that omits `generate_on_upload` or `enabled` keeps the stored value.
Errors are plain text: 400 for a body that is not valid JSON, 404 "Variant not found", 409 "A variant
named “thumb” already exists. Choose another name." and 422 with one sentence for the first invalid
field (name, width and height, fit, format, quality).
### Cache statistics and purge by scope
`GET /v1/image-variants/cache` answers `{ generated_files, disk_used, variants_defined,
variants_enabled, variants_disabled, rejected_requests, rejected_window_days, unused: { files, bytes,
days }, by_variant: [{ name, enabled, files, bytes }], top_images: [{ id, filename, url, files, bytes }] }`.
`by_variant` lists every defined variant by bytes descending then name; `top_images` lists the ten
images using the most variant bytes (ties by id, images that no longer exist are skipped). `unused`
counts the files of enabled variants whose reference time (the last public request, or the creation
time when never requested) is older than 30 days. `rejected_requests` counts the public requests
refused with 400 over today and the six previous UTC days. Pending request tracking is flushed before
the read. Arrays are never `null`.
`POST /v1/image-variants/cache/purge` takes `{ "scope": "all" | "unused" | "variant" | "image",
"variant": "<name>", "image_id": <id> }` (`variant` for the scope `variant`, `image_id` for `image`; a
target given for another scope is ignored) and answers `{ scope, purged_files, purged_bytes,
failed_files }`. A purge that fails on some files still answers 200 with `failed_files` above 0; the
failed rows are kept so the same request can be sent again. Errors are plain text: 400 for a body that
is not valid JSON, 422 "Scope must be all, unused, variant or image." or "A variant name or an image
id is required for this scope.", 404 "Variant not found" or "Image not found", 500 when the rows
cannot be listed.
### Variants of one image
Owners and admins only, session only: a request with an API key is 401. All ids are image ids.
| Route | Purpose |
| --- | --- |
| `GET /v1/media/{id}/variants` | `{ "unsupported": null, "items": [...] }` for the enabled variants in name order. `unsupported` is `"svg"` (with `items: []`) for an SVG. |
| `POST /v1/media/{id}/variants` | Generates every enabled variant that is missing. `{ "generated", "failed", "failures": [{ "name", "cause" }], "items": [...] }`, 200 even when some fail. |
| `POST /v1/media/{id}/variants/{name}` | Generates one enabled variant. 200 with the item. |
| `DELETE /v1/media/{id}/variants/{name}` | Removes the generated file. `{ "purged_files", "purged_bytes" }`, also for a disabled variant. |
An item is `{ name, width, height, fit, format, output_width, output_height, output_format,
generated, size, url }`: `width` and `height` are the settings (`null` when auto), `output_*` the
size and format the file has or will have (never above the source), `generated` is true only when
the file exists with the variant's current settings, `size` is its bytes or `null`, and `url` is
`/static/uploadedImages/<stored name>?v=<name>`. A `cause` is `too_large`, `unsupported_format` or
`failed`. A file generated here is the same file the public url serves, and the other way round.
Uploads generate the variants flagged `generate_on_upload` (enabled ones, raster sources only)
before answering; a failure there is logged and never fails the upload.
Errors are plain text: 400 for a malformed id, 404 "Image not found" or "Variant not found" (also for a
disabled variant on `POST`), 413 "The source image is too large to generate this variant.", 415 "The
source image can't be read, so this variant can't be generated.", 422 "Variants aren't generated for
this format." and 500 "Internal server error".
## Errors
Every error is a plain-text body with one sentence; the status is in the HTTP status line. The
+8
View File
@@ -69,6 +69,14 @@ The use cases `ListActivity`, `GetActivitySummary` and `ExportActivity` turn the
Producers: `ArticleChanges`, `TextChange` and `BodyChange` in `internal/domain/audit` build the change lists (the body change holds only a signed rune count delta), and `NewTarget` and `ReleaseTarget` build the target snapshots (types `article`, `image`, `user`, `invite`, `api_key`, `release`). The use cases create, edit, publish, unpublish and delete article, upload, rename, delete, merge and purge media, register, verify, update profile, create, cancel, renew and accept invite, check, download and restart take the request context first and an `audit.Recorder` last, and record exactly one event after the change or the refusal (the action catalogue is in `docs/api.md`). Events are never written by handlers, middleware or GORM hooks, and nothing is recorded for reads or for a target that does not exist. `useCases/auditFailures.go` holds the fixed table of failure sentences keyed by domain `Err...` sentinels (matched with `errors.Is`, each at most 255 characters; the restart action overrides `ErrJobInProgress`) with the generic fallback "Something went wrong on our side. Please try again."; raw error text is never stored. The manual purge runs the groups through the non-recording `MergeMediaUseCase.merge`, so only one `media.purge` event is written; `RunPurgeUseCase.Execute` is the non-recording run that `PurgeSchedulerUseCase.Tick(now)` uses to record one `system.media_purge` event with the system actor and `context.Background()`. `StartUpdateDownloadUseCase` hands `context.WithoutCancel(ctx)` to its job goroutine so the `system.update_downloaded` event, written when the job becomes `ready` or `failed`, keeps the origin of the request. `middleware.AuditContext` is mounted on the article, user, media and system routers (after the API key middleware where keys are accepted), so API key calls are attributed to the key.
### Image variants
`internal/domain/image` holds the `Variant` entity and its rules (`VariantSettings.Normalized`, the settings `Fingerprint`, `VariantETag`, `ETagMatches`, `IsAnimatedGIF`), the sentinels (`ErrVariantNotFound`, `ErrVariantExists`, `ErrVariantInvalid`, `ErrVariantFormatUnavailable`, `ErrUnknownVariant`, `ErrUnsupportedParameter`, `ErrSourceTooLarge`) and three ports: `VariantRepository` (definitions), `VariantFileRepository` (the index of generated files: variant name, source stored name, cached stored name, content type, size and the settings fingerprint, needed because `Storage.Put` chooses the stored name itself) and `VariantRenderer` (decode a source, encode a variant, report the formats it can encode). `internal/infrastructure/imaging` is the renderer adapter, free of GORM and HTTP; the persistence adapters are `VariantRepository` and `VariantFileRepository` over `models.ImageVariant` and `models.ImageVariantFile`.
Generated bytes are stored only through `image.Storage`, so they follow `STORAGE_DRIVER`, and are never rows of `images`. `ServeVariantUseCase` checks, in order: unsupported parameters, the variant (it must exist and be enabled), the source blob, SVG and animated GIF (served as they are), the size cap, the `If-None-Match` ETag, then the cached file, and generates under a `singleflight` keyed by variant, settings and source. A row whose fingerprint no longer matches, or whose blob vanished, is replaced. `UpdateVariantUseCase` purges a variant's files when a rendering setting changes; `DeleteVariantUseCase` and `PurgeVariantUseCase` purge a blob and then its row, so a failed blob delete keeps the row and a retry can finish; `mediaRemover` purges the files of a source before it deletes the source (delete and merge).
The cache screen adds three ports in `internal/domain/image`: `VariantCacheRepository` (unused totals, top sources by bytes and cursor-paged row listing, implemented by the same persistence type as `VariantFileRepository`), `VariantUsageRepository` (one batched write of request times and per-day rejection counts, and the rejection total of a window) and `VariantUsageTracker` (in-memory hits and rejections). `internal/infrastructure/usage.Tracker` implements the tracker; `GetVariantCacheStatsUseCase` and `PurgeVariantCacheUseCase` use the ports only. See [image-variants.md](image-variants.md).
### Image metadata backfill
At boot, `BackfillImageMetadataUseCase` (through `image.Repository.ListMissingMetadata` and `UpdateMetadata`) fills `filename`, `size`, `content_type`, `width`, `height` and `sha256` for image rows that lack them, reading the bytes through `image.Storage` (the files in `UPLOAD_DIR` with the local adapter, the bucket objects with the S3 adapter). It is idempotent, and a row whose file is missing or unreadable is left as it is without stopping the boot. The old `article_id` column and the `article_images` table are kept and never written.
+4 -1
View File
@@ -34,6 +34,9 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. |
| `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). |
| `RENEWCMS_CONTAINER` | `false` | Set to `true` (case-insensitive; any other value means `false`) when the app runs in a container. Settings then tells the owner to update by pulling a new image instead of offering browser updates, and `can_apply` is always `false`. The Dockerfile and `docker-compose.yml` set it to `true`. Update checks still work. |
| `IMAGE_VARIANT_MAX_SOURCE_BYTES` | `10485760` | Largest stored image, in bytes, that an [image variant](image-variants.md) may be generated from. A request for a bigger source answers 413 with a plain-text body. The original is never affected, and SVG and animated GIF are always served as they are. A value that is not a whole number greater than zero stops the server at boot with an error naming the variable. |
| `IMAGE_VARIANT_MAX_SOURCE_PIXELS` | `50000000` | Largest declared width x height, in pixels, that an [image variant](image-variants.md) may be generated from. It is read from the image header before any pixel buffer is allocated; a bigger source answers 413 with the same plain-text body as the byte cap. The original is never affected, and SVG and animated GIF are always served as they are. A value that is not a whole number greater than zero stops the server at boot with an error naming the variable. |
| `IMAGE_VARIANT_MAX_CONCURRENT_RENDERS` | `4` | Number of [image variant](image-variants.md) renders that may run at once. Other requests wait for a free slot until their client goes away; cached files never wait. A value that is not a whole number greater than zero stops the server at boot with an error naming the variable. |
| `UPDATE_FEED_URL` | `https://git.floriansylvain.fr` | Base URL of the Gitea server that publishes RenewCMS releases. The Settings page asks `{UPDATE_FEED_URL}/api/v1/repos/fsylvain/RenewCMS/releases` when the owner checks for updates. The last result is kept in `update-status.json`, in the data directory. |
Automatic media purge has no environment variable. Its schedule (on or off, interval, `enabled_at`, `last_purge_at`) lives in the purge settings row of the database, is changed from the Settings page or `PATCH /v1/media/purge-settings`, and is read again at every hourly tick.
@@ -105,7 +108,7 @@ For example, `WorkingDirectory=/var/lib/renewcms` with `DB_FILE=./data/renewcms.
## Activity log retention
The activity log needs no environment variable. Its retention period (30, 90, 365 days or forever, 90 by default) is a setting the owner changes with `PATCH /v1/settings`, and the daily cleanup runs at 03:00 in the server's time zone (`TZ`, or the host's zone).
The activity log needs no environment variable. Its retention (an age of 30, 90, 365 days or forever, 90 by default, and an optional maximum size in GB, none by default; whichever limit is reached first removes the oldest events) is a setting the owner changes with `PATCH /v1/settings`, and the daily cleanup runs at 03:00 in the server's time zone (`TZ`, or the host's zone).
## Adding a variable
+9 -4
View File
@@ -30,23 +30,28 @@ The visual spec of record for the back-office SPA and the transactional emails,
| [New article](screens/new-article.md) | `modal from anywhere · /article/new` | 5 | `source/RenewCMS Screens Back-office.dc.html` |
| [Editor](screens/editor.md) | `/article/:id/edit` | 22 | `source/RenewCMS Screens Back-office.dc.html` |
| [Media](screens/media.md) | `/media` | 41 | `source/RenewCMS Screens Back-office.dc.html` |
| [Image variants](screens/image-variants.md) | `/settings/media/variants` · `/settings/media/cache` (plus `/media` and the editor picker) | 51 | `source/RenewCMS Screens Image variants.dc.html` |
| [Media picker](screens/media-picker.md) | `/article/:id/edit` (dialog) | 13 | `source/RenewCMS Screens Back-office.dc.html` |
| [Integration](screens/integration.md) | `/integration` | 8 | `source/RenewCMS Screens Back-office.dc.html` |
| [API keys](screens/integration-api-keys.md) | `/integration/api-keys` | 12 | `source/RenewCMS Screens API keys.dc.html` |
| [Activity](screens/activity.md) | `/activity` | 9 (plus 6 details, 3 My activity) | `source/RenewCMS Screens Activity.dc.html` |
| [Activity](screens/activity.md) | `/activity` | 9 (plus 6 details, 3 My activity) | `source/RenewCMS Screens Back-office.dc.html` |
| [Users](screens/users.md) | `/users` | 17 | `source/RenewCMS Screens Back-office.dc.html` |
| [Settings](screens/settings.md) | `/settings` | 25 | `source/RenewCMS Screens Back-office.dc.html`, `source/RenewCMS Screens Media cleanup.dc.html` |
| [Settings](screens/settings.md) | `/settings` | 25 | `source/RenewCMS Screens Back-office.dc.html` |
| [Profile](screens/profile.md) | `/profile` | 13 | `source/RenewCMS Screens Back-office.dc.html` |
| [Accept invite](screens/accept-invite.md) | `/invite/accept?c=CODE` | 9 | `source/RenewCMS Screens Auth.dc.html` |
| [Global](screens/global.md) | `any route` | 4 | `source/RenewCMS Screens Back-office.dc.html` |
## Other boards
- `source/ActivityFrame.dc.html`: the frame that draws every Activity state, the Home card and the Settings retention card (the Activity board imports it). Its copy, component and API tables are in `source/activity-spec.js`. It holds the exact values. Home and Settings are drawn around dashed placeholders.
The Back-office board is now the one place where every back-office screen is drawn together, including Activity, Media cleanup, Retention and the Home activity section, all through `source/AppFrame.dc.html`. The separate Activity and Media cleanup boards and `SettingsFrame.dc.html` were removed from the export; their states now live in the Back-office board.
- `source/ActivityFrame.dc.html`: the frame that draws every Activity state (the Back-office board imports it). Its copy, component and API tables are in `source/activity-spec.js`, rendered at the bottom of the Back-office board. It holds the exact values. The Home activity section and the Retention card are drawn by `AppFrame.dc.html`.
- `source/MediaFrame.dc.html`: the frame that draws the Media and Media picker states (the Back-office board imports it). Like `AppFrame.dc.html`, it holds the exact values.
- `source/SettingsFrame.dc.html`: the Media cleanup card and its purge dialog (the Media cleanup board imports it, with `MediaFrame.dc.html` for the Duplicates toolbar button). They hold the exact values.
- `source/IntegrationPage.dc.html` and `source/ApiKeysPage.dc.html`: the page components for the Integration and API keys screens (imported by `AppFrame.dc.html`). They hold the exact values.
- `source/RenewCMS Prototype.dc.html`: the clickable flows (first run, edit and publish, password reset, delete, session expiry mid-edit).
- `source/RenewCMS Handoff.dc.html`: the handoff page the first markdown here was generated from. It predates the Users and Profile screens and the invitation email, so it does not list them. Those are written by hand from the Back-office board, `AppFrame.dc.html`, the Prototype and `emails/invitation.html`.
- `source/RenewCMS Components Product.dc.html`: not regenerated for the new screens either. The matching component changes are in `components.md`, under "Added with the Users and Profile screens" and "Added with roles, invite actions and Settings".
- `source/RenewCMS Directions.dc.html`: the three brand directions (history only).
- `source/VariantsFrame.dc.html`: the frame that draws every Image variants state (the Image variants board imports it, inside `AppFrame.dc.html`). Like `AppFrame.dc.html`, it holds the exact values. The board is the only place the variants screens are drawn; the Back-office board does not include them.
+6 -2
View File
@@ -70,7 +70,7 @@ Responsive 400 px card from sm up, with its top 12 vh from the top. Below sm , t
### DashboardShell
The sidebar has five destinations (Home, Articles, Users, Integration, Settings), and the user menu sits at its foot. The top bar carries the breadcrumbs and page title. Collapsing the sidebar leaves a 56 px icon rail. Below lg , the sidebar becomes a slideover.
The sidebar has seven destinations for owner and admin (Home, Articles, Media, Users, Activity, Integration, Settings; regular users get Home, Articles, Media, Activity), and the user menu sits at its foot. For owner and admin, Settings expands to a Media group with Variants and Cache (see "Sidebar entry (1l)" in [image-variants](screens/image-variants.md)); it is built with the `children` of UNavigationMenu, and the same group appears in the mobile drawer. The top bar carries the breadcrumbs and page title. Collapsing the sidebar leaves a 56 px icon rail. Below lg , the sidebar becomes a slideover.
RenewCMS
@@ -400,7 +400,7 @@ The chip at the foot of the sidebar no longer logs out on click. It opens the ac
### DashboardShell (changed)
The navigation gains **Users** (users icon) between Articles and Integration, in the sidebar and in the mobile drawer. Breadcrumbs for the new pages are "Users" and "Profile". It later gains **Settings** (settings icon) after Integration, with the breadcrumb "Settings".
The navigation gains **Users** (users icon) between Articles and Integration, in the sidebar and in the mobile drawer. Breadcrumbs for the new pages are "Users" and "Profile". It later gains **Settings** (settings icon) after Integration, with the breadcrumb "Settings". **Activity** (history icon) sits between Users and Integration; see `screens/activity.md`, "Sidebar".
### UsersTable, UserCardList
@@ -849,3 +849,7 @@ Source: `source/activity-spec.js` (component inventory) and `source/ActivityFram
- Day separators are full-width rows of the table. The "When" cell carries the exact time as its `title`.
- The sentence is composed from the `action` code and params with the i18n messages (English and French); the target name is user content and is not translated.
- Failed and security rows use `--rc-error-soft` / `--ui-error` and `--rc-warning-soft` / `--ui-warning` for emphasis, always paired with an icon or a word, never colour alone.
## Added with Image variants
The full inventory with the Nuxt UI mapping is in [image-variants](screens/image-variants.md#component-inventory). New components: Variants table (and its mobile card row), Variant row (image detail), Variant form, Split comparison slider (`ImageCompare`, a custom component on a native range input, since Nuxt UI has no match), Stat tile, Bar list, Top-N list, Type-to-confirm dialog, Variant select, Variants badge, Filter, Request reference card. Alerts, toasts, dialogs, empty states and skeletons are reused as they are. The only token not already in the system is `--rc-on-sand`.
+7 -1
View File
@@ -21,8 +21,14 @@ A global `router.beforeEach` guard reads `meta.auth`. On the first navigation it
| `/integration/api-keys` | DashboardShell | verified | ApiKeysPage + CreateApiKeyModal | GET /v1/api-key · POST /v1/api-key · DELETE /v1/api-key/{id} |
| `/users` | DashboardShell | verified | UsersPage + InviteUserModal | GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id · POST /v1/user/invite/:id/renew |
| `/settings` | DashboardShell | verified | SettingsPage | GET /v1/settings · PATCH /v1/settings (owner, retention) · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart · GET /v1/media/purge-settings · PATCH /v1/media/purge-settings · GET /v1/media/purge-preview · POST /v1/media/purge |
| `/settings/media/variants` | DashboardShell | admin | VariantsPage + VariantFormSlideover | GET /v1/image-variants · POST /v1/image-variants · PUT /v1/image-variants/{name} · PATCH /v1/image-variants/{name} · DELETE /v1/image-variants/{name} · POST /v1/image-variants/{name}/purge (all proposed) |
| `/settings/media/cache` | DashboardShell | admin | CachePage | GET /v1/image-variants/cache · POST /v1/image-variants/cache/purge (proposed) |
| `/profile` | DashboardShell | verified | ProfilePage | GET /v1/auth/me · PATCH /v1/user/me · POST /v1/auth/password-reset |
| `/invite/accept` | AuthShell | public | AcceptInvitePage | GET /v1/auth/invite?c=* · POST /v1/auth/invite/accept* |
| `/:pathMatch(.*)*` | either | any | NotFoundPage | none |
Auth values: `public` needs no session, `session` needs a logged-in account (verified or not), `verified` needs a verified account, `any` applies to redirects and the catch-all.
The sidebar reaches `/settings/media/variants` and `/settings/media/cache` through the Media group under Settings (owner and admin only). See [image-variants](screens/image-variants.md), frame 1l.
`/media` and `/article/:id/edit` also gain image variants: GET /v1/media/{id}/variants · POST|DELETE /v1/media/{id}/variants/{name} and the `variants` and `sort=total_size` query parameters on GET /v1/media/ (proposed). See [image-variants](screens/image-variants.md).
Auth values: `public` needs no session, `session` needs a logged-in account (verified or not), `verified` needs a verified account, `admin` needs an admin or owner account, `any` applies to redirects and the catch-all.
+3 -3
View File
@@ -2,7 +2,7 @@
- **Route:** `/activity`
- **API:** GET /v1/activity · GET /v1/activity/summary · GET /v1/activity/export (see "API contract" below)
- **Visual reference:** `source/RenewCMS Screens Activity.dc.html`, group "Activity, owner and admin" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle). The frame is `source/ActivityFrame.dc.html`; its copy, component mapping and API tables are `source/activity-spec.js`.
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Activity" (the same board also has the groups "Activity details" and "My activity"), drawn by `source/AppFrame.dc.html` with the page from `source/ActivityFrame.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle). The frame is `source/ActivityFrame.dc.html`; its copy, component mapping and API tables are `source/activity-spec.js`.
The audit log of the back-office: who did what, and when, across articles, media, users and invites, API keys, authentication and system tasks. It is for non-technical owners, so every row reads as a sentence, not a log line. Events are append-only: nothing in the app edits or deletes them. Only the retention period removes them (Settings, "Retention").
@@ -12,8 +12,8 @@ Other groups of the same board:
|---|---|---|
| Details slide-over | The slide-over and its bottom-sheet variant, six events | this file, "Details slide-over" |
| My activity | The same screen for a user who is not owner or admin | this file, "My activity" |
| Home card | The Recent activity card on Home | `screens/home.md`, "Recent activity card" |
| Settings retention | The Retention card on Settings | `screens/settings.md`, "Activity retention" |
| Home section | The Recent activity section on Home | `screens/home.md`, "Recent activity" |
| Settings retention | The Retention card, last on Settings | `screens/settings.md`, "Activity retention" |
## Roles
+5 -5
View File
@@ -55,12 +55,12 @@ The greeting reads "Good morning, {name}", where the name is the display name wh
- Banner: success tone, `circle-check` icon, title "Version 2.3.0 is ready to install", text "Restart from Settings to finish. The site is unavailable for about 10 seconds.", button "Go to Settings"
## Recent activity card
## Recent activity
**API:** GET /v1/activity?size=5. Visual reference: `source/RenewCMS Screens Activity.dc.html`, group "Home card". The board draws the rest of Home as a dashed "Existing Home cards" placeholder, so this card is not pixel-checked against it.
**API:** GET /v1/activity?size=5. Visual reference: `source/RenewCMS Screens Back-office.dc.html`, group "Home" (states Default, Recent activity: loading, error and empty, Regular user), drawn by `source/AppFrame.dc.html`. This is the layout of record. The older Home card on the Activity board is superseded.
A card with the last five events. Header "Recent activity" (regular users: "Your recent activity") and a "View all" link with `arrow-right` to `/activity`. Each row: a 24 px avatar, the sentence clamped to two lines with the target in semibold, then "Marta Alves · 2 min ago" (own events: only the time). A failed event gets a "Failed" badge; a successful security event a small `shield`.
A section with the last five events, built like "Recently updated" above it: a 16 px semibold heading on the left ("Recent activity") and an underlined "View all" link on the right, then a bordered list. Header "Recent activity" (regular users: "Your recent activity") and a "View all" link with `arrow-right` to `/activity`. Each row: a 24 px avatar, the sentence clamped to two lines with the target in semibold, then "Marta Alves · 2 min ago" (own events: only the time). A failed event gets a "Failed" badge; a successful security event a small `shield`.
- Placement as drawn: a 420 px column to the right of the existing cards on desktop; stacked on mobile, drawn above the existing cards. Confirm against the real Home layout before coding.
- Placement: full width, below the stats and the "Recently updated" list, on desktop and mobile. There is no side column. "View all" links to `/activity`.
- States: Owner or admin, Regular user, Loading (five skeleton rows after 150 ms), Error ("Couldn't load recent activity" with "Try again"), Empty ("No activity yet", "Actions on your site will appear here." / own: "Your actions will appear here.").
- The card never blocks the page: an error or empty card leaves the stats and the article list as they are.
- The section never blocks the page: an error or empty card leaves the stats and the article list as they are.
+330
View File
@@ -0,0 +1,330 @@
# Image variants
- **Routes:** `/settings/media/variants` and `/settings/media/cache` (admins and owners), plus additions to `/media` and `/article/:id/edit`
- **API (proposed by the design, to be fixed by the feature spec):** GET/POST /v1/image-variants · PUT/PATCH/DELETE /v1/image-variants/{name} · POST /v1/image-variants/{name}/purge · GET /v1/image-variants/cache · POST /v1/image-variants/cache/purge · GET /v1/media/{id}/variants · POST/DELETE /v1/media/{id}/variants/{name}
- **Public contract:** `<public image URL>?v=<name>`, see section 7
- **Visual reference:** `source/RenewCMS Screens Image variants.dc.html`, drawn by `source/VariantsFrame.dc.html` inside the `AppFrame.dc.html` back-office frame (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle). Every frame is numbered; the numbers below match.
Named variants of an original image. The original is never changed. An admin defines variants (a name, a width and/or height, a fit, a format, a quality). A request for an image with `?v=<name>` generates the variant on first use, caches it, and serves the cached file afterwards. Only variants that exist and are enabled are served: anything else is a 400. There is no arbitrary-size mode and no raw `width`, `height`, `format` or `quality` parameter.
Vocabulary: "variant", "generated", "purge". Delete removes a variant definition; purge removes only its generated files. Never "thumbnail preset", "version" or "rendition".
Variant fields: name (slug, 2 to 32 lowercase letters, numbers and dashes, unique, immutable after creation), width and height (1 to 8000 px, at least one required, images are never enlarged), fit (cover, contain, inside), format (original, JPEG, PNG, WebP; no AVIF), quality (1 to 100, hidden for PNG), generate on upload, enabled.
## 1. Settings → Media → Variants
- **Route:** /settings/media/variants
- **API:** GET /v1/image-variants · PATCH /v1/image-variants/{name} · DELETE /v1/image-variants/{name} · POST /v1/image-variants/{name}/purge (proposed)
Admin-only page under Settings → Media, with two tabs: Variants and Cache. Desktop shows a table; mobile stacks each variant as a card. The status switch always pairs with an icon and the words Enabled or Disabled. The reference card "How requests are handled" sits under the table (section 7 of the brief).
| State | Name | Trigger |
|---|---|---|
| 1a | Loaded | Six variants, one of them disabled (print). |
| 1b | Loading | Skeleton rows appear after 150 ms. |
| 1c | Empty | No variants. Two templates pre-fill the Add variant form. |
| 1d | Error loading | A 500 or network failure on the list request. |
| 1e | Forbidden | A signed-in user who is not an admin opens the URL. |
| 1f | Row menu | The ellipsis button on a row. |
| 1g | Row action in progress | Purge cache was confirmed on hero. The row is dimmed and its controls are disabled. |
| 1h | Purge confirm | Purge cache in the row menu. |
| 1i | Delete confirm | Delete in the row menu. |
| 1j | Delete in progress | Delete was confirmed. The button shows a spinner. |
| 1k | Request reference | Mobile scrolled to the reference card. On desktop it is visible below the table. |
| 1l | Entry: sidebar | From any page, Settings expands in the sidebar to Media: Variants and Cache. Admin only. On mobile the menu button opens it as a drawer. |
**Sidebar entry (1l).** For owner and admin, the Settings item has a chevron (right when closed, down when open) and expands to a group: a muted uppercase label "Media" (11 px, semibold, 0.04em tracking), then "Variants" (layers icon) and "Cache" (database icon). The group is indented 19 px with a 1 px left border; its items are 32 px high (44 px in the mobile drawer) with 14 px icons. The group is open on every `/settings` route. When a subpage is current, its item takes the sand fill and Settings keeps only the highlighted text and medium weight. On `/settings` itself Settings is the current item. Other roles see no chevron and no group. Cache stays inert until its screen exists.
### Copy: Variants list
| State | Where | Copy |
|---|---|---|
| Header | Title, description, button | Image variants · Named sizes and formats generated from your original images. Originals are never changed. · Add variant |
| Table columns | Header row | Name · Dimensions · Fit · Format · Quality · Generated · Cache size · Status |
| Dimensions | Cell | 400×300 · 800 × auto · auto × 2400 |
| Quality | Cell | 80 · — (PNG has no quality) |
| Status | Switch + text | Enabled · Disabled (aria-label: "Enable variant hero") |
| Row menu | Menu items | Edit · Purge cache · Delete |
| Footer summary | Under the table | 6 variants · 1,284 generated files · 212 MB |
| Footer summary (singular) | One variant | 1 variant · 1 generated file · 18 KB |
| Loading | Skeleton | No text. Region announces "Loading variants". |
| Empty | Title · text | No variants yet · A variant is a named size that an image can be requested in, like ?v=thumb. Start with one of these. |
| Empty | Template cards | thumb · 400×300 · cover · WebP — hero · 1200×630 · cover · WebP — Use template |
| Error | Alert | Couldn't load variants · Check your connection and try again. · Try again |
| Forbidden | Title · text · button | You don't have access · Only admins can manage image variants. Ask an admin if you need one changed. · Back to Home |
| Row in progress | Status cell | Purging… |
| Purge success | Toast | Purged 244 files from “hero” |
| Purge dialog | Title · text · buttons | Purge cache for “hero”? · This removes 244 generated files (84 MB). Files are generated again on the next request. · Cancel · Purge 244 files |
| Delete dialog | Title · text · buttons | Delete variant “hero”? · This removes the variant and its 244 generated files (84 MB). Articles that use ?v=hero will start returning 400. This can't be undone. · Cancel · Delete variant |
| Delete success | Toast | Variant “hero” deleted |
| Request reference | Card | How requests are handled · Body of a 400 response · unsupported_parameter replaces unknown_variant when a raw parameter is sent. There is no arbitrary-size mode. |
## 2. Add / edit variant
- **Route:** Slide-over on desktop · full page on mobile
- **API:** POST /v1/image-variants · PUT /v1/image-variants/{name}
Form on the left, live preview on the right (stacked on mobile). The preview uses a sample image with a before/after divider. The divider is a slider: arrow keys move it by 1%, Shift+arrow by 10%, Home and End jump to the ends. Name is locked in edit mode. The edit warning is always visible in edit mode. Preview images are placeholders.
| State | Name | Trigger |
|---|---|---|
| 2a | Create: pristine | Add variant just opened. Save is disabled. |
| 2b | Create: dirty | Valid values. The divider has keyboard focus. Mobile is scrolled to the preview. |
| 2c | Field errors | On blur and on submit. Name format, width above 8000, quality above 100. |
| 2d | No dimension | Width and height are both empty on submit. |
| 2e | Name already taken | POST returns 409. |
| 2f | Saving | Request in flight. Fields and buttons are disabled. |
| 2g | Save error | A 500 or network failure. Values are kept. |
| 2h | Edit: pristine | Opened from Edit. Name is locked. Save is disabled. |
| 2i | Edit: dirty | Quality changed from 82 to 70. |
| 2j | Unsaved changes | Close, Cancel or Escape while dirty. |
| 2k | PNG: no quality | Quality is hidden when the format is PNG. |
### Copy: Add / edit variant
| State | Where | Copy |
|---|---|---|
| Titles | Slide-over header | Add variant · Edit variant: hero |
| Name | Label · placeholder · help | Name · e.g. hero · Lowercase letters, numbers and dashes. Can't be changed later. |
| Name (edit) | Help | The name can't be changed. |
| Name error: format | Inline | Use lowercase letters, numbers and dashes, 2 to 32 characters. |
| Name error: taken | Inline | A variant named “thumb” already exists. Choose another name. |
| Width · Height | Label · suffix · placeholder · help | Width · Height · px · auto · Set a width, a height, or both. 1–8000 px. Images are never enlarged. |
| Dimension error | Inline | Set a width, a height, or both. |
| Dimension range error | Inline | Enter a value from 1 to 8000. |
| Fit | Options · help | Cover: Fills the box and crops what overflows. · Contain: Whole image inside the box, padded to the box size. · Inside: Whole image inside the box, no padding. |
| Format | Options · help | Original · JPEG · PNG · WebP · Keeps the format of each source image. |
| Quality | Label · help · error | Quality · 1–100. Higher is sharper and larger. · Enter a value from 1 to 100. |
| Toggles | Label · help | Generate on upload: Creates this variant when an image is uploaded. · Enabled: Disabled variants return 400 on the public site. |
| Edit warning | Alert (edit only) | Changing dimensions, fit, format or quality purges 244 generated files; they will be regenerated on next request. |
| Preview | Heading · labels · hint | Preview · Sample: north-wall.jpg · Original — 4032 × 3024 · 3.9 MB · hero — 1200×630 · about 96 KB · Drag the divider, or focus it and use the arrow keys. The estimate comes from a sample; output is never larger than the source. |
| Preview (no size) | Over the image | Set a width or height to see a preview. |
| Slider | aria-label · aria-valuetext | Compare original and hero · 50% hero |
| URL | Label · button | URL · /media/north-wall.jpg?v=hero · Copy · Copied |
| Footer | Status · buttons | Unsaved changes · Cancel · Add variant · Save changes · Saving… |
| Save error | Alert | Couldn't save the variant · Check your connection and try again. · Try again |
| Save success | Toast | Variant “hero” added · Variant “hero” saved. 244 files purged. |
| Unsaved dialog | Title · text · buttons | Discard changes? · You have unsaved changes to “hero”. If you close now, they're lost. · Keep editing · Discard changes |
## 3. Media library
- **Route:** /media
- **API:** GET /v1/media?variants=with|without&sort=total_size (proposed)
The existing grid with three additions: a "N variants" badge on cards that have cached files, an All / With variants / Without filter, and the sort option "Largest total size (original + variants)". When sorted by total size, the meta line shows the total.
| State | Name | Trigger |
|---|---|---|
| 3a | Grid with badges | Default view. Images with no cached variants have no badge. |
| 3b | Sort menu | The sort select is open. The new option is last. |
| 3c | Filter: With variants, sorted by total size | The meta line becomes total · original + variants. |
| 3d | Filter: Without variants | Images no variant has been generated for. |
| 3e | Filter: nothing to show | With variants, and the cache is empty. |
| 3f | Delete confirm | Delete on an image that has cached variants. |
### Copy: Media library additions
| State | Where | Copy |
|---|---|---|
| Badge | Card | 4 variants · 1 variant |
| Filter | Segmented control | All · With variants · Without |
| Sort | Select, new option | Largest total size (original + variants) · mobile label: Largest total size |
| Meta line (sorted by total) | Card | 4.2 MB total · 3.9 MB + 260 KB |
| Empty filter | Title · text · button | No images with variants · Variants are generated when an image is requested with ?v=<name>, or from its Variants tab. · Show all images |
| Delete dialog | Title · text · note · buttons | Delete this image? · "north-wall.jpg" is used in 2 articles. If you delete it, they will show a broken image. This can't be undone. · Also removes 4 generated variants (260 KB) · Cancel · Delete image |
| Bulk delete | Dialog line | Also removes 11 generated variants (1.1 MB) |
## 4. Image detail: Variants tab
- **Route:** /media (detail panel)
- **API:** GET /v1/media/{id}/variants · POST /v1/media/{id}/variants/{name} · DELETE /v1/media/{id}/variants/{name} (proposed)
The detail panel gets a Variants tab with one row per enabled variant. On desktop the row actions are icon buttons with tooltips and accessible names; on mobile they are labeled buttons on their own line. Only actions that apply are shown: generated rows offer Copy URL, Preview and Purge; others offer Copy URL and Generate now. The tab shows output dimensions as they will be generated, capped at the source size.
| State | Name | Trigger |
|---|---|---|
| 4a | Partly generated | Four of five generated. Saving is shown against the original. |
| 4b | Nothing generated yet | No cached files for this image. |
| 4c | Generating | Generate all or Generate now was pressed. Rows show a spinner. |
| 4d | Generation failed | The row shows the cause and Retry. Both causes are shown together for reference. |
| 4e | No variants defined | There are no enabled variants. |
| 4f | Source is an SVG | Variants aren't generated for vector images. |
| 4g | Source is an animated GIF | Same rule as SVG. |
| 4h | Preview dialog | Preview on a generated row. Both at actual size. |
### Copy: Variants tab
| State | Where | Copy |
|---|---|---|
| Tabs | Panel | Details · Variants |
| Original | Header block | Original · kept untouched · north-wall.jpg · JPEG · 4032 × 3024 · 3.9 MB |
| Summary | Above the list | 4 of 5 generated · Nothing generated yet. · Generating variants… |
| Generate all | Button | Generate all · Generating… |
| Row status | Badge | Generated · Not generated · Generating · Failed |
| Row meta (generated) | Under the name | 400×300 · WebP · 18 KB · -99% vs original |
| Row meta (not generated) | Under the name | 400×300 · WebP |
| Row actions | Tooltip · aria-label | Copy URL · Preview · Generate now · Purge · Retry |
| Failed: cause | Row line | Unsupported source format · Image too large |
| No variants defined | Title · text · link | No variants defined · Define a variant in Settings to get resized copies of this image. · Settings → Media → Variants |
| SVG / GIF | Alert | Variants aren't generated for this format. · SVG images are served as they are. · Animated GIFs are served as they are. |
| Preview dialog | Title · panes · hint | Preview: thumb · thumb · 400×300 · WebP — 18 KB · saves 99% · Original · 4032×3024 · JPEG — 3.9 MB · Both images are shown at actual size. Scroll to pan. · Close |
| Toasts | After actions | Generated 5 variants · Generated thumb · Purged 4 files (260 KB) · Couldn't generate hero. Image too large. |
## 5. Settings → Media → Cache
- **Route:** /settings/media/cache
- **API:** GET /v1/image-variants/cache · POST /v1/image-variants/cache/purge {scope: all|unused|variant|image} (proposed)
Stat tiles, a per-variant bar list (bar length is the disk size relative to the largest variant) and the ten images with the most variant disk usage. Purge all needs the word "purge" typed. Purge unused removes files of enabled variants not requested in 30 days.
| State | Name | Trigger |
|---|---|---|
| 5a | Loaded | Default view. |
| 5b | Loading | Tiles and lists show skeletons. |
| 5c | Empty cache | Nothing generated. Purge buttons are disabled. |
| 5d | Purging | Progress for a purge in progress. Content is dimmed. |
| 5e | Purge done | The cache is empty and a toast reports the result. |
| 5f | Purge failed | Part of the purge did not complete. |
| 5g | Error loading | A 500 or network failure. |
| 5h | Purge all: confirm | The word is not typed yet. The button is disabled. |
| 5i | Purge all: confirm, typed | The input matches "purge". |
| 5j | Purge unused: confirm | Variants of enabled variants not requested for 30 days. |
### Copy: Cache panel
| State | Where | Copy |
|---|---|---|
| Header | Title · text · buttons | Cache · Generated variant files. Purged files are generated again on the next request. · Purge unused · Purge all variants |
| Tiles | Label · sub text | Generated files · Across 6 variants — Disk used · Originals not included — Variants defined · 5 enabled, 1 disabled — Rejected requests · Last 7 days |
| Bar list | Heading · row | By variant · hero 244 files · 84 MB · Disabled (badge on disabled variants) |
| Top 10 | Heading · row · button | Top 10 images by variant disk usage · north-wall.jpg · 6 variants · 5.8 MB · Purge |
| Empty cache | Title · text | Nothing generated yet · Variants are generated on the first request with ?v=<name>, or from an image's Variants tab. |
| Purging | Banner | Purging 1,284 files · 640 of 1,284 · 106 MB freed |
| Purge done | Toast | Purged 1,284 files, freed 212 MB |
| Purge failed | Toast | Purge didn't finish · 640 of 1,284 files were removed. Try again. · Try again |
| Error | Alert | Couldn't load cache stats · Check your connection and try again. · Try again |
| Purge all dialog | Title · text · field · buttons | Purge all variants? · This removes 1,284 generated files (212 MB) for every variant. Originals aren't touched. Files are generated again on request. · Type purge to confirm · Cancel · Purge all variants |
| Purge unused dialog | Title · text · buttons | Purge unused variants? · This removes 312 files (41 MB) from enabled variants that haven't been requested for 30 days. Files are generated again on request. · Cancel · Purge 312 files |
| Purge image (row) | Toast | Purged 6 files (5.8 MB) for north-wall.jpg |
## 6. Editor image picker
- **Route:** /articles/{id}/edit
- **API:** Inserted URL: /media/{file}?v={name}
The Library tab of the picker gets a Variant select. Options are Original plus the enabled variants, each with its dimensions and format. Once inserted, a block whose variant was deleted or disabled shows an inline warning with a way to fix it. Disabled variants do not appear in the select.
| State | Name | Trigger |
|---|---|---|
| 6a | Variant select | An image is selected. The URL line shows what will be inserted. |
| 6b | Select open | Original plus five enabled variants. |
| 6c | No variants defined | Only Original is offered. |
| 6d | Variant deleted | The block keeps its URL. Pick another variant opens the select. |
| 6e | Variant disabled | Same block, different cause. Proposed copy. |
### Copy: Editor picker
| State | Where | Copy |
|---|---|---|
| Select | Label | Variant |
| Options | Option label | Original · 4032×3024 · JPEG — Thumb · 400×300 · WebP — Card · 800 × auto · WebP — Hero · 1200×630 · WebP — Square · 600×600 · JPEG — Og · 1200×630 · JPEG |
| URL line | Under the select | /media/north-wall.jpg?v=hero |
| No variants | Help | No variants defined. Add one in Settings → Media → Variants. |
| Block warning: deleted | Inline warning | Variant 'hero' no longer exists. This image returns an error on the public site. · Pick another variant |
| Block warning: disabled | Inline warning (proposed) | Variant 'hero' is disabled. This image returns an error on the public site. · Pick another variant |
| Block label | Chip on the image | hero · 1200×630 |
## 7. Public request contract
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.
| Request | Status | Body or result |
|---|---|---|
| /media/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 |
| height, format or quality parameters | 400 | Unsupported parameter |
| /media/missing.png?v=thumb | 404 | Not found |
| 400 body shape | | { "status": 400, "error": "unknown_variant" | "unsupported_parameter", "message": "Unknown variant", "detail": "banner" } |
## 8. Activity log entries
Row copy for the existing activity log. The actor name is bold, the quoted name is the variant name, and the detail line is secondary text.
| Event | Row copy | Detail line |
|---|---|---|
| Variant created | Marta Alves created variant “hero” | 1200×630 · cover · WebP · quality 82 |
| Variant updated | Marta Alves updated variant “hero” | Quality 82 → 70 · purged 244 files |
| Variant deleted | Marta Alves deleted variant “hero” | Removed 244 generated files (84 MB) |
| Variant enabled | Marta Alves enabled variant “hero” | Requests for ?v=hero are served |
| Variant disabled | Marta Alves disabled variant “hero” | Requests for ?v=hero return 400 |
| Cache purged: all | Marta Alves purged all variants | Removed 1,284 files (212 MB) |
| Cache purged: unused | Marta Alves purged unused variants | Removed 312 files (41 MB) not requested for 30 days |
| Cache purged: by variant | Marta Alves purged the cache of variant “hero” | Removed 244 files (84 MB) |
| Cache purged: by image | Marta Alves purged the variants of north-wall.jpg | Removed 4 files (260 KB) |
| Generate all | Marta Alves generated all variants of north-wall.jpg | 5 variants generated |
| Generate all, with a failure | Marta Alves generated all variants of north-wall.jpg | 4 generated · 1 failed (Image too large) |
| Image deleted | Marta Alves deleted north-wall.jpg | Also removed 4 generated variants (260 KB) |
## States matrix
Frame: a numbered state above. Copy: covered by the copy tables, with no separate frame. Existing: unchanged behavior of the existing screen.
| Screen | Loaded | Empty | Loading | Error | Forbidden | In progress | Confirm | Validation | Success |
|---|---|---|---|---|---|---|---|---|---|
| 1 Variants list | Frame 1a | Frame 1c | Frame 1b | Frame 1d | Frame 1e | Frame 1g, 1j | Frame 1h, 1i | – | Copy |
| 2 Add / edit | Frame 2a, 2h | – | – | Frame 2g | Copy | Frame 2f | Frame 2j | Frame 2c, 2d, 2e | Copy |
| 3 Media library | Frame 3a | Frame 3e | Existing | Existing | – | – | Frame 3f | – | – |
| 4 Image detail | Frame 4a | Frame 4b, 4e, 4f, 4g | Existing | Frame 4d | – | Frame 4c | – | – | Copy |
| 5 Cache | Frame 5a | Frame 5c | Frame 5b | Frame 5g, 5f | Copy | Frame 5d | Frame 5h, 5i, 5j | Frame 5h, 5i | Frame 5e |
| 6 Editor picker | Frame 6a, 6b | Frame 6c | Existing | Frame 6d, 6e | – | – | – | – | – |
## Back-office API errors
Shown as a toast, or inline where a field is named. The first line is the title, the second the text. The public media URL does not use these; see section 7.
| Status | Title | Text | Where it shows |
|---|---|---|---|
| 400 | Check the request | A value in the request isn't valid. Reload the page and try again. | Toast |
| 401 | Session expired | Sign in again to continue. | Redirect to sign in, then a toast |
| 403 | You don't have access | Only admins can manage image variants. | Toast, or the forbidden state on the list |
| 404 | Not found | This variant or image no longer exists. Refresh the page. | Toast |
| 409 | Name already taken | A variant named “thumb” already exists. Choose another name. | Inline under Name |
| 413 | Image too large | The source image is too large to generate this variant. | Row cause in the Variants tab |
| 422 | Check the fields | Width and height must be between 1 and 8000, and one of them is required. | Inline under each field |
| 500 | Something went wrong | The server couldn't finish. Try again. If it keeps happening, check the server log. | Alert or toast with Try again |
## Component inventory
Existing components are reused as they are. New components are built on Nuxt UI parts and use only existing tokens. Every icon-only button needs an aria-label and a UTooltip.
| Component | Status | Nuxt UI mapping | Notes |
|---|---|---|---|
| Variants table | New | UTable | Cell slots for the status switch and actions. Below 640 px it renders as a list of UCard. |
| Variant row (settings, mobile) | New | UCard + USwitch + UDropdownMenu | Name, spec line, counts, status switch and menu. |
| Variant row (image detail) | New | UBadge + UButton + UTooltip | Status badge, meta line, contextual actions, error line. Spinner uses UIcon. |
| Variant form | New | UForm, UFormField, UInput, USelect, USlider, USwitch, URadioGroup | Fit is a URadioGroup, horizontal, 3 options. Quality hides when the format is PNG. |
| Slide-over / full page | New | USlideover (desktop) · routed page (mobile) | Close with unsaved changes opens the discard UModal. |
| Split comparison slider | New | No match. Custom ImageCompare on a native range input | Two stacked images, clip-path on the top one. role="slider", arrow keys 1%, Shift 10%, Home and End. Handle ≥ 44 px on touch. |
| Stat tile | New | UCard (default slot) | Icon, label, value, sub text. Skeleton uses USkeleton. |
| Bar list | New | UProgress per row | Row with name, stats and a bar. Value is disk size over the largest variant. |
| Top-N list | New | UCard + list rows + UButton | Thumbnail, name, count, size, Purge. |
| Type-to-confirm dialog | New | UModal + UInput | Confirm button disabled until the input equals "purge". |
| Variant select | New | USelectMenu | Item slot shows name, dimensions, format. Only enabled variants. |
| Variants badge | New | UBadge (outline, icon layers) | Shown on media cards when count > 0. |
| Filter | New | UTabs (variant link) or UButtonGroup | All, With variants, Without. |
| Request reference card | New | UCard + code block | Table of responses and a JSON body. |
| Alerts, toasts, dialogs, empty states, skeletons | Existing | UAlert, useToast, UModal, UEmpty pattern, USkeleton | Same copy patterns as the Media and API keys screens. |
## Vocabulary and accessibility
Use "variant", "generated" and "purge" everywhere. Never "thumbnail preset", "version" or "rendition". Delete removes a variant definition; purge removes only its generated files. Every status is an icon plus text: Generated (circle-check), Not generated (circle-dashed), Generating (loader), Failed (circle-x), Enabled (circle-check), Disabled (circle-off). The comparison slider has a visible focus ring and works from the keyboard. Dialogs trap focus and return it to the opener. Progress is announced with aria-live="polite". Touch targets on mobile are at least 44 px. The only token not already in the system is --rc-on-sand (dark ink on the sand selection color), used for the active nav item in both themes.
## Implementation notes
The board predates two decisions. Where the board and these notes differ, these notes win for behaviour and the board stays the reference for layout and copy structure.
- The public URL is `/static/uploadedImages/<storedName>?v=<name>` (`image.PublicPathPrefix`), not `/media/<file>`. Every `/media/...?v=...` string in the copy tables above (URL field, Copy URL, URL line in the picker, request contract rows) shows that URL instead. `/media` stays the SPA library route.
- WebP output depends on a CGO-free lossy encoder. If none is viable, the Format select offers Original, JPEG and PNG only.
- The padding colour of `contain` is not drawn. The feature spec decides it.
+4
View File
@@ -36,6 +36,10 @@ Detail panel copy:
| Used | "Used in 2 articles", then one row per article: title (underlined link to the editor), badge "Online" (`globe`, inverted) or "Draft" (`pencil-line`, outline), `arrow-up-right`. Footnote: "Counted from the text of your articles each time you open this page." |
| Not used | "Not used in any article" / "This image is safe to delete. No article shows it." |
## Image variants
The Library gains a "N variants" badge, an All / With variants / Without filter, the sort option "Largest total size (original + variants)", a delete dialog that names the generated variants it removes, and a Variants tab in the detail panel. Those states are in [image-variants](image-variants.md), sections 3 and 4.
## Dialogs
All dialogs use the existing ConfirmDialog look (`UModal`, `sm:max-w-md`, bottom sheet on mobile, cancel first). While a request runs the confirm button shows a `loader-circle`, both buttons are disabled and the dialog cannot be dismissed. A failure shows an error alert at the top of the dialog body and the dialog stays open.
+16 -15
View File
@@ -1,7 +1,7 @@
# Settings
- **Route:** `/settings`
- **API:** GET /v1/media/purge-settings · PATCH /v1/media/purge-settings · GET /v1/media/purge-preview · POST /v1/media/purge (see "Media cleanup (second card)" at the end) · GET /v1/settings (version) · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart
- **API:** GET /v1/media/purge-settings · PATCH /v1/media/purge-settings · GET /v1/media/purge-preview · POST /v1/media/purge (see "Media cleanup (third card)" at the end) · GET /v1/settings (version) · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Settings", drawn by `source/AppFrame.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Opened from the sidebar (Settings, settings icon, after Integration) and from the mobile drawer. Every verified user sees it. It holds two sections: Version (everyone) and Updates (the owner has the buttons, admins read the status).
@@ -12,7 +12,7 @@ Out of scope: choosing versions, downgrades, automatic updates, update history.
## The page
Title "Settings", subtitle "Workspace information.", 720 px at most, 20 px between sections. Page padding 32 px on desktop and `20px 16px 88px` on mobile.
One column of cards, top to bottom: Version, Updates, Media cleanup, Retention (owner only). Title "Settings" (20 px/28 px semibold), subtitle "Workspace information.", 720 px at most, 20 px between sections. Every card heading (Version, Updates, Media cleanup, Retention) is 15 px/24 px semibold in `--ui-text-highlighted`; a card heading is never larger than the page title and never smaller than a body line. The Back-office board draws this page scrolled to the card each state is about. Page padding 32 px on desktop and `20px 16px 88px` on mobile.
**Version card** (unchanged): label "Version" and the running build's version in IBM Plex Mono 14 px, shown exactly as the server reports it (for example `v2.2.0-beta`).
@@ -28,7 +28,7 @@ Title "Settings", subtitle "Workspace information.", 720 px at most, 20 px betwe
| Server command | Only in the "Not prepared" state: "For whoever manages the server", a one-line `code` block, a Copy button (reads "Copied" with a check for 2 s, announced), and the note "They run this once on the server. After that, Update and Restart appear here." The command shown is `renewcms enable-browser-updates` and is subject to the real one. |
| Buttons | 32 px high (44 px on mobile, stacked full width), 14 px side padding. One primary at most. Disabled buttons use 40 % opacity. |
| Hint | 12 px muted line under the buttons. |
| Link | Underlined, with an `arrow-up-right`: "How to update" or "Read the release notes". |
| Link | Underlined, with an `external-link`: "How to update" or "Read the release notes". |
Buttons used: **Check for updates** (`refresh-cw`, secondary), **Update** (`download`, primary), **Restart** (`power`, primary and inside the status block when an update is ready, otherwise secondary in the buttons row), **Try again** (`rotate-cw`, primary), and the disabled "Checking…" and "Updating…" (`loader-circle`). Copy such as "Version 2.3.0" names the newer release. Decision for the spec: show the release tag without its leading `v` ("Version 2.3.0-beta" for tag `v2.3.0-beta`), while the Version card keeps the running build exactly as the server reports it.
@@ -126,15 +126,15 @@ For the owner only: an **Update** pill on the Settings sidebar item, and a dot o
| Role changed (403) | An owner was changed to admin while the page was open and pressed a button. The card reloads in the admin view. |
| Update already running (409) | Another owner started it. The card switches to that run's progress. |
## Media cleanup (second card)
## Media cleanup (third card)
Route: `/settings`. Visual reference: `RenewCMS Screens Media cleanup.dc.html` (frames: `SettingsFrame.dc.html`, `MediaFrame.dc.html`).
Route: `/settings`. Visual reference: `source/RenewCMS Screens Back-office.dc.html`, groups "Settings: Media cleanup", "Purge dialog", "Purge results" and "Media duplicates entry" (frames: `source/AppFrame.dc.html`, `source/MediaFrame.dc.html`).
API: `GET/PATCH /v1/media/purge-settings` (owner, admin), `GET /v1/media/purge-preview`, `POST /v1/media/purge`. 401 and 403 are global: no local copy.
Rule: a group is purged only if at least one copy is used in an article. All copies merge into the one used in the most articles (ties: oldest, then lowest id). Groups with no used copy are left alone. Automatic purge is off by default.
### Card
Second card below Version, 20 px gap, same card styling. Page max width 720 px.
Third card, below Updates, 20 px gap, same card styling as the others. The board scrolls the page to this card for its states.
- Title: "Media cleanup". Description: "Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one."
- "Automatic purge" / helper "Runs in the background. Off by default." / USwitch.
- When on: "Every" / helper "Between 1 and 365 days." / number input, suffix "days", default 30.
@@ -180,15 +180,16 @@ Duplicates tab: secondary "Purge duplicates" button (merge) next to the sort men
## Activity retention
**API:** GET /v1/settings · PATCH /v1/settings (owner only). Visual reference: `source/RenewCMS Screens Activity.dc.html`, group "Settings retention". The board draws the other cards as a dashed "Existing Settings cards" placeholder and gives the page a different subtitle ("Site-wide options. Only owners can change them."), which is not a decision to change the existing one.
**API:** GET /v1/settings · PATCH /v1/settings (owner only). Visual reference: `source/RenewCMS Screens Back-office.dc.html`, group "Settings: Retention", drawn by `source/AppFrame.dc.html`.
A third card below Media cleanup, owner only (hidden for admins), same card styling, 640 px at most as drawn.
The last card on the page, below Media cleanup, owner only (hidden for admins and for the read-only Media cleanup view), same card styling and 720 px column as the others.
- Header: "Retention" / "How long activity is kept." "Saved" (check, success) at the right after a save, fading after 2 s.
- "Keep activity for" `USelectMenu`: 30 days, 90 days, 365 days, Forever. Default 90 days. Helper: "Older events are removed automatically." For Forever: "Nothing is removed automatically. The log grows over time."
- Info alert (`lock`): "Activity logs are append-only", "Events can't be edited or deleted from the app. Only the retention period removes them."
- Divider, then read-only lines: "Last cleanup: 6 Oct 2026, 03:00 · 142 events removed" and "Next cleanup: 7 Oct 2026, 03:00". Forever: "Never" and "Not scheduled".
- Saves on change. Choosing a shorter period than the current one first shows a warning alert, "Events older than 30 days will be removed", "This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.", buttons "Apply 30 days" and "Cancel"; nothing is sent until Apply. Lengthening or choosing Forever saves at once.
- Header: "Retention" / "How long activity is kept, or how much of it. Whichever limit is reached first removes the oldest events." "Saved" (check, success) at the right after a save, fading after 2 s.
- "Keep activity for" `USelectMenu`: 30 days, 90 days, 365 days, Forever. Default 90 days. Helper: "Events older than this are removed." when a size limit is set, otherwise "Older events are removed automatically." For Forever: "Nothing is removed automatically. The log grows over time." without a size limit, "No age limit. Only the size limit applies." with one.
- An "or" divider, then "Keep at most" `USelectMenu` (160 px wide, full width on mobile): "No size limit" (default) and sizes in GB. Helper without a limit: "Optional. Logs can grow without a size cap." With one: "When logs pass 5 GB, the oldest events are removed. Currently 3.2 GB." The figure after "Currently" is the real size of the activity log. The size options are listed in the API note below.
- Info alert (`lock`): "Activity logs are append-only", "Events can't be edited or deleted from the app. Only the retention limits remove them."
- Divider, then read-only lines: "Last cleanup: 6 Oct 2026, 03:00 · 142 events removed" and "Next cleanup: 7 Oct 2026, 03:00". Forever with no size limit: "Never" and "Not scheduled". Forever with a size limit still shows a schedule, because the size check runs at every cleanup.
- Both selects save on change. Choosing a shorter age, or a smaller size limit than the current one, first shows a warning alert, "Events older than 30 days will be removed" (or "Events over 5 GB will be removed"), "This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.", buttons "Apply 30 days" (or "Apply 5 GB") and "Cancel"; nothing is sent until Apply. Lengthening, raising, choosing Forever or choosing "No size limit" saves at once.
- Save error alert: "Couldn't save the retention period", "Your previous setting is still in place.", "Try again". The control reverts.
- States: Default (90 days, saved), Shortening the period, Forever, Save error. Loading and Offline follow the Media cleanup card.
- `GET /v1/settings` adds `activity_retention_days` (30, 90, 365 or 0 for forever), `activity_last_cleanup_at` (null before the first cleanup) and `activity_last_cleanup_removed`. `PATCH /v1/settings` takes `{ "activity_retention_days": 30 }`; any other value answers 400 "The request cannot be processed due to a mismatch in the format of the body."; a non-owner gets 403 "Only the owner can do this.". The cleanup runs once a day at 03:00 server time and never touches the last 24 hours.
- States: "Default, 90 days or 5 GB", "Age only (no size limit)", "Size only (forever)", "Shortening the period" (also with a 5 GB limit), "Forever" and "Save error". Loading and Offline follow the Media cleanup card.
- `GET /v1/settings` adds `activity_retention_days` (30, 90, 365 or 0 for forever), `activity_retention_max_gb` (0 for no size limit, otherwise one of the size options), `activity_log_size_bytes`, `activity_last_cleanup_at` (null before the first cleanup) and `activity_last_cleanup_removed`. `PATCH /v1/settings` takes `{ "activity_retention_days": 30 }`, `{ "activity_retention_max_gb": 5 }` or both; any other value answers 400 "The request cannot be processed due to a mismatch in the format of the body."; a non-owner gets 403 "Only the owner can do this.". The cleanup runs once a day at 03:00 server time, removes events older than the age limit, then removes the oldest events until the log is under the size limit, and never touches the last 24 hours.
+80 -4
View File
@@ -27,7 +27,7 @@
<sc-if value="{{ mob }}">
<div style="height:56px;flex-shrink:0;border-bottom:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;padding:0 6px;gap:4px;color:var(--ui-text-highlighted)"><span data-act="m-lead" style="position:relative;width:44px;height:44px;display:flex;align-items:center;justify-content:center"><sc-if value="{{ s.navBadge }}"><span aria-label="Update available" style="position:absolute;top:9px;right:9px;width:10px;height:10px;background:var(--ui-info);border:2px solid var(--ui-bg)"></span></sc-if><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:20px;height:20px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ mLead }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ mLead }}.svg') center / contain no-repeat"></span></span><span style="flex:1;min-width:0;font-weight:600;font-size:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ s.mTitle }}</span><sc-if value="{{ s.mAction }}"><span data-act="m-action" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:20px;height:20px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.mAction }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.mAction }}.svg') center / contain no-repeat"></span></span></sc-if></div>
</sc-if>
<sc-if value="{{ s.offline }}"><div role="status" style="flex-shrink:0;display:flex;align-items:center;gap:10px;padding:10px 16px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted)"><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/wifi-off.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/wifi-off.svg') center / contain no-repeat"></span><span style="flex:1">You're offline. You can keep writing, but saving and publishing will wait until you're back online.</span></div></sc-if>
<sc-if value="{{ s.offline }}"><div role="status" style="flex-shrink:0;display:flex;align-items:center;gap:10px;padding:10px 16px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted)"><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/wifi-off.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/wifi-off.svg') center / contain no-repeat"></span><span style="flex:1">{{ offText }}</span></div></sc-if>
<div style="flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column">
@@ -66,6 +66,14 @@
</div>
</div>
</sc-if>
<sc-if value="{{ act.show }}">
<div style="display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:baseline;justify-content:space-between"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ act.title }}</span><span data-act="nav-activity" style="color:var(--ui-text-toned);text-decoration:underline;text-underline-offset:3px">View all</span></div>
<sc-if value="{{ act.error }}"><div style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;"></span></span><div style="flex:1;display:flex;flex-direction:column;gap:8px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Couldn't load recent activity</span><span style="color:var(--ui-text-toned)">Check your connection and try again.</span></div><span role="button" style="height:28px;padding:0 10px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;font-size:12px;white-space:nowrap;opacity:1"><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/rotate-cw.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/rotate-cw.svg') center / contain no-repeat;"></span>Try again</span></div></div></sc-if>
<sc-if value="{{ act.empty }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:32px 24px;display:flex;flex-direction:column;align-items:center;gap:6px;text-align:center"><span style="color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:20px;height:20px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/history.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/history.svg') center / contain no-repeat;"></span></span><span style="font-weight:600;color:var(--ui-text-highlighted)">No activity yet</span><span style="color:var(--ui-text-muted);max-width:260px">{{ act.emptyText }}</span></div></sc-if>
<sc-if value="{{ act.rows }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ act.rows }}" as="r" hint-placeholder-count="5"><div style="display:flex;align-items:center;gap:12px;min-height:{{ rowH }};padding:8px 12px;border-top:1px solid var(--ui-border-muted)"><sc-if value="{{ r.skel }}"><span style="width:24px;height:24px;flex-shrink:0;background:var(--ui-bg-accented)"></span><span style="flex:1;height:12px;background:var(--ui-bg-accented);max-width:60%"></span></sc-if><sc-if value="{{ r.s }}"><span style="width:24px;height:24px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:var(--rc-sand);color:#111;font:600 10px 'IBM Plex Sans',sans-serif">{{ r.ini }}</span><div style="flex:1;min-width:0;display:flex;flex-direction:column"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ui-text)">{{ r.s }}<span style="font-weight:600;color:var(--ui-text-highlighted)">{{ r.em }}</span></span><span style="font-size:12px;color:var(--ui-text-muted)">{{ r.meta }}</span></div><sc-if value="{{ r.fail }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--rc-error-soft);color:var(--ui-error);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/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;"></span>Failed</span></sc-if></sc-if></div></sc-for></div></sc-if>
</div>
</sc-if>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;gap:14px;align-items:flex-start"><span style="width:36px;height:36px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:var(--rc-sand);color:#111"><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/plug.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/plug.svg') center / contain no-repeat"></span></span><div style="flex:1;display:flex;flex-direction:column;gap:4px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Connect your website</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Your site reads published articles from RenewCMS. Share this guide with whoever builds it.</span><span data-act="nav-integration" style="color:var(--ui-text-highlighted);font-weight:500;text-decoration:underline;text-underline-offset:3px;margin-top:4px">Open the guide</span></div></div>
</div>
</sc-if>
@@ -211,7 +219,7 @@
</sc-if>
<sc-if value="{{ isSettings }}">
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:20px;max-width:720px;overflow:hidden">
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:20px;max-width:720px;flex-shrink:0;margin-top:{{ setScroll }}">
<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)">Settings</span><span style="color:var(--ui-text-muted)">Workspace information.</span></div>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;align-items:center;justify-content:space-between;gap:16px"><span style="font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Version</span><span style="font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)">{{ s.upd.v }}</span></div>
<div role="region" aria-labelledby="updates-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px">
@@ -223,6 +231,8 @@
<sc-if value="{{ s.upd.hint }}"><span style="font-size:12px;color:var(--ui-text-muted);text-wrap:pretty">{{ s.upd.hint }}</span></sc-if>
<sc-if value="{{ s.upd.link }}"><span style="align-self:flex-start;display:flex;align-items:center;gap:6px;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;font-weight:500">{{ s.upd.link }}<span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat"></span></span></sc-if>
</div>
<div role="region" aria-labelledby="cleanup-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px"><sc-if value="{{ cl.loading }}"><div aria-busy="true" style="display:flex;flex-direction:column;gap:16px"><span style="display:block;width:140px;height:16px;background:var(--ui-bg-accented)"></span><span style="display:block;width:90%;height:13px;background:var(--ui-bg-accented)"></span><span style="display:block;width:55%;height:13px;background:var(--ui-bg-accented)"></span><div style="display:flex;justify-content:space-between;align-items:center"><div style="display:flex;flex-direction:column;gap:6px"><span style="display:block;width:120px;height:13px;background:var(--ui-bg-accented)"></span><span style="display:block;width:180px;height:11px;background:var(--ui-bg-accented)"></span></div><span style="display:block;width:36px;height:20px;background:var(--ui-bg-accented)"></span></div><span style="display:block;width:60%;height:13px;background:var(--ui-bg-accented)"></span><div style="height:1px;background:var(--ui-border)"></div><div style="display:flex;justify-content:space-between;align-items:center"><div style="display:flex;flex-direction:column;gap:6px"><span style="display:block;width:100px;height:13px;background:var(--ui-bg-accented)"></span><span style="display:block;width:220px;height:11px;background:var(--ui-bg-accented)"></span></div><span style="display:block;width:140px;height:32px;background:var(--ui-bg-accented)"></span></div></div></sc-if><sc-if value="{{ cl.ready }}"><div style="display:flex;align-items:flex-start;gap:12px"><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span id="cleanup-h" tabindex="-1" style="align-self:flex-start;font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Media cleanup</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.</span></div><div style="height:24px;display:flex;align-items:center;flex-shrink:0"><sc-if value="{{ cl.saving }}"><span style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;"></span>Saving…</span></sc-if><sc-if value="{{ cl.saved }}"><span role="status" style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-success)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;"></span>Saved</span></sc-if></div></div><sc-if value="{{ cl.ro }}"><div style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-info-soft);color:var(--ui-info)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;"></span></span><span style="color:var(--ui-text-toned)">Only owners and admins can change this.</span></div></sc-if><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Automatic purge</span><span style="font-size:12px;color:var(--ui-text-muted)">Runs in the background. Off by default.</span></div><div role="switch" aria-checked="{{ cl.on }}" style="display:flex;align-items:center;justify-content:center;width:{{ cl.swW }};height:{{ cl.swH }};flex-shrink:0;opacity:{{ cl.swOp }}"><div style="position:relative;width:36px;height:20px;border:1px solid {{ cl.swBd }};background:{{ cl.swBg }};outline:{{ cl.swRing }};outline-offset:2px"><div style="position:absolute;top:1px;left:{{ cl.knobL }};width:16px;height:16px;background:var(--ui-bg);border:1px solid {{ cl.knobBd }};box-sizing:border-box"></div></div></div></div><sc-if value="{{ cl.on }}"><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Every</span><span style="font-size:12px;color:var(--ui-text-muted)">Between 1 and 365 days.</span></div><div style="display:flex;flex-direction:column;gaLine truncated
<sc-if value="{{ rt.show }}"><div role="region" aria-labelledby="retention-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px"><div style="display:flex;align-items:flex-start;gap:12px"><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span id="retention-h" tabindex="-1" style="align-self:flex-start;font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Retention</span><span style="color:var(--ui-text-muted);text-wrap:pretty">How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events. Only owners can change it.</span></div><div style="height:24px;display:flex;align-items:center;flex-shrink:0"><sc-if value="{{ rt.saving }}"><span style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;"></span>Saving…</span></sc-if><sc-if value="{{ rt.saved }}"><span role="status" style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-success)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;"></span>Saved</span></sc-if></div></div><sc-if value="{{ rt.err }}"><div style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;"></span></span><div style="flex:1;display:flex;flex-direction:column;gap:8px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Couldn't save the retention period</span><span style="color:var(--ui-text-toned)">Your previous setting is still in place.</span></div><span role="button" style="height:28px;padding:0 10px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;font-size:12px;white-space:nowrap;opacity:1">Try again</span></div></div></sc-if><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Keep activity for</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ rt.help }}</span></div><div style="display:flex;align-items:center;width:{{ rt.selW }};min-height:{{ inH }};padding:0 12px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-size:{{ inF }};color:var(--ui-text-highlighted)"><span style="flex:1">{{ rt.val }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;color:var(--ui-text-muted)"></span></div></div><div style="display:flex;align-items:center;gap:12px;color:var(--ui-text-muted);font-size:12px;font-weight:500"><span style="flex:1;height:1px;background:var(--ui-border)"></span><span>or</span><span style="flex:1;height:1px;background:var(--ui-border)"></span></div><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Keep at most</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ rt.sizeHelp }}</span></div><div style="display:flex;align-items:center;width:{{ rt.selW }};min-height:{{ inH }};padding:0 12px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-size:{{ inF }};color:var(--ui-text-highlighted)"><span style="flex:1">{{ rt.sizeVal }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;cLine truncated
</div>
</sc-if>
@@ -323,6 +333,7 @@
</div>
</sc-if>
{{ purgeDlg }}
<sc-if value="{{ hasToasts }}">
<div style="position:absolute;z-index:60;display:flex;flex-direction:column;gap:8px;left:{{ tL }};right:{{ tR }};bottom:{{ tB }};width:{{ tW }}">
<sc-for list="{{ toasts }}" as="t" hint-placeholder-count="1">
@@ -338,7 +349,7 @@ class Component extends DCLogic {
const s = this.props.s || {}
const m = !!this.props.mobile
const k = s.kind || 'home'
const navItems = [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['users', 'users', 'Users'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
const navItems = s.role === 'user' ? [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['activity', 'history', 'Activity']] : [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['users', 'users', 'Users'], ['activity', 'history', 'Activity'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
const nav = navItems.map(([key, icon, t]) => ({ key, icon, t, bg: s.nav === key ? 'var(--rc-sand)' : 'transparent', badge: key === 'settings' && !!s.navBadge, fg: s.nav === key ? '#111111' : 'var(--ui-text-toned)', fw: s.nav === key ? 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 || {}
@@ -406,6 +417,71 @@ class Component extends DCLogic {
const rsMap = { sent: { bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', icon: 'mail-check', title: 'Reset link sent', text: 'Check marta@renew.example. The link works once, within 1 hour.' }, limited: { bg: 'var(--rc-warning-soft)', fg: 'var(--ui-warning)', icon: 'clock-alert', title: 'Too many requests', text: 'You\'ve asked for several links recently. Wait a few minutes, then try again.' }, error: { bg: 'var(--rc-error-soft)', fg: 'var(--ui-error)', icon: 'circle-x', title: 'Couldn\'t send the link', text: 'Check your connection and try again.' } }
const rsNote = rsMap[rs] || null
const rsBtn = { t: rs === 'sending' ? 'Sending…' : rs === 'sent' ? 'Send again' : 'Send reset link', icon: rs === 'sending' ? 'loader-circle' : 'mail', op: rs === 'limited' ? '0.4' : '1' }
const E5 = [['Marta Alves', 'Published the article ', 'Spring release', '2 min ago', 1], ['Marta Alves', 'Edited the article ', 'Why we reuse original floorboards', '29 min ago', 1], ['Not signed in', 'Failed login attempt for ', 'alex@example.com', '1 h ago', 0], ['Jonas Weber', 'Revoked API key ', 'Mobile app', '2 h ago', 1], ['Scheduler', 'Ran the nightly backup', '', '7 h ago', 1]]
const E4 = [['Marta Alves', 'Published the article ', 'Spring release', '2 min ago', 1], ['Marta Alves', 'Edited the article ', 'Why we reuse original floorboards', '29 min ago', 1], ['Marta Alves', 'Deleted the image ', 'old-banner.png', 'Yesterday', 1], ['Marta Alves', 'Uploaded the image ', 'floorboards-copy.jpg', '3 Oct 2026', 1]]
const ho = s.home || {}, own = s.role === 'user'
const actRows = ho.act === 'loading' ? Array.from({ length: 5 }, () => ({ skel: true })) : ho.act === 'ok' ? (own ? E4 : E5).map(([a, t, em, rel, ok]) => ({ s: t, em, meta: own ? rel : a + ' · ' + rel, fail: !ok, ini: a === 'Scheduler' ? 'SY' : a === 'Not signed in' ? '?' : a.split(' ').map(w => w[0]).join('') })) : null
const act = { show: !!ho.act && k === 'home' && !s.boot, title: own ? 'Your recent activity' : 'Recent activity', rows: actRows, error: ho.act === 'error', empty: ho.act === 'empty', emptyText: own ? 'Your actions will appear here.' : 'Actions on your site will appear here.' }
const C = s.cleanup || {}, cOn = !!C.on, cDis = !!(C.ro || C.offline || C.loading), cErr = !!(C.invalid || C.saveErr)
const cl = { loading: !!C.loading, ready: !C.loading, ro: !!C.ro, notRo: !C.ro, on: cOn, saving: !!C.saving, saved: !!C.saved && !C.saving, swW: m ? '44px' : '36px', swH: m ? '44px' : '24px', swOp: cDis ? '0.4' : '1', swRing: C.focusSw ? '2px solid var(--ui-primary)' : '0', swBd: cOn ? 'var(--ui-primary)' : 'var(--ui-border-accented)', swBg: cOn ? 'var(--ui-primary)' : 'var(--ui-bg-accented)', knobL: cOn ? '17px' : '1px', knobBd: cOn ? 'transparent' : 'var(--ui-border-accented)', daysW: m ? '100%' : '140px', daysAlign: m ? 'stretch' : 'flex-end', daysBd: cErr ? 'var(--ui-error)' : C.focusDays ? 'var(--ui-primary)' : 'var(--ui-border-accented)', daysSh: C.focusDays && !cErr ? '0 0 0 1px var(--ui-primary)' : 'none', daysOp: cDis ? '0.4' : '1', daysVal: C.days ?? '30', hasErr: cErr, errTxt: C.invalid ? 'Enter a whole number of days between 1 and 365.' : 'Couldn\'t save. Try again in a moment.', errMax: m ? 'none' : '300px', errTA: m ? 'left' : 'right', lastLine: C.last ? 'Last purge: ' + C.last.d + ' · ' + C.last.c + ' copies merged · ' + C.last.a + ' articles updated' : 'Never run', nextLine: 'Next purge: ' + (C.next || '14 Jul 2026'), purgeOp: C.offline || C.loading ? '0.4' : '1', btnShrink: m ? '1' : '0' }
const R = s.ret || {}, rd = R.days || '90', fv = rd === '0', sz = R.size || '0', hs = sz !== '0'
const rt = { show: !s.noRet, saving: !!R.saving, saved: !!R.saved, err: !!R.err, shorten: !!R.shorten, val: { '30': '30 days', '90': '90 days', '365': '365 days', '0': 'Forever' }[R.shorten ? '30' : rd], help: fv ? (hs ? 'No age limit. Only the size limit applies.' : 'Nothing is removed automatically. The log grows over time.') : (hs ? 'Events older than this are removed.' : 'Older events are removed automatically.'), last: fv && !hs ? 'Last cleanup: Never' : 'Last cleanup: 6 Oct 2026, 03:00 · 142 events removed', next: fv && !hs ? 'Next cleanup: Not scheduled' : 'Next cleanup: 7 Oct 2026, 03:00', sizeVal: hs ? sz + ' GB' : 'No size limit', sizeHelp: hs ? 'When logs pass ' + sz + ' GB, the oldest events are removed. Currently 3.2 GB.' : 'Optional. Logs can grow without a size cap.', selW: m ? '100%' : '160px' }
const purgeDlg = (() => {
if (!s.purge) return null
const h = React.createElement, bh = m ? 44 : 32
const P = t => Object.fromEntries(t.split(';').filter(x => x.trim()).map(x => { const i = x.indexOf(':'); const k = x.slice(0, i).trim(); return [k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase()), x.slice(i + 1).trim()] }))
const D = (t, ...c) => h('div', { style: P(t) }, ...c)
const S = (t, ...c) => h('span', { style: P(t) }, ...c)
const I = (n, z = 16, t = '') => h('span', { 'aria-hidden': 'true', style: { display: 'inline-block', flexShrink: 0, width: z, height: z, backgroundColor: 'currentColor', WebkitMask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', mask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', ...(t ? P(t) : {}) } })
const B = (label, o = {}) => {
const k = o.k || 'outline'
const bg = k === 'primary' ? 'var(--ui-primary)' : k === 'danger' ? 'var(--ui-error)' : 'var(--ui-bg)'
const fg = k === 'primary' || k === 'danger' ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)'
const bd = k === 'primary' || k === 'danger' ? bg : 'var(--ui-border-accented)'
return S('height:' + (o.h || bh) + 'px;padding:0 ' + (o.px || 12) + 'px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + bg + ';color:' + fg + ';border:1px solid ' + bd + ';font-weight:500;font-size:13px;white-space:nowrap;opacity:' + (o.dis ? 0.4 : 1) + ';' + (o.full ? 'flex:1;' : ''), o.icon && I(o.icon, 16), label)
}
const CHECK = 'conic-gradient(var(--ui-bg-accented) 25%, var(--ui-bg) 0 50%, var(--ui-bg-accented) 0 75%, var(--ui-bg) 0) 0 0/16px 16px'
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: 20, height: 20, 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 = '') => 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, rgb(128 128 128 / 0.12) 0 1px, transparent 1px 12px), ' + TONES[im.tone % 4]) + ';' + t, im.svg ? mark : I('image', 16, 'opacity:0.55'))
const IMX = { 12: { tone: 0 }, 7: { tone: 2 }, 2: { tone: 1 }, 4: { tone: 3, alpha: true, svg: true }, 11: { tone: 1 } }
const Alert = (tone, icon, text, title) => 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:2px', title && S('font-weight:600', title), S('color:var(--ui-text-toned)', text)))
const hl = 'color:var(--ui-text-highlighted)'
const mono = "font-family:'IBM Plex Mono',monospace"
const SkelBar = (w, hh = 13) => S('display:block;width:' + w + ';height:' + hh + 'px;background:var(--ui-bg-accented)')
// ---------- dialog
const G = [{ im: 12, n: 'floorboards.jpg', u: 4, c: 2 }, { im: 7, n: 'IMG_4471.jpg', u: 2, c: 1 }, { im: 2, n: 'kitchen-after.webp', u: 3, c: 3 }, { im: 4, n: 'logo-renew.svg', u: 5, c: 3 }, { im: 11, n: 'lime-plaster.jpg', u: 2, c: 1 }]
const pl = (n, w) => n + ' ' + w + (n === 1 ? '' : 's')
const PV = { groups: 4, copies: 9, arts: 12, list: G.slice(0, 4), more: 0, ...(s.purge && s.purge.pv) }
const summary = pv => pl(pv.groups, 'group') + ' · ' + pv.copies + (pv.copies === 1 ? ' copy' : ' copies') + ' will be deleted · ' + pl(pv.arts, 'article') + ' will be updated'
const GList = pv => D('border:1px solid var(--ui-border)', ...pv.list.map((g, i) => D('display:flex;align-items:center;gap:12px;padding:8px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), Th(IMX[g.im], 'width:40px;height:30px;flex-shrink:0;border:1px solid var(--ui-border)'), D('flex:1;min-width:0;display:flex;flex-direction:column', S('font-weight:500;' + hl + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', g.n), S('font-size:12px;color:var(--ui-text-muted)', 'Kept · used in ' + pl(g.u, 'article'))), S('font-size:12px;color:var(--ui-text-toned);white-space:nowrap;text-align:right', pl(g.c, 'copy').replace('copys', 'copies') + ' merged'))), pv.more > 0 && D('padding:8px 12px;border-top:1px solid var(--ui-border-muted);color:var(--ui-text-muted)', 'and ' + pv.more + ' more groups'))
const Link = () => S('display:inline-flex;align-items:center;gap:4px;align-self:flex-start;font-weight:500;' + hl + ';text-decoration:underline;text-underline-offset:3px;min-height:' + (m ? 44 : 20) + 'px', 'Review in Duplicates', I('arrow-right', 14))
const dg = s.purge
const Shell = (o, body) => D('position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'flex-start') + ';justify-content:center;padding:' + (m ? 16 : 140) + 'px 16px 16px',
D('width:' + (m ? '100%' : '448px') + ';max-height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column',
o.center ? D('padding:28px 24px 0;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(--rc-info-soft);color:var(--ui-info)', I(o.icon, 20)), S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + hl, o.title), S('color:var(--ui-text-muted);text-wrap:pretty;max-width:340px', o.desc))
: D('padding:20px 24px 0;display:flex;flex-direction:column;gap:4px', S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + hl, o.title), o.desc && S('color:var(--ui-text-muted);text-wrap:pretty', o.desc)),
body && D('padding:20px 24px 0;display:flex;flex-direction:column;gap:16px;min-height:0;overflow:hidden', ...body),
D('padding:20px 24px;display:flex;flex-direction:' + (m ? 'column-reverse' : 'row') + ';justify-content:flex-end;gap:8px;flex-shrink:0', o.cancel && B(o.cancel, { dis: o.loading, full: false }), o.confirm && S('height:' + bh + 'px;padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-error);color:var(--ui-bg);font-weight:500;opacity:' + (o.cdis ? 0.4 : 1), o.loading ? I('loader-circle') : o.cicon && I(o.cicon), o.confirm))))
const DESC = 'Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can\'t be undone.'
const DSkel = () => [SkelBar('80%', 16), D('border:1px solid var(--ui-border)', ...[0, 1, 2].map(i => D('display:flex;align-items:center;gap:12px;padding:8px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), SkelBar('40px', 30), D('flex:1;display:flex;flex-direction:column;gap:6px', SkelBar('45%'), SkelBar('60%', 11)))))]
const Dialog = () => {
const k = dg.k, pv = PV
const gp = 'Purge ' + pl(pv.groups, 'group')
const note = (tone, icon, t) => Alert(tone, icon, t)
if (k === 'loading') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: 'Purge duplicates', cdis: true }, DSkel())
if (k === 'nothing') return Shell({ center: true, icon: 'copy', title: 'Nothing to purge', desc: 'No duplicate is used in an article, so there is nothing to merge.', cancel: 'Close' }, null)
const body = [S('font-weight:500;' + hl, summary(pv)), GList(pv), Link()]
if (k === 'confirm') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, body)
if (k === 'progress') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: 'Purging…', loading: true }, body)
if (k === 'error') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, [note('error', 'circle-x', 'Couldn\'t purge the duplicates. Nothing was changed. Try again in a moment.'), ...body])
if (k === 'partial') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Close', confirm: 'Retry', cicon: 'rotate-cw' }, [note('warning', 'triangle-alert', 'Some groups couldn\'t be merged. 3 of 4 groups were purged. Try again to finish.'), ...body])
if (k === 'conflict') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, [note('warning', 'triangle-alert', 'The library changed, so nothing was purged. Check the new summary and try again.'), ...body])
}
return Dialog()
})()
const toasts = (s.toasts || []).map(t => ({ ...t, fg: 'var(--ui-' + t.tone + ')' }))
const endpoints = [
{ path: '/v1/article/', desc: 'Lists every online article.', l1: 'var(--ui-primary)', code: "const res = await fetch('https://cms.renew.example/v1/article/')\nconst articles = await res.json()" },
@@ -417,7 +493,7 @@ class Component extends DCLogic {
const edReady = k === 'editor' && !e.loading && !e.notfound
const edTitle = e.title || 'How we price a bathroom renovation'
return {
cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn,
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' ? '#111111' : 'var(--ui-text-highlighted)',
isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading,
@@ -1,150 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<meta name="design_doc_mode" content="canvas">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>body{margin:0;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px}*{box-sizing:border-box}a{color:inherit;text-underline-offset:3px}a:hover{opacity:.8}</style>
</helmet>
<div ref="{{ rootRef }}" data-screen-label="Activity screens" style="background:var(--rc-canvas);color:var(--ui-text);padding:64px;display:flex;flex-direction:column;gap:72px;width:max-content;min-width:100%">
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screen: Activity log</h1>
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">New /activity screen, a Recent activity card for Home, and a Retention card for Settings. 1440 px at 50%, 390 px at 75%. Owner and admin see everything; regular users get "My activity". The theme switch shows every state in dark mode. Copy, component mapping and API calls are at the bottom.</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="5">
<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>
</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"><dc-import name="ActivityFrame" s="{{ st.s }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></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"><dc-import name="ActivityFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></div></div>
</div>
</div>
</sc-for>
</div>
</section>
</sc-for>
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px">
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Copy per state</h2>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
<sc-for list="{{ copy }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for>
</div>
</section>
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px">
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Component inventory (Nuxt UI v4)</h2>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
<sc-for list="{{ comps }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for>
</div>
</section>
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px">
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API calls</h2>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
<sc-for list="{{ api }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for>
</div>
</section>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;system&quot;,&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;light&quot;,&quot;tsType&quot;:&quot;'system' | 'light' | 'dark'&quot;}}">
class Component extends DCLogic {
state = { mode: this.props.theme ?? 'light', spec: { copy: [], comps: [], api: [] } }
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()
import('./activity-spec.js').then(x => this.setState({ spec: { copy: x.copy, comps: x.comps, api: x.api } })).catch(() => {})
}
componentDidUpdate(p) {
if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme })
this.apply()
}
groups() {
const W = (name, trigger, o = {}, mo = {}) => ({ name, trigger, s: o, m: { ...o, ...mo } })
const U = { role: 'user' }
const f1 = { cat: 'Authentication', out: 'Failed', range: '30 days' }
return [
{ label: 'Activity, owner and admin', title: 'Activity, owner and admin', route: '/activity', api: 'GET /v1/activity · GET /v1/activity/summary', states: [
W('Default, large dataset', 'Newest first, day separators, a security row, 1,284 events across 52 pages. Hover on the first row shows the exact time.', { tip: 1 }),
W('Loading', 'Skeleton strip and table while both requests are in flight.', { state: 'loading' }),
W('Zero-value summary strip', 'Quiet period: Failed and Security show a calm 0.', { sum: 'zero' }),
W('Filtered, card applied', 'Failed actions card clicked, chips and Clear all. Mobile shows the count on Filters.', { f: f1, card: 2, count: 6, total: '6', ids: [3], sum: 'default' }, {}),
W('Mobile filters sheet', 'Filters open as a bottom sheet (desktop uses the inline bar above).', { f: { ...f1, range: 'Custom' }, drawer: 1, count: 6, total: '6', ids: [3] }),
W('Empty, no activity yet', 'New site. Export is disabled.', { state: 'none', sum: 'none' }),
W('Empty, filters match nothing', 'Offers Clear filters.', { state: 'nomatch', f: { q: 'sitemap', cat: 'Media', out: 'Failed' }, total: '0' }),
W('Error with retry', 'Request failed. The summary strip still shows if its call succeeded.', { state: 'error' }),
W('Forbidden (403)', 'API returned 403 for this role or filter.', { state: 'forbidden', role: 'user' })
] },
{ label: 'Details slide-over', title: 'Details slide-over and bottom sheet', route: 'Row click', api: 'Uses the item from GET /v1/activity', states: [
W('Edit with a diff', 'Title change and "Body edited (+312 characters)". No body text.', { panel: 2 }),
W('Settings diff, long text', 'Long values clamp to two lines with Show more.', { panel: 11 }),
W('Failed action, very long target title', 'Failure reason shown, target title wraps.', { panel: 9 }),
W('Deleted target, no diff', 'Struck-through target with a Deleted badge.', { panel: 8 }),
W('System-initiated, no human actor', 'Scheduler with a System badge. IP and browser not applicable.', { panel: 5 }),
W('Security event, failed login', 'Actor not signed in. Restrained shield marker.', { panel: 3 })
] },
{ label: 'My activity', title: 'My activity, regular user', route: '/activity', api: 'GET /v1/activity (server scoped to the user)', states: [
W('Default', 'No summary strip, no actor filter, no Export. Title becomes My activity.', U),
W('Empty', 'The user has not done anything yet.', { ...U, state: 'none' }),
W('Details', 'Same slide-over, own event.', { ...U, panel: 1 })
] },
{ label: 'Home card', title: 'Home, Recent activity card', route: '/', api: 'GET /v1/activity?size=5', states: [
W('Owner or admin', 'Last 5 events with the actor name.', { kind: 'home' }),
W('Regular user', 'Own events only.', { kind: 'home', ...U }),
W('Loading', '', { kind: 'home', card: 'loading' }),
W('Error', '', { kind: 'home', card: 'error' }),
W('Empty', '', { kind: 'home', card: 'empty', ...U })
] },
{ label: 'Settings retention', title: 'Settings, Retention (owner only)', route: '/settings', api: 'GET /v1/settings · PATCH /v1/settings', states: [
W('Default, 90 days', 'Saves on change.', { kind: 'settings', saved: true }),
W('Shortening the period', 'Confirms before older events are removed.', { kind: 'settings', ret: '90', shorten: true }),
W('Forever', 'No cleanup scheduled.', { kind: 'settings', ret: '0' }),
W('Save error', '', { kind: 'settings', err: true })
] }
]
}
renderVals() {
const cur = this.state.mode
const spec = this.state.spec
const rows = a => a.map(r => ({ a: r[0], b: r[1] }))
return {
rootRef: this.rootRef,
modes: ['light', 'dark', 'system'].map(k => ({ label: k[0].toUpperCase() + k.slice(1), on: cur === k, bg: cur === k ? 'var(--ui-primary)' : 'var(--ui-bg)', fg: cur === k ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)', pick: () => this.setState({ mode: k }) })),
groups: this.groups(),
copy: rows(spec.copy), comps: rows(spec.comps), api: rows(spec.api)
}
}
}
</script>
</body>
</html>
@@ -13,12 +13,12 @@
<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 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: back-office and global</h1>
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">Every state of the verified-only routes, plus the global states, at 1440 px (shown at 50%) and 390 px (shown at 75%). The theme switch shows all of them in dark mode. Session expiry and unverified redirects end on screens from <a href="RenewCMS Screens Auth.dc.html">the auth board</a>.</p>
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">Every state of the verified-only routes, plus the global states, at 1440 px (shown at 50%) and 390 px (shown at 75%). Home, Settings and Media show every card in one page, so each card matches its neighbours. Activity, Media cleanup and the activity cards live here too. The theme switch shows all of them in dark mode. Session expiry and unverified redirects end on screens from <a href="RenewCMS Screens Auth.dc.html">the auth board</a>.</p>
</div>
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
@@ -39,8 +39,8 @@
<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 style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isAct }}"><dc-import name="ActivityFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if></div></div>
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isAct }}"><dc-import name="ActivityFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if></div></div>
</div>
</div>
</sc-for>
@@ -49,14 +49,17 @@
</section>
</sc-for>
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Activity: copy per state</h2><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ spCopy }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for></div></section>
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Activity: component inventory (Nuxt UI v4)</h2><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ spComps }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for></div></section>
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Activity: API calls</h2><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ spApi }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for></div></section>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;system&quot;,&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;light&quot;,&quot;tsType&quot;:&quot;'system' | 'light' | 'dark'&quot;}}">
<script type="text/x-dc" data-dc-script data-props="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;system&quot;,&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;system&quot;,&quot;tsType&quot;:&quot;'system' | 'light' | 'dark'&quot;}}">
class Component extends DCLogic {
state = { mode: this.props.theme ?? 'light' }
state = { mode: this.props.theme ?? 'light', spec: { copy: [], comps: [], api: [] } }
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' }
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
@@ -68,7 +71,10 @@ class Component extends DCLogic {
for (const k in vars) el.style.setProperty(k, vars[k])
document.body.style.background = vars['--rc-canvas']
}
componentDidMount() { this.apply() }
componentDidMount() {
this.apply()
import('./activity-spec.js').then(x => this.setState({ spec: { copy: x.copy, comps: x.comps, api: x.api } })).catch(() => {})
}
componentDidUpdate(p) {
if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme })
this.apply()
@@ -122,7 +128,7 @@ class Component extends DCLogic {
const B = (t, kind, icon, dis) => ({ t, icon, bg: kind === 'primary' ? 'var(--ui-primary)' : 'var(--ui-bg)', fg: kind === 'primary' ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)', bd: kind === 'primary' ? 'var(--ui-primary)' : 'var(--ui-border-accented)', op: dis ? '0.4' : '1' })
const UPD = (o) => { const rdy = o.title === 'Version 2.3.0 is ready to install' && o.btns && o.btns.length; if (rdy) o = { ...o, tone: 'warning', icon: 'clock', pendingTag: true, inBtn: o.btns[0], btns: undefined }; const tone = o.tone || 'neutral'; return { v: 'v2.2.0-beta', focus: 'none', ...o, fg: tone === 'neutral' ? 'var(--ui-text-toned)' : 'var(--ui-' + tone + ')', bg: TB[tone], bar: o.pct !== undefined || !!o.indet, barW: o.indet ? '35%' : (o.pct || 0) + '%', barL: o.indet ? '30%' : '0', pct: o.indet ? undefined : o.pct } }
const NOTES = (open) => ({ open, chev: open ? 'chevron-down' : 'chevron-right', ver: '2.3.0', date: 'Released 24 Sep 2026', items: [{ t: 'Images upload faster, and large images no longer stall.' }, { t: 'Fixed a problem where a draft could show an old title after saving.' }, { t: 'Small improvements to the Users page.' }] })
const SU = (o, extra) => withM({ kind: 'settings', nav: 'settings', crumbs: ['Settings'], mTitle: 'Settings', ...extra, upd: UPD(o) })
const SU = (o, extra, mo) => withM({ kind: 'settings', nav: 'settings', crumbs: ['Settings'], mTitle: 'Settings', ...extra, upd: UPD(o) }, mo)
const LAST = 'Last checked today at 09:41'
const RESTART_FOOT = 'Restart takes about 10 seconds. Your site is unavailable while it restarts.'
const bn = (icon, title, text) => ({ icon, title, text, btn: 'Go to Settings', fg: 'var(--ui-info)', bg: 'var(--rc-info-soft)' })
@@ -130,17 +136,36 @@ class Component extends DCLogic {
const notPrep = (copied) => ({ icon: 'info', tone: 'info', title: 'Version 2.3.0 is available', text: 'This site can\'t update itself from the browser yet. Whoever manages your server can set it up once, or update RenewCMS for you.', meta: LAST, cmd: { line: cmdLine, t: copied ? 'Copied' : 'Copy', icon: copied ? 'check' : 'copy', fg: copied ? 'var(--ui-success)' : 'var(--ui-text-highlighted)', note: 'They run this once on the server. After that, Update and Restart appear here.' }, link: 'How to update' })
const restartModal = (o) => ({ title: 'Restart the site?', desc: 'Your site will be unavailable for about 10 seconds. This page reloads on its own when the site is back. Save anything you\'re editing first.', cancel: 'Cancel', confirm: 'Restart site', ...o })
const installModal = (o) => ({ title: 'Restart to finish the update?', desc: 'RenewCMS will install version 2.3.0 and restart. Your site will be unavailable for about 10 seconds. This page reloads on its own when the site is back. Save anything you\'re editing first.', cancel: 'Restart later', confirm: 'Restart now', ...o })
const Act = (name, trigger, o = {}, mo = {}) => ({ isAct: true, name, trigger, ...o, m: { isAct: true, name, trigger, ...o, ...mo } })
const UR = { role: 'user' }
const af1 = { cat: 'Authentication', out: 'Failed', range: '30 days' }
const UTD = { icon: 'circle-check', tone: 'success', title: 'You\'re up to date', text: 'You have the latest version of RenewCMS.', meta: LAST, btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }
const SCR = { c: 300, cm: 480, r: 560, rm: 940 }
const cLast = { d: '14 Jun 2026', c: 3, a: 5 }, cOn = { on: true, days: '30', last: cLast, next: '14 Jul 2026' }
const SC = (kind, name, trigger, cu, extra = {}) => { const sy = kind === 'c' ? SCR.c : SCR.r, sm = kind === 'c' ? SCR.cm : SCR.rm; return withM({ kind: 'settings', nav: 'settings', crumbs: ['Settings'], mTitle: 'Settings', upd: UPD(UTD), scrollY: sy, name, trigger, ...cu, ...extra }, { scrollY: sm }) }
const CU = (name, trigger, cleanup, extra) => SC('c', name, trigger, { cleanup }, extra)
const RT = (name, trigger, ret, extra) => SC('r', name, trigger, { ret }, extra)
const PD = (k, name, trigger, dg = {}) => SC('c', name, trigger, { cleanup: { ...cOn, last: null, on: false }, purge: { k, ...dg } })
const pvMore = { groups: 8, copies: 15, arts: 24, list: [{ im: 12, n: 'floorboards.jpg', u: 4, c: 2 }, { im: 7, n: 'IMG_4471.jpg', u: 2, c: 1 }, { im: 2, n: 'kitchen-after.webp', u: 3, c: 3 }, { im: 4, n: 'logo-renew.svg', u: 5, c: 3 }, { im: 11, n: 'lime-plaster.jpg', u: 2, c: 1 }], more: 3 }
const pvOne = { groups: 1, copies: 1, arts: 2, list: [{ im: 7, n: 'IMG_4471.jpg', u: 2, c: 1 }], more: 0 }
const pv3 = { groups: 3, copies: 7, arts: 10, list: [{ im: 12, n: 'floorboards.jpg', u: 4, c: 2 }, { im: 2, n: 'kitchen-after.webp', u: 3, c: 3 }, { im: 4, n: 'logo-renew.svg', u: 5, c: 3 }], more: 0 }
const purged = TT('success', 'circle-check', 'Duplicates purged', '9 copies merged. 12 articles updated.')
const doneLast = { d: '5 Oct 2026', c: 9, a: 12 }
return this.finish([
{ label: 'Boot', title: 'App boot', route: 'any route, first load', api: 'GET /v1/auth/me (pending)', note: 'While the session is checked, the shell renders with skeletons, never a spinner. The possible outcomes are: 200 with a verified user, which renders the route; 200 with an unverified user, which goes to /register/pending; and 401, which goes to /login?redirect=<path>. Public routes skip this check.', states: [
withM({ ...home, name: 'Checking session', trigger: 'The first paint on any back-office URL, and usually visible for well under a second.', boot: true })
] },
{ label: 'Home', title: 'Home', route: '/home', api: 'GET /v1/article/', note: 'The counts come from the list, so there\'s no separate endpoint. The five most recently updated articles link to the editor.', states: [
withM({ ...home, name: 'Default', trigger: 'The articles loaded.', home: { sub: 'Here\'s where things stand on your site.', stats, recent } }),
withM({ ...home, name: 'Loading', trigger: 'The list request is in flight. Skeletons appear after 150 ms.', home: { sub: 'Here\'s where things stand on your site.', stats: stats.map(() => ({ skel: true })), recent: Array.from({ length: 5 }, () => ({ skel: true })) } }),
{ label: 'Home', title: 'Home', route: '/home', api: 'GET /v1/article/ · GET /v1/activity?size=5', note: 'The counts come from the list, so there\'s no separate endpoint. The five most recently updated articles link to the editor. Below them, a Recent activity section shows the last 5 events (owner and admin) or the user\'s own (regular users), and links to Activity.', states: [
withM({ ...home, name: 'Default', trigger: 'The articles loaded.', home: { sub: 'Here\'s where things stand on your site.', stats, recent, act: 'ok' } }),
withM({ ...home, name: 'Loading', trigger: 'The list request is in flight. Skeletons appear after 150 ms.', home: { sub: 'Here\'s where things stand on your site.', stats: stats.map(() => ({ skel: true })), recent: Array.from({ length: 5 }, () => ({ skel: true })), act: 'loading' } }),
withM({ ...home, name: 'Empty, first run', trigger: 'Zero articles: a single call to action, and no stats.', home: { sub: 'Welcome to RenewCMS.', empty: true } }),
withM({ ...home, name: 'Error', trigger: 'A 500 or network failure on the list request.', home: { sub: 'Here\'s where things stand on your site.', error: true } }),
withM({ ...home, navBadge: true, name: 'Update available (owner)', trigger: 'The owner only. A quiet banner above the content, and an "Update" pill on the Settings item (a dot on the menu icon on mobile, read as "Update available"). Dismissing hides the banner until the next version; the pill stays. Admins see neither.', home: { sub: 'Here\'s where things stand on your site.', stats, recent, banner: bn('circle-arrow-up', 'Version 2.3.0 is available', 'Update it from Settings. It takes about 2 minutes and the site stays online.') } }),
withM({ ...home, navBadge: true, name: 'Update ready to install (owner)', trigger: 'The download is checked. The same banner changes its copy, and a dismiss comes back on the next visit because the update is waiting.', home: { sub: 'Here\'s where things stand on your site.', stats, recent, banner: bn('circle-check', 'Version 2.3.0 is ready to install', 'Restart from Settings to finish. The site is unavailable for about 10 seconds.') } })
withM({ ...home, navBadge: true, name: 'Update available (owner)', trigger: 'The owner only. A quiet banner above the content, and an "Update" pill on the Settings item (a dot on the menu icon on mobile, read as "Update available"). Dismissing hides the banner until the next version; the pill stays. Admins see neither.', home: { sub: 'Here\'s where things stand on your site.', stats, recent, act: 'ok', banner: bn('circle-arrow-up', 'Version 2.3.0 is available', 'Update it from Settings. It takes about 2 minutes and the site stays online.') } }),
withM({ ...home, navBadge: true, name: 'Update ready to install (owner)', trigger: 'The download is checked. The same banner changes its copy, and a dismiss comes back on the next visit because the update is waiting.', home: { sub: 'Here\'s where things stand on your site.', stats, recent, act: 'ok', banner: bn('circle-check', 'Version 2.3.0 is ready to install', 'Restart from Settings to finish. The site is unavailable for about 10 seconds.') } }),
withM({ ...home, name: 'Recent activity: loading', trigger: 'The activity request is in flight. Five skeleton rows.', home: { sub: 'Here\'s where things stand on your site.', stats, recent, act: 'loading' } }),
withM({ ...home, name: 'Recent activity: error', trigger: 'A failed request shows an inline alert in the section only. The rest of Home is unaffected.', home: { sub: 'Here\'s where things stand on your site.', stats, recent, act: 'error' } }),
withM({ ...home, name: 'Recent activity: empty', trigger: 'No events yet.', home: { sub: 'Here\'s where things stand on your site.', stats, recent, act: 'empty' } }),
withM({ ...home, role: 'user', name: 'Regular user', trigger: 'Own events only, titled "Your recent activity". The sidebar has no Users, Integration or Settings.', home: { sub: 'Here\'s where things stand on your site.', stats, recent, act: 'ok' } })
] },
{ label: 'Articles', title: 'Articles', route: '/article', api: 'GET /v1/article/ · PATCH …/publish · PATCH …/unpublish · DELETE /v1/article/{id}', note: 'Rows update when the server responds, not optimistically. Search, tab and sort are stored in the query string.', states: [
withM({ ...L(), name: 'Default', trigger: 'The list loaded, sorted by last updated.' }),
@@ -277,6 +302,30 @@ class Component extends DCLogic {
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "6. Errors", trigger: "Status table and the plain-text error response (status line plus a one-sentence body). Key-specific causes are in the 401, 403 and 429 rows.", int: {"scroll":"errors"} }, { int: {"scroll":"errors"} }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "7. Navigation", trigger: "Desktop: the sticky index with the active item (Media). Mobile: the \"Jump to\" select open. Keyboard: the index buttons and the select are in the tab order, Enter jumps, Escape closes the list.", int: {"scroll":"media"} }, { int: {"scroll":"media","jump":true} })
] },
{ label: 'Activity', title: 'Activity, owner and admin', route: '/activity', api: 'GET /v1/activity · GET /v1/activity/summary', note: 'New route in the sidebar after Users. Owner and admin see everything; regular users get "My activity". Copy, component mapping and API calls are at the bottom of this board.', states: [
Act('Default, large dataset', 'Newest first, day separators, a security row, 1,284 events across 52 pages. Hover on the first row shows the exact time.', { tip: 1 }),
Act('Loading', 'Skeleton strip and table while both requests are in flight.', { state: 'loading' }),
Act('Zero-value summary strip', 'Quiet period: Failed and Security show a calm 0.', { sum: 'zero' }),
Act('Filtered, card applied', 'Failed actions card clicked, chips and Clear all. Mobile shows the count on Filters.', { f: af1, card: 2, count: 6, total: '6', ids: [3], sum: 'default' }),
Act('Mobile filters sheet', 'Filters open as a bottom sheet (desktop uses the inline bar above).', { f: { ...af1, range: 'Custom' }, drawer: 1, count: 6, total: '6', ids: [3] }),
Act('Empty, no activity yet', 'New site. Export is disabled.', { state: 'none', sum: 'none' }),
Act('Empty, filters match nothing', 'Offers Clear filters.', { state: 'nomatch', f: { q: 'sitemap', cat: 'Media', out: 'Failed' }, total: '0' }),
Act('Error with retry', 'Request failed. The summary strip still shows if its call succeeded.', { state: 'error' }),
Act('Forbidden (403)', 'API returned 403 for this role or filter.', { state: 'forbidden', role: 'user' })
] },
{ label: 'Activity details', title: 'Activity: details slide-over and bottom sheet', route: 'Row click', api: 'Uses the item from GET /v1/activity', states: [
Act('Edit with a diff', 'Title change and "Body edited (+312 characters)". No body text.', { panel: 2 }),
Act('Settings diff, long text', 'Long values clamp to two lines with Show more.', { panel: 11 }),
Act('Failed action, very long target title', 'Failure reason shown, target title wraps.', { panel: 9 }),
Act('Deleted target, no diff', 'Struck-through target with a Deleted badge.', { panel: 8 }),
Act('System-initiated, no human actor', 'Scheduler with a System badge. IP and browser not applicable.', { panel: 5 }),
Act('Security event, failed login', 'Actor not signed in. Restrained shield marker.', { panel: 3 })
] },
{ label: 'My activity', title: 'My activity, regular user', route: '/activity', api: 'GET /v1/activity (server scoped to the user)', states: [
Act('Default', 'No summary strip, no actor filter, no Export. Title becomes My activity.', UR),
Act('Empty', 'The user has not done anything yet.', { ...UR, state: 'none' }),
Act('Details', 'Same slide-over, own event.', { ...UR, panel: 1 })
] },
{ label: 'Users', title: 'Users', route: '/users', api: 'GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id (pending) · POST /v1/user/invite/:id/renew (pending)', note: '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.', states: [
withM({ ...U(), name: 'Default', trigger: 'The list loaded, newest first.' }),
withM({ ...U({ users: { loading: true } }), name: 'Loading', trigger: 'The first load, or a retry. Skeletons appear after 150 ms.' }),
@@ -296,7 +345,7 @@ class Component extends DCLogic {
withM({ ...U({ toasts: [T('error', 'circle-x', 'Couldn\'t renew the invite', 'Try again in a moment.')] }), name: 'Renew invite: failed', trigger: '500 or network failure. The row stays Expired and the button can be pressed again.' }),
withM({ ...U({ users: { rows: [{ email: 'lea.martin@renew.example', role: 'admin', st: 'pending', c: 'Today', hl: true }, ...people] } , toasts: [T('success', 'circle-check', 'Invite sent', 'lea.martin@renew.example has 3 days to accept.')] }), name: 'Invite sent', trigger: '201: the dialog closes, the new invite appears at the top as Pending, and a toast confirms.' })
] },
{ label: 'Settings', title: 'Settings: Updates', route: '/settings', api: 'GET /v1/system/update · POST …/check · POST …/download · POST …/update/restart (all pending)', note: 'New Updates card under Version, owner only. Update downloads and checks the new version and changes nothing. Restart installs it and restarts the site (about 10 seconds). "Restart later" is simply leaving the card or the dialog. A plain Restart shows only when no update is waiting. Admins see the version and a note. When the server isn\'t prepared, the card shows the notice and a "How to update" link with no buttons. Progress is a progressbar with a value, announced at 25% steps and when each step changes. The card is a polite live region. After the restart the page reloads, focus lands on the "Updates" heading, and a toast confirms. Buttons are disabled while any step runs, and any owner on any device sees the same state from the server. Cue elsewhere: see Home. Out of scope: choosing versions, downgrades, automatic updates, update history.', states: [
{ label: 'Settings', title: 'Settings: Updates', route: '/settings', api: 'GET /v1/system/update · POST …/check · POST …/download · POST …/update/restart (all pending)', note: 'The page is one column of cards, top to bottom: Version, Updates, Media cleanup, Retention (owner only). The screens below are scrolled to the card they show. New Updates card under Version, owner only. Update downloads and checks the new version and changes nothing. Restart installs it and restarts the site (about 10 seconds). "Restart later" is simply leaving the card or the dialog. A plain Restart shows only when no update is waiting. Admins see the version and a note. When the server isn\'t prepared, the card shows the notice and a "How to update" link with no buttons. Progress is a progressbar with a value, announced at 25% steps and when each step changes. The card is a polite live region. After the restart the page reloads, focus lands on the "Updates" heading, and a toast confirms. Buttons are disabled while any step runs, and any owner on any device sees the same state from the server. Cue elsewhere: see Home. Out of scope: choosing versions, downgrades, automatic updates, update history.', states: [
SU({ icon: 'circle-check', tone: 'success', title: 'You\'re up to date', text: 'You have the latest version of RenewCMS.', meta: LAST, btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { name: 'Up to date', trigger: 'Opened with no newer version. Restart is available but not emphasised.' }),
SU({ icon: 'loader-circle', title: 'Checking for updates…', text: 'This usually takes a few seconds.', btns: [B('Checking…', 'sec', 'loader-circle', true)] }, { name: 'Checking', trigger: 'Check for updates pressed, or the first open after 24 hours. Announced as "Checking for updates". Buttons are disabled.' }),
SU({ icon: 'wifi-off', tone: 'warning', title: 'Couldn\'t check for updates', text: 'RenewCMS couldn\'t reach the update server. Check your connection and try again. Your site isn\'t affected.', meta: 'Last checked yesterday at 18:12', btns: [B('Try again', 'primary', 'rotate-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { name: 'Check failed', trigger: 'Offline, or the update server didn\'t answer (500, network). The last known result is kept.' }),
@@ -319,8 +368,8 @@ class Component extends DCLogic {
SU({ icon: 'circle-check', tone: 'success', title: 'You\'re up to date', text: 'You have the latest version of RenewCMS.', meta: LAST, btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { name: 'Plain restart: confirm', trigger: 'Restart with no update waiting. Nothing is installed.', modal: restartModal() }),
SU({ icon: 'circle-check', tone: 'success', title: '', text: '', btns: [] }, { name: 'Plain restart: restarting', trigger: 'Same overlay as the update restart with shorter copy.', overlay: { title: 'Restarting your site', text: 'This takes about 10 seconds. This page reloads on its own when the site is back.', meta: 'Started at 09:51', barL: '30%' } }),
SU({ icon: 'circle-check', tone: 'success', title: 'Your site is back', text: '', btns: [] }, { name: 'Plain restart: done', trigger: 'The page reloaded. Focus lands on the "Updates" heading.', toasts: [T('success', 'circle-check', 'Site restarted', 'Your site is running again.')] }),
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.', meta: LAST }, { name: 'Admin view', trigger: 'Admins see the version and the note, with no buttons. Up to date: "You\'re up to date" and "Only the owner can update or restart the site." No Home banner or Settings pill.' }),
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.', meta: LAST }, { name: 'Role changed (403)', trigger: 'An owner was changed to admin while the page was open and pressed a button. The card reloads in the admin view.', toasts: [T('error', 'circle-x', 'Only the owner can do this', 'Ask the owner of this site to update RenewCMS.')] }),
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.', meta: LAST }, { noRet: true, name: 'Admin view', trigger: 'Admins see the version and the note, with no buttons. Up to date: "You\'re up to date" and "Only the owner can update or restart the site." No Home banner or Settings pill.' }),
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.', meta: LAST }, { noRet: true, name: 'Role changed (403)', trigger: 'An owner was changed to admin while the page was open and pressed a button. The card reloads in the admin view.', toasts: [T('error', 'circle-x', 'Only the owner can do this', 'Ask the owner of this site to update RenewCMS.')] }),
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart.', meta: LAST, btns: [B('Update', 'primary', 'download', true)] }, { name: 'Update already running (409)', trigger: 'Another owner started it. The card switches to that run\'s progress.', navBadge: true, toasts: [T('info', 'info', 'An update is already running', 'Another owner started it. Progress shows here.')] })
], tableTitle: 'Status to copy', table: [
{ a: '200', b: 'Check, up to date', c: '"You\'re up to date" / "You have the latest version of RenewCMS." / "Last checked today at 09:41"' },
@@ -341,8 +390,52 @@ class Component extends DCLogic {
{ a: 'No answer, 60 s', b: 'Restart overlay', c: '"Still restarting" / "This is taking longer than usual. Keep this page open. It reloads on its own when the site is back, or you can reload it now."' },
{ a: 'Offline', b: 'Whole page', c: 'Existing banner. Check, Update and Restart are disabled. During a restart, the overlay keeps polling and ignores the offline state.' }
] },
{ label: 'Settings: Media cleanup', title: 'Settings: Media cleanup', route: '/settings', api: 'GET /v1/media/purge-settings · PATCH /v1/media/purge-settings (owner, admin)', note: 'A card below Updates, same card styling and 20 px gap. Automatic purge is off by default. Settings save immediately on toggle and on blur or Enter of the days field; there is no Save button. 401 and 403 are global (login redirect, /register/pending) and have no local copy.', states: [
CU('Default: off, never run', 'Owner or admin. Automatic purge is off, so the "Every" row and "Next purge" are hidden.', {}),
CU('On: 30 days, last run', 'Switch on. "Every" appears with the default of 30 days. The last-run line and the next run are shown.', cOn),
CU('Saving', 'Brief state after toggling or leaving the field. "Saving…" with loader-circle sits in the card header; controls stay usable.', { ...cOn, saving: true, focusDays: true }),
CU('Saved', 'The save succeeded. A small "Saved" confirmation shows in the card header and fades after 2 s. It is announced politely.', { ...cOn, saved: true }),
CU('Save failed', 'A 500 or network failure. The error sits under the field and the control reverts to its previous value (30).', { ...cOn, saveErr: true, days: '30', focusDays: true }),
CU('Validation error', 'Empty, 0, above 365 or not a whole number, checked on blur or Enter. Nothing is sent. The field keeps what was typed until corrected.', { ...cOn, days: '400', invalid: true, focusDays: true }),
CU('Read-only (non-admin)', 'Other verified users. Switch and input are disabled, "Purge now" is hidden, and an info alert explains why.', { ...cOn, ro: true }, { noRet: true }),
CU('Loading skeleton', 'The settings request is in flight. Skeleton shows only after 150 ms.', { loading: true }),
CU('Offline', 'navigator.onLine is false. The banner matches Media. Switch, input and button are disabled.', { ...cOn, offline: true }, { offline: true, offText: 'You\'re offline. Changing automatic purge and purging duplicates need a connection.' })
] },
{ label: 'Purge dialog', title: 'Purge duplicates: dialog', route: 'opens from "Purge now" (Settings) and "Purge duplicates" (Media, Duplicates tab)', api: 'GET /v1/media/purge-preview · POST /v1/media/purge', note: 'UModal, sm:max-w-md, bottom sheet on mobile. Cancel first, then the danger confirm. While the request runs, the confirm shows loader-circle and the dialog can\'t be dismissed. The preview is computed before confirming. A group is purged only if one of its copies is used in an article; the kept copy is the one used in the most articles (ties: oldest, then lowest id).', states: [
PD('loading', 'Loading preview', 'The preview request is in flight. Skeleton rows show after 150 ms and the confirm is disabled.'),
PD('confirm', 'Confirm', '"Purge now" pressed. Summary, up to 5 groups, a link to the Duplicates tab.'),
PD('confirm', 'Confirm: many groups', 'More than 5 groups. The list stops at 5 and says how many more.', { pv: pvMore }),
PD('progress', 'In progress', 'POST in flight. Confirm reads "Purging…" with loader-circle; Cancel is disabled and Esc, the overlay and swipe do nothing.'),
PD('nothing', 'Nothing to purge', 'The preview has no group with an image in use. No confirm; one Close button.'),
PD('error', 'Error', '500 or network failure. The dialog stays open, nothing changed. Confirm retries.'),
PD('partial', 'Partial failure', 'Some groups failed. The summary refreshes to what is left. Buttons: Close, Retry.', { pv: pvOne }),
PD('conflict', 'Conflict (409)', 'The library changed since the preview. Nothing was purged, the summary refreshes and the dialog stays open.', { pv: pv3 })
] },
{ label: 'Purge results', title: 'Purge: results and feedback', route: '/settings', api: 'after POST /v1/media/purge', note: 'An unattended run does not toast. The next visit to Settings shows the updated "Last purge" line.', states: [
CU('Success', 'The dialog closes, a toast shows for 5 s, and the "Last purge" line updates in place.', { on: false, last: doneLast }, { toasts: [T('success', 'circle-check', 'Duplicates purged', '9 copies merged. 12 articles updated.')] }),
CU('Success, automatic purge on', 'Same, with the next run moved forward by the interval.', { on: true, days: '30', last: doneLast, next: '4 Nov 2026' }, { toasts: [T('success', 'circle-check', 'Duplicates purged', '9 copies merged. 12 articles updated.')] })
], tableTitle: 'Status to copy', table: [
{ a: '200', b: 'Purge done', c: 'Toast "Duplicates purged" / "9 copies merged. 12 articles updated."' },
{ a: '409', b: 'Library changed', c: '"The library changed, so nothing was purged. Check the new summary and try again."' },
{ a: '207', b: 'Partial', c: '"Some groups couldn\'t be merged. 3 of 4 groups were purged. Try again to finish."' },
{ a: '500', b: 'Purge failed', c: '"Couldn\'t purge the duplicates. Nothing was changed. Try again in a moment."' },
{ a: '500', b: 'Setting not saved', c: '"Couldn\'t save. Try again in a moment."' },
{ a: '400', b: 'Days invalid', c: '"Enter a whole number of days between 1 and 365."' }
] },
{ label: 'Media duplicates entry', title: 'Media: Duplicates tab entry point', route: '/media?filter=duplicates', api: 'owner and admin only', note: 'A secondary "Purge duplicates" button (icon merge) in the toolbar next to the sort menu, and in the stacked toolbar on mobile. The dialog and the member state are listed with the Media states above. Per-group "Merge into this one" is unchanged.', states: [
Mp({ tab: 'dups', name: 'Duplicates tab with the button', trigger: 'Owner or admin.' }),
Mp({ tab: 'dups', offline: true, name: 'Duplicates tab: offline', trigger: 'The button is disabled with the existing banner.' })
] },
{ label: 'Settings: Retention', title: 'Settings: Retention (owner only)', route: '/settings', api: 'GET /v1/settings · PATCH /v1/settings', note: 'The last card on the page, same card styling as Version, Updates and Media cleanup. Saves on change.', states: [
RT('Default, 90 days or 5 GB', 'Saves on change. Whichever limit is reached first removes the oldest events.', { days: '90', size: '5', saved: true }),
RT('Age only (no size limit)', 'The size select reads "No size limit". Same as before this option.', { days: '90' }),
RT('Size only (forever)', 'Age is set to Forever, so only the size limit removes events.', { days: '0', size: '5' }),
RT('Shortening the period', 'Confirms before older events are removed.', { days: '90', size: '5', shorten: true }),
RT('Forever', 'No cleanup scheduled.', { days: '0' }),
RT('Save error', 'The previous setting is still in place.', { days: '90', err: true })
] },
{ label: 'Profile', title: 'Profile', route: '/profile', api: 'GET /v1/auth/me · PATCH /v1/user/me (pending) · POST /v1/auth/password-reset', note: 'Opened from the account menu at the bottom of the sidebar. Username, email and password are read-only. The display name is optional, trimmed, and up to 60 characters. Once saved it replaces the username everywhere a person is named: the sidebar, the account menu, the Home greeting, the Users list (with the username beside it) and the emails RenewCMS sends, such as "Marta Alves invited you to RenewCMS". Saving it empty goes back to the username. The reset link uses the same flow as "Forgot password" and ends on the auth board\'s "Choose a new password" screen.', states: [
withM({ ...home, home: { sub: 'Here\'s where things stand on your site.', stats, recent }, userMenu: true, name: 'Account menu', trigger: 'The account chip at the bottom of the sidebar. On mobile, the account row in the navigation drawer opens it as a sheet. Escape or a click outside closes it.' }),
withM({ ...home, home: { sub: 'Here\'s where things stand on your site.', stats, recent, act: 'ok' }, userMenu: true, name: 'Account menu', trigger: 'The account chip at the bottom of the sidebar. On mobile, the account row in the navigation drawer opens it as a sheet. Escape or a click outside closes it.' }),
P({ loading: true }, { name: 'Loading', trigger: 'Opened directly by URL before /auth/me has returned. From the menu, the cached user renders straight away.' }),
P({}, { name: 'Default, no display name', trigger: 'The usual first visit. The username is used everywhere.' }),
P({ value: dname, focus: true, dirty: true }, { name: 'Editing', trigger: 'Typing in the field. Save turns on, and leaving the page asks for confirmation.' }),
@@ -364,13 +457,14 @@ class Component extends DCLogic {
] }
])
}
finish(gs) { return gs.map(g => ({ ...g, states: g.states.map(st => ({ notMedia: !st.isMedia, ...st, m: { ...st.m } })) })) }
finish(gs) { return gs.map(g => ({ ...g, states: g.states.map(st => ({ notMedia: !st.isMedia && !st.isAct, ...st, m: { ...st.m } })) })) }
renderVals() {
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() }
const rows = a => a.map(r => ({ a: r[0], b: r[1] }))
return { rootRef: this.rootRef, modes, groups: this.groups(), spCopy: rows(this.state.spec.copy), spComps: rows(this.state.spec.comps), spApi: rows(this.state.spec.api) }
}
}
</script>
@@ -0,0 +1,347 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<meta name="design_doc_mode" content="canvas">
<link rel="icon" href="brand/favicon.svg">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&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="Image variants" style="background:var(--rc-canvas);color:var(--ui-text);padding:64px;display:flex;flex-direction:column;gap:72px;width:max-content;min-width:100%">
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Feature: Image variants</h1>
<p style="margin:0;max-width:900px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">Named variants of an original image, requested as <code>/media/photo.png?v=thumb</code>. The original is never changed. Six screens in the AppFrame back-office frame, plus the request contract, activity log copy, states matrix, API error copy and component inventory. 1440 px at 50%, 390 px at 75%. The theme switch shows every state in dark mode. Related: <a href="RenewCMS Screens Back-office.dc.html">Back-office screens</a>.</p>
</div>
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
<button role="radio" aria-checked="{{ m.on }}" onClick="{{ m.pick }}" style="all:unset;cursor:pointer;height:36px;padding:0 14px;display:flex;align-items:center;font-weight:500;font-size:14px;background:{{ m.bg }};color:{{ m.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">{{ m.label }}</button>
</sc-for>
</div>
</div>
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="6">
<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>
<p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">{{ g.note }}</p>
</div>
<div style="display:flex;flex-wrap:wrap;gap: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.id }} · {{ 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"><dc-import name="VariantsFrame" s="{{ st.s }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></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"><dc-import name="VariantsFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></div></div>
</div>
</div>
</sc-for>
</div>
<sc-for list="{{ g.tables }}" as="t" hint-placeholder-count="1">
<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)">{{ t.title }}</span><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><div style="display:grid;grid-template-columns:{{ t.tpl }};padding:8px 12px;gap:16px;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.tpl }};gap:16px;padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><sc-for list="{{ r }}" as="cell" hint-placeholder-count="3"><span style="color:{{ cell.fg }};font-weight:{{ cell.fw }};font-family:{{ cell.ff }};font-size:{{ cell.fs }};text-wrap:pretty;min-width:0">{{ cell.t }}</span></sc-for></div></sc-for></div></div>
</sc-for>
</section>
</sc-for>
<sc-for list="{{ specs }}" as="sp" hint-placeholder-count="5">
<section data-screen-label="{{ sp.label }}" style="display:flex;flex-direction:column;gap:16px;max-width:1240px">
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ sp.title }}</h2>
<p style="margin:0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">{{ sp.note }}</p>
<sc-for list="{{ sp.tables }}" as="t" hint-placeholder-count="1">
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><div style="display:grid;grid-template-columns:{{ t.tpl }};padding:8px 12px;gap:16px;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.tpl }};gap:16px;padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><sc-for list="{{ r }}" as="cell" hint-placeholder-count="3"><span style="color:{{ cell.fg }};font-weight:{{ cell.fw }};font-family:{{ cell.ff }};font-size:{{ cell.fs }};text-wrap:pretty;min-width:0">{{ cell.t }}</span></sc-for></div></sc-for></div>
</sc-for>
</section>
</sc-for>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;system&quot;,&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;system&quot;,&quot;tsType&quot;:&quot;'system' | 'light' | 'dark'&quot;}}">
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()
}
cell(t, i, o = {}) { return { t, fg: i === 0 && !o.plain ? 'var(--ui-text-highlighted)' : o.muted ? 'var(--ui-text-muted)' : 'var(--ui-text-toned)', fw: i === 0 && !o.plain ? 500 : 400, ff: o.mono ? "'IBM Plex Mono',monospace" : "'IBM Plex Sans',sans-serif", fs: o.mono ? '12px' : '13px' } }
tbl(title, cols, tpl, rows, o = {}) { return { title, cols, tpl, rows: rows.map(r => r.map((t, i) => this.cell(t, i, { mono: o.mono && o.mono.includes(i), muted: t === '–' }))) } }
groups() {
const st = (id, name, trigger, s, ms = {}) => ({ id, name, trigger, s, m: { ...s, ...ms } })
const LB = { nav: 'settings', crumbs: ['Settings', 'Media', 'Variants'], mTitle: 'Variants' }
const CB = { nav: 'settings', crumbs: ['Settings', 'Media', 'Cache'], mTitle: 'Cache' }
const MB = { nav: 'media', crumbs: ['Media'], mTitle: 'Media' }
const l = (list, o = {}) => ({ kind: 'list', ...LB, list, ...o })
const cr = { mode: 'create', fit: 'cover', pos: 50 }
const hero = { ...cr, name: 'hero', w: '1200', h: '630', fmt: 'WebP', q: 82, qv: 82, gen: true, en: true, out: '1200×630', est: '96 KB' }
const ed = (e, o = {}) => ({ kind: 'edit', ...LB, ed: e, ...o })
const ET = this.tbl.bind(this)
const C3 = ['State', 'Where', 'Copy']
const T3 = '210px 260px minmax(0,1fr)'
const tb = (title, rows) => ET(title, C3, T3, rows)
return [
{ label: 'Variants list', title: '1. Settings → Media → Variants', route: '/settings/media/variants', api: 'GET /v1/image-variants · PATCH /v1/image-variants/{name} · DELETE /v1/image-variants/{name} · POST /v1/image-variants/{name}/purge (proposed)', note: 'Admin-only page under Settings → Media, with two tabs: Variants and Cache. Desktop shows a table; mobile stacks each variant as a card. The status switch always pairs with an icon and the words Enabled or Disabled. The reference card "How requests are handled" sits under the table (section 7 of the brief).',
states: [
st('1a', 'Loaded', 'Six variants, one of them disabled (print).', l({})),
st('1b', 'Loading', 'Skeleton rows appear after 150 ms.', l({ state: 'loading' })),
st('1c', 'Empty', 'No variants. Two templates pre-fill the Add variant form.', l({ state: 'empty' })),
st('1d', 'Error loading', 'A 500 or network failure on the list request.', l({ state: 'error' })),
st('1e', 'Forbidden', 'A signed-in user who is not an admin opens the URL.', { kind: 'list', ...LB, nav: null, role: 'user', forbidden: true }),
st('1f', 'Row menu', 'The ellipsis button on a row.', l({ menu: 2 })),
st('1g', 'Row action in progress', 'Purge cache was confirmed on hero. The row is dimmed and its controls are disabled.', l({ busy: 2 })),
st('1h', 'Purge confirm', 'Purge cache in the row menu.', l({}, { modal: 'purgeVariant' })),
st('1i', 'Delete confirm', 'Delete in the row menu.', l({}, { modal: 'deleteVariant' })),
st('1j', 'Delete in progress', 'Delete was confirmed. The button shows a spinner.', l({}, { modal: 'deleteVariant', loadingModal: true })),
st('1l', 'Entry: sidebar', 'From any page, Settings expands in the sidebar to Media: Variants and Cache. Admin only. On mobile the menu button opens it as a drawer.', { kind: 'media', ...MB, sideOpen: true, ml: {}, drawer: true }),
st('1k', 'Request reference', 'Mobile scrolled to the reference card. On desktop it is visible below the table.', l({}), { scroll: 900 })
],
tables: [tb('Copy: Variants list', [
['Header', 'Title, description, button', 'Image variants · Named sizes and formats generated from your original images. Originals are never changed. · Add variant'],
['Table columns', 'Header row', 'Name · Dimensions · Fit · Format · Quality · Generated · Cache size · Status'],
['Dimensions', 'Cell', '400×300 · 800 × auto · auto × 2400'],
['Quality', 'Cell', '80 · — (PNG has no quality)'],
['Status', 'Switch + text', 'Enabled · Disabled (aria-label: "Enable variant hero")'],
['Row menu', 'Menu items', 'Edit · Purge cache · Delete'],
['Footer summary', 'Under the table', '6 variants · 1,284 generated files · 212 MB'],
['Footer summary (singular)', 'One variant', '1 variant · 1 generated file · 18 KB'],
['Loading', 'Skeleton', 'No text. Region announces "Loading variants".'],
['Empty', 'Title · text', 'No variants yet · A variant is a named size that an image can be requested in, like ?v=thumb. Start with one of these.'],
['Empty', 'Template cards', 'thumb · 400×300 · cover · WebP — hero · 1200×630 · cover · WebP — Use template'],
['Error', 'Alert', 'Couldn\'t load variants · Check your connection and try again. · Try again'],
['Forbidden', 'Title · text · button', 'You don\'t have access · Only admins can manage image variants. Ask an admin if you need one changed. · Back to Home'],
['Row in progress', 'Status cell', 'Purging…'],
['Purge success', 'Toast', 'Purged 244 files from “hero”'],
['Purge dialog', 'Title · text · buttons', 'Purge cache for “hero”? · This removes 244 generated files (84 MB). Files are generated again on the next request. · Cancel · Purge 244 files'],
['Delete dialog', 'Title · text · buttons', 'Delete variant “hero”? · This removes the variant and its 244 generated files (84 MB). Articles that use ?v=hero will start returning 400. This can\'t be undone. · Cancel · Delete variant'],
['Delete success', 'Toast', 'Variant “hero” deleted'],
['Request reference', 'Card', 'How requests are handled · Body of a 400 response · unsupported_parameter replaces unknown_variant when a raw parameter is sent. There is no arbitrary-size mode.']
])]
},
{ label: 'Add edit variant', title: '2. Add / edit variant', route: 'Slide-over on desktop · full page on mobile', api: 'POST /v1/image-variants · PUT /v1/image-variants/{name}', note: 'Form on the left, live preview on the right (stacked on mobile). The preview uses a sample image with a before/after divider. The divider is a slider: arrow keys move it by 1%, Shift+arrow by 10%, Home and End jump to the ends. Name is locked in edit mode. The edit warning is always visible in edit mode. Preview images are placeholders.',
states: [
st('2a', 'Create: pristine', 'Add variant just opened. Save is disabled.', ed({ ...cr, name: '', w: '', h: '', fmt: 'Original', q: 80, qv: 80, gen: false, en: true, disSave: true, focusName: true })),
st('2b', 'Create: dirty', 'Valid values. The divider has keyboard focus. Mobile is scrolled to the preview.', ed({ ...hero, dirty: true, focus: true }), { scroll: 560 }),
st('2c', 'Field errors', 'On blur and on submit. Name format, width above 8000, quality above 100.', ed({ ...cr, name: 'Hero banner', w: '9000', h: '630', fmt: 'WebP', q: 140, qv: 100, gen: false, en: true, dirty: true, err: { name: 'Use lowercase letters, numbers and dashes, 2 to 32 characters.', w: 'Enter a value from 1 to 8000.', q: 'Enter a value from 1 to 100.' } })),
st('2d', 'No dimension', 'Width and height are both empty on submit.', ed({ ...cr, name: 'banner', w: '', h: '', fmt: 'WebP', q: 80, qv: 80, gen: false, en: true, dirty: true, err: { dim: 'Set a width, a height, or both.' } })),
st('2e', 'Name already taken', 'POST returns 409.', ed({ ...hero, name: 'thumb', w: '400', h: '300', out: '400×300', est: '18 KB', dirty: true, err: { name: 'A variant named “thumb” already exists. Choose another name.' } })),
st('2f', 'Saving', 'Request in flight. Fields and buttons are disabled.', ed({ ...hero, dirty: true, saving: true })),
st('2g', 'Save error', 'A 500 or network failure. Values are kept.', ed({ ...hero, dirty: true, saveErr: true })),
st('2h', 'Edit: pristine', 'Opened from Edit. Name is locked. Save is disabled.', ed({ ...hero, mode: 'edit', files: 244, disSave: true })),
st('2i', 'Edit: dirty', 'Quality changed from 82 to 70.', ed({ ...hero, mode: 'edit', files: 244, q: 70, qv: 70, est: '71 KB', dirty: true })),
st('2j', 'Unsaved changes', 'Close, Cancel or Escape while dirty.', ed({ ...hero, mode: 'edit', files: 244, q: 70, qv: 70, est: '71 KB', dirty: true }, { modal: 'discard' })),
st('2k', 'PNG: no quality', 'Quality is hidden when the format is PNG.', ed({ ...hero, fmt: 'PNG', est: '310 KB', dirty: true }))
],
tables: [tb('Copy: Add / edit variant', [
['Titles', 'Slide-over header', 'Add variant · Edit variant: hero'],
['Name', 'Label · placeholder · help', 'Name · e.g. hero · Lowercase letters, numbers and dashes. Can\'t be changed later.'],
['Name (edit)', 'Help', 'The name can\'t be changed.'],
['Name error: format', 'Inline', 'Use lowercase letters, numbers and dashes, 2 to 32 characters.'],
['Name error: taken', 'Inline', 'A variant named “thumb” already exists. Choose another name.'],
['Width · Height', 'Label · suffix · placeholder · help', 'Width · Height · px · auto · Set a width, a height, or both. 1–8000 px. Images are never enlarged.'],
['Dimension error', 'Inline', 'Set a width, a height, or both.'],
['Dimension range error', 'Inline', 'Enter a value from 1 to 8000.'],
['Fit', 'Options · help', 'Cover: Fills the box and crops what overflows. · Contain: Whole image inside the box, padded to the box size. · Inside: Whole image inside the box, no padding.'],
['Format', 'Options · help', 'Original · JPEG · PNG · WebP · Keeps the format of each source image.'],
['Quality', 'Label · help · error', 'Quality · 1–100. Higher is sharper and larger. · Enter a value from 1 to 100.'],
['Toggles', 'Label · help', 'Generate on upload: Creates this variant when an image is uploaded. · Enabled: Disabled variants return 400 on the public site.'],
['Edit warning', 'Alert (edit only)', 'Changing dimensions, fit, format or quality purges 244 generated files; they will be regenerated on next request.'],
['Preview', 'Heading · labels · hint', 'Preview · Sample: north-wall.jpg · Original — 4032 × 3024 · 3.9 MB · hero — 1200×630 · about 96 KB · Drag the divider, or focus it and use the arrow keys. The estimate comes from a sample; output is never larger than the source.'],
['Preview (no size)', 'Over the image', 'Set a width or height to see a preview.'],
['Slider', 'aria-label · aria-valuetext', 'Compare original and hero · 50% hero'],
['URL', 'Label · button', 'URL · /media/north-wall.jpg?v=hero · Copy · Copied'],
['Footer', 'Status · buttons', 'Unsaved changes · Cancel · Add variant · Save changes · Saving…'],
['Save error', 'Alert', 'Couldn\'t save the variant · Check your connection and try again. · Try again'],
['Save success', 'Toast', 'Variant “hero” added · Variant “hero” saved. 244 files purged.'],
['Unsaved dialog', 'Title · text · buttons', 'Discard changes? · You have unsaved changes to “hero”. If you close now, they\'re lost. · Keep editing · Discard changes']
])]
},
{ label: 'Media library', title: '3. Media library', route: '/media', api: 'GET /v1/media?variants=with|without&sort=total_size (proposed)', note: 'The existing grid with three additions: a "N variants" badge on cards that have cached files, an All / With variants / Without filter, and the sort option "Largest total size (original + variants)". When sorted by total size, the meta line shows the total.',
states: [
st('3a', 'Grid with badges', 'Default view. Images with no cached variants have no badge.', { kind: 'media', ...MB, ml: { ann: 'New: badge' } }),
st('3b', 'Sort menu', 'The sort select is open. The new option is last.', { kind: 'media', ...MB, ml: { menu: true, sort: 'total', ann: 'New: sort option' } }),
st('3c', 'Filter: With variants, sorted by total size', 'The meta line becomes total · original + variants.', { kind: 'media', ...MB, ml: { filter: 'With variants', sort: 'total', ids: [1, 7, 9, 12, 5, 11, 2, 3, 10] } }),
st('3d', 'Filter: Without variants', 'Images no variant has been generated for.', { kind: 'media', ...MB, ml: { filter: 'Without', ids: [4, 6, 8] } }),
st('3e', 'Filter: nothing to show', 'With variants, and the cache is empty.', { kind: 'media', ...MB, ml: { filter: 'With variants', empty: true } }),
st('3f', 'Delete confirm', 'Delete on an image that has cached variants.', { kind: 'media', ...MB, ml: {}, modal: 'deleteImage' })
],
tables: [tb('Copy: Media library additions', [
['Badge', 'Card', '4 variants · 1 variant'],
['Filter', 'Segmented control', 'All · With variants · Without'],
['Sort', 'Select, new option', 'Largest total size (original + variants) · mobile label: Largest total size'],
['Meta line (sorted by total)', 'Card', '4.2 MB total · 3.9 MB + 260 KB'],
['Empty filter', 'Title · text · button', 'No images with variants · Variants are generated when an image is requested with ?v=<name>, or from its Variants tab. · Show all images'],
['Delete dialog', 'Title · text · note · buttons', 'Delete this image? · "north-wall.jpg" is used in 2 articles. If you delete it, they will show a broken image. This can\'t be undone. · Also removes 4 generated variants (260 KB) · Cancel · Delete image'],
['Bulk delete', 'Dialog line', 'Also removes 11 generated variants (1.1 MB)']
])]
},
{ label: 'Image detail', title: '4. Image detail: Variants tab', route: '/media (detail panel)', api: 'GET /v1/media/{id}/variants · POST /v1/media/{id}/variants/{name} · DELETE /v1/media/{id}/variants/{name} (proposed)', note: 'The detail panel gets a Variants tab with one row per enabled variant. On desktop the row actions are icon buttons with tooltips and accessible names; on mobile they are labeled buttons on their own line. Only actions that apply are shown: generated rows offer Copy URL, Preview and Purge; others offer Copy URL and Generate now. The tab shows output dimensions as they will be generated, capped at the source size.',
states: [
st('4a', 'Partly generated', 'Four of five generated. Saving is shown against the original.', { kind: 'detail', ...MB, dt: { rows: ['gen', 'gen', 'gen', 'gen', 'none'] } }),
st('4b', 'Nothing generated yet', 'No cached files for this image.', { kind: 'detail', ...MB, dt: {} }),
st('4c', 'Generating', 'Generate all or Generate now was pressed. Rows show a spinner.', { kind: 'detail', ...MB, dt: { rows: ['gen', 'busy', 'busy', 'none', 'none'] } }),
st('4d', 'Generation failed', 'The row shows the cause and Retry. Both causes are shown together for reference.', { kind: 'detail', ...MB, dt: { rows: ['gen', 'fail', 'fail', 'gen', 'gen'], cause: [0, 'Unsupported source format', 'Image too large'] } }),
st('4e', 'No variants defined', 'There are no enabled variants.', { kind: 'detail', ...MB, dt: { none: true } }),
st('4f', 'Source is an SVG', 'Variants aren\'t generated for vector images.', { kind: 'detail', ...MB, dt: { id: 4, nofmt: 'SVG images are served as they are.' } }),
st('4g', 'Source is an animated GIF', 'Same rule as SVG.', { kind: 'detail', ...MB, dt: { id: 4, nofmt: 'Animated GIFs are served as they are.' } }),
st('4h', 'Preview dialog', 'Preview on a generated row. Both at actual size.', { kind: 'detail', ...MB, dt: { rows: ['gen', 'gen', 'gen', 'gen', 'none'], preview: true } })
],
tables: [tb('Copy: Variants tab', [
['Tabs', 'Panel', 'Details · Variants'],
['Original', 'Header block', 'Original · kept untouched · north-wall.jpg · JPEG · 4032 × 3024 · 3.9 MB'],
['Summary', 'Above the list', '4 of 5 generated · Nothing generated yet. · Generating variants…'],
['Generate all', 'Button', 'Generate all · Generating…'],
['Row status', 'Badge', 'Generated · Not generated · Generating · Failed'],
['Row meta (generated)', 'Under the name', '400×300 · WebP · 18 KB · -99% vs original'],
['Row meta (not generated)', 'Under the name', '400×300 · WebP'],
['Row actions', 'Tooltip · aria-label', 'Copy URL · Preview · Generate now · Purge · Retry'],
['Failed: cause', 'Row line', 'Unsupported source format · Image too large'],
['No variants defined', 'Title · text · link', 'No variants defined · Define a variant in Settings to get resized copies of this image. · Settings → Media → Variants'],
['SVG / GIF', 'Alert', 'Variants aren\'t generated for this format. · SVG images are served as they are. · Animated GIFs are served as they are.'],
['Preview dialog', 'Title · panes · hint', 'Preview: thumb · thumb · 400×300 · WebP — 18 KB · saves 99% · Original · 4032×3024 · JPEG — 3.9 MB · Both images are shown at actual size. Scroll to pan. · Close'],
['Toasts', 'After actions', 'Generated 5 variants · Generated thumb · Purged 4 files (260 KB) · Couldn\'t generate hero. Image too large.']
])]
},
{ label: 'Cache panel', title: '5. Settings → Media → Cache', route: '/settings/media/cache', api: 'GET /v1/image-variants/cache · POST /v1/image-variants/cache/purge {scope: all|unused|variant|image} (proposed)', note: 'Stat tiles, a per-variant bar list (bar length is the disk size relative to the largest variant) and the ten images with the most variant disk usage. Purge all needs the word "purge" typed. Purge unused removes files of enabled variants not requested in 30 days.',
states: [
st('5a', 'Loaded', 'Default view.', { kind: 'cache', ...CB }),
st('5b', 'Loading', 'Tiles and lists show skeletons.', { kind: 'cache', ...CB, cache: { state: 'loading' } }),
st('5c', 'Empty cache', 'Nothing generated. Purge buttons are disabled.', { kind: 'cache', ...CB, cache: { state: 'empty' } }),
st('5d', 'Purging', 'Progress for a purge in progress. Content is dimmed.', { kind: 'cache', ...CB, cache: { state: 'purging' } }),
st('5e', 'Purge done', 'The cache is empty and a toast reports the result.', { kind: 'cache', ...CB, cache: { state: 'empty' }, toasts: [['success', 'circle-check', 'Purged 1,284 files, freed 212 MB', '']] }),
st('5f', 'Purge failed', 'Part of the purge did not complete.', { kind: 'cache', ...CB, toasts: [['error', 'circle-x', 'Purge didn\'t finish', '640 of 1,284 files were removed. Try again.', 'Try again']] }),
st('5g', 'Error loading', 'A 500 or network failure.', { kind: 'cache', ...CB, cache: { state: 'error' } }),
st('5h', 'Purge all: confirm', 'The word is not typed yet. The button is disabled.', { kind: 'cache', ...CB, modal: 'purgeAll' }),
st('5i', 'Purge all: confirm, typed', 'The input matches "purge".', { kind: 'cache', ...CB, modal: 'purgeAllOk' }),
st('5j', 'Purge unused: confirm', 'Variants of enabled variants not requested for 30 days.', { kind: 'cache', ...CB, modal: 'purgeUnused' })
],
tables: [tb('Copy: Cache panel', [
['Header', 'Title · text · buttons', 'Cache · Generated variant files. Purged files are generated again on the next request. · Purge unused · Purge all variants'],
['Tiles', 'Label · sub text', 'Generated files · Across 6 variants — Disk used · Originals not included — Variants defined · 5 enabled, 1 disabled — Rejected requests · Last 7 days'],
['Bar list', 'Heading · row', 'By variant · hero 244 files · 84 MB · Disabled (badge on disabled variants)'],
['Top 10', 'Heading · row · button', 'Top 10 images by variant disk usage · north-wall.jpg · 6 variants · 5.8 MB · Purge'],
['Empty cache', 'Title · text', 'Nothing generated yet · Variants are generated on the first request with ?v=<name>, or from an image\'s Variants tab.'],
['Purging', 'Banner', 'Purging 1,284 files · 640 of 1,284 · 106 MB freed'],
['Purge done', 'Toast', 'Purged 1,284 files, freed 212 MB'],
['Purge failed', 'Toast', 'Purge didn\'t finish · 640 of 1,284 files were removed. Try again. · Try again'],
['Error', 'Alert', 'Couldn\'t load cache stats · Check your connection and try again. · Try again'],
['Purge all dialog', 'Title · text · field · buttons', 'Purge all variants? · This removes 1,284 generated files (212 MB) for every variant. Originals aren\'t touched. Files are generated again on request. · Type purge to confirm · Cancel · Purge all variants'],
['Purge unused dialog', 'Title · text · buttons', 'Purge unused variants? · This removes 312 files (41 MB) from enabled variants that haven\'t been requested for 30 days. Files are generated again on request. · Cancel · Purge 312 files'],
['Purge image (row)', 'Toast', 'Purged 6 files (5.8 MB) for north-wall.jpg']
])]
},
{ label: 'Editor picker', title: '6. Editor image picker', route: '/articles/{id}/edit', api: 'Inserted URL: /media/{file}?v={name}', note: 'The Library tab of the picker gets a Variant select. Options are Original plus the enabled variants, each with its dimensions and format. Once inserted, a block whose variant was deleted or disabled shows an inline warning with a way to fix it. Disabled variants do not appear in the select.',
states: [
st('6a', 'Variant select', 'An image is selected. The URL line shows what will be inserted.', { kind: 'editor', nav: 'articles', crumbs: ['Articles', 'How we price a bathroom renovation'], mTitle: 'Edit article', back: true, edr: { picker: true, cur: 3 } }),
st('6b', 'Select open', 'Original plus five enabled variants.', { kind: 'editor', nav: 'articles', crumbs: ['Articles', 'How we price a bathroom renovation'], mTitle: 'Edit article', back: true, edr: { picker: true, cur: 3, open: true } }),
st('6c', 'No variants defined', 'Only Original is offered.', { kind: 'editor', nav: 'articles', crumbs: ['Articles', 'How we price a bathroom renovation'], mTitle: 'Edit article', back: true, edr: { picker: true, none: true } }),
st('6d', 'Variant deleted', 'The block keeps its URL. Pick another variant opens the select.', { kind: 'editor', nav: 'articles', crumbs: ['Articles', 'How we price a bathroom renovation'], mTitle: 'Edit article', back: true, edr: { block: true, msg: 'Variant \'hero\' no longer exists.' } }),
st('6e', 'Variant disabled', 'Same block, different cause. Proposed copy.', { kind: 'editor', nav: 'articles', crumbs: ['Articles', 'How we price a bathroom renovation'], mTitle: 'Edit article', back: true, edr: { block: true, msg: 'Variant \'hero\' is disabled.' } })
],
tables: [tb('Copy: Editor picker', [
['Select', 'Label', 'Variant'],
['Options', 'Option label', 'Original · 4032×3024 · JPEG — Thumb · 400×300 · WebP — Card · 800 × auto · WebP — Hero · 1200×630 · WebP — Square · 600×600 · JPEG — Og · 1200×630 · JPEG'],
['URL line', 'Under the select', '/media/north-wall.jpg?v=hero'],
['No variants', 'Help', 'No variants defined. Add one in Settings → Media → Variants.'],
['Block warning: deleted', 'Inline warning', 'Variant \'hero\' no longer exists. This image returns an error on the public site. · Pick another variant'],
['Block warning: disabled', 'Inline warning (proposed)', 'Variant \'hero\' is disabled. This image returns an error on the public site. · Pick another variant'],
['Block label', 'Chip on the image', 'hero · 1200×630']
])]
}
]
}
specs() {
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.'],
['/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'],
['height, format or quality parameters', '400', 'Unsupported parameter'],
['/media/missing.png?v=thumb', '404', 'Not found'],
['400 body shape', '', '{ "status": 400, "error": "unknown_variant" | "unsupported_parameter", "message": "Unknown variant", "detail": "banner" }']
], { mono: [0] })] },
{ label: 'Activity log', title: '8. Activity log entries', note: 'Row copy for the existing activity log. The actor name is bold, the quoted name is the variant name, and the detail line is secondary text.', tables: [T(['Event', 'Row copy', 'Detail line'], '220px minmax(0,1fr) minmax(0,1fr)', [
['Variant created', 'Marta Alves created variant “hero”', '1200×630 · cover · WebP · quality 82'],
['Variant updated', 'Marta Alves updated variant “hero”', 'Quality 82 → 70 · purged 244 files'],
['Variant deleted', 'Marta Alves deleted variant “hero”', 'Removed 244 generated files (84 MB)'],
['Variant enabled', 'Marta Alves enabled variant “hero”', 'Requests for ?v=hero are served'],
['Variant disabled', 'Marta Alves disabled variant “hero”', 'Requests for ?v=hero return 400'],
['Cache purged: all', 'Marta Alves purged all variants', 'Removed 1,284 files (212 MB)'],
['Cache purged: unused', 'Marta Alves purged unused variants', 'Removed 312 files (41 MB) not requested for 30 days'],
['Cache purged: by variant', 'Marta Alves purged the cache of variant “hero”', 'Removed 244 files (84 MB)'],
['Cache purged: by image', 'Marta Alves purged the variants of north-wall.jpg', 'Removed 4 files (260 KB)'],
['Generate all', 'Marta Alves generated all variants of north-wall.jpg', '5 variants generated'],
['Generate all, with a failure', 'Marta Alves generated all variants of north-wall.jpg', '4 generated · 1 failed (Image too large)'],
['Image deleted', 'Marta Alves deleted north-wall.jpg', 'Also removed 4 generated variants (260 KB)']
])] },
{ label: 'States matrix', title: 'States matrix', note: 'Frame: a numbered state above. Copy: covered by the copy tables, with no separate frame. Existing: unchanged behavior of the existing screen.', tables: [T(['Screen', 'Loaded', 'Empty', 'Loading', 'Error', 'Forbidden', 'In progress', 'Confirm', 'Validation', 'Success'], '190px repeat(9, minmax(0,1fr))', [
['1 Variants list', 'Frame 1a', 'Frame 1c', 'Frame 1b', 'Frame 1d', 'Frame 1e', 'Frame 1g, 1j', 'Frame 1h, 1i', '–', 'Copy'],
['2 Add / edit', 'Frame 2a, 2h', '–', '–', 'Frame 2g', 'Copy', 'Frame 2f', 'Frame 2j', 'Frame 2c, 2d, 2e', 'Copy'],
['3 Media library', 'Frame 3a', 'Frame 3e', 'Existing', 'Existing', '–', '–', 'Frame 3f', '–', '–'],
['4 Image detail', 'Frame 4a', 'Frame 4b, 4e, 4f, 4g', 'Existing', 'Frame 4d', '–', 'Frame 4c', '–', '–', 'Copy'],
['5 Cache', 'Frame 5a', 'Frame 5c', 'Frame 5b', 'Frame 5g, 5f', 'Copy', 'Frame 5d', 'Frame 5h, 5i, 5j', 'Frame 5h, 5i', 'Frame 5e'],
['6 Editor picker', 'Frame 6a, 6b', 'Frame 6c', 'Existing', 'Frame 6d, 6e', '–', '–', '–', '–', '–']
])] },
{ label: 'API errors', title: 'Back-office API errors', note: 'Shown as a toast, or inline where a field is named. The first line is the title, the second the text. The public media URL does not use these; see section 7.', tables: [T(['Status', 'Title', 'Text', 'Where it shows'], '70px 200px minmax(0,1fr) 260px', [
['400', 'Check the request', 'A value in the request isn\'t valid. Reload the page and try again.', 'Toast'],
['401', 'Session expired', 'Sign in again to continue.', 'Redirect to sign in, then a toast'],
['403', 'You don\'t have access', 'Only admins can manage image variants.', 'Toast, or the forbidden state on the list'],
['404', 'Not found', 'This variant or image no longer exists. Refresh the page.', 'Toast'],
['409', 'Name already taken', 'A variant named “thumb” already exists. Choose another name.', 'Inline under Name'],
['413', 'Image too large', 'The source image is too large to generate this variant.', 'Row cause in the Variants tab'],
['422', 'Check the fields', 'Width and height must be between 1 and 8000, and one of them is required.', 'Inline under each field'],
['500', 'Something went wrong', 'The server couldn\'t finish. Try again. If it keeps happening, check the server log.', 'Alert or toast with Try again']
], { mono: [0] })] },
{ label: 'Component inventory', title: 'Component inventory', note: 'Existing components are reused as they are. New components are built on Nuxt UI parts and use only existing tokens. Every icon-only button needs an aria-label and a UTooltip.', tables: [T(['Component', 'Status', 'Nuxt UI mapping', 'Notes'], '190px 80px 300px minmax(0,1fr)', [
['Variants table', 'New', 'UTable', 'Cell slots for the status switch and actions. Below 640 px it renders as a list of UCard.'],
['Variant row (settings, mobile)', 'New', 'UCard + USwitch + UDropdownMenu', 'Name, spec line, counts, status switch and menu.'],
['Variant row (image detail)', 'New', 'UBadge + UButton + UTooltip', 'Status badge, meta line, contextual actions, error line. Spinner uses UIcon.'],
['Variant form', 'New', 'UForm, UFormField, UInput, USelect, USlider, USwitch, URadioGroup', 'Fit is a URadioGroup, horizontal, 3 options. Quality hides when the format is PNG.'],
['Slide-over / full page', 'New', 'USlideover (desktop) · routed page (mobile)', 'Close with unsaved changes opens the discard UModal.'],
['Split comparison slider', 'New', 'No match. Custom ImageCompare on a native range input', 'Two stacked images, clip-path on the top one. role="slider", arrow keys 1%, Shift 10%, Home and End. Handle ≥ 44 px on touch.'],
['Stat tile', 'New', 'UCard (default slot)', 'Icon, label, value, sub text. Skeleton uses USkeleton.'],
['Bar list', 'New', 'UProgress per row', 'Row with name, stats and a bar. Value is disk size over the largest variant.'],
['Top-N list', 'New', 'UCard + list rows + UButton', 'Thumbnail, name, count, size, Purge.'],
['Type-to-confirm dialog', 'New', 'UModal + UInput', 'Confirm button disabled until the input equals "purge".'],
['Variant select', 'New', 'USelectMenu', 'Item slot shows name, dimensions, format. Only enabled variants.'],
['Variants badge', 'New', 'UBadge (outline, icon layers)', 'Shown on media cards when count > 0.'],
['Filter', 'New', 'UTabs (variant link) or UButtonGroup', 'All, With variants, Without.'],
['Request reference card', 'New', 'UCard + code block', 'Table of responses and a JSON body.'],
['Alerts, toasts, dialogs, empty states, skeletons', 'Existing', 'UAlert, useToast, UModal, UEmpty pattern, USkeleton', 'Same copy patterns as the Media and API keys screens.']
])] },
{ label: 'Vocabulary and accessibility', title: 'Vocabulary and accessibility', note: 'Use "variant", "generated" and "purge" everywhere. Never "thumbnail preset", "version" or "rendition". Delete removes a variant definition; purge removes only its generated files. Every status is an icon plus text: Generated (circle-check), Not generated (circle-dashed), Generating (loader), Failed (circle-x), Enabled (circle-check), Disabled (circle-off). The comparison slider has a visible focus ring and works from the keyboard. Dialogs trap focus and return it to the opener. Progress is announced with aria-live="polite". Touch targets on mobile are at least 44 px. The only token not already in the system is --rc-on-sand (dark ink on the sand selection color), used for the active nav item in both themes.', tables: [] }
]
}
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(), specs: this.specs() }
}
}
</script>
</body>
</html>
@@ -1,139 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<meta name="design_doc_mode" content="canvas">
<link rel="icon" href="brand/favicon.svg">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&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="Media cleanup 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)">Screen: Media cleanup</h1>
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">A Media cleanup card on Settings, a purge dialog, and an entry point on the Media Duplicates tab (also in <a href="RenewCMS Screens Back-office.dc.html">the Back-office board</a>). 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="SettingsFrame" 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="SettingsFrame" 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="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;system&quot;,&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;system&quot;,&quot;tsType&quot;:&quot;'system' | 'light' | 'dark'&quot;}}">
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 W = (name, trigger, o = {}, mo = {}) => ({ name, trigger, ...o, m: { ...o, ...mo } })
const L = { d: '14 Jun 2026', c: 3, a: 5 }
const on = { on: true, days: '30', last: L, next: '14 Jul 2026' }
const D = (k, name, trigger, o = {}) => W(name, trigger, { ...on, last: null, on: false, dialog: { k, ...(o.dg || {}) }, ...o.s })
const pvMore = { groups: 8, copies: 15, arts: 24, list: [{ im: 12, n: 'floorboards.jpg', u: 4, c: 2 }, { im: 7, n: 'IMG_4471.jpg', u: 2, c: 1 }, { im: 2, n: 'kitchen-after.webp', u: 3, c: 3 }, { im: 4, n: 'logo-renew.svg', u: 5, c: 3 }, { im: 11, n: 'lime-plaster.jpg', u: 2, c: 1 }], more: 3 }
const pvOne = { groups: 1, copies: 1, arts: 2, list: [{ im: 7, n: 'IMG_4471.jpg', u: 2, c: 1 }], more: 0 }
const pv3 = { groups: 3, copies: 7, arts: 10, list: [{ im: 12, n: 'floorboards.jpg', u: 4, c: 2 }, { im: 2, n: 'kitchen-after.webp', u: 3, c: 3 }, { im: 4, n: 'logo-renew.svg', u: 5, c: 3 }], more: 0 }
const base = { last: null }
const ST = (o) => ({ ...base, ...o })
const Mp = (o) => ({ isMedia: true, kind: 'page', ...o, m: { isMedia: true, kind: 'page', ...o } })
return this.finish([
{ label: 'Settings', title: 'Settings: Media cleanup', route: '/settings', api: 'GET /v1/media/purge-settings · PATCH /v1/media/purge-settings (owner, admin)', note: 'A second card below Version, 20 px gap, same card styling. Automatic purge is off by default. Settings save immediately on toggle and on blur or Enter of the days field; there is no Save button. 401 and 403 are global (login redirect, /register/pending) and have no local copy.', states: [
W('Default: off, never run', 'Owner or admin. Automatic purge is off, so the "Every" row and "Next purge" are hidden.', ST({})),
W('On: 30 days, last run', 'Switch on. "Every" appears with the default of 30 days. The last-run line and the next run are shown.', on),
W('Saving', 'Brief state after toggling or leaving the field. "Saving…" with loader-circle sits in the card header; controls stay usable.', { ...on, saving: true, focusDays: true }),
W('Saved', 'The save succeeded. A small "Saved" confirmation shows in the card header and fades after 2 s. It is announced politely.', { ...on, saved: true }),
W('Save failed', 'A 500 or network failure. The error sits under the field and the control reverts to its previous value (30).', { ...on, saveErr: true, days: '30', focusDays: true }),
W('Validation error', 'Empty, 0, above 365 or not a whole number, checked on blur or Enter. Nothing is sent. The field keeps what was typed until corrected.', { ...on, days: '400', invalid: true, focusDays: true }),
W('Read-only (non-admin)', 'Other verified users. Switch and input are disabled, "Purge now" is hidden, and an info alert explains why.', { ...on, ro: true }),
W('Loading skeleton', 'The settings request is in flight. Skeleton shows only after 150 ms.', { loading: true }),
W('Offline', 'navigator.onLine is false. The banner matches Media. Switch, input and button are disabled.', { ...on, offline: true })
] },
{ label: 'Dialog', title: 'Purge duplicates: dialog', route: 'opens from "Purge now" (Settings) and "Purge duplicates" (Media, Duplicates tab)', api: 'GET /v1/media/purge-preview · POST /v1/media/purge', note: 'UModal, sm:max-w-md, bottom sheet on mobile. Cancel first, then the danger confirm. While the request runs, the confirm shows loader-circle and the dialog can\'t be dismissed. The preview is computed before confirming. A group is purged only if one of its copies is used in an article; the kept copy is the one used in the most articles (ties: oldest, then lowest id).', states: [
D('loading', 'Loading preview', 'The preview request is in flight. Skeleton rows show after 150 ms and the confirm is disabled.'),
D('confirm', 'Confirm', '"Purge now" pressed. Summary, up to 5 groups, a link to the Duplicates tab.'),
D('confirm', 'Confirm: many groups', 'More than 5 groups. The list stops at 5 and says how many more.', { dg: { pv: pvMore } }),
D('progress', 'In progress', 'POST in flight. Confirm reads "Purging…" with loader-circle; Cancel is disabled and Esc, the overlay and swipe do nothing.'),
D('nothing', 'Nothing to purge', 'The preview has no group with an image in use. No confirm; one Close button.'),
D('error', 'Error', '500 or network failure. The dialog stays open, nothing changed. Confirm retries.'),
D('partial', 'Partial failure', 'Some groups failed. The summary refreshes to what is left. Buttons: Close, Retry.', { dg: { pv: pvOne } }),
D('conflict', 'Conflict (409)', 'The library changed since the preview. Nothing was purged, the summary refreshes and the dialog stays open.', { dg: { pv: pv3 } })
] },
{ label: 'Results', title: 'Results and feedback', route: '/settings', api: 'after POST /v1/media/purge', note: 'An unattended run does not toast. The next visit to Settings shows the updated "Last purge" line.', states: [
W('Success', 'The dialog closes, a toast shows for 5 s, and the "Last purge" line updates in place.', { on: false, last: { d: '5 Oct 2026', c: 9, a: 12 }, toast: true }),
W('Success, automatic purge on', 'Same, with the next run moved forward by the interval.', { on: true, days: '30', last: { d: '5 Oct 2026', c: 9, a: 12 }, next: '4 Nov 2026', toast: true })
] },
{ label: 'Media', title: 'Media: Duplicates tab entry point', route: '/media?filter=duplicates', api: 'owner and admin only', note: 'A secondary "Purge duplicates" button (icon merge) in the toolbar next to the sort menu, and in the stacked toolbar on mobile. Per-group "Merge into this one" is unchanged.', states: [
Mp({ tab: 'dups', name: 'Duplicates tab with the button', trigger: 'Owner or admin.' }),
Mp({ tab: 'dups', modal: { k: 'purge' }, name: 'Dialog from Media', trigger: 'The same dialog as on Settings.' }),
Mp({ tab: 'dups', member: true, name: 'Member: button hidden', trigger: 'Other verified users.' }),
Mp({ tab: 'dups', offline: true, name: 'Offline', trigger: 'The button is disabled with the existing banner.' })
], tableTitle: 'Status to copy', table: [
{ a: '200', b: 'Purge done', c: 'Toast "Duplicates purged" / "9 copies merged. 12 articles updated."' },
{ a: '409', b: 'Library changed', c: '"The library changed, so nothing was purged. Check the new summary and try again."' },
{ a: '207', b: 'Partial', c: '"Some groups couldn\'t be merged. 3 of 4 groups were purged. Try again to finish."' },
{ a: '500', b: 'Purge failed', c: '"Couldn\'t purge the duplicates. Nothing was changed. Try again in a moment."' },
{ a: '500', b: 'Setting not saved', c: '"Couldn\'t save. Try again in a moment."' },
{ a: '400', b: 'Days invalid', c: '"Enter a whole number of days between 1 and 365."' }
] }
])
}
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>
-121
View File
@@ -1,121 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<div style="display:contents">{{ view }}</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:1440,&quot;height&quot;:900},&quot;s&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;object&quot;},&quot;mobile&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;boolean&quot;}}">
class Component extends DCLogic {
renderVals() {
const s = this.props.s || {}, m = !!this.props.mobile, h = React.createElement
const P = t => Object.fromEntries(t.split(';').filter(x => x.trim()).map(x => { const i = x.indexOf(':'); const k = x.slice(0, i).trim(); return [k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase()), x.slice(i + 1).trim()] }))
const D = (t, ...c) => h('div', { style: P(t) }, ...c)
const S = (t, ...c) => h('span', { style: P(t) }, ...c)
const I = (n, z = 16, t = '') => h('span', { 'aria-hidden': 'true', style: { display: 'inline-block', flexShrink: 0, width: z, height: z, backgroundColor: 'currentColor', WebkitMask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', mask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', ...(t ? P(t) : {}) } })
const bh = m ? 44 : 32
const B = (label, o = {}) => {
const k = o.k || 'outline'
const bg = k === 'primary' ? 'var(--ui-primary)' : k === 'danger' ? 'var(--ui-error)' : 'var(--ui-bg)'
const fg = k === 'primary' || k === 'danger' ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)'
const bd = k === 'primary' || k === 'danger' ? bg : 'var(--ui-border-accented)'
return S('height:' + (o.h || bh) + 'px;padding:0 ' + (o.px || 12) + 'px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + bg + ';color:' + fg + ';border:1px solid ' + bd + ';font-weight:500;font-size:13px;white-space:nowrap;opacity:' + (o.dis ? 0.4 : 1) + ';' + (o.full ? 'flex:1;' : ''), o.icon && I(o.icon, 16), label)
}
const CHECK = 'conic-gradient(var(--ui-bg-accented) 25%, var(--ui-bg) 0 50%, var(--ui-bg-accented) 0 75%, var(--ui-bg) 0) 0 0/16px 16px'
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: 20, height: 20, 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 = '') => 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, rgb(128 128 128 / 0.12) 0 1px, transparent 1px 12px), ' + TONES[im.tone % 4]) + ';' + t, im.svg ? mark : I('image', 16, 'opacity:0.55'))
const IMX = { 12: { tone: 0 }, 7: { tone: 2 }, 2: { tone: 1 }, 4: { tone: 3, alpha: true, svg: true }, 11: { tone: 1 } }
const Alert = (tone, icon, text, title) => 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:2px', title && S('font-weight:600', title), S('color:var(--ui-text-toned)', text)))
const hl = 'color:var(--ui-text-highlighted)'
const mono = "font-family:'IBM Plex Mono',monospace"
// ---------- card
const dis = !!(s.ro || s.offline || s.loading)
const on = !!s.on
const Sw = () => D('display:flex;align-items:center;justify-content:center;width:' + (m ? 44 : 36) + 'px;height:' + (m ? 44 : 24) + 'px;flex-shrink:0;opacity:' + (dis ? 0.4 : 1), D('position:relative;width:36px;height:20px;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg-accented)') + ';' + (s.focusSw ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : ''), D('position:absolute;top:1px;width:16px;height:16px;left:' + (on ? 17 : 1) + 'px;background:' + (on ? 'var(--ui-bg)' : 'var(--ui-bg)') + ';border:1px solid ' + (on ? 'transparent' : 'var(--ui-border-accented)') + ';box-sizing:border-box')))
const Row = (label, help, ctl, stack) => D('display:flex;' + (m || stack ? 'flex-direction:column;align-items:stretch;' : 'align-items:flex-start;justify-content:space-between;') + 'gap:' + (m ? 12 : 16) + 'px', D('display:flex;flex-direction:column;gap:2px;min-width:0', S('font-weight:500;' + hl, label), help && S('font-size:12px;color:var(--ui-text-muted)', help)), ctl)
const hasErr = s.invalid || s.saveErr
const errTxt = s.invalid ? 'Enter a whole number of days between 1 and 365.' : 'Couldn\'t save. Try again in a moment.'
const Days = () => D('display:flex;flex-direction:column;gap:6px;' + (m ? '' : 'align-items:flex-end;'),
D('display:flex;align-items:center;width:' + (m ? '100%' : '140px') + ';min-height:' + (m ? 44 : 40) + 'px;border:1px solid ' + (hasErr ? 'var(--ui-error)' : s.focusDays ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';' + (s.focusDays && !hasErr ? 'box-shadow:0 0 0 1px var(--ui-primary);' : '') + 'background:var(--ui-bg);font-size:' + (m ? 16 : 13) + 'px;opacity:' + (dis ? 0.4 : 1), S('flex:1;min-width:0;padding:0 12px;' + mono + ';' + hl, s.days ?? '30'), S('padding:0 12px;color:var(--ui-text-muted);' + mono + ';font-size:13px', 'days')),
hasErr && S('display:flex;gap:6px;align-items:flex-start;color:var(--ui-error);font-size:12px;' + (m ? '' : 'max-width:300px;text-align:right;'), I('circle-alert', 14, 'margin-top:2px'), errTxt))
const stat = s.saving ? S('display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-text-muted)', I('loader-circle', 14), 'Saving…') : s.saved ? S('display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-success)', I('check', 14), 'Saved') : null
const last = s.last === undefined ? null : s.last
const lastLine = last ? 'Last purge: ' + last.d + ' · ' + last.c + ' copies merged · ' + last.a + ' articles updated' : 'Never run'
const Cleanup = () => {
const kids = [
D('display:flex;align-items:flex-start;gap:12px', D('flex:1;display:flex;flex-direction:column;gap:2px', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;' + hl, 'Media cleanup'), S('color:var(--ui-text-muted);text-wrap:pretty', 'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.')), D('height:24px;display:flex;align-items:center;flex-shrink:0', stat))
]
if (s.ro) kids.push(Alert('info', 'info', 'Only owners and admins can change this.'))
kids.push(Row('Automatic purge', 'Runs in the background. Off by default.', Sw()))
if (on) kids.push(Row('Every', 'Between 1 and 365 days.', Days()))
kids.push(D('display:flex;flex-direction:column;gap:4px;color:var(--ui-text-toned)', S('', lastLine), on && S('', 'Next purge: ' + (s.next || '14 Jul 2026'))))
if (!s.ro) {
kids.push(D('height:1px;background:var(--ui-border)'))
kids.push(Row('Purge now', 'Merge every group of duplicates that has an image in use, right away.', D('display:flex;' + (m ? '' : 'flex-shrink:0'), B('Purge duplicates', { icon: 'merge', dis: !!(s.offline || s.loading), full: m, ring: s.focusBtn }))))
}
return D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + (m ? 16 : 20) + 'px;display:flex;flex-direction:column;gap:16px', ...kids)
}
const SkelBar = (w, hh = 13) => S('display:block;width:' + w + ';height:' + hh + 'px;background:var(--ui-bg-accented)')
const CleanupSkel = () => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + (m ? 16 : 20) + 'px;display:flex;flex-direction:column;gap:16px', SkelBar('140px', 16), SkelBar('90%'), SkelBar('55%'), D('display:flex;justify-content:space-between;align-items:center', D('display:flex;flex-direction:column;gap:6px', SkelBar('120px'), SkelBar('180px', 11)), SkelBar('36px', 20)), SkelBar('60%'), D('height:1px;background:var(--ui-border)'), D('display:flex;justify-content:space-between;align-items:center', D('display:flex;flex-direction:column;gap:6px', SkelBar('100px'), SkelBar('220px', 11)), SkelBar('140px', bh)))
const pad = m ? 16 : 24
const page = D('flex:1;min-height:0;overflow:hidden;padding:' + pad + 'px;position:relative', D('max-width:720px;display:flex;flex-direction:column;gap:20px',
D('display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;' + hl, 'Settings'), S('color:var(--ui-text-muted)', 'Workspace information.')),
D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + (m ? 16 : 20) + 'px;display:flex;align-items:center;justify-content:space-between;gap:16px', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;' + hl, 'Version'), S('font:400 14px/22px \'IBM Plex Mono\',monospace;color:var(--ui-text-toned)', 'v2.2.0-beta')),
s.loading ? CleanupSkel() : Cleanup()))
// ---------- dialog
const G = [{ im: 12, n: 'floorboards.jpg', u: 4, c: 2 }, { im: 7, n: 'IMG_4471.jpg', u: 2, c: 1 }, { im: 2, n: 'kitchen-after.webp', u: 3, c: 3 }, { im: 4, n: 'logo-renew.svg', u: 5, c: 3 }, { im: 11, n: 'lime-plaster.jpg', u: 2, c: 1 }]
const pl = (n, w) => n + ' ' + w + (n === 1 ? '' : 's')
const PV = { groups: 4, copies: 9, arts: 12, list: G.slice(0, 4), more: 0, ...(s.dialog && s.dialog.pv) }
const summary = pv => pl(pv.groups, 'group') + ' · ' + pv.copies + (pv.copies === 1 ? ' copy' : ' copies') + ' will be deleted · ' + pl(pv.arts, 'article') + ' will be updated'
const GList = pv => D('border:1px solid var(--ui-border)', ...pv.list.map((g, i) => D('display:flex;align-items:center;gap:12px;padding:8px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), Th(IMX[g.im], 'width:40px;height:30px;flex-shrink:0;border:1px solid var(--ui-border)'), D('flex:1;min-width:0;display:flex;flex-direction:column', S('font-weight:500;' + hl + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', g.n), S('font-size:12px;color:var(--ui-text-muted)', 'Kept · used in ' + pl(g.u, 'article'))), S('font-size:12px;color:var(--ui-text-toned);white-space:nowrap;text-align:right', pl(g.c, 'copy').replace('copys', 'copies') + ' merged'))), pv.more > 0 && D('padding:8px 12px;border-top:1px solid var(--ui-border-muted);color:var(--ui-text-muted)', 'and ' + pv.more + ' more groups'))
const Link = () => S('display:inline-flex;align-items:center;gap:4px;align-self:flex-start;font-weight:500;' + hl + ';text-decoration:underline;text-underline-offset:3px;min-height:' + (m ? 44 : 20) + 'px', 'Review in Duplicates', I('arrow-right', 14))
const dg = s.dialog
const Shell = (o, body) => D('position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'flex-start') + ';justify-content:center;padding:' + (m ? 16 : 140) + 'px 16px 16px',
D('width:' + (m ? '100%' : '448px') + ';max-height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column',
o.center ? D('padding:28px 24px 0;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(--rc-info-soft);color:var(--ui-info)', I(o.icon, 20)), S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + hl, o.title), S('color:var(--ui-text-muted);text-wrap:pretty;max-width:340px', o.desc))
: D('padding:20px 24px 0;display:flex;flex-direction:column;gap:4px', S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + hl, o.title), o.desc && S('color:var(--ui-text-muted);text-wrap:pretty', o.desc)),
body && D('padding:20px 24px 0;display:flex;flex-direction:column;gap:16px;min-height:0;overflow:hidden', ...body),
D('padding:20px 24px;display:flex;flex-direction:' + (m ? 'column-reverse' : 'row') + ';justify-content:flex-end;gap:8px;flex-shrink:0', o.cancel && B(o.cancel, { dis: o.loading, full: false }), o.confirm && S('height:' + bh + 'px;padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-error);color:var(--ui-bg);font-weight:500;opacity:' + (o.cdis ? 0.4 : 1), o.loading ? I('loader-circle') : o.cicon && I(o.cicon), o.confirm))))
const DESC = 'Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can\'t be undone.'
const DSkel = () => [SkelBar('80%', 16), D('border:1px solid var(--ui-border)', ...[0, 1, 2].map(i => D('display:flex;align-items:center;gap:12px;padding:8px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), SkelBar('40px', 30), D('flex:1;display:flex;flex-direction:column;gap:6px', SkelBar('45%'), SkelBar('60%', 11)))))]
const Dialog = () => {
const k = dg.k, pv = PV
const gp = 'Purge ' + pl(pv.groups, 'group')
const note = (tone, icon, t) => Alert(tone, icon, t)
if (k === 'loading') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: 'Purge duplicates', cdis: true }, DSkel())
if (k === 'nothing') return Shell({ center: true, icon: 'copy', title: 'Nothing to purge', desc: 'No duplicate is used in an article, so there is nothing to merge.', cancel: 'Close' }, null)
const body = [S('font-weight:500;' + hl, summary(pv)), GList(pv), Link()]
if (k === 'confirm') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, body)
if (k === 'progress') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: 'Purging…', loading: true }, body)
if (k === 'error') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, [note('error', 'circle-x', 'Couldn\'t purge the duplicates. Nothing was changed. Try again in a moment.'), ...body])
if (k === 'partial') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Close', confirm: 'Retry', cicon: 'rotate-cw' }, [note('warning', 'triangle-alert', 'Some groups couldn\'t be merged. 3 of 4 groups were purged. Try again to finish.'), ...body])
if (k === 'conflict') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, [note('warning', 'triangle-alert', 'The library changed, so nothing was purged. Check the new summary and try again.'), ...body])
}
const Toast = () => D('position:absolute;z-index:60;' + (m ? 'left:16px;right:16px;bottom:16px' : 'right:24px;bottom:24px;width:356px') + ';display:flex;gap:10px;padding:12px 14px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop)', S('margin-top:2px;display:flex;color:var(--ui-success)', I('circle-check')), D('flex:1;display:flex;flex-direction:column;gap:2px', S('font-weight:600;' + hl, 'Duplicates purged'), S('color:var(--ui-text-muted)', '9 copies merged. 12 articles updated.')), I('x'))
// ---------- shell
const nav = [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['users', 'users', 'Users'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
const mk = (z) => h('svg', { width: z, height: z, 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 side = D('width:224px;flex-shrink:0;background:var(--ui-bg);border-right:1px solid var(--ui-border);display:flex;flex-direction:column;padding:12px;gap:2px',
D('display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px', mk(26), S('font-weight:600;font-size:14px;' + hl, 'RenewCMS')),
...nav.map(([k, ic, t]) => D('display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:' + (k === 'settings' ? 'var(--rc-sand)' : 'transparent') + ';color:' + (k === 'settings' ? '#111111' : 'var(--ui-text-toned)') + ';font-weight:' + (k === 'settings' ? 500 : 400), I(ic), t)),
D('flex:1'), D('display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;' + hl, S('width:26px;height:26px;flex-shrink:0;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 11px \'IBM Plex Sans\',sans-serif', 'MA'), S('flex:1;font-weight:500', 'Marta Alves'), I('chevrons-up-down', 14)))
const topbar = m
? D('height:56px;flex-shrink:0;border-bottom:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;padding:0 6px;gap:4px;' + hl, S('width:44px;height:44px;display:flex;align-items:center;justify-content:center', I('menu', 20)), S('flex:1;font-weight:600;font-size:16px', 'Settings'))
: D('height:48px;flex-shrink:0;border-bottom:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;padding:0 24px;gap:8px;color:var(--ui-text-muted)', I('panel-left-close'), S('width:1px;height:16px;background:var(--ui-border);margin:0 6px'), S('font-weight:500;' + hl, 'Settings'))
const offline = s.offline && D('flex-shrink:0;display:flex;align-items:center;gap:10px;padding:10px 16px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted)', I('wifi-off'), S('flex:1', 'You\'re offline. Changing automatic purge and purging duplicates need a connection.'))
const root = D('width:' + (m ? 390 : 1440) + 'px;height:' + (m ? 844 : 900) + 'px;position:relative;display:flex;flex-direction:' + (m ? 'column' : 'row') + ';background:var(--ui-bg-muted);color:var(--ui-text);font-family:\'IBM Plex Sans\',sans-serif;font-size:13px;line-height:20px;overflow:hidden',
!m && side, D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column', topbar, offline, page), dg && Dialog(), s.toast && Toast())
return { view: root }
}
}
</script>
</body>
</html>
+334
View File
@@ -0,0 +1,334 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<div style="display:contents">{{ view }}</div>
</x-dc>
<script type="text/x-dc" data-dc-script>
class Component extends DCLogic {
renderVals() {
const s = this.props.s || {}, m = !!this.props.mobile, h = React.createElement
const P = t => Object.fromEntries(t.split(';').filter(x => x.trim()).map(x => { const i = x.indexOf(':'); const k = x.slice(0, i).trim(); return [k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase()), x.slice(i + 1).trim()] }))
const D = (t, ...c) => h('div', { style: P(t) }, ...c)
const S = (t, ...c) => h('span', { style: P(t) }, ...c)
const I = (n, z = 16, t = '') => h('span', { 'aria-hidden': 'true', style: { display: 'inline-block', flexShrink: 0, width: z, height: z, backgroundColor: 'currentColor', WebkitMask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', mask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', ...(t ? P(t) : {}) } })
const MONO = "font-family:'IBM Plex Mono',monospace"
const bh = m ? 44 : 32
const B = (label, o = {}) => {
const k = o.k || 'outline'
const bg = k === 'primary' ? 'var(--ui-primary)' : k === 'danger' ? 'var(--ui-error)' : k === 'ghost' ? 'transparent' : 'var(--ui-bg)'
const fg = k === 'primary' || k === 'danger' ? 'var(--ui-bg)' : k === 'del' ? 'var(--ui-error)' : 'var(--ui-text-highlighted)'
const bd = k === 'primary' || k === 'danger' ? bg : k === 'ghost' ? 'transparent' : k === 'del' ? 'var(--ui-error)' : 'var(--ui-border-accented)'
const ht = o.h || bh
return S('height:' + ht + 'px;' + (o.sq ? 'width:' + ht + 'px;' : 'padding:0 ' + (o.px || 12) + 'px;') + 'display:flex;align-items:center;justify-content:center;gap:6px;background:' + bg + ';color:' + fg + ';border:1px solid ' + bd + ';font-weight:500;font-size:' + (o.fs || 13) + 'px;white-space:nowrap;flex-shrink:0;opacity:' + (o.dis ? 0.4 : 1) + ';' + (o.full ? 'flex:1;' : '') + (o.ring ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : ''), o.icon && I(o.icon, o.z || 16), label)
}
const 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 Th = (tone, t = '', z = 24, icon = 'image') => D('position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);background:repeating-linear-gradient(135deg, rgb(128 128 128 / 0.12) 0 1px, transparent 1px 12px), ' + TONES[tone % 4] + ';' + t, I(icon, z, 'opacity:0.55'))
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'],
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)
const Sw = (on, dis) => S('width:32px;height:18px;padding:2px;flex-shrink:0;display:flex;justify-content:' + (on ? 'flex-end' : 'flex-start') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';opacity:' + (dis ? 0.4 : 1), S('width:14px;height:14px;background:var(--ui-bg)'))
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;min-width:0', D('display:flex;flex-direction:column;gap:2px', title && S('font-weight:600', title), text && S('color:var(--ui-text-toned)', text)), action && D('display:flex', B(action, { icon: 'rotate-cw', h: m ? 44 : 28, fs: 12, z: 14, ...(actionEl || {}) }))))
const Empty = (icon, title, text, pad = 56, ...extra) => 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:380px;text-wrap:pretty', text), ...extra)
const Note = t => S('align-self:flex-start;height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-toned);font:500 11px \'IBM Plex Mono\',monospace', t)
const Skl = (w, ht = 13) => S('display:block;width:' + w + ';height:' + ht + 'px;background:var(--ui-bg-accented)')
const Field = (lab, val, o = {}) => D('display:flex;flex-direction:column;gap:6px;min-width:0', D('display:flex;justify-content:space-between', S('font-weight:500;color:var(--ui-text-highlighted)', lab), o.count && S(MONO + ';font-size:12px;color:var(--ui-text-muted)', o.count)),
D('display:flex;align-items:center;min-height:' + (m ? 44 : 40) + 'px;border:1px solid ' + (o.err || o.eb ? 'var(--ui-error)' : o.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';' + (o.focus ? 'box-shadow:0 0 0 1px var(--ui-primary);' : '') + 'background:' + (o.dis || o.lock ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';font-size:' + (m ? 16 : 13) + 'px;opacity:' + (o.dis ? 0.6 : 1),
S('flex:1;min-width:0;padding:0 12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:' + (val ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + ';' + (o.mono ? MONO : ''), val || o.ph || ''),
o.suffix && S('padding:0 12px;color:var(--ui-text-muted);' + MONO + ';font-size:13px', o.suffix), o.lock && S('padding:0 12px;display:flex;color:var(--ui-text-muted)', I('lock', 14)), o.sel && S('padding:0 12px;display:flex;color:var(--ui-text-muted)', I('chevron-down', 16))),
o.err && S('display:flex;gap:6px;align-items:flex-start;color:var(--ui-error);font-size:12px', I('circle-alert', 14, 'margin-top:3px'), S('', o.err)), o.help && !o.err && S('color:var(--ui-text-muted);font-size:12px;text-wrap:pretty', o.help))
const SwRow = (lab, help, on, dis) => D('display:flex;align-items:flex-start;gap:12px;min-height:' + (m ? 44 : 32) + 'px', D('flex:1;display:flex;flex-direction:column;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', lab), S('color:var(--ui-text-muted);font-size:12px', help)), D('padding-top:2px', Sw(on, dis)))
const Shell = (o, body) => D('position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'flex-start') + ';justify-content:center;padding:' + (m ? 16 : o.top || 140) + 'px 16px 16px',
D('width:' + (m ? '100%' : (o.w || 460) + 'px') + ';max-height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column',
D('padding:20px 24px 0;display:flex;align-items:flex-start;gap:12px', D('flex:1;display:flex;flex-direction:column;gap:4px', S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', o.title), o.desc && S('color:var(--ui-text-muted);text-wrap:pretty', o.desc)), o.close && S('width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
body,
D('padding:20px 24px;display:flex;flex-direction:' + (m ? 'column-reverse' : 'row') + ';justify-content:flex-end;gap:8px;flex-shrink:0', o.cancel && B(o.cancel, { dis: o.loading }), o.confirm && S('height:' + bh + 'px;padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + (o.tone === 'danger' ? 'var(--ui-error)' : 'var(--ui-primary)') + ';color:var(--ui-bg);font-weight:500;opacity:' + (o.cdis ? 0.4 : 1), o.loading && I('loader-circle'), o.confirm))))
const Toasts = () => D('position:absolute;z-index:60;display:flex;flex-direction:column;gap:8px;' + (m ? 'left:16px;right:16px;bottom:16px' : 'right:24px;bottom:24px;width:372px'), ...s.toasts.map(t => D('display:flex;gap:10px;padding:12px 14px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop)', S('margin-top:2px;display:flex;color:var(--ui-' + t[0] + ')', I(t[1])), D('flex:1;display:flex;flex-direction:column;gap:6px', D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;color:var(--ui-text-highlighted)', t[2]), t[3] && S('color:var(--ui-text-muted)', t[3])), t[4] && B(t[4], { icon: 'rotate-cw', z: 14, h: m ? 44 : 28, fs: 12, px: 10 })), I('x'))))
// ---------- data
const V = [
{ n: 'thumb', w: 400, h: 300, fit: 'cover', fmt: 'WebP', q: 80, files: 412, size: '21 MB', on: true, out: '400×300', kb: '18 KB', sv: '-99%' },
{ n: 'card', w: 800, h: 0, fit: 'inside', fmt: 'WebP', q: 80, files: 318, size: '58 MB', on: true, out: '800×600', kb: '64 KB', sv: '-98%' },
{ n: 'hero', w: 1200, h: 630, fit: 'cover', fmt: 'WebP', q: 82, files: 244, size: '84 MB', on: true, out: '1200×630', kb: '96 KB', sv: '-98%' },
{ n: 'square', w: 600, h: 600, fit: 'cover', fmt: 'JPEG', q: 85, files: 140, size: '19 MB', on: true, out: '600×600', kb: '82 KB', sv: '-98%' },
{ n: 'og', w: 1200, h: 630, fit: 'cover', fmt: 'JPEG', q: 80, files: 96, size: '9 MB', on: true, out: '1200×630', kb: '142 KB', sv: '-96%' },
{ n: 'print', w: 0, h: 2400, fit: 'inside', fmt: 'PNG', q: 0, files: 74, size: '21 MB', on: false, out: '3200×2400', kb: '3.1 MB', sv: '-21%' }
]
const dim = v => v.w && v.h ? v.w + '×' + v.h : v.w ? v.w + ' × auto' : 'auto × ' + v.h
const en = V.filter(v => v.on)
const IM = [
{ id: 1, n: 'north-wall.jpg', sz: '3.9 MB', dim: '4032 × 3024', ty: 'JPEG', used: 2, vc: 4, vs: '260 KB', tot: '4.2 MB', tone: 0 },
{ id: 2, n: 'kitchen-after.webp', sz: '1.2 MB', dim: '2400 × 1600', ty: 'WEBP', used: 3, vc: 5, vs: '410 KB', tot: '1.6 MB', tone: 1 },
{ id: 3, n: 'kitchen-before.webp', sz: '1.1 MB', dim: '2400 × 1600', ty: 'WEBP', used: 1, vc: 5, vs: '395 KB', tot: '1.5 MB', tone: 2 },
{ id: 4, n: 'logo-renew.svg', sz: '8 KB', dim: 'Vector', ty: 'SVG', used: 5, vc: 0, tot: '8 KB', tone: 3 },
{ id: 5, n: 'floorboards-copy.jpg', sz: '2.4 MB', dim: '3600 × 2400', ty: 'JPEG', used: 3, vc: 3, vs: '240 KB', tot: '2.6 MB', tone: 0 },
{ id: 6, n: 'IMG_2208.jpg', sz: '2.4 MB', dim: '3600 × 2400', ty: 'JPEG', used: 2, vc: 0, tot: '2.4 MB', tone: 0 },
{ id: 7, n: 'IMG_4471.jpg', sz: '3.1 MB', dim: '3600 × 2400', ty: 'JPEG', used: 2, vc: 2, vs: '150 KB', tot: '3.3 MB', tone: 2 },
{ id: 8, n: 'window-frame.jpg', sz: '1.8 MB', dim: '3000 × 2000', ty: 'JPEG', used: 0, vc: 0, tot: '1.8 MB', tone: 3 },
{ id: 9, n: 'stone-wall-detail.jpg', sz: '3.1 MB', dim: '3600 × 2400', ty: 'JPEG', used: 1, vc: 1, vs: '60 KB', tot: '3.2 MB', tone: 2 },
{ id: 10, n: 'bathroom-tiles.png', sz: '860 KB', dim: '1800 × 1200', ty: 'PNG', used: 0, vc: 3, vs: '190 KB', tot: '1.1 MB', tone: 1 },
{ id: 11, n: 'lime-plaster.jpg', sz: '2.1 MB', dim: '3200 × 2133', ty: 'JPEG', used: 2, vc: 4, vs: '280 KB', tot: '2.4 MB', tone: 1 },
{ id: 12, n: 'floorboards.jpg', sz: '2.4 MB', dim: '3600 × 2400', ty: 'JPEG', used: 4, vc: 5, vs: '430 KB', tot: '2.8 MB', tone: 0 }
]
const byId = id => IM.find(x => x.id === id)
const mark = k => h('svg', { width: k, height: k, viewBox: '0 0 16 16', shapeRendering: 'crispEdges' }, h('rect', { width: 16, height: 16, fill: 'var(--ui-primary)' }), h('path', { fill: 'var(--ui-bg)', fillRule: 'evenodd', d: 'M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z' }))
const pad = m ? 16 : 24
// ---------- shell
const NAV = [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['users', 'users', 'Users'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
const nav = s.role === 'user' ? NAV.filter(n => ['home', 'articles', 'media'].includes(n[0])) : NAV
const Side = dr => {
const open = s.nav === 'settings' || s.sideOpen, act = s.nav === 'settings' ? (s.crumbs || []).slice(-1)[0] : null, h = dr ? 44 : 32
const row = (ic, t, on, pl, tail, hi) => D('display:flex;align-items:center;gap:10px;height:' + h + 'px;padding:0 10px 0 ' + pl + 'px;background:' + (on ? 'var(--rc-sand)' : 'transparent') + ';color:' + (on ? 'var(--rc-on-sand)' : hi ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)') + ';font-weight:' + (on || hi ? 500 : 400), I(ic, pl > 10 ? 14 : undefined), S('flex:1', t), tail)
return D('width:' + (dr ? '100%' : '224px') + ';height:' + (dr ? '100%' : 'auto') + ';flex-shrink:0;background:var(--ui-bg);' + (dr ? 'box-shadow:var(--rc-shadow-modal);' : 'border-right:1px solid var(--ui-border);') + 'display:flex;flex-direction:column;padding:12px;gap:2px',
D('display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px', mark(26), S('flex:1;font-weight:600;font-size:14px;color:var(--ui-text-highlighted)', 'RenewCMS'), dr && I('x', 20)),
...nav.flatMap(([k, ic, t]) => k !== 'settings' ? [row(ic, t, k === s.nav, 10)] : [row(ic, t, false, 10, I(open ? 'chevron-down' : 'chevron-right', 14), open), open && D('display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)', S('padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)', 'Media'), row('layers', 'Variants', act === 'Variants', 12), row('database', 'Cache', act === 'Cache', 12))]),
D('flex:1'), D('display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;color:var(--ui-text-highlighted)', 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', 'MA'), S('flex:1;font-weight:500', 'Marta Alves'), I('chevrons-up-down', 14)))
}
const side = Side(false)
const crumbs = s.crumbs || []
const topbar = m
? D('height:56px;flex-shrink:0;border-bottom:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;padding:0 6px;gap:4px;color:var(--ui-text-highlighted)', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center', I(s.back ? 'arrow-left' : 'menu', 20)), S('flex:1;font-weight:600;font-size:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', s.mTitle || crumbs[crumbs.length - 1]))
: D('height:48px;flex-shrink:0;border-bottom:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;padding:0 24px;gap:8px;color:var(--ui-text-muted)', I('panel-left-close'), S('width:1px;height:16px;background:var(--ui-border);margin:0 6px'), ...crumbs.flatMap((c, i) => [i > 0 && I('chevron-right', 14), S('color:' + (i === crumbs.length - 1 ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + ';font-weight:' + (i === crumbs.length - 1 ? 500 : 400), c)]))
const PH = (title, desc, ...acts) => D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';align-items:' + (m ? 'stretch' : 'flex-start') + ';gap:' + (m ? 12 : 16) + 'px;flex-shrink:0', D('flex:1;display:flex;flex-direction:column;gap:4px;min-width:0', S('font:600 ' + (m ? 20 : 24) + 'px/32px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', title), desc && S('color:var(--ui-text-muted);max-width:620px;text-wrap:pretty', desc)), acts.filter(Boolean).length > 0 && D('display:flex;gap:8px;flex-shrink:0;' + (m ? 'flex-direction:column' : ''), ...acts.filter(Boolean)))
const SetTabs = act => D('display:flex;gap:20px;border-bottom:1px solid var(--ui-border);flex-shrink:0', ...['Variants', 'Cache'].map(t => S('height:' + (m ? 44 : 36) + 'px;display:flex;align-items:center;gap:6px;border-bottom:2px solid ' + (t === act ? 'var(--ui-primary)' : 'transparent') + ';margin-bottom:-1px;font-weight:' + (t === act ? 500 : 400) + ';color:' + (t === act ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)'), I(t === 'Variants' ? 'layers' : 'database', 14), t)))
const Pg = (...kids) => D('flex:1;min-height:0;overflow:hidden;padding:' + pad + 'px;display:flex;flex-direction:column;gap:16px;position:relative', D('display:flex;flex-direction:column;gap:16px;flex-shrink:0;margin-top:' + (-(s.scroll || 0)) + 'px', ...kids))
const Card = (title, right, ...kids) => D('background:var(--ui-bg);border:1px solid var(--ui-border);display:flex;flex-direction:column;min-width:0', D('display:flex;align-items:center;gap:8px;min-height:44px;padding:0 16px;border-bottom:1px solid var(--ui-border)', S('flex:1;font-weight:600;color:var(--ui-text-highlighted)', title), right), ...kids)
// ---------- 1. variants list
const L = s.list || {}
const Menu = () => D('position:absolute;z-index:20;right:0;top:' + (m ? 44 : 34) + 'px;width:176px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px;display:flex;flex-direction:column;font-size:13px', ...[['pencil', 'Edit', ''], ['eraser', 'Purge cache', ''], ['trash-2', 'Delete', 'color:var(--ui-error)']].map(([ic, t, c], i) => D('display:flex;align-items:center;gap:8px;height:' + (m ? 44 : 32) + 'px;padding:0 8px;color:var(--ui-text-highlighted);' + (i === 0 && L.menu !== undefined ? 'background:var(--ui-bg-elevated);' : '') + c, I(ic, 14), t)))
const stat = (v, i) => { const busy = L.busy === i, on = v.on; return D('display:flex;align-items:center;gap:8px;color:' + (busy ? 'var(--ui-info)' : on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)'), Sw(on, busy), S('display:flex;align-items:center;gap:4px;white-space:nowrap', I(busy ? 'loader-circle' : on ? 'circle-check' : 'circle-off', 14), busy ? 'Purging…' : on ? 'Enabled' : 'Disabled')) }
const act = (i) => S('position:relative;display:flex', B(null, { icon: 'ellipsis', sq: true, k: 'ghost', h: m ? 44 : 28, dis: L.busy === i, ring: L.menu === i }), L.menu === i && Menu())
const cols = 'minmax(0,1.1fr) 104px 76px 72px 68px 92px 92px 132px 44px'
const ListTable = () => {
const hd = D('display:grid;grid-template-columns:' + cols + ';height:36px;align-items:center;padding:0 8px 0 16px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', ...['Name', 'Dimensions', 'Fit', 'Format', 'Quality', 'Generated', 'Cache size', 'Status', ''].map(t => S('', t)))
const rows = V.map((v, i) => D('position:relative;display:grid;grid-template-columns:' + cols + ';min-height:48px;align-items:center;padding:0 8px 0 16px;border-top:1px solid var(--ui-border-muted);background:' + (L.busy === i ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';color:' + (v.on ? 'var(--ui-text)' : 'var(--ui-text-muted)'),
S(MONO + ';font-weight:500;font-size:13px;color:' + (v.on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)'), v.n), S(MONO + ';font-size:12px', dim(v)), S('', v.fit), S('', v.fmt), S('', v.q ? v.q : '—'), S('', v.files.toLocaleString('en-US')), S('', v.size), stat(v, i), act(i)))
return D('background:var(--ui-bg);border:1px solid var(--ui-border)', hd, ...rows, D('padding:10px 16px;border-top:1px solid var(--ui-border);color:var(--ui-text-toned)', '6 variants · 1,284 generated files · 212 MB'))
}
const ListCards = () => D('display:flex;flex-direction:column;gap:8px', ...V.map((v, i) => D('position:relative;background:' + (L.busy === i ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';border:1px solid var(--ui-border);padding:12px;display:flex;flex-direction:column;gap:6px', D('display:flex;align-items:center;gap:8px', S(MONO + ';font-weight:500;font-size:14px;flex:1;color:' + (v.on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)'), v.n), stat(v, i)),
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 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)))),
D('display:flex;flex-direction:column;gap:6px', S('color:var(--ui-text-muted);font-size:12px', 'Body of a 400 response'), h('pre', { style: { margin: 0, padding: 12, background: 'var(--ui-bg-elevated)', color: 'var(--ui-text-highlighted)', fontFamily: "'IBM Plex Mono',monospace", fontSize: 12, lineHeight: '18px', overflow: 'hidden' } }, json))), S('color:var(--ui-text-muted);font-size:12px', 'unsupported_parameter replaces unknown_variant when a raw parameter is sent. There is no arbitrary-size mode.'))
}
const TPL = [['thumb', '400×300 · cover · WebP'], ['hero', '1200×630 · cover · WebP']]
const ListPage = () => {
let body
if (s.forbidden) body = [Empty('lock', 'You don\'t have access', 'Only admins can manage image variants. Ask an admin if you need one changed.', 72, B('Back to Home', { icon: 'arrow-left' }))]
else {
const add = B('Add variant', { k: 'primary', icon: 'plus' })
body = [PH('Image variants', 'Named sizes and formats generated from your original images. Originals are never changed.', add), SetTabs('Variants')]
if (L.state === 'loading') body.push(m ? D('display:flex;flex-direction:column;gap:8px', ...Array.from({ length: 4 }, () => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:12px;display:flex;flex-direction:column;gap:10px', Skl('40%'), Skl('70%', 11), Skl('55%', 11)))) : D('background:var(--ui-bg);border:1px solid var(--ui-border)', D('height:36px;background:var(--ui-bg-elevated)'), ...Array.from({ length: 5 }, () => D('height:48px;border-top:1px solid var(--ui-border-muted);display:flex;align-items:center;gap:32px;padding:0 16px', Skl('120px'), Skl('90px'), Skl('60px'), Skl('60px'), Skl('80px'), Skl('160px')))))
else if (L.state === 'error') body.push(Alert('error', 'circle-x', 'Couldn\'t load variants', 'Check your connection and try again.', 'Try again'))
else if (L.state === 'empty') body.push(Empty('layers', 'No variants yet', 'A variant is a named size that an image can be requested in, like ?v=thumb. Start with one of these.', 40, D('display:flex;gap:12px;width:100%;max-width:520px;' + (m ? 'flex-direction:column' : ''), ...TPL.map(([n, d]) => D('flex:1;border:1px solid var(--ui-border-accented);padding:12px;display:flex;flex-direction:column;gap:2px;text-align:left;min-height:' + (m ? 64 : 0) + 'px', S(MONO + ';font-weight:500;color:var(--ui-text-highlighted)', n), S('font-size:12px;color:var(--ui-text-muted)', d), S('display:flex;align-items:center;gap:4px;margin-top:8px;font-weight:500;color:var(--ui-text-highlighted)', I('plus', 14), 'Use template')))), B('Add variant', { k: 'primary', icon: 'plus', px: 16 })))
else body.push(m ? ListCards() : ListTable(), RefCard())
}
return D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column', topbar, Pg(...body))
}
// ---------- 2. edit slide-over
const E = s.ed || {}, edit = E.mode === 'edit', er = E.err || {}, dis = !!E.saving
const Cmp = () => {
const pos = E.pos || 50, hasV = !!E.out
const chip = (t, side) => S('position:absolute;top:8px;' + side + ':8px;height:20px;padding:0 6px;display:flex;align-items:center;background:var(--ui-bg);border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500;font-size:12px;' + (side === 'right' ? MONO : ''), t)
return D('position:relative;aspect-ratio:3/2;border:1px solid var(--ui-border);overflow:hidden;flex-shrink:0', Th(0, 'position:absolute;inset:0', 32),
hasV && D('position:absolute;inset:0;clip-path:inset(0 0 0 ' + pos + '%);background:repeating-linear-gradient(45deg, rgb(128 128 128 / 0.22) 0 2px, transparent 2px 6px), ' + TONES[1] + ';display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)'),
chip('Original', 'left'), hasV && chip(E.name || 'name', 'right'),
hasV && D('position:absolute;top:0;bottom:0;left:' + pos + '%;width:2px;margin-left:-1px;background:var(--ui-bg);box-shadow:0 0 0 1px var(--ui-border-accented)', S('position:absolute;top:50%;left:50%;width:' + (m ? 44 : 32) + 'px;height:' + (m ? 44 : 32) + 'px;margin:' + (m ? -22 : -16) + 'px 0 0 ' + (m ? -22 : -16) + 'px;display:flex;align-items:center;justify-content:center;background:var(--ui-bg);border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);' + (E.focus ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : ''), I('chevrons-left-right', 16))),
!hasV && D('position:absolute;left:12px;right:12px;bottom:12px;padding:8px 12px;background:var(--ui-bg);border:1px solid var(--ui-border);color:var(--ui-text-toned)', 'Set a width or height to see a preview.'))
}
const Preview = () => D('display:flex;flex-direction:column;gap:12px;min-width:0', D('display:flex;align-items:baseline;gap:8px', S('font-weight:600;color:var(--ui-text-highlighted);flex:1', 'Preview'), S('font-size:12px;color:var(--ui-text-muted)', 'Sample: north-wall.jpg')), Cmp(),
D('display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:12px', D('display:flex;flex-direction:column', S('font-weight:500;color:var(--ui-text-highlighted)', 'Original'), S('font-size:12px;color:var(--ui-text-muted)', '4032 × 3024 · 3.9 MB')), D('display:flex;flex-direction:column', S('font-weight:500;color:var(--ui-text-highlighted);' + MONO, E.name || 'name'), S('font-size:12px;color:var(--ui-text-muted)', E.out ? E.out + ' · about ' + E.est : 'No output yet'))),
S('font-size:12px;color:var(--ui-text-muted)', 'Drag the divider, or focus it and use the arrow keys. The estimate comes from a sample; output is never larger than the source.'),
D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'URL'), D('display:flex;gap:8px', D('flex:1;min-width:0;display:flex;align-items:center;min-height:' + (m ? 44 : 32) + 'px;padding:0 12px;background:var(--ui-bg-elevated);border:1px solid var(--ui-border);' + MONO + ';font-size:12px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:' + (E.name ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)'), '/media/north-wall.jpg?v=' + (E.name ? E.name : '<name>')), B(E.copied ? 'Copied' : 'Copy', { icon: E.copied ? 'check' : 'copy', dis: !E.name }))))
const FITS = [['cover', 'Cover', 'Fills the box and crops what overflows.'], ['contain', 'Contain', 'Whole image inside the box, padded to the box size.'], ['inside', 'Inside', 'Whole image inside the box, no padding.']]
const Form = () => {
const k = []
if (E.saveErr) k.push(Alert('error', 'circle-x', 'Couldn\'t save the variant', 'Check your connection and try again.', 'Try again'))
if (edit) k.push(Alert('warning', 'triangle-alert', null, 'Changing dimensions, fit, format or quality purges ' + (E.files || 244) + ' generated files; they will be regenerated on next request.'))
k.push(Field('Name', E.name, { mono: true, ph: 'e.g. hero', lock: edit, err: er.name, dis, focus: !edit && E.focusName, help: edit ? 'The name can\'t be changed.' : 'Lowercase letters, numbers and dashes. Can\'t be changed later.', count: !edit && E.name ? E.name.length + '/32' : '' }))
k.push(D('display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:12px', Field('Width', E.w, { suffix: 'px', ph: 'auto', err: er.w || er.dim, dis }), Field('Height', E.h, { suffix: 'px', ph: 'auto', err: er.h, eb: !!er.dim, dis })), !er.w && !er.h && !er.dim && S('color:var(--ui-text-muted);font-size:12px;margin-top:-8px', 'Set a width, a height, or both. 1–8000 px. Images are never enlarged.'))
const fit = E.fit || 'cover'
k.push(D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Fit'), D('display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;opacity:' + (dis ? 0.6 : 1), ...FITS.map(([id, t]) => D('min-height:' + (m ? 44 : 36) + 'px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid ' + (id === fit ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (id === fit ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';font-weight:' + (id === fit ? 600 : 400) + ';color:var(--ui-text-highlighted)', id === fit && I('check', 14), t))), S('color:var(--ui-text-muted);font-size:12px', FITS.find(f => f[0] === fit)[2])))
const fmt = E.fmt || 'Original'
k.push(Field('Format', fmt, { sel: true, dis, help: fmt === 'Original' ? 'Keeps the format of each source image.' : null }))
if (fmt !== 'PNG') k.push(D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Quality'), D('display:flex;align-items:center;gap:16px;opacity:' + (dis ? 0.6 : 1), D('flex:1;position:relative;height:' + (m ? 44 : 24) + 'px;display:flex;align-items:center', S('position:absolute;left:0;right:0;height:4px;background:var(--ui-bg-accented)'), S('position:absolute;left:0;width:' + Math.min(100, Math.max(0, E.qv === undefined ? 80 : E.qv)) + '%;height:4px;background:var(--ui-primary)'), S('position:absolute;left:calc(' + Math.min(100, Math.max(0, E.qv === undefined ? 80 : E.qv)) + '% - 8px);width:16px;height:16px;background:var(--ui-primary);border:2px solid var(--ui-bg);box-shadow:0 0 0 1px var(--ui-primary)')), D('width:84px', Field('', String(E.q === undefined ? 80 : E.q), { mono: true, err: er.q ? ' ' : null, eb: !!er.q }))), er.q ? S('display:flex;gap:6px;color:var(--ui-error);font-size:12px', I('circle-alert', 14, 'margin-top:3px'), er.q) : S('color:var(--ui-text-muted);font-size:12px', '1–100. Higher is sharper and larger.')))
k.push(D('display:flex;flex-direction:column;gap:12px;padding-top:4px', SwRow('Generate on upload', 'Creates this variant when an image is uploaded.', !!E.gen, dis), SwRow('Enabled', 'Disabled variants return 400 on the public site.', E.en !== false, dis)))
return D('display:flex;flex-direction:column;gap:16px;min-width:0', ...k)
}
const EditPanel = () => {
const title = edit ? 'Edit variant: ' + E.name : 'Add variant'
const label = E.saving ? 'Saving…' : edit ? 'Save changes' : 'Add variant'
const hd = D('height:56px;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);background:var(--ui-bg)', 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', title), !m && S('width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x')))
const ft = D('height:' + (m ? 72 : 64) + 'px;flex-shrink:0;display:flex;align-items:center;gap:' + (m ? 8 : 12) + 'px;padding:0 ' + (m ? 16 : 20) + 'px;border-top:1px solid var(--ui-border);background:var(--ui-bg)', !m && D('flex:1;display:flex;align-items:center;gap:6px;color:var(--ui-text-toned)', E.dirty && I('circle-dot', 14), E.dirty ? 'Unsaved changes' : ''), B('Cancel', { full: m, dis }), S('height:' + bh + 'px;padding:0 16px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;flex:' + (m ? 1 : '0 0 auto') + ';opacity:' + (E.disSave ? 0.4 : 1), E.saving && I('loader-circle'), label))
const body = m
? D('flex:1;min-height:0;overflow:hidden;background:var(--ui-bg)', D('padding:16px;display:flex;flex-direction:column;gap:24px;margin-top:' + (-(s.scroll || 0)) + 'px', Form(), D('border-top:1px solid var(--ui-border);padding-top:16px', Preview())))
: D('flex:1;min-height:0;overflow:hidden;display:grid;grid-template-columns:440px minmax(0,1fr);background:var(--ui-bg)', D('padding:20px;overflow:hidden', Form()), D('padding:20px;border-left:1px solid var(--ui-border);background:var(--ui-bg-muted);overflow:hidden', Preview()))
return D('width:' + (m ? '100%' : '960px') + ';height:100%;display:flex;flex-direction:column;background:var(--ui-bg);' + (m ? '' : 'box-shadow:var(--rc-shadow-modal)'), hd, body, ft)
}
// ---------- 3. media library
const ML = s.ml || {}
const Tb = () => {
const f = ML.filter || 'All'
const seg = D('display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg);flex-shrink:0', ...['All', 'With variants', 'Without'].map(t => S('height:' + (m ? 44 : 30) + 'px;padding:0 ' + (m ? 10 : 12) + 'px;display:flex;align-items:center;font-weight:500;background:' + (t === f ? 'var(--ui-primary)' : 'transparent') + ';color:' + (t === f ? 'var(--ui-bg)' : 'var(--ui-text-toned)') + ';' + (m ? 'flex:1;justify-content:center;font-size:12px' : ''), t)))
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)
}
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'))))
}
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()]
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)))
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)))
}
// ---------- 4. detail panel
const DT = s.dt || {}
const stOf = (v, i) => (DT.rows && DT.rows[i]) || 'none'
const VR = (v, i) => {
const st = stOf(v, i), gen = st === 'gen', none = st === 'none', busy = st === 'busy', fail = st === 'fail'
const bk = { gen: ['Generated', 'ok'], none: ['Not generated', 'none'], busy: ['Generating', 'busy'], fail: ['Failed', 'fail'] }[st]
const meta = gen ? v.out + ' · ' + v.fmt + ' · ' + v.kb + ' · ' : v.out + ' · ' + v.fmt
const ib = (ic, lab) => m ? B(lab, { icon: ic, h: 44, px: 12 }) : B(null, { icon: ic, sq: true, h: 28 })
const acts = gen ? [ib('link', 'Copy URL'), ib('eye', 'Preview'), ib('eraser', 'Purge')] : none ? [ib('link', 'Copy URL'), ib('zap', 'Generate now')] : fail ? [B('Retry', { icon: 'rotate-cw', h: m ? 44 : 28, fs: 12, z: 14 })] : []
return D('display:flex;flex-direction:column;gap:' + (m ? 8 : 4) + 'px;padding:10px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0') + ';background:' + (busy ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)'),
D('display:flex;align-items:center;gap:8px', S(MONO + ';font-weight:500;font-size:13px;color:var(--ui-text-highlighted)', v.n), Bd(bk[0], bk[1]), D('flex:1'), !m && D('display:flex;gap:4px', ...acts)),
D('color:var(--ui-text-toned);font-size:12px;display:flex;gap:4px;flex-wrap:wrap', S('', meta), gen && S('font-weight:500;color:var(--ui-success)', v.sv + ' vs original')),
fail && D('display:flex;align-items:center;gap:6px;color:var(--ui-error);font-size:12px', I('circle-alert', 14), DT.cause && DT.cause[i] || 'Image too large'),
m && acts.length > 0 && D('display:flex;gap:8px;flex-wrap:wrap', ...acts))
}
const DetailPanel = () => {
const im = byId(DT.id || 1), vlist = DT.none ? [] : en
const gens = vlist.filter((v, i) => stOf(v, i) === 'gen').length
const anyBusy = vlist.some((v, i) => stOf(v, i) === 'busy')
const noFmt = DT.nofmt
const tabs = D('display:flex;gap:20px;border-bottom:1px solid var(--ui-border);padding:0 ' + (m ? 16 : 20) + 'px;flex-shrink:0', ...['Details', 'Variants'].map(t => S('height:' + (m ? 44 : 40) + 'px;display:flex;align-items:center;border-bottom:2px solid ' + (t === 'Variants' ? 'var(--ui-primary)' : 'transparent') + ';margin-bottom:-1px;font-weight:' + (t === 'Variants' ? 500 : 400) + ';color:' + (t === 'Variants' ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)'), t)))
const orig = D('display:flex;gap:12px;align-items:center;padding:12px;border:1px solid var(--ui-border);background:var(--ui-bg-muted)', Th(im.tone, 'width:72px;height:54px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('display:flex;flex-direction:column;min-width:0', S('font-size:12px;color:var(--ui-text-muted)', 'Original · kept untouched'), S('font-weight:600;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', im.n), S('font-size:12px;color:var(--ui-text-toned)', im.ty + ' · ' + im.dim + ' · ' + im.sz)))
const k = [orig]
if (noFmt) k.push(Alert('info', 'info', 'Variants aren\'t generated for this format.', noFmt))
else if (DT.none) k.push(Empty('layers', 'No variants defined', 'Define a variant in Settings to get resized copies of this image.', 28, S('display:inline-flex;align-items:center;gap:4px;font-weight:500;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;min-height:' + (m ? 44 : 20) + 'px;align-items:center', 'Settings → Media → Variants', I('arrow-up-right', 14))))
else {
k.push(D('display:flex;align-items:center;gap:12px', D('flex:1;display:flex;flex-direction:column', S('font-weight:600;color:var(--ui-text-highlighted)', gens + ' of ' + vlist.length + ' generated'), gens === 0 && !anyBusy && S('font-size:12px;color:var(--ui-text-muted)', 'Nothing generated yet.'), anyBusy && S('font-size:12px;color:var(--ui-info)', 'Generating variants…')), B(anyBusy ? 'Generating…' : 'Generate all', { icon: anyBusy ? 'loader-circle' : 'zap', dis: anyBusy })))
k.push(D('border:1px solid var(--ui-border)', ...vlist.map(VR)))
}
return D('position:absolute;inset:0;z-index:40;display:flex;justify-content:flex-end;background:' + (m ? 'var(--ui-bg)' : 'var(--rc-overlay)'),
D('width:' + (m ? '100%' : '460px') + ';height:100%;background:var(--ui-bg);display:flex;flex-direction:column;' + (m ? '' : 'box-shadow:var(--rc-shadow-modal)'),
D('height:56px;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'))),
tabs, D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? 16 : 20) + 'px;display:flex;flex-direction:column;gap:16px', ...k)))
}
const PrevDlg = () => {
const v = en[0], pane = (title, sub, inner) => D('display:flex;flex-direction:column;gap:8px;min-width:0', D('height:' + (m ? 180 : 300) + 'px;overflow:hidden;border:1px solid var(--ui-border);background:var(--ui-bg-muted);display:flex;align-items:' + (m ? 'flex-start' : 'center') + ';justify-content:' + (m ? 'flex-start' : 'center'), inner), D('display:flex;flex-direction:column', S('font-weight:600;color:var(--ui-text-highlighted)', title), S('font-size:12px;color:var(--ui-text-toned)', sub)))
const body = D('padding:20px 24px 0;display:flex;flex-direction:column;gap:12px;min-height:0;overflow:hidden', D('display:grid;grid-template-columns:' + (m ? 'minmax(0,1fr)' : 'minmax(0,1fr) minmax(0,1fr)') + ';gap:16px', pane(v.n + ' · ' + v.out + ' · ' + v.fmt, v.kb + ' · saves 99%', Th(1, 'width:400px;height:300px', 32)), pane('Original · 4032×3024 · JPEG', '3.9 MB · cropped to the preview area', Th(0, 'width:4032px;height:3024px;flex-shrink:0', 32))), S('font-size:12px;color:var(--ui-text-muted)', 'Both images are shown at actual size. Scroll to pan.'))
return Shell({ title: 'Preview: ' + v.n, w: 880, top: 80, close: true, cancel: 'Close' }, body)
}
// ---------- 5. cache
const C = s.cache || {}
const empty = C.state === 'empty'
const Tile = (ic, lab, val, sub) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + (m ? 12 : 16) + 'px;display:flex;flex-direction:column;gap:4px;min-width:0', D('display:flex;align-items:center;gap:6px;color:var(--ui-text-muted)', I(ic, 14), S('', lab)), C.state === 'loading' ? Skl('60%', 28) : S('font:600 ' + (m ? 22 : 28) + 'px/36px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', val), C.state === 'loading' ? Skl('40%', 11) : S('font-size:12px;color:var(--ui-text-muted)', sub))
const BL = () => {
const rows = [...V].sort((a, b) => parseInt(b.size) - parseInt(a.size))
const mx = 84
return Card('By variant', null, D('padding:8px 16px;display:flex;flex-direction:column;gap:' + (m ? 16 : 12) + 'px;margin:8px 0', ...rows.map(v => D('display:flex;flex-direction:column;gap:6px', D('display:flex;align-items:baseline;gap:8px', S(MONO + ';font-weight:500;color:var(--ui-text-highlighted)', v.n), !v.on && Bd('Disabled', 'off', 18), D('flex:1'), S('color:var(--ui-text-toned);font-size:12px', v.files.toLocaleString('en-US') + ' files · ' + v.size)), D('height:8px;background:var(--ui-bg-accented)', S('display:block;height:8px;width:' + (parseInt(v.size) / mx * 100) + '%;background:' + (v.on ? 'var(--ui-primary)' : 'var(--ui-border-accented)')))))))
}
const TOP = [['north-wall.jpg', 6, '5.8 MB', 0], ['lime-plaster.jpg', 6, '4.9 MB', 1], ['kitchen-after.webp', 5, '4.4 MB', 1], ['kitchen-before.webp', 5, '3.9 MB', 2], ['floorboards.jpg', 5, '3.6 MB', 0], ['IMG_4471.jpg', 4, '3.1 MB', 2], ['stone-wall-detail.jpg', 4, '2.8 MB', 2], ['bathroom-tiles.png', 4, '2.6 MB', 1], ['roof-lantern.jpg', 3, '2.3 MB', 3], ['chimney-stack.jpg', 3, '2.1 MB', 0]]
const TL = () => Card('Top 10 images by variant disk usage', null, ...TOP.map(([n, c, sz, t], i) => D('display:flex;align-items:center;gap:12px;min-height:' + (m ? 60 : 40) + 'px;padding:0 ' + (m ? 12 : 16) + 'px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('width:20px;color:var(--ui-text-muted);font-size:12px;' + MONO, i + 1), Th(t, 'width:32px;height:24px;flex-shrink:0;border:1px solid var(--ui-border)', 12), D('flex:1;min-width:0;display:flex;flex-direction:' + (m ? 'column' : 'row') + ';gap:' + (m ? 0 : 12) + 'px', S('flex:1;min-width:0;font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', n), S('color:var(--ui-text-muted);font-size:12px;white-space:nowrap;' + (m ? '' : 'width:150px'), c + ' variants' + (m ? ' · ' + sz : ''))), !m && S('width:60px;text-align:right;color:var(--ui-text-toned)', sz), B(m ? null : 'Purge', { icon: 'eraser', h: m ? 44 : 26, fs: 12, z: 14, px: 10, sq: m }))))
const CachePage = () => {
const busy = C.state === 'purging'
const body = [PH('Cache', 'Generated variant files. Purged files are generated again on the next request.', B('Purge unused', { icon: 'clock', dis: busy || empty }), B('Purge all variants', { k: 'del', icon: 'trash-2', dis: busy || empty })), SetTabs('Cache')]
if (C.state === 'error') body.push(Alert('error', 'circle-x', 'Couldn\'t load cache stats', 'Check your connection and try again.', 'Try again'))
else {
if (busy) body.push(D('display:flex;flex-direction:column;gap:10px;padding:12px 14px;background:var(--rc-info-soft);color:var(--ui-info)', D('display:flex;gap:10px;align-items:center', I('loader-circle'), S('font-weight:600', 'Purging 1,284 files'), D('flex:1'), S('color:var(--ui-text-toned)', '640 of 1,284 · 106 MB freed')), D('height:6px;background:var(--ui-bg-accented)', S('display:block;height:6px;width:50%;background:var(--ui-info)'))))
const z = empty
body.push(D('display:grid;grid-template-columns:repeat(' + (m ? 2 : 4) + ',minmax(0,1fr));gap:' + (m ? 8 : 16) + 'px;opacity:' + (busy ? 0.6 : 1), Tile('files', 'Generated files', z ? '0' : '1,284', z ? 'Nothing cached' : 'Across 6 variants'), Tile('hard-drive', 'Disk used', z ? '0 MB' : '212 MB', z ? 'Nothing cached' : 'Originals not included'), Tile('layers', 'Variants defined', '6', '5 enabled, 1 disabled'), Tile('ban', 'Rejected requests', '37', 'Last 7 days')))
if (z) body.push(Empty('database', 'Nothing generated yet', 'Variants are generated on the first request with ?v=<name>, or from an image\'s Variants tab.', 48))
else if (C.state === 'loading') body.push(D('display:grid;grid-template-columns:' + (m ? 'minmax(0,1fr)' : '460px minmax(0,1fr)') + ';gap:16px', Card('By variant', null, D('padding:16px;display:flex;flex-direction:column;gap:20px', ...Array.from({ length: 5 }, () => D('display:flex;flex-direction:column;gap:8px', Skl('50%'), Skl('100%', 8))))), !m && Card('Top 10 images by variant disk usage', null, D('padding:16px;display:flex;flex-direction:column;gap:20px', ...Array.from({ length: 6 }, () => Skl('90%'))))))
else body.push(D('display:grid;grid-template-columns:' + (m ? 'minmax(0,1fr)' : '460px minmax(0,1fr)') + ';gap:16px;align-items:start;opacity:' + (busy ? 0.6 : 1), BL(), TL()))
}
return D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column', topbar, Pg(...body))
}
const cacheModal = () => {
const k = s.modal
if (k === 'purgeAll' || k === 'purgeAllOk') return { title: 'Purge all variants?', desc: 'This removes 1,284 generated files (212 MB) for every variant. Originals aren\'t touched. Files are generated again on request.', body: [Field('Type purge to confirm', k === 'purgeAllOk' ? 'purge' : 'pur', { mono: true, focus: true })], cancel: 'Cancel', confirm: 'Purge all variants', tone: 'danger', cdis: k === 'purgeAll' }
if (k === 'purgeUnused') return { title: 'Purge unused variants?', desc: 'This removes 312 files (41 MB) from enabled variants that haven\'t been requested for 30 days. Files are generated again on request.', cancel: 'Cancel', confirm: 'Purge 312 files' }
if (k === 'purgeVariant') return { title: 'Purge cache for “hero”?', desc: 'This removes 244 generated files (84 MB). Files are generated again on the next request.', cancel: 'Cancel', confirm: 'Purge 244 files' }
if (k === 'deleteVariant') return { title: 'Delete variant “hero”?', desc: 'This removes the variant and its 244 generated files (84 MB). Articles that use ?v=hero will start returning 400. This can\'t be undone.', cancel: 'Cancel', confirm: 'Delete variant', tone: 'danger', loading: !!s.loadingModal }
if (k === 'discard') return { title: 'Discard changes?', desc: 'You have unsaved changes to “hero”. If you close now, they\'re lost.', cancel: 'Keep editing', confirm: 'Discard changes', tone: 'danger' }
if (k === 'deleteImage') return { title: 'Delete this image?', desc: '"north-wall.jpg" is used in 2 articles. If you delete it, they will show a broken image. This can\'t be undone.', body: [D('display:flex;align-items:center;gap:8px;padding:10px 12px;background:var(--rc-warning-soft);color:var(--ui-warning)', I('layers'), S('font-weight:500;color:var(--ui-text-highlighted)', 'Also removes 4 generated variants (260 KB)'))], cancel: 'Cancel', confirm: 'Delete image', tone: 'danger' }
}
const Modal = () => { const c = cacheModal(); return Shell(c, D('padding:' + (c.body ? '20px 24px 0' : '0') + ';display:flex;flex-direction:column;gap:16px', ...(c.body || []))) }
// ---------- 6. editor + picker
const ED = s.edr || {}
const VOPT = [['Original', '4032×3024', 'JPEG'], ...en.map(v => [v.n[0].toUpperCase() + v.n.slice(1), dim(v), v.fmt === 'JPEG' ? 'JPEG' : v.fmt])]
const VSel = () => {
const cur = ED.cur ?? 0, open = ED.open, none = ED.none
const opts = none ? [VOPT[0]] : VOPT
const lab = o => o[0] + ' · ' + o[1] + ' · ' + o[2]
return D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Variant'),
D('display:flex;align-items:center;min-height:' + (m ? 44 : 40) + 'px;border:1px solid ' + (open ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:var(--ui-bg);font-size:' + (m ? 16 : 13) + 'px;' + (open ? 'box-shadow:0 0 0 1px var(--ui-primary)' : ''), S('flex:1;min-width:0;padding:0 12px;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', lab(opts[cur])), S('padding:0 12px;display:flex;color:var(--ui-text-muted)', I('chevron-down', 16))),
open && D('border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);background:var(--ui-bg);padding:4px', ...opts.map((o, i) => D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 44 : 36) + 'px;padding:0 8px;background:' + (i === cur ? 'var(--ui-bg-elevated)' : 'transparent') + ';color:var(--ui-text-highlighted)', S('width:16px;display:flex', i === cur && I('check', 14)), S('font-weight:' + (i === cur ? 600 : 400) + ';white-space:nowrap;overflow:hidden;text-overflow:ellipsis', lab(o))))),
none ? D('display:flex;align-items:center;gap:6px;color:var(--ui-text-muted);font-size:12px', I('info', 14), 'No variants defined. Add one in Settings → Media → Variants.') : D('color:var(--ui-text-muted);font-size:12px;' + MONO, '/media/north-wall.jpg' + (cur ? '?v=' + opts[cur][0].toLowerCase() : '')))
}
const pT = id => D('display:flex;flex-direction:column;gap:6px;min-width:0', D('position:relative;' + (id === 1 ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : ''), Th(byId(id).tone, 'aspect-ratio:1/1;border:1px solid var(--ui-border)', 20), id === 1 && S('position:absolute;top:4px;right:4px;width:20px;height:20px;display:flex;align-items:center;justify-content:center;background:var(--ui-primary);color:var(--ui-bg)', I('check', 14))), S('font-size:12px;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', byId(id).n))
const Picker = () => Shell({ title: 'Add an image', close: true, w: 640, top: 24, cancel: 'Cancel', confirm: 'Insert image' },
D('display:flex;flex-direction:column;min-height:0;flex:1;overflow:hidden', D('display:flex;gap:20px;border-bottom:1px solid var(--ui-border);padding:0 24px;margin-top:16px', ...['Upload', 'Library'].map(t => S('height:' + (m ? 44 : 36) + 'px;display:flex;align-items:center;gap:6px;border-bottom:2px solid ' + (t === 'Library' ? 'var(--ui-primary)' : 'transparent') + ';margin-bottom:-1px;font-weight:' + (t === 'Library' ? 500 : 400) + ';color:' + (t === 'Library' ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)'), I(t === 'Upload' ? 'upload' : 'images', 14), t))),
D('padding:16px 24px 0;display:flex;flex-direction:column;gap:12px;overflow:hidden', D('display:grid;grid-template-columns:repeat(' + (m ? 3 : 4) + ',minmax(0,1fr));gap:12px ' + (m ? 8 : 12) + 'px', ...[1, 2, 3, 5].slice(0, m ? 3 : 4).map(pT)),
D('display:flex;flex-direction:column;gap:12px;padding-top:12px;border-top:1px solid var(--ui-border)', D('display:flex;gap:12px;align-items:center', Th(0, 'width:48px;height:48px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('display:flex;flex-direction:column;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', 'north-wall.jpg'), S('font-size:12px;color:var(--ui-text-muted)', '4032 × 3024 · 3.9 MB'))), VSel()))))
const Editor = () => {
const bl = ED.block
const img = D('position:relative;aspect-ratio:16/9;border:1px solid var(--ui-border);overflow:hidden', Th(0, 'position:absolute;inset:0;opacity:' + (bl ? 0.5 : 1), 40, bl ? 'image-off' : 'image'), S('position:absolute;top:8px;left:8px;height:20px;padding:0 6px;display:flex;align-items:center;background:var(--ui-bg);border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-size:12px;font-weight:500;' + MONO, 'hero · 1200×630'))
return D('flex:1;min-height:0;overflow:hidden;background:var(--ui-bg-muted);display:flex;flex-direction:column',
D('background:var(--ui-bg);border-bottom:1px solid var(--ui-border);padding:12px ' + pad + 'px;display:flex;align-items:center;gap:12px', S('flex:1;font:600 ' + (m ? 16 : 20) + 'px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'How we price a bathroom renovation'), !m && B('Save', { dis: true }), !m && B('Publish', { k: 'primary' })),
D('flex:1;overflow:hidden;padding:' + (m ? '20px 16px' : '32px 0') + ';display:flex;justify-content:center', D('width:100%;max-width:680px;display:flex;flex-direction:column;gap:16px;font-family:\'Source Serif 4\',serif;font-size:' + (m ? 16 : 18) + 'px;line-height:1.66;color:var(--ui-text-toned)',
S('display:block', 'A bathroom is priced by what has to happen behind the walls before anything gets tiled.'),
bl && D('display:flex;flex-direction:column;border:1px solid var(--ui-warning);font-family:\'IBM Plex Sans\',sans-serif;font-size:13px;line-height:20px', img, Alert('warning', 'triangle-alert', ED.msg, 'This image returns an error on the public site.', 'Pick another variant', { icon: 'layers' })),
S('display:block', 'We don\'t add a margin on materials you choose yourself.'))))
}
const EditorPage = () => D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column', topbar, Editor())
// ---------- compose
const k = s.kind
let main
if (k === 'list') main = ListPage()
else if (k === 'edit') main = m ? null : ListPage()
else if (k === 'media' || k === 'detail') main = MediaPage()
else if (k === 'cache') main = CachePage()
else main = EditorPage()
const root = D('width:' + (m ? 390 : 1440) + 'px;height:' + (m ? 844 : 900) + 'px;position:relative;display:flex;flex-direction:' + (m ? 'column' : 'row') + ';background:var(--ui-bg-muted);color:var(--ui-text);font-family:\'IBM Plex Sans\',sans-serif;font-size:13px;line-height:20px;overflow:hidden',
!m && side, main,
k === 'edit' && (m ? D('position:absolute;inset:0;z-index:40;display:flex', EditPanel()) : D('position:absolute;inset:0;z-index:40;display:flex;justify-content:flex-end;background:var(--rc-overlay)', EditPanel())),
k === 'detail' && (DT.preview ? [DetailPanel(), PrevDlg()] : DetailPanel()),
k === 'editor' && ED.picker && Picker(),
m && s.drawer && D('position:absolute;inset:0;z-index:60;display:flex;background:var(--rc-overlay)', D('width:300px;max-width:85%;height:100%', Side(true))),
s.modal && Modal(), s.toasts && Toasts())
return { view: root }
}
}
</script>
</body>
</html>
+16 -1
View File
@@ -322,6 +322,21 @@ Added by the Media screens, none of which exist yet:
| My activity | `/activity` | No strip, Person filter or Export; own events only |
| Export | Header button, toasts | "Preparing file…", then "Export started. Your file will download shortly." or "Couldn't export activity. Try again." |
| Home card | `/home` | Owner or admin, regular user, loading, error, empty |
| Retention | `/settings` | Default, shortening (confirm), Forever, save error |
| Retention | `/settings` | Default (90 days or 5 GB), age only, size only (Forever), shortening (confirm), save error |
401 is global. The 403 on `/activity` has local copy because the page itself is refused; the 403 on the summary or export of a non-admin is never shown, since the controls are hidden.
## Added with Image variants
Every state is drawn on `source/RenewCMS Screens Image variants.dc.html` and listed with its trigger and copy in [image-variants](screens/image-variants.md), which also holds the states matrix and the back-office error copy (400, 401, 403, 404, 409, 413, 422, 500).
| Screen | Route | States |
|---|---|---|
| Variants list | `/settings/media/variants` | Loaded, loading, empty (two templates), error, forbidden, row menu, row action in progress, purge confirm, delete confirm, delete in progress, request reference |
| Add / edit variant | slide-over (desktop), full page (mobile) | Create pristine and dirty, field errors, no dimension, name taken (409), saving, save error, edit pristine and dirty, unsaved changes, PNG without quality |
| Media library | `/media` | Grid with badges, sort menu, filter With variants, filter Without, nothing to show, delete confirm with variants |
| Image detail | `/media` (panel) | Partly generated, nothing generated, generating, generation failed, no variants defined, SVG source, animated GIF source, preview dialog |
| Cache | `/settings/media/cache` | Loaded, loading, empty, purging, purge done, purge failed, error, purge all (confirm, typed), purge unused |
| Editor picker | `/article/:id/edit` | Variant select, select open, no variants defined, variant deleted, variant disabled |
The public request contract (200, 400 `unknown_variant` or `unsupported_parameter`, 404) is not a screen; it is section 7 of the same file.
+127
View File
@@ -0,0 +1,127 @@
# Image variants
An owner or admin defines named variants of the stored images. A request for
`/static/uploadedImages/<storedName>?v=<name>` generates the variant on first use, caches it and
serves the cached file afterwards. The original is never changed. Only a variant that exists and is
enabled is served. The visual spec is `docs/design/screens/image-variants.md`; the management routes
are in [api.md](api.md#image-variants); the layering is in [architecture.md](architecture.md#image-variants).
## Public contract
| Request | Result |
| --- | --- |
| `…/a1b2.png` | The stored file, exactly as before this feature. |
| `…/a1b2.png?v=thumb` | 200, the cached variant. Other query parameters (`t=123`) are ignored. |
| `?v=` with an unknown, disabled, deleted or badly written name | 400 `{"status":400,"error":"unknown_variant","message":"Unknown variant","detail":"<name>"}`. |
| `width`, `height`, `format` or `quality` | 400 `unsupported_parameter` with the parameter as `detail`. It wins over `unknown_variant`. |
| A missing source (also `..` and nested paths) | 404, after the parameter checks. |
| A blob that has no image record (a generated variant file, an orphan) | 404 for `?v=`, after the parameter checks and before any storage read. Without `?v=` the blob is served as before. |
| A source bigger than `IMAGE_VARIANT_MAX_SOURCE_BYTES` | 413 `The source image is too large to generate this variant.` (plain text). The original is never capped. |
| A source whose header declares more than `IMAGE_VARIANT_MAX_SOURCE_PIXELS` (width x height) | The same 413 and body. The header is read before any pixel buffer is allocated, so a small file declaring 30000 x 30000 is refused without being decoded. |
| A source that cannot be decoded, a header that cannot be read, or a failing render | 500, nothing is cached, the next request tries again. |
The 400 body is JSON with `Content-Type: application/json`. Every other error is plain text.
SVG sources and animated GIFs are served as they are, with the variant parameters validated but no
generation, even above the size cap. A one-frame GIF is a normal raster source.
The response carries an `ETag` derived from the variant settings (width, height, fit, format, quality)
and the source stored name. It is an opaque quoted value. `If-None-Match` answers 304 before the cache
is read. Changing `generate_on_upload` or `enabled` does not change it; changing any rendering
setting does, so a client holding an old value gets a fresh 200. `Cache-Control` stays
`public, no-cache`.
## Cache
Every generated file is a blob in `image.Storage` (local directory or S3) plus a row in
`image_variant_files` (variant, source stored name, cached stored name, content type, size, settings
fingerprint). The pair (variant, source) is unique. Concurrent first requests share one generation
(`singleflight` keyed by variant, settings and source); different variants or different images are
generated in parallel.
Only a stored name that has an image record can be a variant source, so generated files and orphan blobs
are never rendered. At most `IMAGE_VARIANT_MAX_CONCURRENT_RENDERS` (default 4) renders run at once:
the slot is taken inside the shared generation, so identical concurrent requests use one slot, and a
request waiting for a slot stops waiting when its client goes away, without rendering. A cached
file never takes a slot. The pixel cap defaults to 50000000 pixels.
- Editing a rendering setting (`width`, `height`, `fit`, `format`, `quality`) purges the variant's
files; the next request regenerates. Switching `generate_on_upload` or `enabled` keeps them, so
re-enabling a variant serves from its kept cache.
- A row whose fingerprint differs from the variant (an edit raced a generation) or whose blob is gone
is regenerated and replaced, and the old blob is removed.
- Deleting a variant, purging it, deleting a source image or merging copies removes the blobs first
and the rows second. A blob that cannot be deleted keeps its row, the call answers 500 and a retry
finishes the job. A blob that is already missing does not stop a purge.
- `generate_on_upload` is stored and returned only. Generating at upload time is not part of this
release.
### Request tracking and the Cache screen
`/settings/media/cache` shows what the cache holds and purges it by scope (all, unused, one variant,
one image). It reads `GET /v1/image-variants/cache` and writes `POST /v1/image-variants/cache/purge`
([api.md](api.md)).
Two things are tracked for it, without a database write per request. `usage.Tracker`
(`internal/infrastructure/usage`) keeps in memory, behind the `image.VariantUsageTracker` port, the
last time each (variant, source) file was requested and a counter of rejected requests per UTC day.
`ServeVariantUseCase` records a hit when a cached file is served, when a file is generated by the
request and on a 304; it counts a rejection when the request answers 400 (unknown or disabled
variant, unsupported parameter). A missing source (404), a request without `?v=` and the admin
generation routes record nothing. The tracker flushes one batch through `image.VariantUsageRepository`
every 30 seconds, as soon as 500 distinct files are pending, before `GET /v1/image-variants/cache`
reads, and on graceful shutdown; only `api/dependecyInjection.go` builds it and starts the loop. A
failed flush keeps the pending hits and counts, so nothing is lost or counted twice. The stored
`last_requested_at` of a file never moves back, a late update for a purged file changes nothing, and
day rows older than 7 days are deleted by the flush.
A file is unused when its reference time (`last_requested_at`, or `created_at` when never requested)
is older than 30 days and its variant is enabled. A purge reads the rows in batches of 500 and keeps
the rows whose blob cannot be deleted. Each scope records its own activity action
(`media.variant_purge_all`, `media.variant_purge_unused`, `media.variant_purge`,
`media.variant_purge_image`).
## Geometry
Images are never enlarged.
- With one dimension, the image keeps its aspect ratio and the output is at most that size.
- `inside` fits the whole image in the box, without padding.
- `cover` fills the box and crops around the centre. A box larger than the source is first scaled
down, keeping its aspect ratio, until it fits inside the source (300 x 200 into 400 x 300 gives
267 x 200).
- `contain` with both width and height scales the image to fit inside the box, never enlarging it, and
centres it on a canvas of exactly width x height: a 300 x 200 image in a 400 x 300 box stays
300 x 200 on a 400 x 300 canvas, and a 40 x 20 image in a 40 x 40 box is padded to 40 x 40. With a
single dimension there is no box, so no padding and no upscaling.
### Padding colour of `contain`
The design does not draw it, so it is decided here: the padding is transparent for `png` and `webp`
outputs and white (`#FFFFFF`) for `jpeg` outputs, which have no alpha. A `jpeg` output also flattens
any transparency of the source onto white (`#FFFFFF`).
## Formats
`original` keeps the source format (a GIF source becomes PNG). `jpeg` and `webp` use `quality`
(default 82); `png` ignores it and the API returns `quality: null`. A WebP source always decodes. The
renderer reports the formats it can encode; the API refuses a format it cannot encode with
`Format must be original, jpeg or png.`.
## Spike: a CGO-free lossy WebP encoder
The standard library and `golang.org/x/image` can decode WebP but not encode it, and the build must
stay `CGO_ENABLED=0` (six release targets, pure-Go SQLite).
| Candidate | Lossy | `CGO_ENABLED=0` | Verdict |
| --- | --- | --- | --- |
| `golang.org/x/image/webp` | decode only | yes | Cannot encode. |
| `github.com/gen2brain/webp` v0.6.4 (libwebp translated to Go with wasm2go; `purego` only for the optional dynamic library) | yes, with alpha | yes | Adopted. |
The spike built `gen2brain/webp` for linux, darwin and windows on amd64 and arm64 with
`CGO_ENABLED=0`, and `go list -deps` shows no package with cgo files. On a noisy 4032 x 3024 image
a lossy encode took about 0.75 s at quality 20 and 1.5 s at quality 95 (a smaller, real photograph is
faster). Output is `RIFF....WEBP`, and a lower quality gives a smaller file. Decision: `webp` is offered
as an output format. Should the dependency ever be dropped, `NewVariantRendererEncoding` already
builds a renderer without the WebP encoder: the API then refuses `webp` with 422, and a WebP source
with format `original` is served as PNG.
+1
View File
@@ -34,3 +34,4 @@ Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create -
| `v2.8.1-beta` | v2.8.1-beta - Docker deployment |
| `v2.8.2-beta` | v2.8.2-beta - Security headers and clearer log in errors |
| `v2.8.3-beta` | v2.8.3-beta - Security policy fixes for the web app |
| `v3.1.0-beta` | v3.1.0-beta - Image variants |
+43
View File
@@ -0,0 +1,43 @@
This release adds image variants: named sizes and formats of your images, generated on first use and cached, plus a size limit for the activity log.
## New
**Image variants**
- Owners and admins define named variants (width, height, fit, format and quality) in the new variants screens under Settings. Variants can be created, edited, enabled, disabled, purged and deleted.
- Add `?v=<name>` to any uploaded image URL to get the variant. It is generated on the first request, cached, and served from the cache afterwards. The original is never changed, and URLs without `?v=` behave as before.
- Each image in the media library shows its variants, and lets you generate or purge them one by one or all at once.
- The new Cache screen shows how many files are generated, the disk space used, how many requests were rejected and what has been unused for 30 days. Purge everything, unused files, one variant or one image.
- Output can keep the original format or be converted to JPEG, PNG or lossy WebP. Images are never enlarged. SVG and animated GIF are served as they are.
- Oversized sources are refused with a clear error instead of exhausting memory, and identical concurrent requests share one generation.
- The new screens are available in English and French.
**Activity log**
- Retention can now also be capped by size, in GB, in addition to its age. Whichever limit is reached first removes the oldest events. No cap is set by default.
## Improvements and fixes
- Fix the missing routes of the integration pages.
- Harmonize the design of the latest screens with the design spec.
## API
- `GET /v1/image-variants` and `POST /v1/image-variants` list and create variants. `PUT /v1/image-variants/{name}` replaces a definition, `PATCH` enables or disables it and `DELETE` removes it with its files. `POST /v1/image-variants/{name}/purge` removes the generated files only.
- `GET /v1/image-variants/cache` returns the cache statistics, and `POST /v1/image-variants/cache/purge` purges by scope (`all`, `unused`, `variant` or `image`).
- `GET /v1/media/{id}/variants` lists the variants of an image. `POST /v1/media/{id}/variants` generates all missing ones and `POST` or `DELETE /v1/media/{id}/variants/{name}` generates or purges one.
- All of these routes are for owners and admins and accept a session only: an API key answers `403`.
- `GET /static/uploadedImages/<storedName>?v=<name>` serves the variant. An unknown or disabled name, or a `width`, `height`, `format` or `quality` parameter, answers `400`. A source over the size or pixel limit answers `413`. The response carries an `ETag` and answers `304` to `If-None-Match`.
- `PATCH /v1/settings` also accepts a maximum activity log size in GB, returned as `activity_retention_max_gb`.
## Under the hood
- Add the image variant domain, repositories, renderer, in-memory usage tracker flushed every 30 seconds, and use cases, behind the existing storage port so local and S3 storage both work.
- Document the feature in `docs/image-variants.md`, `docs/api.md` and `docs/architecture.md`, and add the specifications, design screens and Go and Vitest suites.
## Upgrading
- Replace the binary, or pull the new image. The variant tables are created automatically at startup. No variant exists until you define one.
- Three optional environment variables are new: `IMAGE_VARIANT_MAX_SOURCE_BYTES` (`10485760`), `IMAGE_VARIANT_MAX_SOURCE_PIXELS` (`50000000`) and `IMAGE_VARIANT_MAX_CONCURRENT_RENDERS` (`4`). An existing install needs no change.
- Generated variants are stored next to your images, in the upload directory or the S3 bucket, and count toward the storage you use.
- Generating a variant when an image is uploaded is not part of this release.
+9 -2
View File
@@ -30,7 +30,7 @@ Feature: The back-office keeps an append-only activity log that owners can read
| change.kind | text, body |
Scenario: The repository exposes no way to edit or delete one event
Then the audit Repository interface has exactly the methods Append, List, Summary and DeleteOlderThan
Then the audit Repository interface has exactly the methods Append, List, Summary, DeleteOlderThan, DeleteOldest and SizeBytes
And the persistence implementation exposes no other exported method than those and its constructor
And the HTTP surface has no POST, PUT, PATCH or DELETE under /v1/activity
@@ -464,7 +464,7 @@ Feature: The back-office keeps an append-only activity log that owners can read
| 2026-10-06T03:00:00 | 2026-10-06T04:00:00 | false |
| 2026-10-06T03:00:00 | 2026-10-07T03:00:00 | true |
Scenario: Forever never runs
Scenario: Forever with no size limit never runs
Given the retention is 0
When Tick runs at 2026-10-06T03:00:00
Then ran is false and DeleteOlderThan is not called
@@ -559,3 +559,10 @@ Feature: The back-office keeps an append-only activity log that owners can read
When the owner sends GET /v1/activity
Then the three events are listed newest first with their documented items
And GET /v1/activity?security=true lists only the failed login
Scenario: The size limit extension amends the retention rules
Given features/activity_home_card_and_retention.feature, rule "Retention size limit"
Then the settings default also has activity_retention_max_gb 0 and activity_log_size_bytes 0
And PATCH /v1/settings also accepts activity_retention_max_gb validated against audit.SizeOptions()
And the last 24 hours are never removed by DeleteOldest either
And a retention change is recorded with the max_gb_from and max_gb_to params when the size changes
@@ -7,7 +7,7 @@ Feature: Recent activity card on Home and Retention card on Settings
Background:
Given the activity API (features/activity-log-backend.feature, activity-log-producers.feature, activity_screen.feature) and the settings retention fields already exist
And this slice adds one field to the settings API, activity_next_cleanup_at, and wires the SPA to the existing endpoints; there is no other Go change
And the Home card and the Settings "Retention" card are the two items activity_screen.feature left out of scope; the spec of record is docs/design/screens/home.md ("Recent activity card") and docs/design/screens/settings.md ("Activity retention"), groups "Home card" and "Settings retention" of RenewCMS Screens Activity.dc.html
And the Home card and the Settings "Retention" card are the two items activity_screen.feature left out of scope; the spec of record is docs/design/screens/home.md ("Recent activity") and docs/design/screens/settings.md ("Activity retention"), groups "Home" and "Settings: Retention" of RenewCMS Screens Back-office.dc.html
And both boards draw the existing cards as dashed placeholders, so neither card is pixel-compared and Vitest is the SPA coverage
And "owner" and "admin" are the full Home view; any other or absent session role is the regular-user view
And every new English string has a French entry in messages.ts
@@ -347,3 +347,272 @@ Feature: Recent activity card on Home and Retention card on Settings
Scenario: Existing behaviour is unchanged
Then every scenario of home.feature, settings_page.feature (except its exact-body statement, superseded above) and the Media cleanup scenarios still holds with GET /v1/activity?size=5 mocked and the settings mock widened with the four activity_* fields
Rule: Retention size limit, an optional maximum size as an alternative to the age limit
Background:
Given this rule supersedes the statements above where they conflict: the settings body, the next-cleanup rule, the PATCH validation, the card copy and the shortening rule
And this extension changes Go code in the audit settings, the audit repositories, the settings and cleanup use cases and the settings handler
And GET /v1/settings and the PATCH response carry exactly {"version", "activity_retention_days", "activity_retention_max_gb", "activity_log_size_bytes", "activity_last_cleanup_at", "activity_last_cleanup_removed", "activity_next_cleanup_at"}, snake_case
And activity_next_cleanup_at is null only when activity_retention_days is 0 and activity_retention_max_gb is 0, otherwise it is the next 03:00 (audit.CleanupHour) in the server zone strictly after now, so Forever with a size limit has a schedule
And the PATCH body is a partial object with activity_retention_days in {0, 30, 90, 365} and/or activity_retention_max_gb in {0, 1, 5, 10, 25, 50}, absent keys are left unchanged, and an empty object, a null, a wrong type, any other value or malformed JSON answers 400 with apperr.BodyErrorMessage and saves nothing
And a non-owner PATCH answers 403 "Only the owner can do this."
And validation goes through validate tags and validator.Validate.Struct
And the size options are one list in the audit domain, audit.SizeOptions(), mirroring RetentionOptions()
And activity_log_size_bytes is the payload size of the activity events table, measured by audit.Repository.SizeBytes: the sum of the OCTET_LENGTH of the text columns action, actor_name, target_type, target_id, target_name, failure_reason, ip, user_agent, params and changes plus a fixed per-row overhead constant defined in the domain
And it is not the database file size and excludes indexes, free pages and other tables, and repositories never touch files
And the audit Repository gains SizeBytes() (int64, error) and DeleteOldest(limit int, before time.Time) (int, error); DeleteOldest removes the oldest events by created_at then id and only those older than before
And the cleanup runs the age step when days is greater than 0, then removes the oldest events in batches through DeleteOldest, calling SizeBytes between batches, until the log is at or under the limit or a batch removes 0
And the cutoff of every removal is the 24 hour guard (audit.KeepRecentHours); when the limit cannot be met because of the guard, the cleanup stops without error, records the run and tries again at the next 03:00
And Tick returns early only when days is 0 and max_gb is 0
And the removed count is the total of the age and size removals, in the last-cleanup fields and in the system.activity_cleanup params {"removed": n}
And system.activity_retention_changed is recorded once per PATCH that changes something, with params {"from","to"} when the age changes and "max_gb_from" and "max_gb_to" when the size changes, and no event when nothing changes
And lib/activitySentence.ts has an English and a French sentence for a size change
And GET /v1/settings computes the size on every call and a SizeBytes failure answers 500, like any use case error; every verified user receives the number, only the owner sees the card
And the SPA shows the size in GB (1 GB is 1024^3 bytes) with one decimal, or "Less than 0.1 GB" below 0.1 GB
And the header description is "How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events. Only owners can change it."
And the append-only alert reads "Events can't be edited or deleted from the app. Only the retention limits remove them."
And below the age select an "or" divider is followed by the label "Keep at most" over a USelectMenu 160 px wide (full width on mobile) with the items "No size limit", "1 GB", "5 GB", "10 GB", "25 GB", "50 GB" (values 0, 1, 5, 10, 25, 50)
And the age helper is "Older events are removed automatically." without a size limit, "Events older than this are removed." with one, "Nothing is removed automatically. The log grows over time." for Forever without a size limit and "No age limit. Only the size limit applies." for Forever with one
And the size helper is "Optional. Logs can grow without a size cap." without a limit and "When logs pass 5 GB, the oldest events are removed. Currently 3.2 GB." with one, the figure being activity_log_size_bytes and never a hard-coded value
And the size rank is 1 < 5 < 10 < 25 < 50 < No size limit and a lower rank than the saved one is "smaller"
And choosing a smaller size, including a size when there was none, sends nothing and shows the warning "Events over {n} GB will be removed" with "This happens at the next cleanup on {date}. It can't be undone.", "Apply {n} GB" and "Cancel"; choosing a larger size or "No size limit" saves at once
And each select sends only its own key, both share one status area, one error alert and one pending warning, and a warning concerns one control at a time: a longer or larger choice on either control drops it and another shortening replaces it
And the visual states are "Default, 90 days or 5 GB", "Age only (no size limit)", "Size only (forever)", the shortening warning with a 5 GB limit, Forever and Save error
Scenario Outline: Settings API, GET reports the size limit and the measured size
Given the saved retention is <days> days with a size limit of <gb> GB and the activity table holds <bytes> bytes of payload
When a verified user sends GET /v1/settings at the server time 2026-10-06T10:00:00+02:00
Then the status is 200 and the body has exactly the seven keys
And "activity_retention_max_gb" is <gb> and "activity_log_size_bytes" is <bytes>
And "activity_next_cleanup_at" is <next>
Examples:
| days | gb | bytes | next |
| 90 | 0 | 0 | "2026-10-07T03:00:00+02:00" |
| 90 | 5 | 3435973837 | "2026-10-07T03:00:00+02:00" |
| 0 | 5 | 3435973837 | "2026-10-07T03:00:00+02:00" |
| 0 | 0 | 1024 | null |
Scenario: Settings API, a fresh install defaults to no size limit
Given no settings row exists
When a verified user sends GET /v1/settings
Then activity_retention_days is 90, activity_retention_max_gb is 0 and activity_log_size_bytes is 0
Scenario Outline: Settings API, PATCH saves a size limit, an age, or both
Given the saved retention is 90 days and no size limit and the user is the owner
When the owner sends PATCH /v1/settings with <body>
Then the status is 200 and the body has activity_retention_days <days>, activity_retention_max_gb <gb> and activity_next_cleanup_at <next>
And the repository holds <days> and <gb>
Examples:
| body | days | gb | next |
| {"activity_retention_max_gb": 5} | 90 | 5 | the next 03:00 server time |
| {"activity_retention_days": 0, "activity_retention_max_gb": 5} | 0 | 5 | the next 03:00 server time |
| {"activity_retention_days": 30} | 30 | 0 | the next 03:00 server time |
| {"activity_retention_days": 0} | 0 | 0 | null |
Scenario: Settings API, leaving the size limit while Forever clears the schedule
Given the saved retention is 0 days with a size limit of 5 GB
When the owner sends PATCH /v1/settings with {"activity_retention_max_gb": 0}
Then activity_next_cleanup_at is null
Scenario Outline: Settings API, PATCH rejects invalid size input
Given the saved retention is 90 days with a size limit of 5 GB
When the owner sends PATCH /v1/settings with <body>
Then the status is 400 with the text of apperr.BodyErrorMessage and nothing is saved
Examples:
| body |
| {"activity_retention_max_gb": 2} |
| {"activity_retention_max_gb": -1} |
| {"activity_retention_max_gb": 100} |
| {"activity_retention_max_gb": 5.5} |
| {"activity_retention_max_gb": "5"} |
| {"activity_retention_max_gb": null} |
| {"activity_retention_days": 30, "activity_retention_max_gb": 2} |
| {} |
Scenario: Settings API, a PATCH changing nothing is a no-op
Given the saved retention is 90 days and 5 GB
When the owner sends PATCH /v1/settings with {"activity_retention_days": 90, "activity_retention_max_gb": 5}
Then the status is 200, nothing is saved and no audit event is recorded
Scenario: Settings API, a non-owner cannot set the size limit
When an admin sends PATCH /v1/settings with {"activity_retention_max_gb": 5}
Then the status is 403 with "Only the owner can do this." and nothing is saved
Scenario: Settings API, a failing size measurement answers 500
Given SizeBytes fails
When a verified user sends GET /v1/settings
Then the status is 500 with the generic internal error text
Scenario Outline: Settings API, the change is audited
Given the saved retention is 90 days and no size limit
When the owner sends PATCH /v1/settings with <body>
Then one event "system.activity_retention_changed" exists with params <params>
Examples:
| body | params |
| {"activity_retention_days": 30} | {"from": 90, "to": 30} |
| {"activity_retention_max_gb": 5} | {"max_gb_from": 0, "max_gb_to": 5} |
| {"activity_retention_days": 30, "activity_retention_max_gb": 5} | {"from": 90, "to": 30, "max_gb_from": 0, "max_gb_to": 5} |
Scenario Outline: Cleanup, the size limit removes the oldest events
Given the retention is <days> days, the size limit is 5 GB and the log is 7 GB
And the oldest events are older than the guard
When the cleanup runs at 2026-10-06T03:00:00
Then events are removed oldest first, by created_at then id, until SizeBytes is at or under 5 GB
And the removed count is the total of the age removals and the size removals
And the last cleanup time is the clock's time
Examples:
| days |
| 90 |
| 0 |
Scenario: Cleanup, age first then size
Given the retention is 30 days, the size limit is 5 GB, 100 events are older than 30 days and the log still exceeds 5 GB after they go
When the cleanup runs
Then the 100 events go first, then the oldest remaining events until the log is at or under 5 GB
Scenario: Cleanup, the age limit alone is enough
Given the log is under the size limit after the age removal
When the cleanup runs
Then no size removal happens
Scenario: Cleanup, Forever with no size limit never runs
Given the retention is 0 days and no size limit
When Tick runs at 2026-10-06T03:00:00
Then ran is false and neither DeleteOlderThan nor DeleteOldest is called
Scenario: Cleanup, the last 24 hours are protected even when the size cannot be met
Given the size limit is 1 GB and the log is 4 GB, all of it from the last 24 hours
When the cleanup runs
Then nothing is removed, no error is returned, the last cleanup records 0 removed and the next tick tries again
Scenario: Cleanup, a failing size removal is retried
Given DeleteOldest fails
When Tick runs
Then the error is returned and logged, the last cleanup fields are unchanged and the next tick runs again
Scenario: Cleanup, a size removal records one system event
Given the age step removed 10 events and the size step removed 40
When the cleanup runs
Then one "system.activity_cleanup" event exists with params {"removed": 50}
Scenario: Persistence measures the table payload portably
Given a real in-memory SQLite database and events with known text lengths, one with a multi-byte name
Then SizeBytes equals the sum of the byte lengths of the text columns plus the fixed per-row overhead
And it is 0 for an empty table
And deleting events lowers it
Scenario: Persistence deletes the oldest events up to a limit and a guard
Given 10 events, 3 of them newer than the guard
When DeleteOldest runs with limit 5
Then the 5 oldest events are removed
When DeleteOldest runs with limit 20
Then only the 2 remaining events older than the guard are removed and the 3 newest stay
Scenario: Settings retention, Default with 90 days or 5 GB
Given the role is "owner" and GET /v1/settings resolves 200 with activity_retention_days 90, activity_retention_max_gb 5 and activity_log_size_bytes 3435973837
When the owner navigates to /settings
Then the age select shows "90 days" with the helper "Events older than this are removed."
And an "or" divider and "Keep at most" show "5 GB" with the helper "When logs pass 5 GB, the oldest events are removed. Currently 3.2 GB."
And the lock alert reads "Events can't be edited or deleted from the app. Only the retention limits remove them."
And "Next cleanup" shows the date from activity_next_cleanup_at
Scenario: Settings retention, Age only (no size limit)
Given activity_retention_max_gb is 0
When the owner navigates to /settings
Then the size select reads "No size limit" with the helper "Optional. Logs can grow without a size cap." and the age helper is "Older events are removed automatically."
Scenario: Settings retention, Size only (forever)
Given activity_retention_days is 0, activity_retention_max_gb is 5 and activity_next_cleanup_at is set
When the owner navigates to /settings
Then the age helper reads "No age limit. Only the size limit applies." and "Next cleanup" shows the date, not "Not scheduled"
Scenario: Settings retention, Forever with no size limit
Given activity_retention_days is 0, activity_retention_max_gb is 0 and activity_next_cleanup_at is null
When the owner navigates to /settings
Then the age helper reads "Nothing is removed automatically. The log grows over time." and "Next cleanup" reads "Not scheduled"
Scenario Outline: Settings retention, the measured size is formatted
Given the size limit is 5 GB and activity_log_size_bytes is <bytes>
When the owner navigates to /settings
Then the size helper ends with "<shown>"
Examples:
| bytes | shown |
| 3435973837 | Currently 3.2 GB. |
| 0 | Currently Less than 0.1 GB. |
| 53687091 | Currently Less than 0.1 GB. |
| 5368709120 | Currently 5.0 GB. |
Scenario Outline: Settings retention, a larger size or no limit saves at once
Given the saved size is <from>
When the owner chooses "<to>"
Then PATCH is sent once with {"activity_retention_max_gb": <value>}, never the age key, and no warning shows
And the shared status reads "Saving…" then "Saved"
Examples:
| from | to | value |
| 5 GB | 10 GB | 10 |
| 5 GB | No size limit | 0 |
| 1 GB | 50 GB | 50 |
Scenario Outline: Settings retention, a smaller size asks first
Given the saved size is <from>
When the owner chooses "<to>"
Then no PATCH is sent and the size select shows "<to>"
And a warning alert shows "Events over <n> GB will be removed" and "This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone." with "Apply <n> GB" and "Cancel"
Examples:
| from | to | n |
| 10 GB | 5 GB | 5 |
| No size limit | 5 GB | 5 |
| 50 GB | 1 GB | 1 |
Scenario: Settings retention, Apply and Cancel for a smaller size
Given the warning for 5 GB is shown on a saved 10 GB
When the owner activates "Apply 5 GB" and PATCH resolves 200 with activity_retention_max_gb 5
Then PATCH was sent once with {"activity_retention_max_gb": 5} and "Saved" shows
Given the warning is shown again
When the owner activates "Cancel"
Then the size select reads the saved value and no PATCH was sent
Scenario: Settings retention, only one warning at a time
Given the age warning for 30 days is open
When the owner chooses "5 GB" as a smaller size
Then the age warning is replaced by the size warning and the age select reverts to the saved value
When the owner chooses a longer age or a larger size
Then the warning is dropped and PATCH is sent once for that control
Scenario: Settings retention, switching age to Forever with a size limit
Given the saved period is 90 days and 5 GB
When the owner chooses "Forever"
Then PATCH is sent with {"activity_retention_days": 0} and the age helper becomes "No age limit. Only the size limit applies."
And "Next cleanup" still shows the returned date
Scenario Outline: Settings retention, a size save error reverts only the size select
Given the saved size is 5 GB and the owner chooses "10 GB"
When PATCH /v1/settings <failure>
Then the error alert "Couldn't save the retention period" with "Your previous setting is still in place." and "Try again" shows
And the size select reads "5 GB" again and the age select is unchanged
When the owner activates "Try again" and PATCH resolves 200
Then PATCH was sent a second time with {"activity_retention_max_gb": 10} and "Saved" replaces the alert
Examples:
| failure |
| resolves with a 500 response |
| resolves with a 400 response |
| resolves with a 403 response |
| never reaches the server |
Scenario: Settings retention, offline disables the size select too
Given the owner is on /settings and the browser goes offline
Then both selects, "Apply" and "Cancel" are disabled
Scenario: The French interface covers the size strings
Given the language is French
Then every new string has a French entry in messages.ts, and the Activity sentence for a size change is French
@@ -72,3 +72,51 @@ Settings (mount SettingsView, GET /v1/settings mocked with the five-key body):
- [ ] Home at 1440 and 390 px, light and dark: 420 px column in the far-right of the full content width, 16 px gap, row clamping, "View all" 44 px touch target; the article column fills the remaining width and still reads well.
- [ ] Keyboard and focus: Home tab order "New article", article rows, "View all"; Settings select, Apply, Cancel in order and focus back on the select after Cancel.
- [ ] Hover, focus rings, the 2 s "Saved" fade and reduced motion.
## Retention size limit extension
Spec: rule "Retention size limit" in activity_home_card_and_retention.feature. Items 14 to 25 above are updated for the widened mock body (activity_retention_max_gb, activity_log_size_bytes) and the new helper and alert copy.
Implementation note for coder (informational, not a QA assertion):
- audit.Settings gains RetentionMaxGB; the settings use case result carries LogSizeBytes from audit.Repository.SizeBytes.
- audit.SizeOptions() is [0, 1, 5, 10, 25, 50]; invalid values return ErrInvalidRetention.
- audit.Repository gains SizeBytes() (int64, error) and DeleteOldest(limit int, before time.Time) (int, error).
- audit.SettingsRepository.SaveRetention takes days and max_gb; the model and mapper gain retention_max_gb.
- The cleanup use case runs the age step when days > 0, then loops DeleteOldest batches with SizeBytes until the log fits or a batch removes 0; the cutoff is the 24 h guard.
- The handler request struct is a partial with pointer fields and validate tags; an empty object is rejected.
- settingsResponse gains activity_retention_max_gb and activity_log_size_bytes. Update the settings_page tests that count keys.
### Go (httptest, fakes)
- G11. GET key set is exactly the seven keys; max_gb and size_bytes carry the fake's values; a fresh fake gives 90, 0, 0.
- G12. GET next-cleanup table: (90, 0), (90, 5), (0, 5) are non-null; (0, 0) is JSON null with the key present.
- G13. PATCH table: size alone, age alone, both, Forever plus size, size 0 with Forever (next becomes null); the fake repository holds the pair.
- G14. PATCH invalid: sizes 2, -1, 100, 5.5, "5", null, mixed with a valid age, {}, not JSON; each 400 with the BodyErrorMessage text and nothing saved. Non-owner 403, no cookie 401.
- G15. No-op PATCH of both saved values: 200, no save, no event.
- G16. Audit params table for the three bodies (age, size, both).
- G17. Cleanup use case with a fake events repository that has a size model: age then size, Forever plus size, Forever with no size (ran=false, no calls), size already met (no DeleteOldest), the guard stops at 24 h with 0 removed and no error, DeleteOldest error propagates and leaves the last cleanup unchanged, total removed in the event params and in SaveLastCleanup, the batch loop terminates when a batch removes 0.
- G18. CleanupDue table unchanged; Tick runs for Forever when a size limit is set.
- G19. Domain: SizeOptions validation; next-cleanup is nil only when both are 0.
- G20. Persistence on in-memory SQLite (extend test/activity_persistence_test.go): SizeBytes with multi-byte text, the empty table, after delete; DeleteOldest with the limit and the guard; the migrated model has retention_max_gb defaulting to 0.
- G21. The interface-shape test is updated to the six methods.
- G22. A SizeBytes error propagates and the handler answers 500 "Internal server error".
### SPA (Vitest), extending web/src/views/__tests__/SettingsRetention.spec.ts
- 26. Default 90 days or 5 GB; Age only; Size only (forever); Forever without a size: selects, helpers, "Currently 3.2 GB.", the "or" divider, the lines.
- 27. Size formatting table (3.2, "Less than 0.1 GB", 5.0); 1024^3 base.
- 28. Larger size or No size limit: one PATCH with only the size key, "Saving…" then "Saved" (fake timers).
- 29. Smaller size, and a size when none was set: no PATCH, warning copy, Apply sends one PATCH, Cancel reverts.
- 30. One warning at a time; a longer or larger choice drops it.
- 31. Save errors (500, 400, 403, network): only the size select reverts; Try again re-sends the same body without re-confirming; 403 refreshes the session and the card disappears.
- 32. Offline disables both selects and Apply and Cancel; 401 is global.
- 33. French parity for the new strings; lib/activitySentence.spec.ts covers the size-change sentence in English and French.
### Visual (task visual:check)
The board draws the card in group "Settings: Retention" with the states 'Age only (no size limit)' and 'Size only (forever)'. In settings.md the states are bullets, not headings, so confirm on the first run whether the tool iterates them. Setups use the board's own sample data (5 GB, 3.2 GB) and the owner /v1/settings mock gets the widened body. Clock-sensitive states stay skip: 'clock-sensitive'. No exception beforehand.
### Manual verification
- [ ] Real SQLite file: activity_log_size_bytes grows as events are added and falls after a forced cleanup.
- [ ] Forced cleanup with a small limit removes the oldest events and leaves the last 24 h.
- [ ] Optional: SizeBytes on real MySQL and PostgreSQL.
- [ ] Real PATCH as owner for each option, as admin 403, as owner with 2 GB 400.
- [ ] Keyboard order: age select, size select, Apply, Cancel; focus returns to the triggering select after Cancel.
- [ ] 1440 and 390 px, light and dark: the "or" divider and the 160 px or full-width size select as drawn in the board.
+778
View File
@@ -0,0 +1,778 @@
Feature: Image variants are generated on first request, cached, and managed by admins
As an admin or owner of a RenewCMS site
I want to define named variants of the original images and have them generated and cached on demand
So that articles can ask for /static/uploadedImages/<storedName>?v=<name> and get a right-sized file,
the original is never changed, and nothing but a defined, enabled variant can be requested
Background:
Given the spec of record is docs/design/screens/image-variants.md sections 1, 2 and 7, its "Back-office API errors" table and section 8 for the activity rows
And this slice is API only: no SPA code, no change to docs/design
And it excludes the design's cache screen routes (GET /v1/image-variants/cache, POST /v1/image-variants/cache/purge), the per-image routes (/v1/media/{id}/variants...) and the media library variants filter and sort
And internal/domain/image gains the entity Variant with the JSON fields name, width, height, fit, format, quality, generate_on_upload, enabled, created_at and updated_at in snake_case
And Variant.Name is a slug of 2 to 32 characters among a-z, 0-9 and "-", unique and immutable after creation
And Variant.Width and Variant.Height are 1 to 8000 pixels or absent (JSON null, "auto" in the design), and at least one is present
And Variant.Fit is one of cover, contain, inside, and defaults to cover
And Variant.Format is one of original, jpeg, png, webp, and defaults to original
And Variant.Quality is 1 to 100 and defaults to 82; for format png it is not stored and is returned as null, and a quality sent with png is ignored
And generate_on_upload defaults to false and enabled defaults to true
And internal/domain/image defines the sentinels ErrVariantNotFound, ErrVariantExists, ErrVariantInvalid, ErrUnknownVariant, ErrUnsupportedParameter and ErrSourceTooLarge, asserted with errors.Is
And internal/domain/image defines the port VariantRepository (variants CRUD) and the port VariantFileRepository, an index of generated files holding variant name, source stored name, cached stored name, content type, size in bytes and the settings fingerprint, because Storage.Put chooses the stored name itself
And internal/domain/image defines the port VariantRenderer, implemented in internal/infrastructure and kept free of GORM and HTTP, which decodes a source and encodes a variant, and reports which output formats it can encode
And generated bytes are stored only through image.Storage, are named by Storage.Put like any stored image, and are never reachable as library images
And the use cases (create, update, set enabled, delete, purge, list variants, serve variant) depend only on domain interfaces
And the variant routes are mounted at /v1/image-variants behind jwtauth.Verifier, middleware.AuditContext, jwtauth.Authenticator, middleware.RequireCurrentToken, middleware.RequireVerifiedUser and middleware.RequireOwnerOrAdmin, with no API key authentication
And request bodies are validated with validate tags and validator.Validate.Struct, never by hand
And successful responses are JSON with snake_case fields, and error responses on /v1 are plain text from http.Error, except the public 400 described below
And a variant is returned as {"name", "width", "height", "fit", "format", "quality", "generate_on_upload", "enabled", "generated_files", "cache_size", "created_at", "updated_at"}, with cache_size in bytes
And the variant list is ordered by name ascending
And the contain padding colour is decided here: transparent for png and webp outputs, and white (#FFFFFF) for jpeg outputs, which carry no alpha
And a jpeg output flattens any transparency of the source onto white (#FFFFFF)
And a new env var IMAGE_VARIANT_MAX_SOURCE_BYTES, default 10485760, is listed in envDefaults in cmd/server/main.go (not in requiredEnvVars), in .env.example and in docs/configuration.md
And a new env var IMAGE_VARIANT_MAX_SOURCE_PIXELS, default 50000000, caps the declared width x height of a source, read from the image header before any pixel buffer is allocated, and is listed in the same three places
And a new env var IMAGE_VARIANT_MAX_CONCURRENT_RENDERS, default 4, bounds the renders running at once, with waiting requests queued until a slot frees or their context ends, and is listed in the same three places
And contain always pads to the box when both width and height are given: the image is scaled to fit inside the box, never enlarged, and centred on a canvas of exactly width x height; with a single dimension there is no box and no padding
And the first implementation task is a spike of a CGO-free lossy WebP encoder with CGO_ENABLED=0
And the public serving path stays /static/uploadedImages/<storedName>, mounted in api/router.go, and is outside /v1 and outside any auth
Rule: Only owners and admins reach the variant routes
Scenario Outline: Every variant route is guarded
When the client sends <method> <path> with <credentials>
Then the response status is <status>
And the plain text body is <body>
And no variant, generated file or activity event is read or changed
Examples:
| method | path | credentials | status | body |
| GET | /v1/image-variants | no cookie | 401 | apperr.UnauthorizedErrorMessage |
| GET | /v1/image-variants | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage |
| GET | /v1/image-variants | the cookie of an unverified user| 403 | apperr.VerificationRequiredErrorMessage |
| GET | /v1/image-variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| POST | /v1/image-variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| PUT | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| PATCH | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| DELETE | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| POST | /v1/image-variants/hero/purge | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| GET | /v1/image-variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
Scenario Outline: Owners and admins are both allowed
When <role> sends GET /v1/image-variants with a valid cookie
Then the response status is 200
Examples:
| role |
| the owner |
| an admin |
Rule: Listing
Scenario: An empty list is an empty array
Given no variant exists
When an admin sends GET /v1/image-variants
Then the response status is 200
And the body is {"items": []}, with items an empty array and not null in the raw JSON
Scenario: The list carries the generated count and cache size per variant
Given the variants "hero" (cover 1200x630 webp 82, enabled) and "card" (inside 800 x auto jpeg 80, disabled)
And the cache index holds 3 generated files of 1000, 2000 and 3000 bytes for "hero" and none for "card"
When an admin sends GET /v1/image-variants
Then "items" is ordered "card", "hero"
And "card" has width 800, height null, fit "inside", format "jpeg", quality 80, enabled false, generated_files 0 and cache_size 0
And "hero" has width 1200, height 630, fit "cover", format "webp", quality 82, enabled true, generated_files 3 and cache_size 6000
Scenario: A png variant lists a null quality
Given the variant "logo" with format png
When an admin sends GET /v1/image-variants
Then "logo" has quality null
Rule: Creating a variant
Scenario: Creating a variant with every field
When an admin sends POST /v1/image-variants with {"name": "hero", "width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82, "generate_on_upload": true, "enabled": true}
Then the response status is 201
And the body is the variant with generated_files 0 and cache_size 0
And exactly one variant "hero" is stored
Scenario: Omitted optional fields take their defaults
When an admin sends POST /v1/image-variants with {"name": "thumb", "width": 400}
Then the response status is 201
And the body has width 400, height null, fit "cover", format "original", quality 82, generate_on_upload false and enabled true
Scenario Outline: A valid boundary value is accepted
When an admin sends POST /v1/image-variants with <body>
Then the response status is 201
Examples:
| body |
| {"name": "ab", "width": 1} |
| {"name": "a-b-c", "height": 8000} |
| {"name": "<32 characters a-z0-9->", "width": 8000, "height": 8000} |
| {"name": "q1", "width": 10, "quality": 1} |
| {"name": "q100", "width": 10, "quality": 100} |
Scenario Outline: An invalid field answers 422 with the field's sentence and stores nothing
When an admin sends POST /v1/image-variants with <body>
Then the response status is 422
And the plain text body is "<message>"
And no variant is stored and no activity event is recorded
Examples:
| body | message |
| {"name": "a", "width": 10} | Use lowercase letters, numbers and dashes, 2 to 32 characters. |
| {"name": "<33 characters>", "width": 10} | Use lowercase letters, numbers and dashes, 2 to 32 characters. |
| {"name": "Hero", "width": 10} | Use lowercase letters, numbers and dashes, 2 to 32 characters. |
| {"name": "he ro", "width": 10} | Use lowercase letters, numbers and dashes, 2 to 32 characters. |
| {"name": "hero"} | Width and height must be between 1 and 8000, and one of them is required. |
| {"name": "hero", "width": 0} | Width and height must be between 1 and 8000, and one of them is required. |
| {"name": "hero", "width": 8001} | Width and height must be between 1 and 8000, and one of them is required. |
| {"name": "hero", "height": -1} | Width and height must be between 1 and 8000, and one of them is required. |
| {"name": "hero", "width": 10, "fit": "stretch"} | Fit must be cover, contain or inside. |
| {"name": "hero", "width": 10, "format": "avif"} | Format must be original, jpeg, png or webp. |
| {"name": "hero", "width": 10, "quality": 0} | Quality must be between 1 and 100. |
| {"name": "hero", "width": 10, "quality": 101} | Quality must be between 1 and 100. |
Scenario Outline: A malformed request answers 400
When an admin sends POST /v1/image-variants with <body>
Then the response status is 400
And the plain text body is apperr.BodyErrorMessage
Examples:
| body |
| not json |
| {"name": "hero", "width": "wide"} |
| an empty body |
Scenario: A duplicate name answers 409 and keeps the existing variant
Given the variant "thumb" 400 x 300 cover jpeg quality 80
When an admin sends POST /v1/image-variants with {"name": "thumb", "width": 800, "format": "png"}
Then the response status is 409
And the plain text body is "A variant named “thumb” already exists. Choose another name."
And the stored variant "thumb" is unchanged
Rule: Updating a variant
Background:
Given the variant "hero" is 1200x630 cover webp quality 82, enabled, with generate_on_upload false
And the cache index holds 244 generated files for "hero" (84 MB) and 3 generated files for "thumb"
Scenario: Changing quality updates the variant and purges its generated files
When an admin sends PUT /v1/image-variants/hero with {"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 70, "generate_on_upload": false, "enabled": true}
Then the response status is 200
And the body is the variant with quality 70, generated_files 0, cache_size 0 and "purged_files": 244
And the 244 cached blobs of "hero" are deleted from image.Storage and their index rows are gone
And the files of "thumb" are untouched
Scenario Outline: A change to a rendering setting purges
When an admin sends PUT /v1/image-variants/hero changing <setting> to <value>, everything else as stored
Then the response status is 200
And "purged_files" is 244
Examples:
| setting | value |
| width | 1000 |
| height | null |
| fit | inside |
| format | jpeg |
| quality | 60 |
Scenario Outline: A change to a switch does not purge
When an admin sends PUT /v1/image-variants/hero changing <setting> to <value>, everything else as stored
Then the response status is 200
And "purged_files" is 0 and generated_files is still 244
And no blob is deleted
Examples:
| setting | value |
| generate_on_upload | true |
| enabled | false |
Scenario: A PUT identical to the stored variant changes nothing
When an admin sends PUT /v1/image-variants/hero with the stored values
Then the response status is 200 and "purged_files" is 0
And updated_at is unchanged and no activity event is recorded
Scenario: Switching the format to png drops the quality and purges
When an admin sends PUT /v1/image-variants/hero with format "png" and quality 40
Then the response status is 200 and quality is null and "purged_files" is 244
Scenario: Changing the quality of a png variant is a no-op
Given the variant "logo" with format png
When an admin sends PUT /v1/image-variants/logo with the same values and quality 30
Then "purged_files" is 0 and quality is still null
Scenario: The name is immutable
When an admin sends PUT /v1/image-variants/hero with {"name": "banner", "width": 1200, "height": 630}
Then the stored variant is still named "hero" and no variant "banner" exists
Scenario Outline: An invalid PUT answers like a create and changes nothing
When an admin sends PUT /v1/image-variants/hero with <body>
Then the response status is 422 and the stored variant and its 244 generated files are unchanged
Examples:
| body |
| {"quality": 101} |
| {"width": null, "height": null} |
| {"width": 9000} |
| {"fit": "stretch"} |
Scenario: An unknown variant answers 404
When an admin sends PUT /v1/image-variants/missing with a valid body
Then the response status is 404 and the plain text body is "Variant not found"
Rule: Enabling and disabling
Scenario Outline: PATCH toggles enabled and keeps the cache
Given the variant "hero" has enabled <before> and 244 generated files
When an admin sends PATCH /v1/image-variants/hero with {"enabled": <after>}
Then the response status is 200 and the body has enabled <after> and generated_files 244
And no blob is deleted
Examples:
| before | after |
| true | false |
| false | true |
Scenario Outline: A PATCH that is not valid or not found
When an admin sends PATCH /v1/image-variants/<name> with <body>
Then the response status is <status>
Examples:
| name | body | status |
| hero | not json | 400 |
| hero | {} | 422 |
| hero | {"enabled": "no"} | 400 |
| missing | {"enabled": true} | 404 |
Rule: Deleting a variant and purging its cache
Scenario: Deleting removes the definition and its generated files
Given the variant "hero" with 244 generated files and the variant "thumb" with 3
When an admin sends DELETE /v1/image-variants/hero
Then the response status is 204 with an empty body
And "hero" is gone, its 244 blobs are deleted from image.Storage and its index rows are gone
And "thumb" and its 3 files are untouched
Scenario: Purging removes only the generated files
Given the variant "hero" with 244 generated files of 84 MB in total
When an admin sends POST /v1/image-variants/hero/purge
Then the response status is 200 and the body is {"purged_files": 244, "purged_bytes": <total bytes>}
And the variant "hero" still exists unchanged and GET /v1/image-variants shows generated_files 0 and cache_size 0
Scenario: Purging an empty cache answers zero
When an admin sends POST /v1/image-variants/hero/purge and "hero" has no generated file
Then the response status is 200 and the body is {"purged_files": 0, "purged_bytes": 0}
Scenario Outline: An unknown variant answers 404
When an admin sends <method> /v1/image-variants/missing<suffix>
Then the response status is 404 and the plain text body is "Variant not found"
Examples:
| method | suffix |
| DELETE | |
| POST | /purge |
Scenario: A blob that cannot be deleted keeps its index row and the call fails
Given the variant "hero" with 3 generated files and image.Storage failing to delete the second one
When an admin sends POST /v1/image-variants/hero/purge
Then the response status is 500 with the body "Internal server error"
And the 2 other files are deleted and the failing file keeps its index row so that a retry can finish
Scenario: A blob already missing from storage does not stop a purge
Given the variant "hero" with an index row whose blob no longer exists
When an admin sends POST /v1/image-variants/hero/purge
Then the response status is 200 and the row is removed
Scenario: Deleting a source image purges its generated files
Given the image "north-wall.jpg" with generated files for "hero" and "thumb"
When an admin sends DELETE /v1/media/<its id>
Then the response status is 204 and the generated blobs and index rows of that image are gone
And the generated files of other images are untouched
Scenario: Merging copies purges the generated files of the removed copies
Given the images 5, 6 and 7 share one hash and 6 and 7 each have generated files
When an admin sends POST /v1/media/merge keeping 5 and merging [6, 7]
Then the generated blobs and rows of images 6 and 7 are gone
Rule: Public serving of ?v=<name>
Background:
Given the variant "thumb" is cover 400x300 jpeg quality 80 and enabled
And the image "photo.png" is stored as "a1b2.png", a 1000 x 800 PNG
And no generated file exists yet
Scenario: The first request generates and caches
When the public client sends GET /static/uploadedImages/a1b2.png?v=thumb
Then the response status is 200 with the content type "image/jpeg"
And the body decodes to a 400 x 300 JPEG
And exactly one blob was put in image.Storage for the variant and one index row exists with the same size as the body
And the original blob "a1b2.png" is unchanged
Scenario: The second request serves the cached file without rendering
Given GET /static/uploadedImages/a1b2.png?v=thumb was served once
When the public client sends the same request again
Then the body is byte-identical to the first one
And the renderer was called once in total and image.Storage Put was called once in total
Scenario: A request without ?v= is the unchanged original
When the public client sends GET /static/uploadedImages/a1b2.png
Then the response is the stored PNG with the same headers as before this slice
And the renderer is not called
Scenario: The ETag derives from the variant settings
When the public client sends GET /static/uploadedImages/a1b2.png?v=thumb
Then the response has an ETag that is a quoted opaque value
And requesting it again returns the same ETag
And sending If-None-Match with that ETag answers 304 with an empty body
Scenario Outline: The ETag changes when a setting changes and stays when a switch changes
Given the ETag E1 of GET /static/uploadedImages/a1b2.png?v=thumb
When an admin changes <setting> of "thumb" to <value>
And the public client requests the same URL again
Then the new ETag is different from E1: <changes>
Examples:
| setting | value | changes |
| quality | 70 | yes |
| width | 500 | yes |
| height | 200 | yes |
| fit | inside | yes |
| format | png | yes |
| generate_on_upload | true | no |
Scenario: A stale If-None-Match after an edit gets a fresh 200
Given the client holds E1 and the admin changed the quality of "thumb"
When the client sends If-None-Match E1
Then the response is 200 with the regenerated file and a new ETag
Scenario: Editing a variant regenerates on the next request
Given GET ...?v=thumb was served once
When an admin changes the quality of "thumb" from 80 to 70
And the public client requests the same URL
Then a new blob is generated and the previous blob was deleted from image.Storage
Scenario: A cached file that vanished from storage is generated again
Given the index row for "thumb" and "a1b2.png" exists but image.Storage reports ErrBlobNotFound for it
When the public client requests GET ...?v=thumb
Then the response is 200 with a freshly generated file and the index row points to the new blob
Scenario: Concurrent first requests generate once
When 20 concurrent clients send GET /static/uploadedImages/a1b2.png?v=thumb
Then every response is 200 with identical bytes
And the renderer was called once and image.Storage Put was called once (singleflight keyed by variant and image)
Scenario: Different variants and different images are not serialised together
Given the variant "card" and the image "c3d4.png"
When the four (variant, image) pairs are requested concurrently while each render blocks
Then all four renders are in flight at the same time
Scenario: A failed generation is not cached and is retried
Given the renderer fails for the first call
When the public client requests GET ...?v=thumb twice
Then the first response is 500 and no blob or row exists
And the second response is 200
Rule: Unknown, disabled and raw parameters
Scenario Outline: A request outside the contract answers 400 with the JSON error body
When the public client sends GET <url>
Then the response status is 400 with the content type "application/json"
And the body is {"status": 400, "error": "<error>", "message": "<message>", "detail": "<detail>"}
And the renderer is not called and nothing is stored
Examples:
| url | error | message | detail |
| /static/uploadedImages/a1b2.png?v=banner | unknown_variant | Unknown variant | banner |
| /static/uploadedImages/a1b2.png?v=Thumb | unknown_variant | Unknown variant | Thumb |
| /static/uploadedImages/a1b2.png?v= | unknown_variant | Unknown variant | |
| /static/uploadedImages/a1b2.png?v=hero (disabled) | unknown_variant | Unknown variant | hero |
| /static/uploadedImages/a1b2.png?width=400 | unsupported_parameter | Unsupported parameter | width |
| /static/uploadedImages/a1b2.png?height=300 | unsupported_parameter | Unsupported parameter | height |
| /static/uploadedImages/a1b2.png?format=webp | unsupported_parameter | Unsupported parameter | format |
| /static/uploadedImages/a1b2.png?quality=50 | unsupported_parameter | Unsupported parameter | quality |
| /static/uploadedImages/a1b2.png?v=thumb&width=400 | unsupported_parameter | Unsupported parameter | width |
| /static/uploadedImages/a1b2.png?v=banner&quality=50 | unsupported_parameter | Unsupported parameter | quality |
Scenario: Other query parameters are ignored
When the public client sends GET /static/uploadedImages/a1b2.png?v=thumb&t=123
Then the response status is 200 with the thumb variant
Scenario: A disabled variant serves again once enabled, from its kept cache
Given the variant "thumb" was served once, then disabled
When the public client requests ?v=thumb
Then the response is 400 unknown_variant
When an admin enables "thumb" and the client requests again
Then the response is 200 and image.Storage Put was called once in total
Scenario: A deleted variant answers 400
Given the variant "thumb" is deleted
When the public client requests ?v=thumb
Then the response is 400 unknown_variant
Rule: Missing and unreadable sources
Scenario Outline: A missing source answers 404
When the public client sends GET <url>
Then the response status is 404 and nothing is generated
Examples:
| url |
| /static/uploadedImages/missing.png?v=thumb |
| /static/uploadedImages/../secret.png?v=thumb |
| /static/uploadedImages/nested/a1b2.png?v=thumb|
Scenario: Parameter errors come before the missing source
When the public client sends GET /static/uploadedImages/missing.png?v=banner
Then the response status is 400 unknown_variant
Scenario: A source that cannot be decoded answers 500 and caches nothing
Given the stored blob "bad.png" holds bytes that are not an image
When the public client requests /static/uploadedImages/bad.png?v=thumb
Then the response status is 500 and nothing is stored
Scenario Outline: Only a source that is a known image record can be a variant source
Given the blob "<name>" exists in image.Storage
And <record>
When the public client requests /static/uploadedImages/<name>?v=thumb
Then the response status is <status>
And nothing is generated and nothing is stored
Examples:
| name | record | status |
| a1b2.png | an image record with that stored name exists | 200 |
| gen-1.jpg | the blob is a generated variant file, with no image record | 404 |
| orphan.png | the blob has no image record | 404 |
Scenario: The record lookup comes after the parameter checks and before any storage read
When the public client requests /static/uploadedImages/orphan.png?v=banner
Then the response status is 400 unknown_variant
And when requested with ?v=thumb the response is 404 and image.Storage Open is not called
Scenario: The original of any stored blob is still served without ?v=
When the public client requests /static/uploadedImages/gen-1.jpg
Then the response is the stored bytes as before this slice
Rule: Source size cap
Scenario Outline: A source beyond IMAGE_VARIANT_MAX_SOURCE_BYTES answers 413
Given IMAGE_VARIANT_MAX_SOURCE_BYTES is 1000
And the stored source "big.png" is <size> bytes
When the public client requests /static/uploadedImages/big.png?v=thumb
Then the response status is <status>
Examples:
| size | status |
| 1000 | 200 |
| 1001 | 413 |
Scenario: The 413 body is plain text and nothing is generated
Given a source of 1001 bytes with the cap at 1000
Then the body is "The source image is too large to generate this variant." and the renderer is not called
Scenario: The cap never affects the original
Given a source of 1001 bytes with the cap at 1000
When the public client requests it without ?v=
Then the response is 200 with the stored bytes
Scenario Outline: The env var rejects invalid values at boot
When the settings are loaded with IMAGE_VARIANT_MAX_SOURCE_BYTES=<value>
Then the result is <result>
Examples:
| value | result |
| (unset) | 10485760 |
| 2048 | 2048 |
| 0 | an error naming IMAGE_VARIANT_MAX_SOURCE_BYTES |
| -5 | an error naming IMAGE_VARIANT_MAX_SOURCE_BYTES |
| many | an error naming IMAGE_VARIANT_MAX_SOURCE_BYTES |
Rule: Source pixel cap, checked before decoding
Scenario Outline: A source beyond IMAGE_VARIANT_MAX_SOURCE_PIXELS answers 413 without decoding it
Given IMAGE_VARIANT_MAX_SOURCE_PIXELS is 10000
And the stored source "wide.png" is a small file whose header declares <w> x <h> pixels
When the public client requests /static/uploadedImages/wide.png?v=thumb
Then the response status is <status>
And the renderer never allocates the pixel buffer for a refused source
Examples:
| w | h | status |
| 100 | 100 | 200 |
| 101 | 100 | 413 |
| 30000 | 30000 | 413 |
Scenario: The pixel 413 body is plain text and nothing is generated
Given a source declaring more pixels than the cap
Then the body is "The source image is too large to generate this variant." and the renderer is not called and nothing is stored
Scenario: The pixel cap never affects the original or SVG
Given a source declaring more pixels than the cap
When the public client requests it without ?v=
Then the response is 200 with the stored bytes
Scenario: A header that cannot be read answers 500 and caches nothing
Given the stored blob "bad.png" holds bytes that are not an image
When the public client requests /static/uploadedImages/bad.png?v=thumb
Then the response status is 500 and nothing is stored
Scenario Outline: The pixel cap env var
When the settings are loaded with IMAGE_VARIANT_MAX_SOURCE_PIXELS=<value>
Then the result is <result>
Examples:
| value | result |
| (unset) | 50000000 |
| 2048 | 2048 |
| 0 | an error naming IMAGE_VARIANT_MAX_SOURCE_PIXELS |
| -5 | an error naming IMAGE_VARIANT_MAX_SOURCE_PIXELS |
| many | an error naming IMAGE_VARIANT_MAX_SOURCE_PIXELS |
Rule: Concurrent renders are bounded
Scenario: At most IMAGE_VARIANT_MAX_CONCURRENT_RENDERS renders run at once
Given IMAGE_VARIANT_MAX_CONCURRENT_RENDERS is 2 and the renderer blocks
When 5 requests for 5 distinct uncached (variant, image) pairs arrive
Then exactly 2 renders are in flight and the other 3 wait for a slot
And once a render is released, a waiting one starts, and every request ends with 200
Scenario: A cached file never takes a render slot
Given every render slot is held
When the public client requests a variant file that is already cached
Then the response is 200 immediately
Scenario: A waiting request whose client has gone does not render
Given every render slot is held and a request waits for a slot
When its context is cancelled
Then it stops waiting, no render starts for it and nothing is stored
Scenario: Identical concurrent requests still share one render and one slot
When 20 concurrent clients request the same uncached (variant, image) pair
Then the renderer is called once and only one slot is taken
Scenario Outline: The concurrency env var
When the settings are loaded with IMAGE_VARIANT_MAX_CONCURRENT_RENDERS=<value>
Then the result is <result>
Examples:
| value | result |
| (unset) | 4 |
| 1 | 1 |
| 0 | an error naming IMAGE_VARIANT_MAX_CONCURRENT_RENDERS |
| -1 | an error naming IMAGE_VARIANT_MAX_CONCURRENT_RENDERS |
| many | an error naming IMAGE_VARIANT_MAX_CONCURRENT_RENDERS |
Rule: SVG and animated GIF are served as they are
Scenario: An SVG with a valid variant is served unchanged
Given the image "logo.svg" is stored as "e5f6.svg"
When the public client requests /static/uploadedImages/e5f6.svg?v=thumb
Then the response is 200 with the stored bytes, the content type "image/svg+xml" and the existing Content-Security-Policy
And the renderer is not called and no blob or row is created
Scenario: An SVG with an unknown variant is still a 400
When the public client requests /static/uploadedImages/e5f6.svg?v=banner
Then the response is 400 unknown_variant
Scenario: An SVG larger than the cap is still served
Given the cap is lower than the SVG size
Then the response is 200 with the stored bytes
Scenario: An animated GIF is served unchanged
Given a stored blob "anim.gif" holding an animated GIF
When the public client requests /static/uploadedImages/anim.gif?v=thumb
Then the response is 200 with the stored bytes, the content type "image/gif" and no generated file
Scenario: A single-frame GIF is treated like any raster source
Given a stored blob "still.gif" holding a one-frame GIF
When the public client requests it with ?v=thumb
Then the response is a generated variant
Rule: Output geometry never upscales
Scenario Outline: The output size follows the fit and never exceeds the source
Given a variant with width <w>, height <h>, fit <fit> and format png
And a source of <sw> x <sh> pixels
When the variant is requested
Then the decoded output is <ow> x <oh>
Examples:
| sw | sh | w | h | fit | ow | oh |
| 4032 | 3024 | 400 | 300 | cover | 400 | 300 |
| 4032 | 3024 | 800 | | cover | 800 | 600 |
| 4032 | 3024 | | 600 | contain | 800 | 600 |
| 4032 | 3024 | 1000 | 1000 | inside | 1000 | 750 |
| 4032 | 3024 | 1000 | 1000 | cover | 1000 | 1000 |
| 4032 | 3024 | 1000 | 1000 | contain | 1000 | 1000 |
| 300 | 200 | 800 | | cover | 300 | 200 |
| 300 | 200 | | 800 | inside | 300 | 200 |
| 300 | 200 | 400 | 300 | cover | 267 | 200 |
| 300 | 200 | 400 | 300 | contain | 400 | 300 |
| 300 | 200 | 1200 | 1200 | contain | 1200 | 1200 |
| 300 | 200 | 1200 | 1200 | inside | 300 | 200 |
Scenario: Cover crops around the centre
Given a 40 x 20 source whose columns 0 to 9 are red, 10 to 29 are green and 30 to 39 are blue
When the variant cover 20 x 20 png is requested
Then the output is 20 x 20 and every pixel is green
Scenario: Contain pads transparent on png and webp
Given a 40 x 20 opaque red source and the variant contain 40 x 40 png
When it is requested
Then the output is 40 x 40, the top 10 rows and bottom 10 rows are fully transparent, and the middle 20 rows are red
Scenario: Contain pads white on jpeg
Given the same source and the variant contain 40 x 40 jpeg
Then the top 10 rows and bottom 10 rows are white (#FFFFFF) within JPEG tolerance
Scenario: Inside adds no padding
Given the same source and the variant inside 40 x 40 png
Then the output is 40 x 20 with no transparent pixel
Scenario: A transparent PNG source flattened to JPEG becomes white
Given a fully transparent 10 x 10 PNG and the variant 10 x 10 jpeg
Then every pixel of the output is white within JPEG tolerance
Rule: Output format
Scenario Outline: The output format and content type
Given the source "<source>" and the variant format <format>
Then the response content type is <type> and the body signature is <signature>
Examples:
| source | format | type | signature |
| png | original | image/png | PNG |
| jpeg | original | image/jpeg | JPEG |
| png | jpeg | image/jpeg | JPEG |
| jpeg | png | image/png | PNG |
Scenario: Quality changes the size of a lossy output
Given a photographic 600 x 600 source and the variants "lo" quality 20 and "hi" quality 95, both jpeg
Then the "lo" output is smaller than the "hi" output
Scenario: A png output ignores quality
Given two png variants stored with quality 30 and 95 submitted at creation
Then both outputs are byte-identical
Rule: WebP is conditional on the spike
Scenario: When a CGO-free lossy WebP encoder exists, webp is offered
Given the spike found an encoder that builds with CGO_ENABLED=0
When an admin creates a variant with format "webp"
Then the response is 201
And requesting it returns "image/webp" with a RIFF/WEBP signature, the right dimensions, and a lower quality giving a smaller file
And a webp source with format "original" is served as webp
Scenario: When no encoder is viable, webp is refused
Given the spike found no usable encoder and the renderer reports png, jpeg as encodable
When an admin sends POST /v1/image-variants with {"name": "wp", "width": 10, "format": "webp"}
Then the response status is 422 and the plain text body is "Format must be original, jpeg or png."
And creating with original, jpeg and png still works
Scenario: A stored GIF is served with the image/gif content type
Given a stored blob "anim.gif"
When the public client requests /static/uploadedImages/anim.gif
Then the response content type is "image/gif"
Scenario: A webp source still decodes without an encoder
Given a webp source and a jpeg variant
Then the response is 200 with a JPEG
Scenario: Without an encoder, a webp source with format original falls back to png
Given no webp encoder and a webp source with a variant of format original
Then the response content type is image/png
Scenario: The build stays CGO-free
When the module is built with CGO_ENABLED=0 and -tags nofrontend
Then it succeeds and go list -deps ./... lists no package that imports "C"
Rule: Every variant change is recorded in the activity log
Background:
Given recording follows features/activity-log-producers.feature: once, from the use case, after the change, with the request origin
And the category is "media" and the target is {"type": "image_variant", "id": "<name>", "name": "<name>"}
And the actions are media.variant_create, media.variant_update, media.variant_delete, media.variant_enable, media.variant_disable and media.variant_purge
And the change fields allowed in the log are extended with width, height, fit, format and quality, all of kind "text"
Scenario: Creating a variant is recorded
When the owner creates "hero" 1200x630 cover webp quality 82
Then one event exists with action "media.variant_create", target {"type": "image_variant", "id": "hero", "name": "hero"}, outcome "success" and params {"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82}
Scenario: Updating a variant is recorded with the diff and the purge count
Given "hero" has quality 82 and 244 generated files
When the owner changes its quality to 70
Then one event exists with action "media.variant_update", changes [{"field": "quality", "kind": "text", "before": "82", "after": "70"}] and params {"purged_files": 244}
Scenario: A switch-only update does not claim a purge
When the owner sets generate_on_upload to true on "hero"
Then no "media.variant_update" event with a rendering change is recorded, and no event exists for that switch
Scenario: Deleting a variant is recorded with the removed files
Given "hero" has 244 generated files totalling 84 MB
When the owner deletes it
Then one event exists with action "media.variant_delete", target "hero" and params {"purged_files": 244, "purged_bytes": <total>}
Scenario Outline: Enabling and disabling are recorded
Given "hero" has enabled <before>
When the owner sends PATCH /v1/image-variants/hero with {"enabled": <after>}
Then one event exists with action "<action>" and target "hero"
Examples:
| before | after | action |
| false | true | media.variant_enable |
| true | false | media.variant_disable |
Scenario: A PATCH to the current value records nothing
When the owner sets enabled to its current value
Then the response is 200 and no event is recorded
Scenario: A refused create is recorded as failed
Given "thumb" exists
When the owner creates "thumb" again
Then one event exists with action "media.variant_create", outcome "failed", failure_reason "A variant with that name already exists." and target "thumb"
Scenario Outline: Requests that fail validation or find nothing record nothing
When <request>
Then no event is recorded
Examples:
| request |
| POST /v1/image-variants with width 9000 |
| PUT /v1/image-variants/missing with a valid body |
| DELETE /v1/image-variants/missing |
| PATCH /v1/image-variants/missing with {"enabled": true} |
| POST /v1/image-variants/missing/purge |
| GET /v1/image-variants |
| a public GET ...?v=thumb that generates a file |
Scenario: Purging a variant's cache is recorded
Given "hero" has 244 generated files totalling 84 MB
When the owner sends POST /v1/image-variants/hero/purge
Then one event exists with action "media.variant_purge", target {"type": "image_variant", "id": "hero", "name": "hero"}, outcome "success" and params {"purged_files": 244, "purged_bytes": <total>}
Scenario: Purging an empty cache records nothing
Given "hero" has no generated file
When the owner sends POST /v1/image-variants/hero/purge
Then the response is 200 with {"purged_files": 0, "purged_bytes": 0} and no event is recorded
Scenario: A purge that fails on storage is recorded as failed
Given "hero" has 3 generated files and image.Storage fails to delete the second
When the owner sends POST /v1/image-variants/hero/purge
Then the response is 500 and one event exists with action "media.variant_purge", outcome "failed" and failure_reason "Something went wrong on our side. Please try again."
Scenario: A failing audit write never changes the response
Given the audit repository fails on Append
When the owner creates "hero"
Then the response is 201 and the variant exists
Scenario: The existing allowlist scenario is updated
Then the only change fields any producer emits are title, online, body, filename, display_name, width, height, fit, format and quality
Rule: Layering and hygiene
Scenario: The dependency rule holds
Then internal/domain/image imports only the stdlib and other internal/domain packages
And the variant use cases import no persistence, mailer, models, gorm, net/http, chi or api package
And the handler for /v1/image-variants and the public handler import no persistence, mailer, models or gorm package
And only api/dependecyInjection.go constructs the variant repositories, the renderer and the use cases
And the new GORM models are registered in internal/infrastructure/persistence/database/migrate.go
And no comment is added to any Go file, test or doc, except tool directives
+74
View File
@@ -0,0 +1,74 @@
# QA procedure: image-variants
Go only, no SPA. Package `test` (black-box). New files: `test/image_variants_admin_http_test.go`, `test/image_variants_serving_http_test.go`, `test/image_variants_geometry_test.go`, `test/image_variants_audit_test.go`, `test/image_variants_config_test.go`. Fake type names must be unique across the package. No live server, no real SMTP, no real SQLite file, no `time.Sleep`.
## Setup shared by every case
1. `validator.InitValidator()` once. `jwtauth.New("HS256", []byte("test-secret"), nil)` stands in for `JWT_SECRET`.
2. New hand-written in-memory fakes: `mockVariantRepo` (CRUD, stores creation order, unique-name check returning `image.ErrVariantExists`), `mockVariantFileRepo` (index rows with variant, source stored name, cached stored name, content type, size, fingerprint, with `Delete` calls recorded and a per-row failure switch), `mockVariantStorage` (an in-memory `image.Storage` that names blobs `gen-<n>.<ext>`, counts `Put`, `Open` and `Delete` calls, can fail `Delete` for one name, and can block `Put`/render on a channel), `countingRenderer` (wraps the real stdlib-based renderer, counts calls, can fail the first call, can block on a channel, `EncodableFormats()` configurable for the webp-on or webp-off cases). Reuse `mockImageRepo`, the user fake, the audit fakes from `test/activity_support_test.go` (`mockAuditRepo`, `fakeAuditClock`).
3. Users: owner 1, admin 2, verified plain user 3, unverified user 4. Real signed `jwt` cookies with `user_id` and `token_version`; a stale cookie with a lower `token_version`. A valid API key in the key fake for the "API key refused" row.
4. Admin router: a fresh `chi.NewRouter()` mirroring `api/router.go`: `jwtauth.Verifier`, `middleware.AuditContext(getUser, nil)`, `jwtauth.Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`, `RequireOwnerOrAdmin`, mounted at `/v1/image-variants`, with no `apiKeyAuth`.
5. Public router: mount the real `staticimages.NewHandler(...)` (with the new variant dependency) at `/static/uploadedImages` on the same kind of router, over `mockVariantStorage` holding the originals. The router wraps `chimiddleware.GetHead` like `InitRoutes`.
6. Images are generated in the test with `image/png`, `image/jpeg` and `image/draw` (solid or half-and-half colours so crops and padding can be asserted pixel by pixel). Decode responses with `image.Decode`. An animated GIF is built with `image/gif` and two frames. A real SVG string is stored as `e5f6.svg`. Use `x/image/webp` to decode webp fixtures.
7. Set the cap by constructing the use case with the value; do not touch the process environment except in the config test.
## A. Guards (feature "Only owners and admins")
8. Table: each method, path and credential gives the status and the exact body constant; assert no variant, index, storage or audit fake was touched. API key gets 401. Owner and admin both get 200.
## B. List and create
9. Empty list: raw JSON has `"items":[]`. Seeded list: order by name, `width`/`height` as JSON `null` when absent (assert on the raw body), `quality` null for png, counts and sizes from the file index.
10. Create: full body, defaults body, boundary table (name 2 and 32 chars, `width` 1 and 8000, quality 1 and 100). Invalid table gives 422 with each exact sentence and verifies nothing stored and no audit event. Malformed JSON rows give 400 with `apperr.BodyErrorMessage`. Duplicate gives 409 with the exact sentence and leaves the stored variant unchanged.
11. When `EncodableFormats()` lacks webp, the format rejection sentence is `Format must be original, jpeg or png.` and `webp` is refused with 422; run the format rows in both configurations.
## C. Update, toggle, delete, purge
12. PUT table over `width`, `height`, `fit`, `format`, `quality` (purges 244 seeded index rows: assert the blob `Delete` calls equal the row set, the index is empty for the variant, the other variant's rows untouched, `purged_files` equals 244). `generate_on_upload` and `enabled` rows purge nothing. Identical PUT leaves `updated_at` as it was and records no event. A png variant ignores a quality edit. The body `name` is ignored.
13. Invalid PUT rows leave variant and rows unchanged. Unknown name gives 404 with `Variant not found`.
14. PATCH: both directions keep the index rows and call no storage `Delete`; 400/422/404 rows.
15. DELETE: 204 with empty body, variant gone, its blobs and rows gone, others untouched. 404 row.
16. Purge: 200 with `purged_files` and `purged_bytes`, variant intact, list shows zero. Empty cache returns zeros. Storage `Delete` failing on the second of three blobs gives 500, two rows removed, one row kept. A blob already absent (storage `Delete` returns nil per `LocalStorage` contract) removes the row.
17. Source delete and merge: through the real delete and merge media use cases, assert the generated blobs and rows of the removed image(s) are deleted and those of other images are kept. Assert the 204 status and the existing library behaviour are unchanged.
## D. Public serving
18. First request: 200, content type, decoded size, exactly one `Put` and one index row equal to the body size, original blob untouched. Second request: byte-identical body, renderer count 1, `Put` count 1.
19. No `?v=`: the response equals the pre-slice response (headers and bytes) and the renderer count is 0. Other parameters such as `t=123` are ignored.
20. ETag: present, stable, `If-None-Match` gives 304 with an empty body and no renderer call on a cached file. Edit table (quality, width, height, fit, format change the ETag; `generate_on_upload` does not). A stale `If-None-Match` after an edit gets 200 with a new ETag. After an edit, the next request puts one new blob and the old blob was deleted.
21. Vanished blob: index row present, storage `Open` returns `ErrBlobNotFound`: regenerated, row repointed.
22. Concurrency (run with `-race`): 20 goroutines released by a barrier channel against a renderer blocking on a channel; all 200 with identical bytes, renderer count 1, `Put` count 1. Four distinct (variant, image) pairs with blocking renderers: assert all four renders start before any is released (use a `sync.WaitGroup` rendezvous in the fake, no sleeping). A failing first render gives 500 with no row and a successful retry.
23. 400 table: every URL of the outline, with the exact JSON body (decode and compare `status`, `error`, `message`, `detail`), `Content-Type` starting with `application/json`, renderer count 0, storage `Put` count 0. Precedence: `unsupported_parameter` beats `unknown_variant`; 400 beats 404 for a missing source.
24. Disabled then enabled: 400, then 200 served from the kept row (`Put` count unchanged). Deleted variant: 400.
25. 404 table: missing name, `..` traversal, nested path. 500 for undecodable bytes with nothing stored.
26. Cap: boundary table 1000 bytes (200) and 1001 bytes (413), the 413 plain text body, the renderer count 0, the original served without `?v=`.
27. SVG and GIF: unchanged bytes, content types `image/svg+xml` (with the existing CSP header) and `image/gif`, renderer count 0, nothing stored, unknown variant still 400, SVG larger than the cap still 200. A one-frame GIF is rendered.
## E. Geometry and format (unit-level, no HTTP, same files)
28. Table (feature "Output geometry"): decode each output and assert width and height. Build the source with a solid colour for the size rows.
29. Pixel assertions: cover crops around the centre of a 40 x 20 source with red columns 0-9, green 10-29, blue 30-39 and every pixel of the 20 x 20 output is green; contain pads transparent on png (alpha 0 on the padded rows, assert RGBA) and white on jpeg (within a tolerance of 8 per channel); inside has no transparent pixel; a fully transparent PNG source flattened to jpeg is white.
30. Format table: signature bytes (`\x89PNG`, `\xff\xd8`, `RIFF....WEBP`) and content types for original, jpeg, png. Lossy size: quality 20 smaller than quality 95 on a noisy fixture. PNG variants stored with quality 30 and 95 give byte-identical outputs.
31. WebP, conditional: with an encoder, assert `RIFF/WEBP`, dimensions via `webp.DecodeConfig`, lower quality gives a smaller file, and a webp source with `original` stays webp. Without one, a webp source rendered to jpeg works and `original` falls back to png. Record in the test name which build is under test.
## F. Config
32. Settings load: unset gives 10485760, `2048` gives 2048, `0`, `-5` and `many` give an error naming `IMAGE_VARIANT_MAX_SOURCE_BYTES`. Assert the var is present in `envDefaults` (and absent from `requiredEnvVars`), in `.env.example` and in `docs/configuration.md` (grep-style test on the files).
## G. Audit
33. Create, update (diff plus `purged_files`), delete (`purged_files`, `purged_bytes`), enable, disable and purge events (purge: counts on success, nothing for an empty cache or an unknown name, a failed event with the generic reason when storage fails) with the exact action, category `media`, target `image_variant`, actor and source from the origin. No-op PUT and PATCH record nothing; switch-only PUT records nothing for the rendering diff. Duplicate create records a failed event with its reason; validation errors, 404s, list and public generation record nothing. A failing audit repository leaves the response and the change untouched. Update the allowlist assertion of `test/activity_log_producers*_test.go` to the new field set.
## H. Mutation guard
34. `task mutate` scope is `./internal/infrastructure/useCases`, scoped with `MUTATE_FILES` to the new use case files and `MUTATE_RUN` to the new test names. Each new branch needs a killing assertion: the 1 and 8000 and 2 and 32 boundaries (just inside and just outside), the "at least one dimension" rule, every rendering-setting equality in the purge decision with equal and differing values, the cap comparison (`>` not `>=`, at 1000 and 1001), the never-upscale `min`, the singleflight key (variant and image both), the ETag input set, the check order (parameters, variant, source, as-is types, cap, ETag, cache), and the audit no-op guards. Bare-bool conditions produce no mutant, so cover both branches anyway.
## I. Layering and hygiene
35. Guard test (`go list -deps` or grep): `internal/domain/image` imports no GORM, chi, http or infrastructure; the variant use cases import no `persistence`, `mailer`, `models`, `gorm`, `net/http`, chi or `api`; the handlers import no `persistence`, `mailer`, `models` or `gorm`.
36. `CGO_ENABLED=0 go build -tags nofrontend ./...` succeeds, and `go list -deps` shows no package importing `"C"`.
37. No comments in new or edited Go files, tests and docs (the hook enforces it). Docs: `docs/api.md` lists the routes and response shapes, `docs/architecture.md` the ports and the cache, `docs/configuration.md` the env var.
## J. Hardening follow-up
44. Pixel cap: a PNG whose IHDR declares 30000 x 30000 (a few hundred bytes of real data) answers 413 with the plain text body and the renderer fake records zero calls; boundary 100 x 100 passes and 101 x 100 fails at a cap of 10000; the original is still served without `?v=`; an unreadable header gives 500. Settings tests for IMAGE_VARIANT_MAX_SOURCE_PIXELS (unset 50000000, 2048, 0, -5, many) and presence in envDefaults, `.env.example`, `docs/configuration.md`.
45. Render slots: a renderer fake that blocks on a channel proves at most N renders run at once, the rest wait and then complete with 200; a cached hit does not wait; a cancelled request context stops waiting and renders nothing; identical concurrent requests share one slot. Settings tests for IMAGE_VARIANT_MAX_CONCURRENT_RENDERS (unset 4, 1, 0, -1, many) and the three-place documentation check. Run with `-race`.
46. Known source: a blob without an image record (generated cache file or orphan) answers 404 for `?v=` with no storage `Open` call; parameter checks (400) still come first; the original of that blob is still served without `?v=`.
47. Contain geometry: 300 x 200 in 400 x 300 gives a 400 x 300 canvas with the image centred and unscaled (transparent padding on png); 300 x 200 in 1200 x 1200 gives 1200 x 1200; single-dimension contain keeps no padding.
## Manual (not automated)
38. Real boot with `task dev:api` over a scratch SQLite file and `UPLOAD_DIR`: create `thumb`, request `/static/uploadedImages/<name>?v=thumb` in a browser, check the file lands in `UPLOAD_DIR`, a second request is served without re-rendering, edit the quality and check the old file disappears. Check `AutoMigrate` created the new tables.
39. S3 driver (`STORAGE_DRIVER=s3`, MinIO): generated blobs land in the bucket, purge removes them, serving works without Range or seek support.
40. Visual quality of a real photograph in jpeg and, if the spike succeeded, webp; check the contain padding looks right against a white and a dark page.
41. WebP spike: record the evaluated encoders, the build result with `CGO_ENABLED=0`, speed and size on a 4032x3024 photo, and the decision. Report to the user if none works.
42. Memory and time on a source near `IMAGE_VARIANT_MAX_SOURCE_BYTES` with very large pixel dimensions.
43. Read the activity rows in the real Activity screen against section 8 copy.
+627
View File
@@ -0,0 +1,627 @@
Feature: Image variants cache panel: cache statistics, purge by scope, request tracking and the Cache screen
As an owner or admin of a RenewCMS site
I want to see what the variant cache holds and to purge all of it, the unused part, one variant or one image
So that I can reclaim disk without deleting a variant, and the activity log records every purge
Background:
Given the spec of record is docs/design/screens/image-variants.md sections 5 and 8, its "Back-office API errors" table, the cache frames of docs/design/source/VariantsFrame.dc.html and docs/design/routes.md
And the routes the design marks "proposed" are fixed here: GET /v1/image-variants/cache and POST /v1/image-variants/cache/purge, mounted in NewImageVariantRouter in api/router.go behind the existing owner or admin middleware chain, so a request with an API key has no user_id claim and answers 401
And features/image-variants.feature and features/image_variants_per_image_api.feature are the contract for variants, index rows and per-image purge, and are unchanged
And a cached file is one VariantFile index row, a row counts whatever the state of its fingerprint, and sizes are in bytes (1 MB = 1048576)
And the page is /settings/media/cache, a verified route rendered as CachePage inside DashboardShell, and it supersedes the aria-disabled "Cache" tab of features/image_variants_screens.feature, which becomes a link to this route
And request validation goes through validate tags and validator.Validate.Struct, an unreadable JSON body answers 400 with apperr.BodyErrorMessage, a failed validation answers 422, and error responses are plain text from http.Error
And new apperr constants are CachePurgeScopeErrorMessage = "Scope must be all, unused, variant or image." and CachePurgeTargetErrorMessage = "A variant name or an image id is required for this scope."
And request tracking has two parts: the last time each generated file was requested, and a counter of rejected requests per UTC day
And tracking never writes to the database on a request: hits are kept in memory by a thread-safe tracker behind a domain port, merged per (variant, source) key, and flushed in one batch by a background loop every 30 seconds, when 500 distinct keys are pending, on GET /v1/image-variants/cache before it reads, and on graceful shutdown
And the flush reaches the database through a domain repository port, the models are added to internal/infrastructure/persistence/database/migrate.go: a nullable last_requested_at column on ImageVariantFile and a new model ImageVariantRejectionDay with a unique day and a count
And the SQL stays portable across SQLite, MySQL/MariaDB and PostgreSQL: plain GROUP BY, ORDER BY, LIMIT and comparisons, no dialect function and no upsert syntax outside GORM's portable clauses
And the cache body is JSON with snake_case fields, arrays are never null, and the Default fixture below is used by the scenarios
And the Default fixture holds six variants, the enabled ones are card, hero, og, square and thumb, the disabled one is print, and the index holds:
| variant | files | bytes |
| hero | 244 | 88080384 |
| card | 318 | 60817408 |
| thumb | 412 | 22020096 |
| print | 74 | 22020096 |
| square | 140 | 19922944 |
| og | 96 | 9437184 |
And 312 files (42991616 bytes) of enabled variants have a reference time older than 30 days, the reference time of a row being last_requested_at when set and created_at otherwise
And the ten images using the most variant bytes are, in order: north-wall.jpg (id 1, 6 files, 6082765 bytes), lime-plaster.jpg (6, 5138022), kitchen-after.webp (5, 4613734), kitchen-before.webp (5, 4089446), floorboards.jpg (5, 3774873), IMG_4471.jpg (4, 3250585), stone-wall-detail.jpg (4, 2936012), bathroom-tiles.png (4, 2726297), roof-lantern.jpg (3, 2411724) and chimney-stack.jpg (3, 2202009)
And 37 rejected requests were counted over the last 7 UTC days
Rule: Only owners and admins reach the cache routes
Scenario Outline: Every cache route is guarded
When the client sends <method> <path> with <credentials>
Then the response status is <status>
And the plain text body is <body>
And no index row, blob, counter or activity event is read or changed
Examples:
| method | path | credentials | status | body |
| GET | /v1/image-variants/cache | no cookie | 401 | apperr.UnauthorizedErrorMessage |
| GET | /v1/image-variants/cache | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage |
| GET | /v1/image-variants/cache | the cookie of an unverified user | 403 | apperr.VerificationRequiredErrorMessage |
| GET | /v1/image-variants/cache | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| POST | /v1/image-variants/cache/purge | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| GET | /v1/image-variants/cache | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
| POST | /v1/image-variants/cache/purge | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
Scenario Outline: Owners and admins are both allowed
When <role> sends GET /v1/image-variants/cache with a valid cookie
Then the response status is 200
Examples:
| role |
| the owner |
| an admin |
Rule: The cache statistics
Scenario: The Default fixture
When an admin sends GET /v1/image-variants/cache
Then the response status is 200
And the body holds "generated_files" 1284 and "disk_used" 222298112
And it holds "variants_defined" 6, "variants_enabled" 5 and "variants_disabled" 1
And it holds "rejected_requests" 37 and "rejected_window_days" 7
And it holds "unused" {"files": 312, "bytes": 42991616, "days": 30}
And "by_variant" lists every defined variant sorted by bytes descending then name ascending: hero, card, print, thumb, square, og, each as {"name", "enabled", "files", "bytes"}
And "top_images" lists the ten images above in that order, each as {"id", "filename", "url", "files", "bytes"} where url is "/static/uploadedImages/<storedName>"
Scenario: Counts are the sum of the index rows
Then "generated_files" equals the sum of the "files" of "by_variant" and "disk_used" equals the sum of their "bytes"
Scenario: A variant without a file is listed with zero
Given the variant "banner" is defined and enabled and has no file
When an admin sends GET /v1/image-variants/cache
Then "by_variant" holds {"name": "banner", "enabled": true, "files": 0, "bytes": 0} and "variants_defined" is 7
Scenario: A disabled variant keeps its files in the totals
Then "by_variant" holds print with "enabled" false, "files" 74 and "bytes" 22020096
And print is counted in "generated_files" and "disk_used" but not in "unused"
Scenario: An image deleted behind the index is not a top image
Given index rows exist for a stored name that no image record has
When an admin sends GET /v1/image-variants/cache
Then the rows count in "generated_files", "disk_used" and "by_variant" and no entry of "top_images" names that stored name
Scenario: Top images are capped at ten and tie-break by id
Given eleven images have variant files and images 7 and 8 have equal bytes
When an admin sends GET /v1/image-variants/cache
Then "top_images" has ten entries and image 7 comes before image 8
Scenario: An empty cache
Given the index holds no row
When an admin sends GET /v1/image-variants/cache
Then the response status is 200
And "generated_files" is 0, "disk_used" is 0, "unused" is {"files": 0, "bytes": 0, "days": 30}
And the raw body holds "top_images":[] and every "by_variant" entry has files 0
And "variants_defined" and "rejected_requests" are unchanged
Scenario: No variant defined
Given no variant exists
When an admin sends GET /v1/image-variants/cache
Then the raw body holds "by_variant":[] and "variants_defined":0
Scenario: Unused counts only enabled variants older than the cutoff
Given a row of "hero" with last_requested_at 29 days ago and a row of "hero" with last_requested_at 31 days ago
And a row of "print", a disabled variant, with last_requested_at 90 days ago
And a row of "card" with no last_requested_at and a created_at 45 days ago
Then the rows of "hero" 31 days ago and of "card" are counted in "unused", and the other two are not
Scenario: A row requested exactly 30 days ago is still in use
Given a row whose reference time is exactly the cutoff
Then it is not counted in "unused"
Scenario: The statistics are aggregated, not read per image
When an admin sends GET /v1/image-variants/cache
Then the file repository answered the totals, the unused totals and the top images in one aggregated call each, never one query per image or per file
Scenario: The read flushes pending tracking first
Given 12 hits and 3 rejections are pending in memory
When an admin sends GET /v1/image-variants/cache
Then one batch write happens before the read and "rejected_requests" includes the 3 pending
Scenario: The statistics change nothing else
When an admin sends GET /v1/image-variants/cache
Then no blob is opened, no render happens, no row is deleted and no activity event is recorded
Scenario: A failing repository answers 500
Given the file repository fails to total
When an admin sends GET /v1/image-variants/cache
Then the response status is 500 and the plain text body is "Internal server error"
Rule: The last time each generated file was requested is tracked without a write per hit
Scenario Outline: What counts as a request for a file
When <request>
Then <effect> for the pair (variant, stored name) in memory and no database write happens
Examples:
| request | effect |
| the public client gets /static/uploadedImages/a1b2.jpg?v=thumb, cached | the last requested time is set |
| the public client gets it and the file is generated by that request | the last requested time is set |
| the public client gets it with a matching If-None-Match and receives 304 | the last requested time is set |
| the public client gets /static/uploadedImages/a1b2.jpg with no variant | nothing is recorded |
| the public client gets it with ?v=unknown | nothing is recorded |
| the public client gets it with ?v=print, a disabled variant | nothing is recorded |
| the public client gets /static/uploadedImages/missing.jpg?v=thumb | nothing is recorded |
| an admin sends POST /v1/media/1/variants/thumb | nothing is recorded |
| an admin sends GET /v1/image-variants/cache | nothing is recorded |
Scenario: A thousand hits on one file are one pending key
When the public client requests the same cached file 1000 times
Then the database received zero writes during the requests
And after a flush exactly one batch write holds one update for that file with the time of the last hit
Scenario: The flush writes one batch for many files
Given 40 distinct files were requested
When the tracker flushes
Then the repository received one batch of 40 updates in one call
Scenario: Nothing pending means no write
When the tracker flushes and nothing was recorded since the last flush
Then the repository is not called
Scenario: The flush loop and the size trigger
Given 500 distinct keys are pending
Then a flush is started without waiting for the interval
And a flush also happens at each 30 second tick and on graceful shutdown
Scenario: A failed flush keeps the pending hits
Given the repository fails on the batch
When the tracker flushes, then the repository recovers and the tracker flushes again
Then the second batch holds the hits of both flushes with the latest time per file and nothing is lost or counted twice
Scenario: A request is never blocked by or failed by the tracker
Given the repository fails on every batch
When the public client requests a cached file
Then the response is 200 with the file bytes
Scenario: A stored time never moves back
Given a row with last_requested_at 10:05
When a batch carries the time 10:01 for that file
Then last_requested_at is still 10:05
Scenario: A purged file ignores a late update
Given the batch carries a time for a file that was purged since
When the tracker flushes
Then no row is created and the call succeeds
Scenario: Concurrent requests are race free
When 50 goroutines record hits and rejections while another flushes
Then the totals written equal the recorded ones, with the race detector enabled
Rule: Rejected public requests are counted per day
Scenario Outline: What counts as rejected
When the public client sends GET <url>
Then <effect>
Examples:
| url | effect | status |
| /static/uploadedImages/a1b2.jpg?v=unknown | the rejected counter of today is +1 | 400 |
| /static/uploadedImages/a1b2.jpg?v=print | the rejected counter of today is +1 | 400 |
| /static/uploadedImages/a1b2.jpg?width=400 | the rejected counter of today is +1 | 400 |
| /static/uploadedImages/a1b2.jpg?v=Thumb | the rejected counter of today is +1 | 400 |
| /static/uploadedImages/missing.jpg?v=thumb | the counter is unchanged | 404 |
| /static/uploadedImages/a1b2.jpg?v=thumb | the counter is unchanged | 200 |
| /static/uploadedImages/a1b2.jpg | the counter is unchanged | 200 |
Scenario: A rejection is an in-memory increment
When the public client sends 1000 rejected requests
Then the database received zero writes during the requests
And after a flush one batch adds 1000 to today's day row
Scenario: The window is today and the six previous UTC days
Given the tracker clock reads 2026-10-08 23:59 UTC and day rows of 2026-10-02 (5) and 2026-10-01 (9)
When an admin sends GET /v1/image-variants/cache
Then "rejected_requests" counts the row of 2026-10-02 and not the row of 2026-10-01
Scenario: A day boundary starts a new bucket
Given a rejection at 2026-10-08 23:59:59 UTC and another at 2026-10-09 00:00:00 UTC
Then they are counted in two different day rows
Scenario: Old day rows are pruned by the flush
Given a day row older than 7 days
When the tracker flushes
Then that row is deleted and the rows of the window are kept
Scenario: A failed flush keeps the pending rejections
Given the repository fails on the batch
When the tracker flushes, then the repository recovers and flushes again
Then the rejections are written once
Rule: Purging the cache by scope
Background:
Given the Default fixture with blobs in image.Storage for every row
And images 1 "north-wall.jpg" and 2 "lime-plaster.jpg" hold index rows
Scenario: Purge all removes every generated file of every variant
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "all"}
Then the response status is 200
And the body is {"scope": "all", "purged_files": 1284, "purged_bytes": 222298112, "failed_files": 0}
And every generated blob is deleted from image.Storage and the index holds no row
And the original blobs and every variant definition are unchanged
Scenario: Purge all includes the files of disabled variants
Then the 74 files of "print" are among the 1284 purged
Scenario: Purge unused removes the unused files of enabled variants only
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "unused"}
Then the response status is 200
And the body is {"scope": "unused", "purged_files": 312, "purged_bytes": 42991616, "failed_files": 0}
And the remaining 972 rows are untouched, including any row of "print" and every row requested within 30 days
Scenario: Purge by variant removes the files of that variant only
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "variant", "variant": "hero"}
Then the body is {"scope": "variant", "purged_files": 244, "purged_bytes": 88080384, "failed_files": 0}
And the files of the other variants are untouched
Scenario: Purge by variant works for a disabled variant
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "variant", "variant": "print"}
Then "purged_files" is 74
Scenario: Purge by image removes every variant file of that image
Given image 1 holds 6 rows of 6082765 bytes
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "image", "image_id": 1}
Then the body is {"scope": "image", "purged_files": 6, "purged_bytes": 6082765, "failed_files": 0}
And the files of image 2 are untouched and the original of image 1 is unchanged
Scenario: The next public request generates a purged file again
Given a purge with {"scope": "all"} answered 200
When the public client sends GET /static/uploadedImages/a1b2.jpg?v=thumb
Then the response is 200 and a new blob was put
Scenario: Purging an empty cache answers zero
Given the index holds no row
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "all"}
Then the response status is 200 and the body is {"scope": "all", "purged_files": 0, "purged_bytes": 0, "failed_files": 0}
Scenario: A blob already missing from storage does not stop a purge
Given image.Storage reports a missing blob for one row
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "all"}
Then its row is removed and counted as purged and the others are purged
Scenario Outline: A request that cannot be served
When an admin sends POST /v1/image-variants/cache/purge with <body>
Then the response status is <status> and the plain text body is <message>
And no blob is deleted and no row is deleted and no activity event is recorded
Examples:
| body | status | message |
| {"scope": "everything"} | 422 | apperr.CachePurgeScopeErrorMessage |
| {"scope": "ALL"} | 422 | apperr.CachePurgeScopeErrorMessage |
| {} | 422 | apperr.CachePurgeScopeErrorMessage |
| {"scope": "variant"} | 422 | apperr.CachePurgeTargetErrorMessage |
| {"scope": "variant", "variant": ""} | 422 | apperr.CachePurgeTargetErrorMessage |
| {"scope": "image"} | 422 | apperr.CachePurgeTargetErrorMessage |
| {"scope": "image", "image_id": 0} | 422 | apperr.CachePurgeTargetErrorMessage |
| {"scope": "image", "image_id": -1} | 422 | apperr.CachePurgeTargetErrorMessage |
| {"scope": "variant", "variant": "missing"} | 404 | apperr.VariantNotFoundErrorMessage |
| {"scope": "image", "image_id": 9999} | 404 | Image not found |
| not json | 400 | apperr.BodyErrorMessage |
Scenario Outline: A target given for a scope that ignores it is ignored
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "<scope>", "variant": "hero", "image_id": 1}
Then the response status is 200 and the purged files are those of the scope "<scope>" alone
Examples:
| scope |
| all |
| unused |
Rule: A purge that does not complete reports what it did
Background:
Given the Default fixture and image.Storage fails to delete the blobs of 644 rows
Scenario: A partial purge answers 200 with the failed count
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "all"}
Then the response status is 200
And the body is {"scope": "all", "purged_files": 640, "purged_bytes": <bytes of those 640>, "failed_files": 644}
And the 644 rows are kept, so a second call can retry them
Scenario: Retrying removes the rest
Given a partial purge answered 200
When image.Storage recovers and the admin sends the same request again
Then "purged_files" is 644 and "failed_files" is 0
Scenario: A failure to list the rows answers 500
Given the file repository fails to list the rows to purge
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "all"}
Then the response status is 500 and the plain text body is "Internal server error"
Scenario: Rows are processed in bounded batches
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "all"} over 5000 rows
Then the rows were read in batches of at most 500 and never all loaded at once
Rule: Each purge scope is recorded in the activity log
Background:
Given recording follows features/activity-log-producers.feature: once, from the use case, after the change, with the request origin
And the category is "media"
And the domain audit gains the actions media.variant_purge_all and media.variant_purge_unused, and reuses media.variant_purge and media.variant_purge_image
Scenario Outline: A purge that removes files is recorded
When the owner sends POST /v1/image-variants/cache/purge with <body>
Then one event exists with action "<action>", target <target>, outcome "success" and params <params>
Examples:
| body | action | target | params |
| {"scope": "all"} | media.variant_purge_all | none | {"purged_files": 1284, "purged_bytes": 222298112} |
| {"scope": "unused"} | media.variant_purge_unused | none | {"purged_files": 312, "purged_bytes": 42991616, "older_than_days": 30} |
| {"scope": "variant", "variant": "hero"}| media.variant_purge | {"type": "image_variant", "id": "hero", "name": "hero"} | {"purged_files": 244, "purged_bytes": 88080384} |
| {"scope": "image", "image_id": 1} | media.variant_purge_image | {"type": "image", "id": "1", "name": "north-wall.jpg"} | {"purged_files": 6, "purged_bytes": 6082765} |
Scenario: A partial failure is a success that names the failed count
Given a purge removes 640 files and fails on 644
When the owner sends POST /v1/image-variants/cache/purge with {"scope": "all"}
Then one event exists with action "media.variant_purge_all", outcome "success" and params {"purged_files": 640, "purged_bytes": <bytes>, "failed_files": 644}
Scenario: Every file failing is a failed event with a plain reason
Given image.Storage fails to delete every blob
When the owner sends POST /v1/image-variants/cache/purge with {"scope": "all"}
Then the response is 200 with "purged_files" 0 and "failed_files" 1284
And one event exists with action "media.variant_purge_all", outcome "failed" and failure_reason "Something went wrong on our side. Please try again."
Scenario Outline: Nothing removed, nothing recorded
When <request>
Then no event is recorded
Examples:
| request |
| the owner purges all on an empty cache |
| the owner purges unused when nothing is unused |
| the owner purges the variant "hero" and it has no file |
| the owner purges image 1 and it has no file |
| the owner sends an unknown scope |
| the owner purges an unknown variant |
| the owner purges an unknown image |
| the owner sends GET /v1/image-variants/cache |
Scenario: A failing audit write never changes the response
Given the audit repository fails on Append
When the owner sends POST /v1/image-variants/cache/purge with {"scope": "all"}
Then the response is 200 with the same body and the files are gone
Scenario: The events read back through the existing activity API
Given one event of each of the four scopes was recorded
When the owner sends GET /v1/activity?category=media
Then the four events are listed with their action, target and params
Scenario Outline: The Activity screen words each scope
Given an activity item with action "<action>", outcome "<outcome>" and the params above
Then the sentence reads "<sentence>" and the detail line reads "<detail>"
Examples:
| action | outcome | sentence | detail |
| media.variant_purge_all | success | Purged all variants | Removed 1,284 files (212 MB) |
| media.variant_purge_all | failed | Failed to purge all variants | |
| media.variant_purge_unused | success | Purged unused variants | Removed 312 files (41 MB) not requested for 30 days |
| media.variant_purge_unused | failed | Failed to purge unused variants | |
| media.variant_purge | success | Purged the cache of variant “hero” | Removed 244 files (84 MB) |
| media.variant_purge | failed | Failed to purge the cache of variant “hero” | |
| media.variant_purge_image | success | Purged the variants of north-wall.jpg | Removed 6 files (5.8 MB) |
Scenario: A partial purge adds the failed count to the detail line
Given an item "media.variant_purge_all" with params {"purged_files": 640, "purged_bytes": 111149056, "failed_files": 644}
Then the detail line reads "Removed 640 files (106 MB) · 644 failed"
Rule: Cache screen, access and header
Background:
Given the session user has role "owner" or "admin" from GET /v1/auth/me unless a scenario says otherwise
And the page uses Nuxt UI components only and design tokens, with no raw hex value and no arbitrary Tailwind colour
And every new English string has a French entry in web/src/i18n/messages.ts
And the stats come from GET /v1/image-variants/cache once per navigation, and every size is shown with the formatter of the Variants page (1 MB = 1048576), numbers with thousands separators
And plain-text API errors are mapped by status, never shown raw
And the desktop breadcrumb is "Settings › Media › Cache", the mobile top bar title is "Cache", and the sidebar item "Settings" is current
Scenario Outline: A session whose role is not owner or admin gets the Forbidden state
Given the session has <session>
When the user opens /settings/media/cache
Then the page shows the empty block with a lock icon, the title "You don't have access" and the text "Only admins can manage image variants. Ask an admin if you need one changed."
And it offers one button "Back to Home" with an arrow-left icon, which routes to /home
And no header, no tabs, no tile and no list is drawn
And <request>
Examples:
| session | request |
| no role | no request to GET /v1/image-variants/cache is sent |
| role "owner" but the API answers 403 | the request was sent and its 403 is handled here |
Scenario: The header and the tabs
When the Default fixture is loaded
Then the title is "Cache" and the description is "Generated variant files. Purged files are generated again on the next request."
And two buttons are shown: "Purge unused" with a clock icon and "Purge all variants" with a trash-2 icon in the danger tone
And two tabs follow: "Variants" (icon layers, a link to /settings/media/variants) and "Cache" (icon database, active, medium weight, 2 px primary underline)
And activating "Variants" routes to /settings/media/variants, where its own "Cache" tab routes back here
Rule: Cache screen, loaded
Scenario: The four stat tiles
When the Default fixture is loaded
Then four tiles show, as label, value and sub text:
| label | value | sub text |
| Generated files | 1,284 | Across 6 variants |
| Disk used | 212 MB | Originals not included |
| Variants defined | 6 | 5 enabled, 1 disabled |
| Rejected requests | 37 | Last 7 days |
And the tiles form four columns on desktop and two on mobile
Scenario: The By variant list
When the Default fixture is loaded
Then the card "By variant" lists in the API order:
| name | stats | badge | bar width of the largest |
| hero | 244 files · 84 MB | | 100 % |
| card | 318 files · 58 MB | | 69 % |
| print | 74 files · 21 MB | Disabled | 25 % |
| thumb | 412 files · 21 MB | | 25 % |
| square | 140 files · 19 MB | | 23 % |
| og | 96 files · 9 MB | | 11 % |
And the bar width is the bytes of the row over the largest bytes, 0 when the largest is 0
And a disabled variant draws its bar in the border-accented colour and the others in the primary colour
And the names are IBM Plex Mono medium and the singular reads "1 file"
Scenario: The Top 10 list on desktop
When the Default fixture is loaded
Then the card "Top 10 images by variant disk usage" lists ten rows numbered 1 to 10, each with a 32 x 24 thumbnail of the original, the filename, "N variants", the size and a "Purge" button with an eraser icon
And the first row reads north-wall.jpg, 6 variants, 5.8 MB and the last chimney-stack.jpg, 3 variants, 2.1 MB
And the singular reads "1 variant"
Scenario: The Top 10 list on mobile
Given the viewport is below 640 px
When the Default fixture is loaded
Then the tiles are two columns, the two cards stack, each Top 10 row is 60 px high with the size after the count ("6 variants · 5.8 MB") and the Purge button is icon only, 44 px square, with aria-label "Purge north-wall.jpg" and a tooltip
Scenario: The layout is two columns on desktop
Given the viewport is 640 px wide or more
Then By variant is 460 px wide and Top 10 takes the rest
Rule: Cache screen, loading, empty and error
Scenario: Loading
Given GET /v1/image-variants/cache is unresolved
Then at 149 ms nothing is drawn and at 150 ms the region has aria-busy and the label "Loading cache"
And the four tiles show skeletons for the value and the sub text, By variant shows 5 skeleton rows, Top 10 shows 6 skeleton rows on desktop and is hidden on mobile
And a response at 100 ms never draws skeletons
Scenario: Empty cache
Given the stats hold zero files and variants_defined 6
Then the tiles read "0 · Nothing cached" for Generated files, "0 MB · Nothing cached" for Disk used, "6 · 5 enabled, 1 disabled" and "37 · Last 7 days"
And an empty block with a database icon shows the title "Nothing generated yet" and the text "Variants are generated on the first request with ?v=<name>, or from an image's Variants tab."
And "Purge unused" and "Purge all variants" are disabled and no By variant or Top 10 card is drawn
Scenario Outline: Error loading
Given the GET answers <failure>
Then an error alert shows the icon circle-x, the title "Couldn't load cache stats", the text "Check your connection and try again." and the button "Try again"
And no tile, list or table is drawn and the alert has no close button
When the user activates "Try again"
Then GET /v1/image-variants/cache is sent again and the Default fixture renders
Examples:
| failure |
| a 500 |
| a network error |
Scenario: Purge unused is disabled when nothing is unused
Given the stats hold "unused" {"files": 0, "bytes": 0, "days": 30} and 1284 files
Then "Purge unused" is disabled and "Purge all variants" is enabled
Rule: Cache screen, purge all with a typed word
Scenario: The dialog opens with the button disabled
When the user activates "Purge all variants"
Then a dialog titled "Purge all variants?" shows "This removes 1,284 generated files (212 MB) for every variant. Originals aren't touched. Files are generated again on request."
And a field labelled "Type purge to confirm" in IBM Plex Mono is focused, "Cancel" is offered and "Purge all variants" in the danger tone is disabled
And no request is sent
Scenario Outline: The word unlocks the button
Given the dialog is open
When the user types "<typed>"
Then the confirm button is <state>
Examples:
| typed | state |
| pur | disabled |
| Purge | disabled |
| purge | enabled |
| purge | enabled |
| purged | disabled |
Scenario: Cancel and Escape send nothing
When the user activates Cancel, or presses Escape
Then the dialog closes, focus returns to "Purge all variants" and no request is sent
Scenario: Confirming sends the purge
Given the typed word is "purge"
When the user activates "Purge all variants"
Then POST /v1/image-variants/cache/purge is sent once with {"scope": "all"} and the dialog closes
Rule: Cache screen, purge unused
Scenario: The dialog states the count from the stats
When the user activates "Purge unused"
Then a dialog titled "Purge unused variants?" shows "This removes 312 files (41 MB) from enabled variants that haven't been requested for 30 days. Files are generated again on request."
And it offers "Cancel" and "Purge 312 files", with no typed word
Scenario: Confirming sends the purge
When the user activates "Purge 312 files"
Then POST /v1/image-variants/cache/purge is sent once with {"scope": "unused"}
Rule: Cache screen, purging, done and failed
Scenario: Purging
Given a purge request is in flight and the stats hold 1284 files
Then the page shows an info banner with a loader icon and "Purging 1,284 files" with an indeterminate bar, announced with aria-live="polite"
And the tiles and lists are dimmed to 60 % opacity, "Purge unused", "Purge all variants" and every row Purge button are disabled, and the tabs stay active
Scenario: Purge done after purge all
Given the purge answered {"scope": "all", "purged_files": 1284, "purged_bytes": 222298112, "failed_files": 0}
Then a success toast reads "Purged 1,284 files, freed 212 MB"
And the stats are fetched again once and the empty cache state shows
Scenario: Purge done after purge unused
Given the purge answered {"scope": "unused", "purged_files": 312, "purged_bytes": 42991616, "failed_files": 0}
Then a success toast reads "Purged 312 files, freed 41 MB" and the stats are fetched again once
Scenario: Purge failed
Given the purge answered {"scope": "all", "purged_files": 640, "purged_bytes": 111149056, "failed_files": 644}
Then an error toast shows the title "Purge didn't finish", the text "640 of 1,284 files were removed. Try again." and the button "Try again"
And the stats are fetched again once
When the user activates "Try again"
Then the same request is sent again, without asking for the word again
Scenario Outline: A purge request that errors
Given the POST answers <failure>
Then the banner is removed, the content is restored and a toast shows <title> and <text>
Examples:
| failure | title | text |
| a 400 | Check the request | A value in the request isn't valid. Reload the page and try again. |
| a 403 | You don't have access| Only admins can manage image variants. |
| a 500 | Something went wrong | The server couldn't finish. Try again. If it keeps happening, check the server log. |
| a network error | Something went wrong | The server couldn't finish. Try again. If it keeps happening, check the server log. |
Scenario: A 401 sends the user to sign in
Given the POST answers 401
Then the user is redirected to sign in and a toast reads "Session expired" with "Sign in again to continue."
Rule: Cache screen, purging the variants of one image
Scenario: A row Purge sends the image scope
When the user activates "Purge" on the north-wall.jpg row
Then POST /v1/image-variants/cache/purge is sent once with {"scope": "image", "image_id": 1} and no confirm dialog is shown
And while it runs the row shows a loader on its button and the other Purge buttons stay enabled
Scenario: Done
Given the purge answered {"scope": "image", "purged_files": 6, "purged_bytes": 6082765, "failed_files": 0}
Then a success toast reads "Purged 6 files (5.8 MB) for north-wall.jpg" and the stats are fetched again once
Scenario: The image no longer exists
Given the POST answers 404
Then a toast shows "Not found" and "This variant or image no longer exists. Refresh the page." and the stats are fetched again
Scenario: Failed
Given the purge answered {"scope": "image", "purged_files": 4, "purged_bytes": 1000, "failed_files": 2}
Then the "Purge didn't finish" toast reads "4 of 6 files were removed. Try again." with "Try again"
Rule: Accessibility and vocabulary
Scenario: Roles and announcements
Then the tabs have role tablist and tab, the dialog traps focus and returns it to its opener, the icon-only buttons have an aria-label and a tooltip, touch targets on mobile are at least 44 px, and every status is an icon plus text
And no rendered text contains "thumbnail preset", "version" or "rendition"
Rule: Layering and hygiene
Scenario: The dependency rule holds
Then internal/domain/image and internal/domain/audit import only the stdlib and other internal/domain packages
And the tracker port, the usage repository port and the cache port are declared in internal/domain/image
And the new use cases (cache stats, purge cache) and the tracker import no persistence, mailer, models, gorm, net/http, chi or api package
And the handler imports no persistence, mailer, models or gorm package
And only api/dependecyInjection.go constructs the tracker, starts its flush loop, flushes it on shutdown and wires the use cases into imagevariant.NewHandler and the serve path of the public handler
And a repository method with a single-row Find or Save keeps its current signature
And the models are listed in internal/infrastructure/persistence/database/migrate.go
And no comment is added to any file, except tool directives
+100
View File
@@ -0,0 +1,100 @@
# QA procedure: image_variants_cache
Two parts: Go for the API and tracking, Vitest for the Cache screen. No live server, no real SQLite file, no real SMTP, no `time.Sleep` (use an injected clock and manual flushes).
## Go (package `test`, black-box)
New files: `test/image_variants_cache_http_test.go`, `test/image_variants_cache_tracking_test.go`, `test/image_variants_cache_purge_test.go`, `test/image_variants_cache_audit_test.go`. Extend `test/image_variants_support_test.go`. Fake type names must stay unique in the package.
### Setup shared by every case
1. `validator.InitValidator()` once. `jwtauth.New("HS256", []byte("test-secret"), nil)` stands in for `JWT_SECRET`.
2. Reuse `mockVariantRepo`, `mockVariantFileRepo`, `mockVariantStorage`, `countingRenderer`, `mockImageRepo`, the user fake, the API-key fake and the audit fakes of `test/activity_support_test.go`. Add to `mockVariantFileRepo`: `last_requested_at` per row, the aggregated totals, unused totals (cutoff, enabled variants only), top-images query (limit 10, bytes desc, id asc), batched listing for purge (batch size recorded), the batch update of requested times, with a call counter per method. Add fakes for the usage repository (`mockVariantUsageRepo`: day rows, batch writes counted, switchable failure) and a controllable clock.
3. Users: owner 1, admin 2, verified plain user 3, unverified user 4. Real signed `jwt` cookies with `user_id` and `token_version`; a stale cookie; a valid API key.
4. Router: a fresh `chi.NewRouter()` mirroring `NewImageVariantRouter` in `api/router.go` (`jwtauth.Verifier`, `AuditContext`, `RequireSignedIn`, `Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`, `RequireOwnerOrAdmin`) with `Get("/cache")` and `Post("/cache/purge")` beside the existing variant routes, mounted at `/v1/image-variants`. Mount the real public handler `staticimages.NewHandler(...)` at `/static/uploadedImages` over the same storage, variant use cases and the real tracker.
5. Seed the Default fixture of the feature (six variants, the per-variant bytes, 312 unused files with the 29/31/30 day boundary rows, the ten top images, 37 rejections over the window). Images are generated in the test with `image/png`, `image/jpeg`, `image/draw`.
### A. Guards
6. Table of the feature: each method, path and credential gives the status and exact body constant; assert no file, storage, usage or audit fake was touched. API key gets 401. Owner and admin get 200.
### B. Statistics
7. Default fixture: decode the body and assert every field and value, `by_variant` order and content, `top_images` order, `url`. Sum invariants. Zero-file variant listed; disabled variant in totals but not in `unused`; orphan index rows counted but absent from `top_images`; tie at the tenth place; empty cache raw body `"top_images":[]`; no variant raw `"by_variant":[]`.
8. `unused` boundaries: 29 days (in use), exactly the cutoff (in use), 31 days (unused), `created_at` fallback, disabled variant excluded.
9. Aggregation: the file fake saw one totals call, one unused call and one top-images call, regardless of the number of images. A pending-tracking fixture asserts one batch write before the read, and that the 3 pending rejections are in `rejected_requests`. A failing totals call gives 500 `Internal server error`. GET records no audit event and opens no blob.
### C. Tracking
10. Hits: for each row of the "what counts" table drive the real public handler and assert the pending key set and zero usage writes. 1000 hits on one file: zero writes, then one flush gives one update with the last time. 40 files: one batch of 40. Nothing pending: repository not called. 500 distinct keys trigger a flush without the tick (use the injected trigger, not a timer). Failed batch then recovery: nothing lost, nothing counted twice, latest time wins. A failing repository never fails or slows the public response. The stored time never moves back. A late update for a purged file creates no row.
11. Rejections: for each row of the table drive the public handler and assert the counter and the status. 1000 rejections: zero writes, one batch adds 1000. Window: clock at 2026-10-08 23:59 UTC, rows of 2026-10-02 counted, 2026-10-01 not. Day boundary at 23:59:59 and 00:00:00 UTC gives two rows. The flush prunes rows older than 7 days. Failed flush keeps the pending counts.
12. Race (run with `-race`): 50 goroutines record hits and rejections while another flushes; totals written equal totals recorded. Shutdown: closing the container flushes once.
### D. Purge
13. One case per scope with the numbers of the feature (all 1284 and 222298112, unused 312 and 42991616, variant hero 244 and 88080384, image 1 6 and 6082765): assert the body, each deleted blob name, each deleted row, and that originals and definitions are unchanged. Disabled variant: scope all and scope variant include it; scope unused excludes it. A following public GET renders again.
14. Empty cache returns zeros. A blob missing from storage still removes its row and counts as purged.
15. Refusals table: unknown scope, case change, empty body, missing or empty variant, missing, zero and negative `image_id`, unknown variant (404 `Variant not found`), unknown image (404 `Image not found`), invalid JSON (400 `apperr.BodyErrorMessage`). Assert no storage or row change and no audit event. Extra target fields for `all` and `unused` are ignored.
16. Partial failure: storage fails on 644 rows; 200 with `purged_files` 640, `failed_files` 644, failing rows kept; retry after recovery purges the rest. Listing failure gives 500. Batch size at most 500 over 5000 rows (assert the largest batch the fake returned).
### E. Audit
17. One case per scope row: action, target (none for all and unused, `image_variant` for variant, `image` for image), outcome, params including `older_than_days` 30 for unused. Partial failure is a success carrying `failed_files`. All-failed is a `failed` event with the generic reason. The no-op rows record nothing. A failing audit repository leaves the response and files unchanged. `GET /v1/activity?category=media` lists the four events. Extend the `failureReasons` mapping test for the two new actions.
### F. Mutation guard
18. `task mutate` scope is `./internal/infrastructure/useCases`, scoped with `MUTATE_FILES` to the new use case files and `MUTATE_RUN` to the new test names. Each new branch needs a killing assertion: the unused cutoff comparison (below, at and above), the enabled filter of unused, the `purged_files > 0` guard, the success versus failed outcome (`purged > 0` versus `failed > 0`), the top-10 limit and its id tie-break, the window start (7 days) and the never-moving-back time guard. Bare-bool conditions produce no mutant, so cover both branches anyway.
### G. Layering and hygiene
19. Guard test (`go list -deps` or grep): the domain imports only the stdlib and domain packages; the new use cases and the tracker import no `persistence`, `mailer`, `models`, `gorm`, `net/http`, chi or `api`; the handler imports no `persistence`, `mailer`, `models` or `gorm`. Check `migrate.go` lists `ImageVariantRejectionDay`. `CGO_ENABLED=0 go build -tags nofrontend ./...` succeeds. No comment in new or edited files. `docs/api.md` lists the two routes, the shapes, the scopes and the errors, and `docs/architecture.md` or the variants doc describes the tracker and its 30 second flush.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `request` per case (GET `/v1/image-variants/cache`, POST `/v1/image-variants/cache/purge`); no backend. Mount `CachePage` inside `DashboardShell` with a resolved verified session (id 1, username "marta", role "owner") and a test router at `/settings/media/cache`. Mock `matchMedia` for the 640 px breakpoint; run layout cases at desktop and below-640. Use fake timers for the 150 ms skeleton delay. No test freezes the system date (this screen shows no date).
Default fixture = the Background of the feature, with the six variants, the ten top images and the numbers above.
1. **Access, Examples-driven**: no role, role "owner", role "admin", 403 on the list. Forbidden copy and "Back to Home"; no request when the role is missing.
2. **Header and tabs**: title, description, the two buttons with icons, the two tabs; "Variants" routes to `/settings/media/variants`; the Variants page's "Cache" tab now routes here (update the existing VariantsPage spec that asserted aria-disabled); breadcrumb and mobile title.
3. **Tiles**: the four rows of the table; two columns on mobile, four on desktop.
4. **By variant**: order, stats text, the Disabled badge, bar width percentages (100, 69, 25, 25, 23, 11), the colour classes for enabled and disabled, `1 file` singular, 0 width when the largest is 0.
5. **Top 10**: ten rows, numbering, thumbnails, `6 variants`, `1 variant`, sizes; mobile 60 px rows with the size after the count and an icon-only 44 px Purge with aria-label and tooltip.
6. **Loading**: nothing at 149 ms; at 150 ms `aria-busy`, label "Loading cache", skeleton counts (5 and 6; Top 10 hidden on mobile); a response at 100 ms never draws skeletons.
7. **Empty**: tile subs "Nothing cached", empty block copy, both purge buttons disabled, no lists. **Purge unused** disabled when unused is 0.
8. **Error, Examples-driven**: 500 and network error, alert copy, no close button, Try again re-sends and renders.
9. **Purge all dialog**: copy, focus on the field, confirm disabled; the typed-word table (`pur`, `Purge`, `purge`, `purged`); Cancel and Escape send nothing and return focus; confirm sends `{"scope": "all"}` once.
10. **Purge unused dialog**: copy with 312 and 41 MB, "Purge 312 files", sends `{"scope": "unused"}`.
11. **Purging**: banner copy "Purging 1,284 files", `aria-live="polite"`, dimmed content, all purge controls disabled, tabs active.
12. **Done**: toasts "Purged 1,284 files, freed 212 MB" and "Purged 312 files, freed 41 MB"; one refetch; the empty state after purge all.
13. **Failed**: toast "Purge didn't finish", "640 of 1,284 files were removed. Try again.", one refetch, Try again re-sends the same body without the typed word.
14. **Errors, Examples-driven**: 400, 403, 500, network, 401 (redirect then toast), each with the copy of the table; banner removed and content restored.
15. **Row purge**: sends `{"scope": "image", "image_id": 1}` with no dialog; button loader; other rows enabled; toast "Purged 6 files (5.8 MB) for north-wall.jpg"; 404 toast and refetch; partial failure "4 of 6 files were removed. Try again.".
16. **Activity sentences** (extend `activitySentence.spec.ts` and `ActivityVariants.spec.ts`): the seven rows of the sentence table, the partial detail line, and `SENTENCE_ACTIONS` including the three actions.
17. **French**: `setLocale('fr')`; a catalogue test that every key used on the page and in the new sentences exists in `messages.ts`; the page renders without missing keys.
18. **Vocabulary and accessibility**: no rendered text contains "thumbnail preset", "version" or "rendition"; tabs, labels, focus return.
## Visual (task visual:check -- image-variants, desktop and mobile, light and dark)
NOT RUN: the user forbade running task visual:check for this slice, so every item below is reported NOT RUN.
Reference: `docs/design/screens/image-variants.md` section 5 and the cache frame in `docs/design/source/VariantsFrame.dc.html` (lines 250-278, with `AppFrame.dc.html` for the shell). Add each state to `web/visual/states.ts` with the board's data. The gate: every non-skipped capture within 10 %, both viewports, both themes. None of these states depends on the date or time, so none is skipped.
Board sample data the mocks must reuse: variants thumb 412 files 21 MB, card 318 and 58 MB, hero 244 and 84 MB, square 140 and 19 MB, og 96 and 9 MB, print 74 and 21 MB (disabled); totals 1,284 files and 212 MB; 6 variants, 5 enabled and 1 disabled; 37 rejected requests; unused 312 files and 41 MB; the Top 10 of the frame (north-wall.jpg 6 variants 5.8 MB, lime-plaster.jpg 6 and 4.9 MB, kitchen-after.webp 5 and 4.4 MB, kitchen-before.webp 5 and 3.9 MB, floorboards.jpg 5 and 3.6 MB, IMG_4471.jpg 4 and 3.1 MB, stone-wall-detail.jpg 4 and 2.8 MB, bathroom-tiles.png 4 and 2.6 MB, roof-lantern.jpg 3 and 2.3 MB, chimney-stack.jpg 3 and 2.1 MB); session name "Marta Alves". The mock returns the By variant rows in the board's order (hero, card, then the ties).
- [ ] 5a Loaded
- [ ] 5b Loading
- [ ] 5c Empty cache
- [ ] 5d Purging (the frame draws "640 of 1,284 · 106 MB freed" with a 50 % bar; the draft shows an indeterminate bar and no progress line, so this state is the one at risk against the budget; see open question 1)
- [ ] 5e Purge done (empty cache; the toast)
- [ ] 5f Purge failed (toast "Purge didn't finish")
- [ ] 5g Error loading
- [ ] 5h Purge all: confirm (typed "pur", button disabled)
- [ ] 5i Purge all: confirm, typed (typed "purge")
- [ ] 5j Purge unused: confirm
- [ ] Layout from the frame, not `components.md`: tiles 4 columns and 16 px gap on desktop, 2 columns and 8 px on mobile; tile padding 16 px (12 px mobile), value 28 px (22 px mobile); two columns 460 px / rest; bars 8 px high; Top 10 rows 40 px (60 px mobile) with the Purge button 26 px high (44 px square icon-only on mobile).
- [ ] Not drawn, no visual state: the error toasts for 400, 403, 500 and 404, the row-purge toast, the row loader, the disabled Purge unused with a non-empty cache; Vitest only.
- [ ] Not covered by screenshots: keyboard and focus order (Tab reaches the buttons, the tabs, each Purge; the dialog traps focus and Escape returns it), hover and active states, motion (dialog, skeleton pulse stopped under reduced motion).
## Manual verification
- **Real flush loop**: `task dev:api` over a scratch SQLite file. Request `/static/uploadedImages/<storedName>?v=thumb` repeatedly, wait 30 s, then open the Cache page and check "Rejected requests" after a few `?v=unknown` requests. Check in the file that `last_requested_at` is set once per file and the day row holds the count.
- **Graceful shutdown**: record hits, stop the server with Ctrl+C within 30 s, restart and check the counts survived.
- **Real purge**: generate real variants, then purge all, unused (age a row in the file), one image, and check the files disappear from `UPLOAD_DIR` and reappear on request.
- **S3 driver** (`STORAGE_DRIVER=s3`, MinIO): purge all and by image.
- **Large cache**: 20,000 index rows; check the Cache page loads in a reasonable time and the purge keeps memory flat.
- **Other databases**: run the stats and purge against MariaDB and PostgreSQL if available (portable SQL).
- **Activity screen**: read the four new events against section 8 of the design.
- **French**: switch the language and read every state, the dialogs and the toasts.
@@ -0,0 +1,211 @@
Feature: The editor's Variant select and the warning on blocks whose variant is gone
As an owner or admin writing an article
I want to choose a variant when I insert an image from the library, and be warned when a variant used by an image in my article was deleted or disabled
So that the article asks the right size and I notice before readers get an error
Background:
Given the spec of record is docs/design/screens/image-variants.md section 6 and its states 6a to 6e, docs/design/screens/media-picker.md, the board docs/design/source/RenewCMS Screens Image variants.dc.html drawn by VariantsFrame.dc.html
And this slice is SPA only: it uses GET /v1/image-variants (owner/admin, features/image-variants.feature) and the media library API unchanged, and builds on features/editor_media_picker.feature, which stays as it is except where this file says so
And the Settings screens and the media page are not in this slice
And the Variant select is a USelectMenu in the Library tab's selection panel, under the thumbnail row, before the "Alt text (optional)" field
And the list of variants is requested with GET /v1/image-variants once per opening of the dialog, when the Library tab first shows an image selection, and only enabled variants are offered
And a variant option reads "<Name> · <dimensions> · <format>" where the name is capitalised, the dimensions read "400×300", "800 × auto" or "auto × 2400" as in the Settings list, and the format reads WebP, JPEG, PNG or Original; the first option reads "Original · <width>×<height> · <JPEG|PNG|WEBP>" from the selected image
And the options are Original first, then the enabled variants in the order the API returns them (name ascending)
And the url line under the select shows what will be inserted, in mono 12 px muted: "/static/uploadedImages/<storedName>" for Original and "/static/uploadedImages/<storedName>?v=<name>" for a variant (the design's "/media/<file>" is replaced per the design's own implementation notes)
And the select label reads "Variant" (medium weight), the control is 40 px high on desktop and 44 px high with 16 px text on mobile, and its open list has 36 px options on desktop and 44 px on mobile with a check on the selected one
And the fixture is the library of features/editor_media_picker.feature (north-wall.jpg id 1 stored "1.jpg", 4032 x 3024 JPEG, 3.9 MB) and the variants thumb (cover 400 x 300 webp), card (inside 800 x auto webp), hero (cover 1200 x 630 webp), og (cover 1200 x 630 jpeg), square (cover 600 x 600 jpeg), all enabled, in name order card, hero, og, square, thumb, and "print" disabled
And every new English string has a French entry in web/src/i18n/messages.ts, and no comment is added to any file
Rule: The Variant select
Scenario: Selecting an image shows the select with Original chosen
Given the Library tab shows the fixture
When the user selects "north-wall.jpg"
Then GET /v1/image-variants is sent once
And the panel shows the thumbnail row, then the label "Variant" over a select reading "Original · 4032×3024 · JPEG", then the url line "/static/uploadedImages/1.jpg", then the alt text field
And "Insert image" is enabled
Scenario: The open list offers Original and the enabled variants
When the user opens the select
Then the options read, in order:
| Original · 4032×3024 · JPEG |
| Card · 800 × auto · WebP |
| Hero · 1200×630 · WebP |
| Og · 1200×630 · JPEG |
| Square · 600×600 · JPEG |
| Thumb · 400×300 · WebP |
And "Print" is not offered
And the select border is primary with a 1 px primary ring while it is open
Scenario: Choosing a variant changes the url line
When the user chooses "Hero · 1200×630 · WebP"
Then the select reads that option and the url line reads "/static/uploadedImages/1.jpg?v=hero"
Scenario: The choice is reset when another image is selected
Given "north-wall.jpg" is selected with the variant "Hero"
When the user selects "kitchen-after.webp"
Then the select reads "Original · 2400×1600 · WEBP" and the url line has no "?v="
Scenario: The variants list is not requested again within one opening
Given the user selected one image and then another
Then GET /v1/image-variants was sent once
Scenario: No variants defined
Given GET /v1/image-variants resolves {"items": []}
When the user selects "north-wall.jpg"
Then the select offers only "Original · 4032×3024 · JPEG"
And the help line reads "No variants defined. Add one in Settings → Media → Variants." with an info icon, and "Settings → Media → Variants" links to /settings/media/variants
And the url line is not shown
Scenario: Only disabled variants count as none
Given every variant is disabled
Then the select offers only Original and the "No variants defined" help shows
Scenario: An SVG offers only Original
When the user selects "logo-renew.svg"
Then the select offers only "Original · Vector · SVG" and no help line shows
Scenario Outline: The variants list fails to load
Given GET /v1/image-variants <failure>
When the user selects "north-wall.jpg"
Then the select offers only Original, no error alert shows, and "Insert image" is enabled
Examples:
| failure |
| resolves with a 500 response |
| resolves with a 403 response |
| never reaches the server |
Scenario: Keyboard
Given the select has focus
Then Enter or Space opens it, the arrow keys move between options, Enter chooses, Escape closes it and returns focus to the select
Rule: Inserting a variant
Scenario Outline: The inserted url carries the variant
Given "north-wall.jpg" is selected with the variant "<option>" and the alt text "A wall"
When the user activates "Insert image"
Then insertImage is called with "<src>", "A wall" and image id 1
And the image is in the editor's content with data-image-id 1
And SaveStatus reads "Unsaved changes", the toast "Image added" shows, the dialog closes and no POST, PATCH or DELETE has been sent
Examples:
| option | src |
| Original | /static/uploadedImages/1.jpg |
| Hero | /static/uploadedImages/1.jpg?v=hero |
| Thumb | /static/uploadedImages/1.jpg?v=thumb |
Scenario: The Upload tab is unchanged
When the user inserts an image from the Upload tab
Then the select is not shown and the inserted url has no "?v="
Scenario: An image already in the article can be inserted again with another variant
When the user selects "north-wall.jpg", which carries "In this article", chooses "Thumb" and activates "Insert image"
Then a second image node with src ending "?v=thumb" and data-image-id 1 is inserted
Scenario: The cache-busting parameter coexists with the variant
Given the media revision is 3
Then an image node whose src is "/static/uploadedImages/1.jpg?v=hero" is displayed as "/static/uploadedImages/1.jpg?v=hero&rev=3"
Scenario: Leaving without inserting changes nothing
Given a variant is chosen
When the user cancels, closes or presses Escape
Then the dialog closes, the editor's content and dirty state are unchanged and no toast shows
Rule: A block whose variant was deleted or disabled
Background:
Given the article body holds an image node with src "/static/uploadedImages/1.jpg?v=hero" and data-image-id 1
Scenario: A deleted variant
Given GET /v1/image-variants resolves without "hero"
When the editor opens the article
Then the block keeps its src and its data-image-id
And the block is a 1 px --ui-warning bordered box holding the image dimmed to 50 % with an image-off icon, then a warning alert
And the alert has a triangle-alert icon, the title "Variant 'hero' no longer exists.", the text "This image returns an error on the public site." and a button "Pick another variant" with a layers icon
And the block does not show "This image is no longer available"
Scenario: A disabled variant
Given GET /v1/image-variants resolves with "hero" disabled
When the editor opens the article
Then the same block shows the title "Variant 'hero' is disabled." and the same text and button
Scenario: An enabled variant has no warning but a chip
Given GET /v1/image-variants resolves with "hero" enabled (cover 1200 x 630)
Then the block shows no warning
And a chip "hero · 1200×630" sits 8 px from the top-left of the image, mono 12 px medium, 20 px high, on --ui-bg with a 1 px accented border
Scenario: A disabled variant still shows its chip
Given "hero" is disabled
Then the chip "hero · 1200×630" shows on the dimmed image
Scenario: A deleted variant shows no chip
Given "hero" is deleted
Then no chip shows on the block
Scenario: A block without a variant is unchanged
Given an image node with src "/static/uploadedImages/1.jpg"
Then it shows no chip and no warning and behaves as before
Scenario: The variants list is requested only when needed
Given the article body holds no "?v=" in an image src
When the editor opens the article
Then GET /v1/image-variants is not sent
Scenario: The list is requested once for all blocks
Given the body holds two blocks with "?v=hero" and "?v=thumb"
Then GET /v1/image-variants is sent once and each block is judged against it
Scenario Outline: The variants list fails
Given GET /v1/image-variants <failure>
When the editor opens the article containing "?v=hero"
Then no warning and no chip show and the editor stays usable
Examples:
| failure |
| resolves with a 500 response |
| resolves with a 403 response |
| never reaches the server |
Scenario: Pick another variant opens the select on the block
When the user activates "Pick another variant"
Then the same Variant select opens anchored on the alert with Original and the enabled variants
And Escape closes it and returns focus to the button
Scenario Outline: Choosing an option rewrites the block
Given the select is open on the block of image 1
When the user chooses "<option>"
Then the block's src becomes "<src>", its data-image-id and alt are unchanged
And the warning disappears and the chip shows "<chip>" when a variant is chosen
And SaveStatus reads "Unsaved changes" and no request other than the already made list request is sent
Examples:
| option | src | chip |
| Thumb | /static/uploadedImages/1.jpg?v=thumb | thumb · 400×300 |
| Original | /static/uploadedImages/1.jpg | no chip |
Scenario: Only the clicked block is rewritten
Given two blocks of image 1 both with "?v=hero"
When the user rewrites the first one
Then the second keeps "?v=hero" and its warning
Scenario: The warning is not saved in the article
When the article is saved
Then the saved body contains the img element with its src and no warning markup
Scenario: The warning is announced
Then the alert has role="alert" semantics or an aria-live region so that a screen reader reads the title
Scenario: Touch targets
Given the viewport is below 640 px
Then the "Pick another variant" button is at least 44 px high
Rule: Accessibility, vocabulary and hygiene
Scenario: Vocabulary
Then the screens say "variant", never "thumbnail preset", "version" or "rendition"
Scenario: French strings
Given the locale is French
Then every new string has a French entry and no English string appears
@@ -0,0 +1,44 @@
# QA: Editor picker Variant select and block warnings
## Implementation note for coder (informational, not a QA assertion)
No Go change. The variant of a block is read from the `v` query parameter of the image node's src; the lookup is by name against GET /v1/image-variants (all variants, enabled or not). The hosted `rev=` cache-bust parameter from withMediaRevision is appended after `?v=` and is ignored by the server. A broken variant block must not use the existing "This image is no longer available" message, which says the image was deleted. Only enabled variants are offered in the select. New strings need English and French entries in web/src/i18n/messages.ts.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Reuse `web/src/views/__tests__/EditorMediaPicker.spec.ts` and its fixtures. Mock useApi's `request` per case (GET /v1/media/, GET /v1/image-variants). Mount EditorView at /article/12/edit with a resolved verified session (role "owner"). Mock `matchMedia` for the 640 px breakpoint, run layout cases at both widths. Fake timers only for the 150 ms skeletons and 120 ms debounce already used there. No test freezes the system date.
1. **Select, default** - selecting "north-wall.jpg" sends GET /v1/image-variants once; the panel order is thumbnail row, "Variant" label, select, url line, alt text; Original is chosen; "Insert image" enabled.
2. **Options** - the six options in order and format labels; "Print" (disabled) absent; the open style (primary border, check); the url line for Original and for a variant; the choice resets when another image is selected; the list is not requested again within one opening.
3. **No variants** - `{"items": []}` and all-disabled: only Original, the help line and its link target, no url line.
4. **SVG** - only "Original · Vector · SVG" and no help.
5. **List failure, Examples-driven** - 500, 403 and network failure: Original only, no alert, insert works.
6. **Keyboard** - Enter and Space open, arrows move, Enter chooses, Escape closes and returns focus.
7. **Insert, Examples-driven** - Original, Hero, Thumb: insertImage arguments, data-image-id, dirty state, toast, closing, no write request. Upload tab unchanged. Inserting an image already in the article twice. The `rev=` parameter after `?v=`.
8. **Cancel paths** - Cancel, x, Escape leave the editor untouched.
9. **Block states** - deleted variant (title, text, button, border, dimmed placeholder with image-off, no "no longer available"), disabled variant (title), enabled variant (no warning, chip text `hero · 1200×630`, chip classes), disabled variant chip, deleted variant no chip, plain src unchanged.
10. **When the list is requested** - not requested for a body without `?v=`; requested once for several blocks; failure Examples (500, 403, network): no warning, editor usable.
11. **Pick another variant** - the select opens on the block, Escape closes with focus back; choosing Thumb rewrites only that block's src to `?v=thumb`, keeps data-image-id and alt, the warning disappears and the chip shows, dirty state set; choosing Original removes the parameter and shows no chip; a second block of the same image is untouched.
12. **Save** - the saved HTML contains the img element and no warning markup.
13. **Accessibility** - role or live region on the warning, 44 px button class on mobile, vocabulary scan.
14. **French** - `setLocale('fr')`; every new key exists in messages.ts and the dialog and block render without missing keys.
## Visual (task visual:check -- image-variants <state>, desktop and mobile, light and dark)
Reference: docs/design/screens/image-variants.md, exact values in `docs/design/source/VariantsFrame.dc.html` (Picker, VSel, Editor). Add each state to `web/visual/states.ts` with the board's data: the article "How we price a bathroom renovation", the library tiles north-wall.jpg (selected), the five enabled variants in board order thumb, card, hero, square, og, and the block chip "hero · 1200×630". The gate is hard: every non-skipped capture within 10 %. None is clock-sensitive.
- [ ] 6a Variant select (Hero chosen, url line shows `?v=hero` as drawn with the real path)
- [ ] 6b Select open (Original plus five enabled variants)
- [ ] 6c No variants defined (Original only, help line)
- [ ] 6d Variant deleted (block warning "Variant 'hero' no longer exists.")
- [ ] 6e Variant disabled (block warning "Variant 'hero' is disabled.")
- [ ] Layout from the board frame: select label and control heights (40 px / 44 px, 16 px text on mobile), option height (36 px / 44 px), the block box (1 px warning border around the dimmed image and the alert), chip position (8 px from the top-left).
- [ ] The board draws the url line as `/media/north-wall.jpg?v=hero`; the capture will differ by that string only. If the 10 % gate fails on it, ask the user for a per-state exception (needs `approvedBy`), do not edit the board.
- [ ] Not drawn, no visual state: the Alt text field under the select, list failure, SVG, keyboard.
- [ ] Not covered by screenshots: keyboard and focus order, focus return after the select closes, hover, motion.
## Manual verification
- **Round trip against `task dev`**: insert an image with a variant, save the article, open the public article and confirm the image loads from the variant url; delete or disable that variant in Settings, reload the editor and check the warning; use "Pick another variant" and save again.
- **French**: read the select, its help and the block warning.
- **Real browser** at mobile width: select list and block warning touch targets.
@@ -0,0 +1,430 @@
Feature: Image variants on the Media page: badge, filter, sort, Variants tab, delete dialogs and activity
As an owner or admin of a RenewCMS site
I want the Media page to show which images have generated variants, let me generate, preview and purge them for one image, and warn me what a delete also removes
So that I can keep the cache of variants under control without leaving the library
Background:
Given the spec of record is docs/design/screens/image-variants.md sections 3, 4 and 8 (the Generate all and Cache purged: by image rows), docs/design/screens/media.md, docs/design/screens/activity.md, the board docs/design/source/RenewCMS Screens Image variants.dc.html drawn by VariantsFrame.dc.html (frames 3a to 3f and 4a to 4h), and docs/design/routes.md
And this slice is SPA only: the API is the one of features/image_variants_per_image_api.feature and features/image-variants.feature, and features/media_browse.feature and features/media_upload_selection_duplicates.feature stay as they are except where this file says so
And the editor picker is in features/image_variants_editor_picker.feature, and the Settings → Media → Cache screen is not in this slice
And every new English string has a French entry in web/src/i18n/messages.ts, only design copy is verbatim, and French wording is proposed
And the page uses Nuxt UI components only and design tokens, with no raw hex value and no arbitrary Tailwind colour, and no comment is added to any file
And the stored url of an image is "/static/uploadedImages/<storedName>" and the url of its variant is that plus "?v=<name>"
And sizes use the library's formatter ("8 KB", "260 KB", "3.9 MB"), dimensions in the library read "4032 × 3024" and in variants "400×300", "800 × auto", "auto × 2400"
And the library fixture is the 12-image fixture of features/media_browse.feature, extended with variant_count and variant_size from the board:
| id | filename | size | variant_count | variant_size | total |
| 1 | north-wall.jpg | 4089446 | 4 | 266240 | 4.2 MB |
| 2 | kitchen-after.webp | 1258291 | 5 | 419840 | 1.6 MB |
| 3 | kitchen-before.webp | 1153434 | 5 | 404480 | 1.5 MB |
| 4 | logo-renew.svg | 8192 | 0 | 0 | 8 KB |
| 5 | floorboards-copy.jpg | 2516582 | 3 | 245760 | 2.6 MB |
| 6 | IMG_2208.jpg | 2516582 | 0 | 0 | 2.4 MB |
| 7 | IMG_4471.jpg | 3250586 | 2 | 153600 | 3.3 MB |
| 8 | window-frame.jpg | 1887436 | 0 | 0 | 1.8 MB |
| 9 | stone-wall-detail.jpg | 3250586 | 1 | 61440 | 3.2 MB |
| 10 | bathroom-tiles.png | 880640 | 3 | 194560 | 1.1 MB |
| 11 | lime-plaster.jpg | 2202009 | 4 | 286720 | 2.4 MB |
| 12 | floorboards.jpg | 2516582 | 5 | 440320 | 2.8 MB |
And the five enabled variants of the Variants tab, in the order the API returns them (name), are card (inside 800 x auto webp), hero (cover 1200 x 630 webp), og (cover 1200 x 630 jpeg), square (cover 600 x 600 jpeg), thumb (cover 400 x 300 webp), and the disabled variant "print" is never listed
And the Variants tab of image 1 reads its rows from GET /v1/media/1/variants: output 800×600, 1200×630, 1200×630, 600×600, 400×300 and sizes in bytes thumb 18432, card 65536, hero 98304, square 83968, og 145408
And the session role comes from GET /v1/auth/me and is "owner" unless a scenario says otherwise
And every success toast lasts 5 s and toast ids follow the action-plus-id convention
Rule: The library badge
Scenario: A card with generated variants carries the badge
Given GET /v1/media/ resolves 200 with the extended fixture
When the user navigates to /media
Then the card "north-wall.jpg" shows the badge "4 variants" after "Used in 2"
And the card "kitchen-after.webp" shows "Used in 3" then "5 variants"
And the badge has the info-soft fill, the layers icon, 12 px medium text and is 20 px high
Scenario Outline: The badge text and its absence
Given the Default state has loaded
Then the card for "<filename>" shows the badges "<badges>"
Examples:
| filename | badges |
| north-wall.jpg | Used in 2, 4 variants |
| stone-wall-detail.jpg | Duplicate, Used in 1, 1 variant |
| window-frame.jpg | Unused |
| logo-renew.svg | Used in 5 |
| IMG_2208.jpg | Duplicate, Used in 2 |
Scenario: The accessible name of a card is unchanged
Then the card for "north-wall.jpg" is still named "north-wall.jpg, 3.9 MB, used in 2 articles"
Scenario: List view shows no variants badge
Given the Default state has loaded with ?view=list
Then the table and the card list show no "variants" badge
Rule: The variants filter
Scenario: The segmented control sits between the search field and the sort menu
Given the Default state has loaded at desktop width
Then the toolbar reads, left to right, the tabs, the search field, a segmented control "All", "With variants", "Without" (30 px high, 1 px accented border, 12 px horizontal padding) and the sort menu
And "All" is selected, with the primary fill and inverted text
Scenario: On mobile the control is its own full-width row
Given the Default state has loaded below md width
Then the toolbar stacks search, tabs, the segmented control with three equal segments of 44 px and 12 px text, then the sort menu and the grid toggle
Scenario Outline: Choosing a segment writes the URL and requests the list
Given the Default state has loaded
When the user selects "<segment>"
Then the URL <url> and GET /v1/media/ is sent with "<request>" and limit=100
And the list shows a skeleton after 150 ms
Examples:
| segment | url | request |
| With variants | gains ?variants=with | filter=all&sort=newest&variants=with |
| Without | gains ?variants=without| filter=all&sort=newest&variants=without |
Scenario: All removes the parameter
Given the URL is /media?variants=with
When the user selects "All"
Then ?variants leaves the URL and the request carries no variants parameter
Scenario Outline: The query string is the source of truth
When the user opens /media<url>
Then GET /v1/media/ is sent with "<request>" and the control shows "<control>"
Examples:
| url | request | control |
| ?variants=with | filter=all&sort=newest&variants=with | segment "With variants" selected |
| ?variants=without&filter=unused | filter=unused&sort=newest&variants=without | tab "Unused" and segment "Without" selected |
| ?variants=all | filter=all&sort=newest | segment "All" selected, URL kept clean |
| ?variants=zzz | filter=all&sort=newest | segment "All" selected, URL kept clean |
Scenario: With variants returns only the images that have cached files
Given GET /v1/media/?...&variants=with&sort=total_size resolves 200 with ids 1, 7, 9, 12, 5, 11, 2, 3, 10
Then nine cards show in that order and each carries a variants badge
Scenario: Without variants returns the images with no cached file
Given GET /v1/media/?...&variants=without resolves 200 with ids 4, 6, 8
Then three cards show and none carries a variants badge
Rule: Sorting by total size
Scenario: The new option is last in the sort menu
When the user opens the sort menu
Then the options read "Newest", "Oldest", "Name", "Largest", "Largest total size (original + variants)" in that order, the last one 340 px wide on desktop and labelled "Largest total size" on mobile
And the selected option carries a check
Scenario: Choosing it sends sort=total_size
When the user picks "Largest total size (original + variants)"
Then the URL gains sort=total_size and GET /v1/media/ is sent with sort=total_size
And the sort button reads "Largest total size (original + variants)" on desktop and "Largest total size" on mobile
Scenario Outline: The meta line shows the total while sorted by total size
Given the list is sorted by total size
Then the card for "<filename>" shows the meta line "<meta>" in place of "size · dimensions"
Examples:
| filename | meta |
| north-wall.jpg | 4.2 MB total · 3.9 MB + 260 KB |
| kitchen-after.webp | 1.6 MB total · 1.2 MB + 410 KB |
| window-frame.jpg | 1.8 MB total |
| logo-renew.svg | 8 KB total |
Scenario: Other sorts keep the usual meta line
Given the list is sorted by Newest
Then the card for "north-wall.jpg" shows "3.9 MB · 4032 × 3024"
Scenario: A stale sort value is dropped
When the user opens /media?sort=total
Then the sort shows "Newest" and the URL is clean
Rule: Nothing to show with the variants filter
Scenario: With variants and an empty cache
Given GET /v1/media/?...&variants=with resolves 200 with no item and no q is set and the tab is All
Then the layers icon shows with the title "No images with variants", the text "Variants are generated when an image is requested with ?v=<name>, or from its Variants tab." and an outline button "Show all images"
And the toolbar, including the segmented control, stays visible
Scenario: Show all images resets the variants filter
When the user activates "Show all images"
Then ?variants leaves the URL, the segmented control reads "All" and the Default cards reappear
Rule: Delete dialogs name the generated variants they also remove
Scenario: A single delete of an image with variants
Given the detail panel is open for "north-wall.jpg" (used in 2, 4 variants of 266240 bytes)
When the user activates "Delete"
Then the dialog keeps its title "Delete an image that's in use?", its description and its list of the two articles
And a note shows in the body with the layers icon on a warning-soft background: "Also removes 4 generated variants (260 KB)"
And no extra request is sent to compute it
Scenario Outline: The note is singular for one variant and absent for none
Given the detail panel is open for "<filename>"
When the user activates "Delete"
Then the note reads "<note>"
Examples:
| filename | note |
| stone-wall-detail.jpg | Also removes 1 generated variant (60 KB) |
| window-frame.jpg | no note is shown |
Scenario: Bulk delete sums the selection
Given the user selected "north-wall.jpg", "kitchen-after.webp" and "window-frame.jpg"
When the user activates "Delete" in the bulk bar
Then the bulk dialog keeps its copy and its one row per image
And the note under the rows reads "Also removes 9 generated variants (670 KB)"
Scenario: A bulk delete with no variant in the selection shows no note
Given the user selected "window-frame.jpg" and "IMG_2208.jpg"
Then the dialog shows no note
Scenario: The delete flow itself is unchanged
Then the pending, failure, 404 and success behaviours of features/media_browse.feature apply, and after a successful delete the list is requested again in the background
Rule: The Variants tab in the detail panel
Scenario: The panel gets two tabs for an owner or an admin
When the user opens the panel of "north-wall.jpg"
Then two tabs sit under the 56 px header: "Details" (selected) and "Variants", 40 px high on desktop and 44 px on mobile, 20 px apart, with a 2 px primary underline on the selected one
And the Details tab is the existing panel body, unchanged
Scenario: A session that is not owner or admin sees no tab
Given the session role is neither "owner" nor "admin"
When the user opens a panel
Then no tab strip shows, no request to /v1/media/{id}/variants is sent, and the panel is the existing one
Scenario: Selecting the tab requests the variants once
When the user selects "Variants"
Then GET /v1/media/1/variants is sent
And selecting "Details" then "Variants" again does not send it a second time for the same open panel
Scenario: Loading
Given GET /v1/media/1/variants has not answered
Then nothing is drawn in the tab body for 149 ms
And at 150 ms a region announces "Loading variants" with aria-busy="true" and holds 5 skeleton rows
Scenario Outline: The variants request fails
Given GET /v1/media/1/variants fails with <failure>
Then an error alert shows the title "Couldn't load variants", the text "Check your connection and try again." and a "Try again" button with a rotate-cw icon
When the user activates "Try again"
Then the request is sent again
Examples:
| failure |
| status 500 |
| a network failure |
Scenario: Partly generated
Given GET /v1/media/1/variants resolves with card, hero, square and thumb generated and og not
Then the tab shows, in order: the Original block, the summary, the list
And the Original block (72 x 54 thumbnail, 12 px gap, 12 px padding, 1 px border, --ui-bg-muted) reads "Original · kept untouched", "north-wall.jpg", "JPEG · 4032 × 3024 · 3.9 MB"
And the summary reads "4 of 5 generated" in semibold next to a "Generate all" button with the zap icon
And the list is a bordered group of five rows in the order card, hero, og, square, thumb
Scenario Outline: A generated row
Then the row "<name>" shows its name in mono 13 px medium, the badge "Generated" (success-soft, circle-check), and the meta "<meta>"
And the part "<saving> vs original" is in the success colour and the meta before it is toned 12 px text
Examples:
| name | meta | saving |
| thumb | 400×300 · WebP · 18 KB · -99% | -99% |
| card | 800×600 · WebP · 64 KB · -98% | -98% |
| hero | 1200×630 · WebP · 96 KB · -98% | -98% |
| square | 600×600 · JPEG · 82 KB · -98% | -98% |
Scenario: A not generated row
Then the row "og" shows the badge "Not generated" (outline, circle-dashed) and the meta "1200×630 · JPEG" with no size and no saving
Scenario Outline: The saving against the original
Given the original is 4089446 bytes
When a variant file is <size> bytes
Then the saving reads "<text>"
Examples:
| size | text |
| 18432 | -99% |
| 65536 | -98% |
| 98304 | -98% |
| 145408 | -96% |
| 3250586 | -21% |
| 4089446 | 0% |
| 4500000 | +10% |
Scenario: Nothing generated yet
Given GET /v1/media/1/variants resolves with no row generated
Then the summary reads "0 of 5 generated" over the muted line "Nothing generated yet."
And every row shows "Not generated"
Scenario: Row actions on desktop are icon buttons
Given the viewport is 640 px wide or more
Then a generated row shows three 28 px square icon buttons Copy URL (link), Preview (eye) and Purge (eraser), each with a tooltip and an accessible name equal to its label
And a not generated row shows Copy URL (link) and Generate now (zap)
Scenario: Row actions on mobile are labelled buttons on their own line
Given the viewport is below 640 px
Then each action is a 44 px high labelled button on a line under the meta, "Copy URL", "Preview", "Purge" or "Copy URL", "Generate now"
Scenario: Generate all is disabled when everything is generated
Given all five rows are generated
Then "Generate all" is disabled
Scenario: Generate now
When the user activates "Generate now" on the row "og"
Then POST /v1/media/1/variants/og is sent
And the row "og" shows the badge "Generating" (info-soft, loader-circle), its actions are gone, its background is --ui-bg-elevated and the other rows stay as they are
And the summary shows "Generating variants…" in the info colour and "Generate all" reads "Generating…" with a loader-circle and is disabled
When the API answers 200 with the item generated
Then the row shows "Generated" with its meta, the summary reads "5 of 5 generated"
And a toast shows icon circle-check, id "media-variant-generated-1-og" and title "Generated og"
Scenario: Generate all
Given only thumb is generated
When the user activates "Generate all"
Then POST /v1/media/1/variants is sent
And the four rows not generated show "Generating", the generated row stays, and the summary and button read as above
When the API answers 200 with "generated" 4 and the five items
Then every row shows "Generated" and a toast shows id "media-variants-generated-1" and title "Generated 4 variants"
Scenario: A single generated variant reads in the singular
When Generate all answers "generated" 1
Then the toast title is "Generated 1 variant"
Scenario: Generate all with a failure
When Generate all answers 200 with "generated" 4, "failed" 1, failures [{"name": "hero", "cause": "too_large"}] and the five items
Then the row "hero" shows the badge "Failed" (error-soft, circle-x), a line with a circle-alert icon in the error colour at 12 px reading "Image too large", and a "Retry" button with a rotate-cw icon (labelled, 28 px high, 12 px text)
And the summary reads "4 of 5 generated" and the toast reads "Generated 4 variants"
Scenario Outline: A single generation fails with a cause
When POST /v1/media/1/variants/hero answers <status> with the plain text body
Then the row "hero" shows "Failed" and the line "<cause>" with "Retry"
And an error toast shows the title "Couldn't generate hero." and the text "<cause>."
Examples:
| status | cause |
| 413 | Image too large |
| 415 | Unsupported source format |
Scenario: Failed state is not remembered
Given the row "hero" shows "Failed"
When the panel is closed and opened again, and the tab is selected
Then the row "hero" shows "Not generated"
Scenario: Retry sends the same request
When the user activates "Retry" on the row "hero"
Then POST /v1/media/1/variants/hero is sent and the row shows "Generating"
Scenario Outline: Other failures of a generation
When POST /v1/media/1/variants/og answers <status>
Then the row "og" returns to "Not generated"
And <result>
Examples:
| status | result |
| 500 | a toast shows "Something went wrong" and "The server couldn't finish. Try again. If it keeps happening, check the server log." |
| 403 | a toast shows "You don't have access" and "Only admins can manage image variants." |
| 404 | a toast shows "Not found" and "This variant or image no longer exists. Refresh the page." and the tab is requested again |
| network failure | the same toast as 500 |
Scenario: The image is gone
When the tab is requested again after a 404 and GET /v1/media/1/variants answers 404
Then the panel closes, the image leaves the list and the info toast "That image no longer exists" shows, as in features/media_browse.feature
Scenario: Copy URL copies the absolute variant url
When the user activates "Copy URL" on the row "thumb"
Then "{origin}/static/uploadedImages/<storedName>?v=thumb" is written to the clipboard, also for a row that is not generated
And a toast shows id "media-variant-url-copied-1-thumb", title "URL copied" and text "Paste it wherever you need the image address."
Scenario: Purge a generated row
When the user activates "Purge" on the row "thumb"
Then DELETE /v1/media/1/variants/thumb is sent
When the API answers 200 {"purged_files": 1, "purged_bytes": 18432}
Then the row shows "Not generated" with no size, the summary reads "3 of 5 generated" and a toast shows id "media-variants-purged-1-thumb" and title "Purged 1 file (18 KB)"
Scenario Outline: Purge fails
When DELETE /v1/media/1/variants/thumb answers <status>
Then the row stays "Generated" and the toast of the back-office error table for <status> shows
Examples:
| status |
| 403 |
| 404 |
| 500 |
Scenario: Preview
When the user activates "Preview" on the row "thumb"
Then a dialog opens, 880 px wide on desktop and a bottom sheet on mobile, titled "Preview: thumb", with a "Close" button
And two panes sit side by side on desktop (stacked on mobile), each 300 px high (180 px on mobile), bordered, on --ui-bg-muted, overflow scrolling to pan
And the left pane shows the variant file at actual size under "thumb · 400×300 · WebP" and "18 KB · saves 99%"
And the right pane shows the original at actual size under "Original · 4032×3024 · JPEG" and "3.9 MB"
And the hint reads "Both images are shown at actual size. Scroll to pan."
And focus is trapped in the dialog and returns to the Preview button on close
Scenario: No variants defined
Given GET /v1/media/1/variants resolves with no item and "unsupported" null
Then the Original block shows, then the empty block with the layers icon, the title "No variants defined", the text "Define a variant in Settings to get resized copies of this image." and a link "Settings → Media → Variants" with an arrow-up-right icon to /settings/media/variants
And no summary, no list and no "Generate all" show
Scenario: An SVG source
Given the panel is open for "logo-renew.svg" and GET /v1/media/4/variants resolves {"unsupported": "svg", "items": []}
Then the Original block reads "SVG · Vector · 8 KB"
And an info alert shows the title "Variants aren't generated for this format." and the text "SVG images are served as they are."
And no summary, no list and no "Generate all" show
Rule: Generate all and purge by image in the Activity screen
Scenario Outline: The two new sentences
Given an activity event with action "<action>" and outcome "success" and target name "north-wall.jpg"
Then the Action cell reads "<sentence>" with "north-wall.jpg" in semibold, last
Examples:
| action | sentence |
| media.variant_generate_all| Generated all variants of north-wall.jpg |
| media.variant_purge_image | Purged the variants of north-wall.jpg |
Scenario Outline: The failed sentences
Given an activity event with action "<action>", outcome "failed" and target name "north-wall.jpg"
Then the Action cell reads "<sentence>" and the Failed badge shows
Examples:
| action | sentence |
| media.variant_generate_all| Failed to generate the variants of north-wall.jpg |
| media.variant_purge_image | Failed to purge the variants of north-wall.jpg |
Scenario Outline: The detail line under the sentence
Given an activity event with action "<action>" and params <params>
Then a muted 12 px line under the sentence reads "<detail>" in the row and in the details panel
Examples:
| action | params | detail |
| media.variant_generate_all| {"generated": 5, "failed": 0, "causes": []} | 5 variants generated |
| media.variant_generate_all| {"generated": 1, "failed": 0, "causes": []} | 1 variant generated |
| media.variant_generate_all| {"generated": 4, "failed": 1, "causes": ["too_large"]} | 4 generated · 1 failed (Image too large) |
| media.variant_generate_all| {"generated": 3, "failed": 2, "causes": ["too_large", "unsupported_format"]} | 3 generated · 2 failed (Image too large, Unsupported source format) |
| media.variant_purge_image | {"purged_files": 4, "purged_bytes": 266240} | Removed 4 files (260 KB) |
| media.variant_purge_image | {"purged_files": 1, "purged_bytes": 18432} | Removed 1 file (18 KB) |
Scenario: Events without a detail line are unchanged
Given an event whose action has no detail line, such as "media.rename"
Then the row shows no second line
Scenario: The French interface
Given the locale is French
Then the two sentences, their failed forms and the detail lines come from messages.ts and no English string appears
Rule: Accessibility, vocabulary and hygiene
Scenario: Status is never colour alone
Then each row status shows an icon and a word (Generated circle-check, Not generated circle-dashed, Generating loader, Failed circle-x) and each icon-only button has an accessible name and a tooltip
Scenario: Touch targets on mobile
Given the viewport is below 640 px
Then the segmented control, the tabs, the row buttons and the dialog buttons are at least 44 px high
Scenario: Progress is announced
When a generation starts and ends
Then an aria-live="polite" region announces "Generating variants" and the result
Scenario: Vocabulary
Then the screens say "variant", "generated" and "purge", never "thumbnail preset", "version" or "rendition"
Scenario: French strings
Given the locale is French
Then every new string of this file has a French entry and no English string appears
@@ -0,0 +1,60 @@
# QA: Image variants on the Media page
## Implementation note for coder (informational, not a QA assertion)
The API is built by features/image_variants_per_image_api.feature. This slice has no Go change. The role check lives in the panel via the session role from GET /v1/auth/me (there is no "admin" auth requirement in router meta). Plain-text API errors are read through ApiError.body and mapped by status; never display the raw body. The percent-saved helper lives in one module under web/src/lib/variants/ and rounds half away from zero, then clamps the magnitude to 99 while the file is non-empty. New strings need English and French entries in web/src/i18n/messages.ts. A mock of GET /v1/media/{id}/variants returns the API order (name); only the visual mock uses the board order.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock useApi's `request` per case (GET /v1/media/, GET /v1/media/{id}/variants, POST /v1/media/{id}/variants[/{name}], DELETE /v1/media/{id}/variants/{name}, DELETE /v1/media/{id}, GET /v1/activity). Reuse `web/src/views/__tests__/mediaTestKit.ts` and `variantsTestKit.ts`; add `variant_count` and `variant_size` to the 12-image fixture of the Background. Mount MediaView inside DashboardShell with a resolved verified session (id 1, role "owner") and a test router at /media. Mock `matchMedia` for the 640 px and md breakpoints; run layout cases at desktop and below. Fake timers for the 150 ms skeleton and for nothing else. Mock navigator.clipboard. No test freezes the system date and none asserts a relative date.
1. **Badge, Examples-driven** - the five card rows, the position after Used in / Duplicate, singular "1 variant", no badge at 0, the unchanged accessible name, no badge in list view.
2. **Filter control** - placement desktop and mobile, selected style, each segment sends `variants=with|without` with `limit=100` and shows the skeleton after 150 ms, "All" removes the parameter, the four URL rows (`?variants=all` and `zzz` dropped and the URL cleaned), combination with `?filter=unused`.
3. **Sort** - the five options in order, the new label and its mobile label, `sort=total_size` in the URL and request, the stale `?sort=total` dropped.
4. **Meta line** - the four rows while sorted by total size, and the usual line under Newest.
5. **Empty filter** - 3e copy, the toolbar stays, "Show all images" removes `?variants` and restores the default cards; no `q` set is the only case this panel shows.
6. **Delete dialogs** - the note for 4 and for 1 variant and its absence at 0, in-use title and description unchanged, the bulk sum "9 generated variants (670 KB)" for the three selected images and no note when the sum is 0, no extra request, the unchanged pending, failure and 404 flows, the background refresh after success.
7. **Tabs** - present for owner and admin, absent for another role with no variants request, `GET /v1/media/{id}/variants` sent once per open panel on first selection of the tab, Details body unchanged.
8. **Loading and load error, Examples-driven** - 149 and 150 ms, aria-busy and label, five skeleton rows, 500 and network alert and Try again.
9. **Partly generated** - Original block copy, summary "4 of 5 generated", row order and every cell of the generated and not generated rows.
10. **Saving percent, Examples-driven** - the seven size rows against 4089446 bytes, plus "-100%" never shown.
11. **Nothing generated, no variants defined, SVG** - copy, link target `/settings/media/variants`, no list, no Generate all.
12. **Row actions** - icon buttons with tooltips and accessible names on desktop, labelled buttons below 640 px, the actions per state, Generate all disabled when all five are generated.
13. **Generate now** - request, Generating row and summary and disabled button (resolve later with fake timers off, using a deferred promise), success toast id and title, 403, 404 (tab reloaded; second 404 runs the image-gone flow), 500 and network rows return the row to Not generated.
14. **Generate all** - request, rows in flight, success toast ("Generated 4 variants", singular "Generated 1 variant"), partial failure rows with causes and Retry, the two single-failure causes (413 and 415) with their toast text.
15. **Failed state is local** - closing and reopening shows Not generated; Retry re-sends the per-name POST.
16. **Copy URL** - absolute url with `?v=`, for a not generated row too, toast id, and a rejected clipboard shows no success toast.
17. **Purge** - request, response `{purged_files: 1, purged_bytes: 18432}`, row, summary and the toast title; 403, 404, 500.
18. **Preview** - copy of both panes and the hint, 880 px / bottom sheet, focus trap and focus return, images are the variant url and the original url.
19. **Activity sentences** - the two sentences, the failed forms, the detail lines for every params row including the singular forms and the two-cause join, no second line for other actions, French catalogue test (every new key present in messages.ts).
20. **Accessibility** - icon plus word for every status, live region text, 44 px classes on mobile, vocabulary scan (no "thumbnail preset", "version", "rendition").
## Visual (task visual:check -- image-variants <state>, desktop and mobile, light and dark)
Reference: docs/design/screens/image-variants.md, board "Image variants", exact values in `docs/design/source/VariantsFrame.dc.html` and `AppFrame.dc.html`. Add each state to `web/visual/states.ts` with the board's data. The gate is hard: every non-skipped capture within 10 %, both viewports, both themes. None of these states is clock-sensitive, so none is skipped. Board sample data: the 12 library images with their tone, sizes, used counts, variant counts and variant sizes (IM in VariantsFrame), the five enabled variants in board order thumb, card, hero, square, og (the mock returns the board order for the capture only), north-wall.jpg 4032 x 3024 3.9 MB, row sizes 18 KB, 64 KB, 96 KB, 82 KB, 142 KB. Session name "Marta Alves".
- [ ] 3a Grid with badges
- [ ] 3b Sort menu open, new option selected
- [ ] 3c With variants, sorted by total size (ids 1, 7, 9, 12, 5, 11, 2, 3, 10)
- [ ] 3d Without variants (ids 4, 6, 8)
- [ ] 3e Nothing to show (With variants, empty)
- [ ] 3f Delete confirm for north-wall.jpg with the "Also removes 4 generated variants (260 KB)" note
- [ ] 4a Partly generated (thumb, card, hero, square generated, og not)
- [ ] 4b Nothing generated yet
- [ ] 4c Generating (thumb generated, card and hero generating, square and og not) - reached by pressing Generate all against a mock whose POST stays pending
- [ ] 4d Generation failed (card "Unsupported source format", hero "Image too large", others generated) - reached by a Generate all that resolves with those failures
- [ ] 4e No variants defined
- [ ] 4f SVG source (logo-renew.svg)
- [ ] 4h Preview dialog on "thumb"
- [ ] Layout from the board frame, not components.md: the segmented control (30 px desktop, 44 px mobile segments, accented border), the badge fill and icon, the panel tabs (40 px / 44 px), the Original block (72 x 54 thumbnail), the row paddings (10 px 12 px) and gaps (4 px desktop, 8 px mobile), the 28 px square icon buttons, the preview pane heights (300 px / 180 px).
- [ ] 4g (animated GIF) has no state: the source cannot be uploaded.
- [ ] Not drawn, no visual state: load error, toasts, the 403/404/500 paths, bulk delete note, the Activity sentences and detail lines (Vitest only).
- [ ] Not covered by screenshots: keyboard and focus order (Tab reaches the tab strip, Generate all, each row's buttons in order, the dialogs trap focus, Escape closes Preview), tooltips on hover, motion.
## Manual verification
- **Round trip against `task dev`** with real images: upload an image, open its Variants tab, press Generate now on one row and Generate all, check the sizes and "saves N%" against the files, Purge a row, Preview at actual size, Copy URL then open the pasted URL.
- **Library**: filter With variants / Without and sort by total size against the real cache; delete an image with variants and confirm the note matches the files removed.
- **Real clipboard** in a secure context (localhost).
- **French**: switch the language and read the tab, the dialogs, the toasts and the Activity rows.
- **Activity**: read the two new rows and their detail lines in the real Activity screen against section 8.
@@ -0,0 +1,514 @@
Feature: Image variants per image: library fields, per-image routes and generate on upload
As an owner or admin of a RenewCMS site
I want to see, generate and purge the variants of one image, and to have variants generated when I upload
So that the media screens show what is cached for each image, and the activity log records when
I generate all the variants of an image or purge them
Background:
Given the spec of record is docs/design/screens/image-variants.md sections 3, 4, 6 and 8 and its "Back-office API errors" table
And the routes the design marks "proposed" are fixed here, and features/image-variants.feature is the contract for the variants themselves
And this slice is API only: the SPA is in features/image_variants_media_screens.feature and features/image_variants_editor_picker.feature, docs/design is unchanged and the public serving of ?v=<name> is unchanged
And it excludes the Settings → Media → Cache routes (GET /v1/image-variants/cache, POST /v1/image-variants/cache/purge)
And the stored name of an image is image.StoredName(Path), the public url of one of its variants is "/static/uploadedImages/<storedName>?v=<name>", and a variant file is generated by the same use case path as a public request (same limits, same render slots, same singleflight key, same index row), so a file generated here is a cache hit for the public url and the other way round
And a variant file counts as generated for an image only when its index row exists and carries the variant's current settings fingerprint
And internal/domain/image gains the sentinels ErrVariantSourceUnsupported (an SVG source) and ErrVariantSourceUnreadable (the source cannot be decoded), asserted with errors.Is, and reuses ErrSourceTooLarge, ErrVariantNotFound and ErrImageNotFound
And internal/domain/image gains one pure function returning the output width and height for a source size and a variant's settings, following the table of features/image-variants.feature "Output geometry never upscales", and both the renderer and the per-image listing use it
And image.VariantFileRepository gains one method returning, for every source at once, the number of index rows and their total bytes keyed by source stored name, so a library page never queries per image
And internal/domain/audit gains the actions media.variant_generate_all and media.variant_purge_image (category "media", target {"type": "image", "id": "<image id>", "name": "<filename>"}) and the params generated, failed and causes
And the new routes are mounted in api/router.go inside NewMediaRouter, in the group behind middleware.RequireOwnerOrAdmin, so a request with an API key has no user_id claim and answers 401
And request validation, when there is a body, goes through validate tags and validator.Validate.Struct, and error responses are plain text from http.Error
And new apperr constants are VariantSourceUnsupportedErrorMessage = "Variants aren't generated for this format." and VariantSourceUnreadableErrorMessage = "The source image can't be read, so this variant can't be generated.", and the existing VariantSourceTooLargeErrorMessage = "The source image is too large to generate this variant." is reused
And a per-image variant is JSON {"name", "width", "height", "fit", "format", "output_width", "output_height", "output_format", "generated", "size", "url"} where width and height are the variant's settings (null when auto), output_* are the dimensions and format the file has or will have, generated is a boolean, size is the file size in bytes or null when not generated, and url is the public url above
And a generation failure cause is one of "too_large" (ErrSourceTooLarge), "unsupported_format" (ErrVariantSourceUnreadable) or "failed" (anything else)
And every library item gains the integers variant_count and variant_size (the number of index rows of that image whatever the variant's state, and their bytes), both 0 for an image with no file
Rule: Only owners and admins reach the per-image variant routes
Scenario Outline: Every per-image variant route is guarded
When the client sends <method> <path> with <credentials>
Then the response status is <status>
And the plain text body is <body>
And no variant, index row, blob or activity event is read or changed
Examples:
| method | path | credentials | status | body |
| GET | /v1/media/1/variants | no cookie | 401 | apperr.UnauthorizedErrorMessage |
| GET | /v1/media/1/variants | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage |
| GET | /v1/media/1/variants | the cookie of an unverified user | 403 | apperr.VerificationRequiredErrorMessage |
| GET | /v1/media/1/variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| POST | /v1/media/1/variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| POST | /v1/media/1/variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| DELETE | /v1/media/1/variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| GET | /v1/media/1/variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
| POST | /v1/media/1/variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
Scenario Outline: Owners and admins are both allowed
When <role> sends GET /v1/media/1/variants with a valid cookie
Then the response status is 200
Examples:
| role |
| the owner |
| an admin |
Scenario Outline: A malformed id or an unknown image
When an admin sends <method> /v1/media/<id>/variants<suffix>
Then the response status is <status> and the plain text body is <body>
Examples:
| method | id | suffix | status | body |
| GET | abc | | 400 | apperr.IdUint32ErrorMessage |
| POST | abc | | 400 | apperr.IdUint32ErrorMessage |
| GET | 9999 | | 404 | Image not found |
| POST | 9999 | | 404 | Image not found |
| POST | 9999 | /hero | 404 | Image not found |
| DELETE | 9999 | /hero | 404 | Image not found |
Rule: Listing the variants of one image
Background:
Given image 1 "north-wall.jpg" is a 4032 x 3024 JPEG of 4089446 bytes stored as "a1b2.jpg"
And image 2 "logo.svg" is an SVG stored as "e5f6.svg"
And the variants, in name order: "card" (inside, 800 x auto, webp, 80), "hero" (cover, 1200 x 630, webp, 82), "og" (cover, 1200 x 630, jpeg, 80), "print" (inside, auto x 2400, png, disabled), "square" (cover, 600 x 600, jpeg, 85), "thumb" (cover, 400 x 300, webp, 80), all enabled unless said
And the index holds current rows for a1b2.jpg: thumb 18432 bytes, card 65536, hero 98304, square 83968, and a row for the disabled "print" of 3250586 bytes
Scenario: The enabled variants, in name order, with their output and what is cached
When an admin sends GET /v1/media/1/variants
Then the response status is 200
And "unsupported" is null
And "items" are card, hero, og, square, thumb, in that order, and "print" is absent
And the items read:
| name | width | height | output_width | output_height | output_format | generated | size |
| card | 800 | null | 800 | 600 | webp | true | 65536 |
| hero | 1200 | 630 | 1200 | 630 | webp | true | 98304 |
| og | 1200 | 630 | 1200 | 630 | jpeg | false | null |
| square | 600 | 600 | 600 | 600 | jpeg | true | 83968 |
| thumb | 400 | 300 | 400 | 300 | webp | true | 18432 |
And the url of "thumb" is "/static/uploadedImages/a1b2.jpg?v=thumb"
Scenario: A row whose fingerprint is stale is not generated
Given the index row of "hero" for a1b2.jpg carries the fingerprint of an older quality
When an admin sends GET /v1/media/1/variants
Then "hero" has generated false and size null
Scenario Outline: A variant in the original format takes the format of the source
Given a variant "keep" with format original over a source of content type <content_type> and webp encoding <webp>
When an admin sends GET /v1/media/<id>/variants
Then "keep" has output_format "<output_format>"
Examples:
| content_type | webp | output_format |
| image/jpeg | available | jpeg |
| image/png | available | png |
| image/webp | available | webp |
| image/webp | unavailable | png |
Scenario Outline: The output size never exceeds the source
Given a source of <sw> x <sh> pixels and a variant with width <w>, height <h> and fit <fit>
When an admin sends GET /v1/media/<id>/variants
Then the variant has output_width <ow> and output_height <oh>
And they equal the dimensions the renderer produces for the same source and settings
Examples:
| sw | sh | w | h | fit | ow | oh |
| 4032 | 3024 | 800 | | cover | 800 | 600 |
| 4032 | 3024 | | 2400 | inside | 3200 | 2400 |
| 4032 | 3024 | 8000 | 8000 | cover | 3024 | 3024 |
| 300 | 200 | 800 | | cover | 300 | 200 |
| 300 | 200 | 400 | 300 | cover | 267 | 200 |
| 300 | 200 | 400 | 300 | contain | 400 | 300 |
| 300 | 200 | 1200 | 1200 | inside | 300 | 200 |
Scenario: A source that no variant can be generated for answers an empty list
When an admin sends GET /v1/media/2/variants
Then the response status is 200 and the body is {"unsupported": "svg", "items": []}
Scenario: No enabled variant answers an empty array, not null
Given every variant is disabled
When an admin sends GET /v1/media/1/variants
Then the response status is 200 and the raw body holds "items":[] and "unsupported":null
Scenario: Listing reads nothing it should not change
When an admin sends GET /v1/media/1/variants
Then no blob is opened, no render happens, no row is written and no activity event is recorded
Rule: Generating one variant of one image
Background:
Given image 1 "north-wall.jpg" is a 1000 x 800 PNG stored as "a1b2.png"
And the variants "thumb" (cover 400 x 300 jpeg 80, enabled), "hero" (cover 1200 x 630 jpeg 82, enabled) and "off" (inside 300 x auto png, disabled)
And no generated file exists yet
Scenario: The first generation renders, stores and indexes the file
When an admin sends POST /v1/media/1/variants/thumb
Then the response status is 200
And the body is the per-image variant "thumb" with generated true, output 400 x 300, output_format "jpeg" and size equal to the stored blob's size
And exactly one blob was put in image.Storage and one index row exists for thumb and a1b2.png
And the original blob a1b2.png is unchanged
And no activity event is recorded
Scenario: The public url then serves that file without rendering
Given POST /v1/media/1/variants/thumb answered 200
When the public client sends GET /static/uploadedImages/a1b2.png?v=thumb
Then the body is byte-identical to the stored blob and the renderer was called once in total
Scenario: Generating a variant that is already generated renders nothing
Given POST /v1/media/1/variants/thumb answered 200
When an admin sends POST /v1/media/1/variants/thumb again
Then the response status is 200 with the same size and the renderer was called once in total
Scenario: A public request and an admin request at the same time render once
When the public client and an admin request thumb for a1b2.png concurrently while the render blocks
Then the renderer is called once and image.Storage Put is called once
Scenario Outline: A request that cannot be served
When an admin sends POST /v1/media/<id>/variants/<name>
Then the response status is <status> and the plain text body is <body>
And nothing is rendered and nothing is stored
Examples:
| id | name | status | body |
| 1 | missing | 404 | Variant not found |
| 1 | off | 404 | Variant not found |
| 1 | Thumb | 404 | Variant not found |
Scenario: An SVG answers 422
Given image 2 is an SVG stored as "e5f6.svg"
When an admin sends POST /v1/media/2/variants/thumb
Then the response status is 422 and the plain text body is apperr.VariantSourceUnsupportedErrorMessage
And nothing is rendered and nothing is stored
Scenario Outline: A source beyond a cap answers 413
Given <cap> is lower than the source
When an admin sends POST /v1/media/1/variants/thumb
Then the response status is 413 and the plain text body is apperr.VariantSourceTooLargeErrorMessage
And nothing is stored
Examples:
| cap |
| IMAGE_VARIANT_MAX_SOURCE_BYTES |
| IMAGE_VARIANT_MAX_SOURCE_PIXELS |
Scenario: A source that cannot be decoded answers 415
Given the stored blob a1b2.png holds bytes that are not an image
When an admin sends POST /v1/media/1/variants/thumb
Then the response status is 415 and the plain text body is apperr.VariantSourceUnreadableErrorMessage
And nothing is stored
Scenario: Any other render failure is a generic 500 and is not cached
Given the renderer fails for its next call
When an admin sends POST /v1/media/1/variants/thumb twice
Then the first response is 500 with the body "Internal server error" and no blob or row exists
And the second response is 200
Scenario: A generation does not change the original, the other variants or the library list
When an admin sends POST /v1/media/1/variants/thumb
Then the stored image record, the other variants and the index rows of other images are unchanged
Rule: Generating every missing variant of one image
Background:
Given image 1 "north-wall.jpg" is a 4032 x 3024 JPEG stored as "a1b2.jpg"
And the enabled variants "card", "hero", "og", "square" and "thumb" and the disabled variant "print"
And no generated file exists yet
Scenario: Generate all renders every enabled variant and answers the refreshed list
When an admin sends POST /v1/media/1/variants
Then the response status is 200
And "generated" is 5, "failed" is 0 and "failures" is an empty array
And "items" are the five enabled variants in name order, all with generated true and their sizes
And exactly five blobs and five index rows exist and "print" has none
Scenario: Variants already generated are skipped and not counted
Given "card" and "thumb" are already generated
When an admin sends POST /v1/media/1/variants
Then "generated" is 3, the renderer was called three times in total beyond the earlier two, and all five items have generated true
Scenario Outline: A failing variant is reported and the others are still generated
Given generating "hero" fails with <failure>
When an admin sends POST /v1/media/1/variants
Then the response status is 200
And "generated" is 4, "failed" is 1 and "failures" is [{"name": "hero", "cause": "<cause>"}]
And the "hero" item has generated false and the other four have generated true
Examples:
| failure | cause |
| ErrSourceTooLarge | too_large |
| ErrVariantSourceUnreadable | unsupported_format |
| a renderer error that wraps no sentinel | failed |
Scenario: Nothing to generate answers zero
Given all five enabled variants are generated
When an admin sends POST /v1/media/1/variants
Then the response status is 200, "generated" is 0 and "failed" is 0 and the renderer is not called
Scenario: No enabled variant answers zero
Given every variant is disabled
When an admin sends POST /v1/media/1/variants
Then the response status is 200, "generated" is 0, "failed" is 0 and "items" is an empty array
Scenario: An SVG answers 422
Given image 2 is an SVG
When an admin sends POST /v1/media/2/variants
Then the response status is 422 and the plain text body is apperr.VariantSourceUnsupportedErrorMessage
Rule: Purging the generated files of one variant of one image
Background:
Given image 1 stored as "a1b2.jpg" and image 3 stored as "c3d4.jpg"
And "thumb" and "hero" are generated for both images, thumb files of 18432 bytes and hero files of 98304 bytes
Scenario: Purging a generated variant removes its file and its row
When an admin sends DELETE /v1/media/1/variants/thumb
Then the response status is 200 and the body is {"purged_files": 1, "purged_bytes": 18432}
And the thumb blob of image 1 is deleted from image.Storage and its index row is gone
And the hero file of image 1 and both files of image 3 are untouched
Scenario: The next public request generates the file again
Given DELETE /v1/media/1/variants/thumb answered 200
When the public client sends GET /static/uploadedImages/a1b2.jpg?v=thumb
Then the response is 200 and a new blob was put
Scenario: Purging a variant that is not generated answers zero
Given the "thumb" file of image 1 is not generated
When an admin sends DELETE /v1/media/1/variants/thumb
Then the response status is 200 and the body is {"purged_files": 0, "purged_bytes": 0}
Scenario: A disabled variant keeps its files and they can be purged
Given "thumb" is disabled
When an admin sends DELETE /v1/media/1/variants/thumb
Then the response status is 200 and "purged_files" is 1
Scenario: An unknown variant answers 404
When an admin sends DELETE /v1/media/1/variants/missing
Then the response status is 404 and the plain text body is "Variant not found"
Scenario: A blob that cannot be deleted keeps its row and the call fails
Given image.Storage fails to delete the thumb blob of image 1
When an admin sends DELETE /v1/media/1/variants/thumb
Then the response status is 500 with the body "Internal server error" and the index row is kept
Rule: Variants flagged "generate on upload" are generated when an image is uploaded
Background:
Given the variants "thumb" (generate_on_upload true, enabled), "hero" (generate_on_upload true, enabled), "og" (generate_on_upload true, disabled) and "card" (generate_on_upload false, enabled)
And image.Storage and the renderer are fakes
Scenario: Flagged and enabled variants are generated before the response
When the owner sends POST /v1/media with a valid 1000 x 800 PNG
Then the response status is 201
And the index holds one row for "thumb" and one for "hero" for the new stored name, and none for "og" or "card"
And the item has variant_count 2 and variant_size equal to the two row sizes
And exactly one activity event exists, the "media.upload" event, and no variant event
Scenario: A failing variant never fails the upload
Given the renderer fails when generating "hero"
When the owner uploads a valid PNG
Then the response status is 201, "thumb" is generated, "hero" is not, and variant_count is 1
And the image exists and the failure is written to the process log with the variant name and the image id
Scenario: A source beyond a cap still uploads
Given IMAGE_VARIANT_MAX_SOURCE_PIXELS is lower than the new image
When the owner uploads it
Then the response status is 201 and variant_count is 0
Scenario: An SVG upload generates nothing
When the owner uploads a valid SVG
Then the response status is 201, the renderer is not called and variant_count is 0
Scenario: No flagged variant means no render
Given no variant has generate_on_upload true
When the owner uploads a valid PNG
Then the renderer is not called
Scenario: A refused upload generates nothing
When the owner uploads a PDF
Then the response status is 415 and the renderer is not called
Scenario Outline: Changing the flag does not backfill existing images
Given the library already holds image 1 with no generated file
When an admin <action>
Then the renderer is not called and image 1 has no generated file
Examples:
| action |
| creates the variant "wide" with generate_on_upload true |
| sends PUT /v1/image-variants/card with generate_on_upload true |
Scenario: Uploading the same bytes again generates files for the new image only
Given the library holds image 1 with generated files
When the owner uploads the same bytes again
Then the new image has its own generated files and image 1's files are unchanged
Rule: The library list carries the variant count and size and can filter and sort on them
Background:
Given the library of features/media_library.feature "Listing" (images 1 to 5, newest is 5) with these sizes and index rows:
| id | filename | size | variant files | variant bytes | total |
| 1 | north-wall.jpg | 4089446 | 4 | 266240 | 4355686 |
| 2 | floorboards.jpg | 2516582 | 1 | 41000 | 2557582 |
| 3 | floorboards-copy.jpg | 2516582 | 0 | 0 | 2516582 |
| 4 | window-frame.jpg | 812345 | 3 | 3000000 | 3812345 |
| 5 | logo.svg | 1204 | 0 | 0 | 1204 |
And the rows of image 4 belong to a disabled variant
And articles use the images as in that feature, so images 3, 4 and 5 are unused
Scenario: Every item carries variant_count and variant_size
When the client sends GET /v1/media/ with a valid cookie
Then item 1 has variant_count 4 and variant_size 266240
And item 4 has variant_count 3 and variant_size 3000000, although its variant is disabled
And items 3 and 5 have variant_count 0 and variant_size 0
And the response order, used_in, duplicate and counts are those of the existing feature
Scenario: The list reads the totals of all sources in one call
When the client sends GET /v1/media/ with a valid cookie
Then the variant file repository's per-source totals method was called once
Scenario: A new upload and a rename answer the same fields
When the owner renames image 1 to "north-wall-01"
Then the response item has variant_count 4 and variant_size 266240
Scenario Outline: variants narrows the list and the counts stay library-wide
When the client sends GET /v1/media/?variants=<variants> with a valid cookie
Then the "items" ids, as a set, are <ids>
And "counts" is still {"all": 5, "unused": 3, "duplicates": 1}
Examples:
| variants | ids |
| with | 1, 2, 4 |
| without | 3, 5 |
Scenario: variants, filter, q and sort combine
When the client sends GET /v1/media/?variants=with&filter=unused with a valid cookie
Then the "items" ids are 4
When the client sends GET /v1/media/?variants=with&q=floorboards with a valid cookie
Then the "items" ids are 2
Scenario: sort=total_size orders by original plus variants, largest first, then by id
When the client sends GET /v1/media/?sort=total_size with a valid cookie
Then the "items" ids are 1, 4, 2, 3, 5 in that order
Scenario: sort=largest is unchanged and sorts by the original alone
When the client sends GET /v1/media/?sort=largest with a valid cookie
Then the "items" ids are 1, 2, 3, 4, 5 in that order
Scenario: Equal totals fall back to the lowest id first
Given images 2 and 3 have equal totals
When the client sends GET /v1/media/?sort=total_size with a valid cookie
Then image 2 comes before image 3
Scenario: variants=with and sort=total_size page through the list
When the client sends GET /v1/media/?variants=with&sort=total_size&limit=2 with a valid cookie
Then the "items" ids are 1, 4 and "next_cursor" is a non-empty string
When the client sends the same query with limit=2 and that cursor
Then the "items" ids are 2 and "next_cursor" is null
Scenario: variants=with on a library with an empty cache answers an empty list
Given no variant file exists
When the client sends GET /v1/media/?variants=with with a valid cookie
Then the response status is 200, "items" is an empty array and "counts" is unchanged
Scenario Outline: A malformed query answers 400
When the client sends GET /v1/media/?<query> with a valid cookie
Then the response status is 400 and the response body is the text apperr.MediaQueryErrorMessage
Examples:
| query |
| variants=bogus |
| variants=all |
| variants=With |
| variants= |
| sort=Total_size |
| sort=total |
Scenario: The number shown by a delete is the number a delete removes
Given image 1 has four index rows and four blobs
When the client sends DELETE /v1/media/1 with a valid cookie
Then the response status is 204 and exactly those four blobs and four rows are gone
Scenario: An article that uses a variant url still counts as using the image
Given article 1's body contains "/static/uploadedImages/<image 1 stored name>?v=hero"
When the client sends GET /v1/media/1/usage
Then article 1 is listed and the list item has used_in 1
Scenario: Merging keeps the variant parameter of the switched url
Given article 1's body contains the url of image 3 followed by "?v=hero"
When the client merges image 3 into image 2
Then article 1's body contains the url of image 2 followed by "?v=hero"
Rule: Generate all and purge by image are recorded in the activity log
Background:
Given recording follows features/activity-log-producers.feature: once, from the use case, after the change, with the request origin
And the category is "media" and the target is {"type": "image", "id": "<image id>", "name": "<filename>"}
And image 1 "north-wall.jpg" has five enabled variants and none generated
Scenario: Generate all is recorded with the counts
When the owner sends POST /v1/media/1/variants
Then one event exists with action "media.variant_generate_all", target {"type": "image", "id": "1", "name": "north-wall.jpg"}, outcome "success" and params {"generated": 5, "failed": 0, "causes": []}
Scenario: A partial failure is a success that names its causes
Given generating "hero" fails with ErrSourceTooLarge
When the owner sends POST /v1/media/1/variants
Then one event exists with action "media.variant_generate_all", outcome "success" and params {"generated": 4, "failed": 1, "causes": ["too_large"]}
Scenario: Every variant failing is a failed event with a plain reason
Given generating every variant fails with ErrSourceTooLarge
When the owner sends POST /v1/media/1/variants
Then the response status is 200 with "generated" 0 and "failed" 5
And one event exists with action "media.variant_generate_all", outcome "failed" and failure_reason "The source image is too large to generate this variant."
Scenario Outline: Nothing was done, nothing is recorded
When <request>
Then no event is recorded
Examples:
| request |
| the owner sends POST /v1/media/1/variants and all five are already generated |
| the owner sends POST /v1/media/1/variants and no variant is enabled |
| the owner sends POST /v1/media/1/variants on an SVG |
| the owner sends POST /v1/media/1/variants/thumb |
| the owner sends POST /v1/media/9999/variants |
| the owner sends GET /v1/media/1/variants |
| a public GET /static/uploadedImages/a1b2.jpg?v=thumb that generates a file |
| an upload that generates files on upload |
Scenario: Purging the files of one variant of an image is recorded
Given "thumb" is generated for image 1 with a file of 18432 bytes
When the owner sends DELETE /v1/media/1/variants/thumb
Then one event exists with action "media.variant_purge_image", target {"type": "image", "id": "1", "name": "north-wall.jpg"}, outcome "success" and params {"purged_files": 1, "purged_bytes": 18432}
Scenario Outline: A purge that removes nothing, or finds nothing, is not recorded
When <request>
Then no event is recorded
Examples:
| request |
| the owner sends DELETE /v1/media/1/variants/thumb and nothing is generated |
| the owner sends DELETE /v1/media/1/variants/missing |
| the owner sends DELETE /v1/media/9999/variants/thumb |
Scenario: A purge that fails on storage is recorded as failed
Given image.Storage fails to delete the thumb blob
When the owner sends DELETE /v1/media/1/variants/thumb
Then the response is 500 and one event exists with action "media.variant_purge_image", outcome "failed" and failure_reason "Something went wrong on our side. Please try again."
Scenario: A failing audit write never changes the response
Given the audit repository fails on Append
When the owner sends POST /v1/media/1/variants
Then the response is 200 with the same body and the files exist
Scenario: The new events read back through the existing activity API
Given one generate-all and one purge-by-image event were recorded
When the owner sends GET /v1/activity?category=media
Then both events are listed with their documented shape, params and target
Rule: Layering and hygiene
Scenario: The dependency rule holds
Then internal/domain/image imports only the stdlib and other internal/domain packages
And the new use cases (list image variants, generate image variant, generate all, purge image variant, generate on upload) import no persistence, mailer, models, gorm, net/http, chi or api package
And the media handler imports no persistence, mailer, models or gorm package
And only api/dependecyInjection.go constructs the new use cases and wires them into media.NewHandler
And no comment is added to any Go file, test or doc, except tool directives
@@ -0,0 +1,53 @@
# QA procedure: image_variants_per_image_api
Go only, no SPA. Package `test` (black-box). New files: `test/image_variants_per_image_http_test.go`, `test/image_variants_generate_on_upload_test.go`, `test/media_variants_list_http_test.go`, `test/image_variants_per_image_audit_test.go`. Extend `test/image_variants_support_test.go`; fake type names must stay unique in the package. No live server, no real SQLite file, no real SMTP, no `time.Sleep`.
## Setup shared by every case
1. `validator.InitValidator()` once. `jwtauth.New("HS256", []byte("test-secret"), nil)` stands in for `JWT_SECRET`.
2. Reuse the fakes of `test/image_variants_support_test.go`: `mockVariantRepo`, `mockVariantFileRepo`, `mockVariantStorage`, `countingRenderer`. Add to `mockVariantFileRepo` the per-source totals method (rows and bytes by source stored name) with a call counter. Reuse `mockImageRepo` (width, height, size, content type, SHA256, created_at seeded), `mockArticleRepo`, the user fake and the audit fakes of `test/activity_support_test.go`.
3. Users: owner 1, admin 2, verified plain user 3 (role other than owner/admin), unverified user 4. Real signed `jwt` cookies with `user_id` and `token_version`; a stale cookie with a lower `token_version`; a valid API key in the key fake.
4. Router: a fresh `chi.NewRouter()` mirroring `NewMediaRouter` in `api/router.go`: `jwtauth.Verifier`, the API-key middleware, `AuditContext`, `Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`, then the owner/admin group holding the new routes beside the existing purge routes and the ungrouped media routes. Mount at `/v1/media`. Mount the real public handler `staticimages.NewHandler(...)` at `/static/uploadedImages` over the same storage and variant use cases so a generated file is observed through the public url.
5. Images are generated in the test with `image/png`, `image/jpeg`, `image/draw` (as in the other variant tests). The SVG is a real string. Seed the Listing library of features/media_library.feature for the list cases and the numbers of the Rule's table.
## A. Guards
6. Table of the feature: each method, path and credential gives the status and the exact body constant; assert no variant, index, storage or audit fake was touched. API key gets 401. Owner and admin both get 200. Malformed and unknown ids: 400 `apperr.IdUint32ErrorMessage`, 404 `Image not found`.
## B. Listing the variants of one image
7. Seed the Rule's Background. Decode the body and assert order (name), the absence of the disabled variant, `width`/`height` as JSON `null` on the raw body, output size and format, `generated`, `size` (null when not generated), `url`. Stale fingerprint row: `generated` false. Disabled variant's row ignored. SVG: raw body `{"unsupported":"svg","items":[]}`. No enabled variant: raw `"items":[]`.
8. Output format Examples: `countingRenderer.EncodableFormats()` configured for webp on and off; a webp source with `original` gives `png` without an encoder.
9. Geometry table: for each row assert the body's `output_width`/`output_height` and then render the same source and settings with the real renderer and assert the decoded size equals them (one source of truth).
10. Listing renders nothing: renderer count 0, storage `Open`/`Put` counts 0, no audit event.
## C. Generating one
11. First generation: 200, item fields, exactly one `Put`, one index row whose size equals the item `size`, original blob untouched, no audit event. A public GET afterwards returns the same bytes with the renderer count still 1. A second POST: 200, renderer count unchanged.
12. Concurrency (run with `-race`): a renderer blocking on a channel; one admin POST and one public GET for the same pair released together; assert renderer count 1 and `Put` count 1.
13. Refusals: unknown, disabled and wrongly-cased variant names give 404 `Variant not found` with renderer count 0. SVG gives 422 with the constant. Byte cap and pixel cap give 413 (construct the use case with the low cap). Undecodable bytes give 415 with the constant. A renderer failure gives 500 `Internal server error`, no blob, no row, and the next call is 200.
## D. Generating all
14. Fresh image: 200, `generated` 5, `failed` 0, `failures` raw `[]`, five items all generated, five blobs and rows, `print` untouched. Pre-generated subset: counts only the newly generated, renderer count equals the missing ones.
15. Failure Examples: make the renderer fake return `ErrSourceTooLarge`, `ErrVariantSourceUnreadable` and a plain error for `hero` only; assert the status 200, `failures`, the causes and the four others generated. Nothing to generate, no enabled variant and SVG (422) rows.
## E. Purging one
16. 200 with `purged_files` 1 and `purged_bytes` equal to the seeded size; blob `Delete` called for that name only; other variant and the other image untouched; a following public GET renders again. Not generated and disabled-variant rows; unknown variant 404. Storage `Delete` failing gives 500 and the row is kept.
## F. Generate on upload
17. Upload through the real upload path with the Rule's four variants: after the 201, assert rows for `thumb` and `hero` only, `variant_count` 2 and `variant_size` equal to the sum, exactly one audit event (`media.upload`). Renderer failing for `hero`: 201, one row, the log line (capture with a test logger handed to the use case, assert it names the variant and the image id). Pixel cap: 201 and zero rows. SVG, no flagged variant, and 415 upload: renderer count 0. Creating a variant or a PUT with the flag on does not render for existing images. Same bytes uploaded twice: separate rows per stored name.
## G. Library list
18. Seed the Rule's table. `variant_count`/`variant_size` per item (raw JSON integers, 0 not null). Per-source totals fake call count is 1 per list call. `variants=with|without` sets; `counts` unchanged; combination rows; `sort=total_size` order `1,4,2,3,5`, `sort=largest` order `1,2,3,4,5`; tie breaks by id (make two totals equal); cursor walk with `limit=2`; empty cache with `variants=with`; every 400 row with `apperr.MediaQueryErrorMessage`. Rename response carries the fields.
19. Delete consistency: seed four rows and four blobs for image 1, DELETE, assert exactly those four `Delete` calls and rows. Usage and merge rows with a `?v=hero` suffix in the article body.
## H. Audit
20. Generate-all success, partial failure (`causes`), all-failed (`failed` outcome and exact reason), the no-op rows (all generated, no enabled variant, SVG, single generate, unknown image, GET, public generation, upload generation). Purge-by-image success params, the zero rows, the storage-failure row (500 plus failed event with the generic reason). A failing audit repository leaves response and files unchanged. `GET /v1/activity?category=media` lists both new events. Extend the mapping test of `failureReasons` for the three new sentences.
## I. Mutation guard
21. `task mutate` scope is `./internal/infrastructure/useCases`, scoped with `MUTATE_FILES` to the new use case files and `MUTATE_RUN` to the new test names. Each new branch needs a killing assertion: the generated-flag fingerprint comparison (equal and differing), the `generate_on_upload` AND `enabled` condition (each alone false), skipped already-generated variants, the failure-cause mapping order, `generated > 0` versus `failed > 0` for the outcome, the `purged_files > 0` guard, the total-size sum (original plus variants, not variants alone) and its id tie-break. Bare-bool conditions produce no mutant, so cover both branches anyway.
## J. Layering and hygiene
22. Guard test (`go list -deps` or grep): the domain imports only the stdlib and domain packages; the new use cases import no `persistence`, `mailer`, `models`, `gorm`, `net/http`, chi or `api`; the media handler imports no `persistence`, `mailer`, `models` or `gorm`. `CGO_ENABLED=0 go build -tags nofrontend ./...` succeeds. No comment in new or edited Go files, tests or docs. `docs/api.md` lists the new routes, shapes, params and errors.
## Manual (not automated)
23. `task dev:api` over a scratch SQLite file and `UPLOAD_DIR`: upload an image with a flagged variant and check the file lands in `UPLOAD_DIR`; then use the generate-all and purge routes with curl and a real cookie and check the files appear and disappear.
24. S3 driver (`STORAGE_DRIVER=s3`, MinIO): per-image generate and purge, upload with a flagged variant.
25. Latency of an upload of a 10 MB image with five flagged variants.
26. Read the two new events in the real Activity screen against section 8 copy.
+690
View File
@@ -0,0 +1,690 @@
Feature: Image variants settings screens, list and add/edit slide-over
As an owner or admin of a RenewCMS site
I want to list, enable, disable, purge, delete, add and edit image variants from Settings → Media
So that I can manage the named sizes articles request with ?v=<name> without leaving the back office
Background:
Given the spec of record is docs/design/screens/image-variants.md sections 1 and 2 and its "Back-office API errors" table, the board docs/design/source/RenewCMS Screens Image variants.dc.html drawn by VariantsFrame.dc.html, and docs/design/routes.md
And this slice is SPA only: no Go change, and the API is the one of features/image-variants.feature
And it excludes the Cache tab content, the Media library additions, the image detail Variants tab and the editor picker (sections 3 to 6 of the design)
And /settings/media/variants is a verified route rendered as VariantsPage inside DashboardShell, with the sidebar item "Settings" current
And the desktop breadcrumb is "Settings › Media › Variants" and the mobile top bar title is "Variants"
And the page uses Nuxt UI components only and design tokens, with no raw hex value and no arbitrary Tailwind colour
And every new English string has a French entry in web/src/i18n/messages.ts
And the list comes from GET /v1/image-variants once per navigation, and items are used in the order the API returns (name ascending)
And the Default fixture holds six variants, in API order, with cache_size in bytes (1 MB = 1048576):
| name | width | height | fit | format | quality | generated_files | cache_size | enabled |
| card | 800 | null | inside | webp | 80 | 318 | 58 MB | true |
| hero | 1200 | 630 | cover | webp | 82 | 244 | 84 MB | true |
| og | 1200 | 630 | cover | jpeg | 80 | 96 | 9 MB | true |
| print | null | 2400 | inside | png | null | 74 | 21 MB | false |
| square | 600 | 600 | cover | jpeg | 85 | 140 | 19 MB | true |
| thumb | 400 | 300 | cover | webp | 80 | 412 | 21 MB | true |
And the session user has role "owner" or "admin" from GET /v1/auth/me unless a scenario says otherwise
And plain-text API errors are mapped by status, never shown raw
Rule: Only admins and owners see the page
Scenario Outline: A session whose role is not owner or admin gets the Forbidden state
Given the session has <session>
When the user opens /settings/media/variants
Then the sidebar shows no current item
And the page shows the empty block with a lock icon, the title "You don't have access" and the text "Only admins can manage image variants. Ask an admin if you need one changed."
And it offers one button "Back to Home" with an arrow-left icon
And no page header, no tabs, no table, no cards and no reference card are drawn
And <request>
Examples:
| session | request |
| no role | no request to GET /v1/image-variants is sent |
| role "owner" but the API answers 403 to the list | the request was sent and its 403 is handled here |
Scenario: Back to Home leaves the page
Given the Forbidden state is shown
When the user activates "Back to Home"
Then the route is /home
Scenario Outline: An owner or an admin sees the list
Given the session role is "<role>"
When the user opens /settings/media/variants
Then GET /v1/image-variants is sent once and the page header is shown
Examples:
| role |
| owner |
| admin |
Rule: Header and tabs
Scenario: The page header and the tabs
When the Default fixture is loaded
Then the title is "Image variants"
And the description is "Named sizes and formats generated from your original images. Originals are never changed."
And a primary button "Add variant" with a plus icon is shown, right of the title on desktop and below the description on mobile
And two tabs follow the header: "Variants" (icon layers, active, medium weight, 2 px primary underline) and "Cache" (icon database, toned text), the Cache tab is aria-disabled="true", activating it changes nothing and no route exists for it
Rule: Loaded, table on desktop and cards on mobile
Scenario: Desktop table
Given the viewport is 640 px wide or more
When the Default fixture is loaded
Then a table has the columns "Name", "Dimensions", "Fit", "Format", "Quality", "Generated", "Cache size", "Status" and an unlabeled actions column
And the grid is minmax(0,1.1fr) 104px 76px 72px 68px 92px 92px 132px 44px, the header is 36 px high on --ui-bg-elevated and rows are at least 48 px high with a top border
And the rows are, in order, with cells name | dimensions | fit | format | quality | generated | cache size | status:
| card | 800 × auto | inside | WebP | 80 | 318 | 58 MB | Enabled |
| hero | 1200×630 | cover | WebP | 82 | 244 | 84 MB | Enabled |
| og | 1200×630 | cover | JPEG | 80 | 96 | 9 MB | Enabled |
| print | auto × 2400| inside | PNG | — | 74 | 21 MB | Disabled |
| square | 600×600 | cover | JPEG | 85 | 140 | 19 MB | Enabled |
| thumb | 400×300 | cover | WebP | 80 | 412 | 21 MB | Enabled |
And the name and dimensions cells are IBM Plex Mono, the name 13 px medium and the dimensions 12 px
And the row of a disabled variant has muted text
And the footer under the table reads "6 variants · 1,284 generated files · 212 MB"
And below the footer the Request reference card is visible
Scenario Outline: Dimensions, quality and format cells
Given a variant with width <width>, height <height>, format <format> and quality <quality>
Then the Dimensions cell reads "<dimensions>", the Format cell reads "<label>" and the Quality cell reads "<qualityCell>"
Examples:
| width | height | format | quality | dimensions | label | qualityCell |
| 400 | 300 | webp | 80 | 400×300 | WebP | 80 |
| 800 | null | jpeg | 80 | 800 × auto | JPEG | 80 |
| null | 2400 | png | null | auto × 2400 | PNG | — |
| 600 | 600 | original | 82 | 600×600 | Original | 82 |
Scenario: Mobile cards
Given the viewport is below 640 px
When the Default fixture is loaded
Then each variant is a card with 12 px padding and a 1 px border, stacked with 8 px gaps
And the first line of the card "hero" is the name in IBM Plex Mono 14 px medium with the status switch at its right
And the second line is the mono 12 px spec line "1200×630 · cover · WebP · q82"
And the third line is "244 generated · 84 MB" with the row menu button at its right
And the card "print" spec line is "auto × 2400 · inside · PNG" with no quality part
And the footer text "6 variants · 1,284 generated files · 212 MB" follows the last card
And the Request reference card follows the footer, in one column
Scenario: Singular footer
Given the list holds only "thumb" with 1 generated file of 18 KB
Then the footer reads "1 variant · 1 generated file · 18 KB"
Scenario: Footer sums are computed from the list
Given the Default fixture
Then the footer file count is the sum of generated_files and the size is the sum of cache_size
Rule: Loading
Scenario: Skeletons appear only after 150 ms
Given GET /v1/image-variants has not answered
Then nothing is drawn in the content area at 149 ms
And at 150 ms the header and tabs stay, and a region announces "Loading variants" with aria-busy="true"
And on desktop it holds a 36 px header bar and 5 skeleton rows of 48 px with six bars
And below 640 px it holds 4 skeleton cards with three bars of 40 %, 70 % and 55 % width
And no text is drawn in the skeleton
Scenario: A fast answer never flashes skeletons
Given GET /v1/image-variants answers before 150 ms
Then no skeleton is ever drawn
Rule: Empty
Scenario: No variants
Given GET /v1/image-variants answers {"items": []}
Then the header and tabs are shown
And the empty block has a layers icon, the title "No variants yet" and the text "A variant is a named size that an image can be requested in, like ?v=thumb. Start with one of these."
And two template cards sit side by side on desktop and stacked on mobile (at least 64 px high):
| thumb | 400×300 · cover · WebP | Use template |
| hero | 1200×630 · cover · WebP | Use template |
And a primary button "Add variant" with a plus icon follows them
And no table, footer or Request reference card is drawn
Scenario Outline: A template opens the form pre-filled
Given the empty state is shown
When the user activates the template <template>
Then the Add variant form opens in create mode with name "<template>", width <width>, height <height>, fit cover, format WebP and quality <quality>
And generate on upload is off and enabled is on
And Save is enabled
Examples:
| template | width | height | quality |
| thumb | 400 | 300 | 80 |
| hero | 1200 | 630 | 82 |
Rule: Error loading
Scenario Outline: A failed list request
Given GET /v1/image-variants fails with <failure>
Then the header and tabs are shown
And an error alert shows the title "Couldn't load variants", the text "Check your connection and try again." and a button "Try again" with a rotate-cw icon
And the alert has no close button and no table, cards, footer or reference card are drawn
Examples:
| failure |
| status 500 |
| a network failure |
Scenario: Try again reloads
Given the error alert is shown
When the user activates "Try again"
Then GET /v1/image-variants is sent again and the Default fixture replaces the alert
Rule: Status switch
Scenario: Each row has a labelled switch paired with an icon and a word
When the Default fixture is loaded
Then every row shows a switch (32 × 18 px) and the icon circle-check with "Enabled" or the icon circle-off with "Disabled"
And the switch of "hero" has the accessible name "Enable variant hero"
Scenario Outline: Toggling sends PATCH and updates the row
Given the variant "<name>" is <before>
When the user toggles its switch
Then PATCH /v1/image-variants/<name> is sent with {"enabled": <after>}
And while it is in flight the switch is disabled
And when it answers 200 the status reads "<label>" and the other fields of the row are unchanged
And no purge happens and generated_files and cache_size are those of the response
Examples:
| name | before | after | label |
| hero | enabled | false | Disabled |
| print | disabled | true | Enabled |
Scenario Outline: A failed toggle reverts and reports
Given the variant "hero" is enabled
When the user toggles its switch and the API answers <status>
Then the switch and the word return to "Enabled"
And a toast shows the title "<title>" and the text "<text>"
Examples:
| status | title | text |
| 403 | You don't have access | Only admins can manage image variants. |
| 404 | Not found | This variant or image no longer exists. Refresh the page. |
| 500 | Something went wrong | The server couldn't finish. Try again. If it keeps happening, check the server log. |
Rule: Row menu
Scenario: The ellipsis opens a three-item menu
When the user activates the ellipsis button of the row "hero" (accessible name and tooltip "Actions for hero")
Then a 176 px menu opens at the right of the row with the items "Edit" (pencil), "Purge cache" (eraser) and "Delete" (trash-2, error colour)
And on desktop items are 32 px high and on mobile 44 px high
And the ellipsis button shows a primary focus ring while the menu is open
Scenario: Menu keyboard behaviour
Given the menu is open
Then the arrow keys move between items, Enter activates the focused item and Escape closes the menu with focus back on the ellipsis button
Scenario Outline: Menu items
Given the menu of "hero" is open
When the user chooses "<item>"
Then <result>
Examples:
| item | result |
| Edit | the add/edit slide-over opens in edit mode for "hero" |
| Purge cache | the purge dialog opens |
| Delete | the delete dialog opens |
Rule: Purge cache
Scenario: The purge dialog
When the purge dialog of "hero" opens (hero has 244 files and 84 MB)
Then the title is "Purge cache for “hero”?"
And the text is "This removes 244 generated files (84 MB). Files are generated again on the next request."
And the buttons are "Cancel" and a primary "Purge 244 files"
And focus is trapped in the dialog and returns to the ellipsis button on close
Scenario: Cancel closes without a request
When the user activates "Cancel" or presses Escape
Then the dialog closes, no request is sent and the row is unchanged
Scenario: Confirming purges while the row is dimmed
When the user activates "Purge 244 files"
Then POST /v1/image-variants/hero/purge is sent
And the dialog closes
And the row "hero" is on --ui-bg-elevated, its switch and ellipsis button are disabled and its status cell reads "Purging…" with a loader-circle icon in the info colour
And the other rows stay interactive
Scenario: Purge succeeds
Given the API answers 200 {"purged_files": 244, "purged_bytes": 88080384}
Then the row "hero" shows Generated 0 and Cache size 0 KB, its status returns to Enabled and its controls are active
And the footer is recomputed with no refetch
And a toast reads "Purged 244 files from “hero”" (the count comes from the response)
Scenario Outline: Purge fails
When the purge answers <status>
Then the row returns to its previous values and controls
And a toast shows "<title>" and "<text>"
Examples:
| status | title | text |
| 404 | Not found | This variant or image no longer exists. Refresh the page. |
| 500 | Something went wrong | The server couldn't finish. Try again. If it keeps happening, check the server log. |
Rule: Delete variant
Scenario: The delete dialog
When the delete dialog of "hero" opens
Then the title is "Delete variant “hero”?"
And the text is "This removes the variant and its 244 generated files (84 MB). Articles that use ?v=hero will start returning 400. This can't be undone."
And the buttons are "Cancel" and a danger "Delete variant"
Scenario: Confirming shows a spinner and blocks dismissal
When the user activates "Delete variant"
Then DELETE /v1/image-variants/hero is sent
And the confirm button shows a loader-circle icon and "Cancel" is disabled
And Escape and a backdrop click do not close the dialog
Scenario: Delete succeeds
Given the API answers 204
Then the dialog closes, the row "hero" is removed, the footer reads "5 variants · 1,040 generated files · 128 MB"
And a toast reads "Variant “hero” deleted"
Scenario: Deleting the last variant shows the empty state
Given only "thumb" exists and DELETE answers 204
Then the "No variants yet" empty state replaces the table
Scenario Outline: Delete fails
When the delete answers <status>
Then the dialog closes, the row stays [404: the list is reloaded], and a toast shows "<title>" and "<text>"
Examples:
| status | title | text |
| 404 | Not found | This variant or image no longer exists. Refresh the page. |
| 500 | Something went wrong | The server couldn't finish. Try again. If it keeps happening, check the server log. |
Rule: Request reference card
Scenario: The card under the table
Given the list is loaded
Then a card titled "How requests are handled" shows two columns on desktop (1.3fr / 1fr, 16 px gap) and one on mobile
And the left column is a bordered table (rows at least 36 px, 44 px on mobile):
| /static/uploadedImages/photo.png?v=thumb | 200 | Image |
| Unknown or disabled variant | 400 | Unknown variant |
| width, height, format or quality | 400 | Unsupported parameter |
| Missing source image | 404 | Not found |
And the first cell is mono 12 px
And the 200 badge is success-soft with circle-check and the 400 and 404 badges are error-soft with circle-x
And the right column has the muted label "Body of a 400 response" over a mono 12 px block reading exactly:
"""
{
"status": 400,
"error": "unknown_variant",
"message": "Unknown variant",
"detail": "banner"
}
"""
And the note "unsupported_parameter replaces unknown_variant when a raw parameter is sent. There is no arbitrary-size mode." closes the card
Scenario: The card is not drawn when there is no list
Given the state is Loading, Empty, Error or Forbidden
Then the Request reference card is absent
Rule: Slide-over opening and closing
Scenario: Add variant opens the slide-over on desktop
Given the viewport is 640 px wide or more
When the user activates "Add variant"
Then a slide-over 960 px wide opens at the right over an overlay, the list stays behind it
And its header is 56 px high with the title "Add variant" and a close button (x icon, accessible name "Close")
And its body has the form on the left (440 px column) and the preview on the right on --ui-bg-muted, separated by a border
And its footer is 64 px high with "Cancel" and a primary "Add variant", and the status text is empty
And focus moves to the Name field
Scenario: Add variant opens a full page on mobile
Given the viewport is below 640 px
When the user activates "Add variant"
Then a full-screen page opens with a 56 px header holding an arrow-left back button and the title "Add variant", no x button
And the form is followed by the Preview section, stacked, separated by a top border
And the footer is 72 px high with "Cancel" and "Add variant" sharing the width
Scenario: Edit opens with the stored values
When the user chooses "Edit" on the row "hero"
Then the title is "Edit variant: hero"
And Name is "hero" in a locked, elevated field with a lock icon and the help "The name can't be changed."
And Width 1200, Height 630, Fit Cover, Format WebP, Quality 82, Generate on upload off and Enabled on are shown
And the always-visible warning alert reads "Changing dimensions, fit, format or quality purges 244 generated files; they will be regenerated on next request."
And "Save changes" is disabled and the footer status is empty
Scenario: Dismissing a pristine form closes immediately
Given a form with no change
When the user activates "Cancel", the close button or presses Escape
Then the slide-over closes with no dialog and focus returns to the opener
Scenario: Dismissing a dirty form asks first
Given the form is dirty
When the user activates "Cancel", the close button, the back arrow or presses Escape
Then a dialog opens with the title "Discard changes?" and the text "You have unsaved changes to “hero”. If you close now, they're lost." in edit mode
And its buttons are "Keep editing" and a danger "Discard changes"
And "Keep editing" returns to the form with its values
And "Discard changes" closes the slide-over and discards the values
Scenario: The discard text names the new variant in create mode
Given the form is dirty in create mode with name "banner"
When the user closes it
Then the text reads "You have unsaved changes to “banner”. If you close now, they're lost."
And with an empty name the text reads "You have unsaved changes. If you close now, they're lost."
Rule: Form states
Scenario: Create: pristine
When Add variant has just opened
Then Name is empty and focused (primary border and 1 px primary ring) with the placeholder "e.g. hero" and the help "Lowercase letters, numbers and dashes. Can't be changed later."
And Width and Height are empty with the placeholder "auto", the suffix "px" and the help "Set a width, a height, or both. 1–8000 px. Images are never enlarged."
And Fit is a horizontal 3-option radio group, Cover selected, with the help "Fills the box and crops what overflows."
And Format is a select showing "Original" with the help "Keeps the format of each source image."
And Quality shows a slider and a number field at 80 with the help "1–100. Higher is sharper and larger."
And the switch "Generate on upload" (help "Creates this variant when an image is uploaded.") is off
And the switch "Enabled" (help "Disabled variants return 400 on the public site.") is on
And "Add variant" in the footer is disabled
And the Preview shows "Set a width or height to see a preview." over the sample
Scenario Outline: The Fit help follows the selection
When Fit <fit> is selected
Then the help reads "<help>"
Examples:
| fit | help |
| Cover | Fills the box and crops what overflows. |
| Contain | Whole image inside the box, padded to the box size. |
| Inside | Whole image inside the box, no padding. |
Scenario: The Name counter
When the user types "hero"
Then a mono counter "4/32" shows at the right of the label; in edit mode there is no counter
Scenario: Create: dirty with valid values
Given the user typed name "hero", Width 1200, Height 630, Format WebP, Quality 82, Generate on upload on
Then "Add variant" is enabled and the footer status reads "Unsaved changes" with a circle-dot icon
And the Preview shows the comparison, its chips and the estimate (see Preview rules)
Scenario Outline: Field errors show on blur and on submit
When the user enters <value> in <field> and leaves it
Then the field has an error border and the inline message "<message>" with a circle-alert icon
And the help text is replaced by the message
And once an error shows it is revalidated on every input and clears when the value is valid
Examples:
| field | value | message |
| Name | Hero banner | Use lowercase letters, numbers and dashes, 2 to 32 characters. |
| Name | a | Use lowercase letters, numbers and dashes, 2 to 32 characters. |
| Name | 33 letters | Use lowercase letters, numbers and dashes, 2 to 32 characters. |
| Width | 9000 | Enter a value from 1 to 8000. |
| Width | 0 | Enter a value from 1 to 8000. |
| Height | 8001 | Enter a value from 1 to 8000. |
| Quality | 140 | Enter a value from 1 to 100. |
| Quality | 0 | Enter a value from 1 to 100. |
Scenario Outline: Valid boundary values show no error
Then <field> <value> shows no error
Examples:
| field | value |
| Name | ab |
| Name | a-b-c |
| Width | 1 |
| Height | 8000 |
| Quality | 1 |
| Quality | 100 |
Scenario: Submitting with several errors sends nothing
Given name "Hero banner", Width 9000, Height 630, Format WebP, Quality 140
When the user activates "Add variant"
Then the three errors of the Field errors scenario show (Name format, "Enter a value from 1 to 8000." under Width, "Enter a value from 1 to 100." under Quality)
And no request is sent and focus moves to the first invalid field
Scenario: No dimension
Given name "banner", Width and Height both empty, Format WebP
When the user activates "Add variant"
Then the message "Set a width, a height, or both." shows under Width, and the Height field has an error border without text
And the default help under the two fields is hidden
And no request is sent
Scenario: PNG hides Quality
When Format PNG is selected
Then the Quality section (slider, number field and help) is not rendered
And the estimate and output label follow the PNG rules
And the POST or PUT body has no "quality" key
Scenario: Switching back from PNG restores the previous quality
Given Quality was 70 before PNG was selected
When Format WebP is selected again
Then Quality shows 70
Scenario: The Quality slider and field stay in sync
When the slider moves to 60
Then the number field reads 60
When the number field is set to 90
Then the slider fill is at 90 %
Rule: Saving a new variant
Scenario: Add sends POST with the form values
Given name "hero", Width 1200, Height 630, Fit Cover, Format WebP, Quality 82, Generate on upload on, Enabled on
When the user activates "Add variant"
Then POST /v1/image-variants is sent with {"name": "hero", "width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82, "generate_on_upload": true, "enabled": true}
Scenario: An empty Width or Height is sent as null or omitted
Given name "card", Width 800, Height empty
Then the body has "width": 800 and no positive "height"
And an empty dimension is omitted or null in the body, both being valid for the API
Scenario: While saving, the form is locked
Given the POST is in flight
Then every field, switch and select is disabled at 60 % opacity
And "Cancel" is disabled and the primary button reads "Saving…" with a loader-circle icon
And Escape, the close button and a backdrop click do not close the slide-over
Scenario: A successful create
Given the API answers 201 with the variant
Then the slide-over closes, the new row is inserted in name order without a refetch with Generated 0 and Cache size 0 KB, the footer is recomputed
And a toast reads "Variant “hero” added"
And focus returns to "Add variant"
Scenario: A create from the empty state leaves the empty state
Given the list was empty and a create answers 201
Then the table shows one row and the footer reads "1 variant · 0 generated files · 0 KB"
Scenario: Name already taken (409)
Given name "thumb" and the API answers 409 with "A variant named “thumb” already exists. Choose another name."
Then the slide-over stays open and the Name field has an error border with the message "A variant named “thumb” already exists. Choose another name."
And all values are kept, focus is on Name, the form is re-enabled and no alert or toast is shown
And editing the name clears the message
Scenario Outline: Save error
Given the API fails with <failure>
Then the slide-over stays open with all values kept and the form re-enabled
And an error alert at the top of the form shows the title "Couldn't save the variant", the text "Check your connection and try again." and a button "Try again"
And "Try again" repeats the same request; the alert disappears when the request starts
Examples:
| failure |
| status 500 |
| a network failure |
Scenario Outline: Other statuses on save
Given the API answers <status> to the save
Then the slide-over stays open with values kept and a toast shows "<title>" and "<text>"
Examples:
| status | title | text |
| 400 | Check the request | A value in the request isn't valid. Reload the page and try again. |
| 403 | You don't have access | Only admins can manage image variants. |
| 404 | Not found | This variant or image no longer exists. Refresh the page. |
Scenario Outline: A 422 from the server is shown inline
Given the API answers 422 with the plain text "<server text>"
Then the message "<inline>" shows under <field>
Examples:
| server text | inline | field |
| Use lowercase letters, numbers and dashes, 2 to 32 characters. | Use lowercase letters, numbers and dashes, 2 to 32 characters. | Name |
| Width and height must be between 1 and 8000, and one of them is required. | Set a width, a height, or both. | Width |
| Quality must be between 1 and 100. | Enter a value from 1 to 100. | Quality |
| Fit must be cover, contain or inside. | the "Couldn't save the variant" alert | the form top |
Rule: Saving an edit
Scenario: Edit: dirty
Given the user changes Quality from 82 to 70 on "hero"
Then "Save changes" is enabled, the footer status reads "Unsaved changes", the Quality number reads 70
And the output label reads "1200×630 · about 71 KB"
And the warning alert stays visible
Scenario: Save sends PUT with the complete definition
When the user activates "Save changes"
Then PUT /v1/image-variants/hero is sent with {"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 70, "generate_on_upload": false, "enabled": true} and no "name"
Scenario: A successful edit reports the purge
Given the API answers 200 with quality 70, generated_files 0, cache_size 0 and "purged_files": 244
Then the slide-over closes, the row "hero" is updated in place without a refetch (quality 70, Generated 0, Cache size 0 KB)
And a toast reads "Variant “hero” saved. 244 files purged."
Scenario: A switch-only edit says nothing about purging
Given the user turned "Generate on upload" on only and the API answers 200 with "purged_files": 0
Then the toast reads "Variant “hero” saved."
Scenario: Saving while saving and the save error behave as in create
Then the Saving, 409-free error, other-status and Try again behaviours of the create rule apply with PUT
Rule: Preview and ImageCompare
Scenario: Preview layout
Then the Preview heading "Preview" is followed, right-aligned and muted, by "Sample: north-wall.jpg"
And the compare box is a 3:2 box with a 1 px border and hidden overflow
And two labels sit under it: "Original" over "4032 × 3024 · 3.9 MB", and the variant name in mono over "1200×630 · about 96 KB"
And the hint "Drag the divider, or focus it and use the arrow keys. The estimate comes from a sample; output is never larger than the source." follows
And the images are placeholders, not real photos
Scenario: Compare box content
Given name "hero", Width 1200, Height 630
Then the original placeholder fills the box and the variant placeholder covers the part right of the divider (clip-path inset(0 0 0 50%))
And a chip "Original" sits 8 px from the top-left and a mono chip "hero" 8 px from the top-right
And the divider is a 2 px line at 50 % with a 32 px square handle (44 px on mobile) holding a chevrons-left-right icon centred on it
Scenario: ImageCompare is a native range input
Then the slider is an input type="range" with min 0, max 100, step 1, value 50
And it has the accessible name "Compare original and hero" and aria-valuetext "50% hero"
And aria-valuenow is 50
Scenario Outline: Keyboard behaviour
Given the slider has focus at value <start>
When the user presses <keys>
Then the value is <end> and aria-valuetext is "<end>% hero"
And the divider and the clip move to <end> %
Examples:
| start | keys | end |
| 50 | ArrowRight | 51 |
| 50 | ArrowUp | 51 |
| 50 | ArrowLeft | 49 |
| 50 | ArrowDown | 49 |
| 50 | Shift+ArrowRight | 60 |
| 50 | Shift+ArrowLeft | 40 |
| 50 | Home | 0 |
| 50 | End | 100 |
| 100 | ArrowRight | 100 |
| 0 | ArrowLeft | 0 |
| 95 | Shift+ArrowRight | 100 |
| 5 | Shift+ArrowLeft | 0 |
Scenario: Focus ring
When the slider has keyboard focus
Then the handle shows a 2 px primary outline with a 2 px offset
And the slider keeps its value when the form fields change
Scenario: Pointer dragging
When the user drags the handle to the middle of the right half
Then the value becomes 75 and the divider follows
Scenario: No dimension, no comparison
Given Width and Height are both empty
Then the compare box shows only the original placeholder, with no variant layer, no divider, no handle and no variant chip
And the overlay reads "Set a width or height to see a preview." 12 px from the left, right and bottom
And the variant label reads "name" over "No output yet" [the board's placeholder name]
Scenario Outline: Output size, never enlarged
Given the sample is 4032 × 3024
When the form has Width <w>, Height <h> and Fit <fit>
Then the output label dimensions read "<out>"
Examples:
| w | h | fit | out |
| 1200 | 630 | cover | 1200×630 |
| 800 | | cover | 800×600 |
| | 2400 | inside | 3200×2400 |
| 600 | 600 | contain | 600×600 |
| 1000 | 1000 | inside | 1000×750 |
| 8000 | 8000 | cover | 4032×3024 |
Scenario Outline: Size estimate
Given the sample north-wall.jpg
When the form has name "<name>", <w>×<h> <fit>, Format <format>, Quality <quality>
Then the label reads "<out> · about <estimate>"
Examples:
| name | w | h | fit | format | quality | out | estimate |
| hero | 1200 | 630 | cover | WebP | 82 | 1200×630 | 96 KB |
| hero | 1200 | 630 | cover | WebP | 70 | 1200×630 | 71 KB |
| hero | 1200 | 630 | cover | PNG | n/a | 1200×630 | 310 KB |
| thumb | 400 | 300 | cover | WebP | 80 | 400×300 | 18 KB |
Scenario: The estimate follows quality and format
Then a lower quality gives a smaller or equal estimate for the same size and format
And the estimate never exceeds the original size 3.9 MB
Scenario: The sample name and slider labels follow the name
Given the name is empty and a width is set
Then the chip and label use the placeholder "name" and the slider's accessible name reads "Compare original and name"
Rule: URL and copy
Scenario: The URL row
Given the name is "hero"
Then the label "URL" is followed by a read-only mono 12 px box reading "/static/uploadedImages/north-wall.jpg?v=hero" on --ui-bg-elevated, and a button "Copy" with a copy icon
Scenario: Without a name
Given the name is empty
Then the box reads "/static/uploadedImages/north-wall.jpg?v=<name>" in muted text and "Copy" is disabled
Scenario: Copy writes the URL
Given the name is "hero"
When the user activates "Copy"
Then the clipboard receives the displayed URL
And the button reads "Copied" with a check icon
And it returns to "Copy" with the copy icon after a short delay (tested with fake timers)
Scenario: A clipboard failure does not claim success
Given navigator.clipboard.writeText rejects
Then the button keeps "Copy" and the page raises no error alert
Scenario: In edit mode the URL uses the locked name
Given the edit form of "hero"
Then the URL box reads "/static/uploadedImages/north-wall.jpg?v=hero" and "Copy" is enabled
Rule: Accessibility and hygiene
Scenario: Status is never colour alone
Then every status shows an icon and a word, and dialogs trap focus and return it to the opener
Scenario: Touch targets on mobile
Given the viewport is below 640 px
Then buttons, menu items, the switch rows, form controls and the slider handle are at least 44 px high
Scenario: Vocabulary
Then the screens say "variant", "generated" and "purge", never "thumbnail preset", "version" or "rendition"
Scenario: Hygiene
Then no comment is added to any file, and the new components use only Nuxt UI parts and the existing tokens (the only new token allowed is --rc-on-sand, already exported)
Rule: Estimate module, French and row order
Scenario: The estimator is one replaceable module
Then the size estimate coefficients live in a single named module under the variants area
And every place that shows an estimate labels it "about N KB"
And the Preview components import the estimate only through that module
Scenario: French strings
Given the locale is French
Then every new string of these screens has a French entry in web/src/i18n/messages.ts and no English string appears in the UI
Scenario: Row order
Then tests use the API order (by name) and only the visual-check mock uses the board order
+156
View File
@@ -0,0 +1,156 @@
# QA: Image variants settings screens
## Implementation note for coder (informational, not a QA assertion)
The API is built and covered by features/image-variants.qa.md: this slice has no Go change and no HTTP-level
section. The auth requirement "admin" does not exist in router meta (AuthRequirement is
public | session | verified | any); the role check lives in VariantsPage via the session role from
GET /v1/auth/me. Plain-text API errors are read through ApiError.body; never display the raw body.
Sizes: the new formatter is not formatSize. Every new string has an English and a
French entry in web/src/i18n/messages.ts.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock useApi's `request` per case (GET /v1/image-variants, POST /v1/image-variants, PUT, PATCH,
DELETE, POST .../purge); no backend. Mount VariantsPage inside DashboardShell with a resolved verified
session (id 1, username "marta", role "owner") and a test router at /settings/media/variants.
Default fixture = the Background's 6 variants in API order. Mock `matchMedia` for the 640 px
breakpoint; run layout cases at desktop and below-640. Use fake timers for the 150 ms skeleton delay.
Mock navigator.clipboard. No test freezes the system date (this screen shows no date).
1. **Access, Examples-driven** — session without role; role "owner"; role "admin"; list answering
ApiError(403). Assert the Forbidden copy and "Back to Home" (navigates to /home) and that no list
request is sent when the role is missing; assert the page header for owner and admin.
2. **Header and tabs** — title, description, "Add variant", tabs "Variants" (active) and "Cache"
(aria-disabled, click does nothing), breadcrumb "Settings", "Media", "Variants" and
mobile title "Variants".
3. **Loaded, desktop** — column labels, the 6 rows in order with every cell of the table, "—" for
print, "Disabled" for print, muted text on the disabled row, footer "6 variants · 1,284 generated
files · 212 MB", the reference card.
4. **Cell formats, Examples-driven** — the four dimension/quality/format cases.
5. **Loaded, mobile** — 6 cards, the spec lines (`1200×630 · cover · WebP · q82`, print without a
quality part), "244 generated · 84 MB", the footer after the cards, the reference card.
6. **Footer** — Default sums; a one-variant list "1 variant · 1 generated file · 18 KB"; an
after-delete recompute "5 variants · 1,040 generated files · 128 MB".
7. **Loading** — unresolved GET: nothing at 149 ms; at 150 ms the region with aria-busy and the label
"Loading variants", 5 skeleton rows on desktop / 4 cards below 640, no text; a response at 100 ms never
draws skeletons.
8. **Empty** — copy, the two template cards, "Add variant", no reference card; each template opens
the form with its values (thumb 400×300 cover WebP 80; hero 1200×630 cover WebP 82).
9. **Error, Examples-driven** — ApiError(500) and NetworkError: alert copy, "Try again", no close
button, no table; Try again re-sends GET and renders the Default fixture.
10. **Status switch** — accessible name "Enable variant hero"; toggling hero sends `PATCH
/v1/image-variants/hero {"enabled": false}`, switch disabled while in flight, row shows Disabled,
no toast; print on sends `{"enabled": true}`; failures 403/404/500 revert the switch and raise
the toast copy of the table.
11. **Row menu** — items Edit/Purge cache/Delete in order, ArrowDown/Up and Enter, Escape closes and
returns focus to the ellipsis button; Edit opens the edit form, the others open their dialog.
12. **Purge** — dialog copy for hero ("Purge 244 files", "84 MB"); Cancel/Escape send nothing; confirm
sends `POST /v1/image-variants/hero/purge`, closes the dialog, row dimmed with "Purging…" and
disabled controls while others stay active; resolve `{purged_files: 244, purged_bytes: 88080384}`
-> row 0 files, status Enabled, toast "Purged 244 files from “hero”"; failures 404/500 -> row restored + toast.
13. **Delete** — dialog copy; confirm sends `DELETE /v1/image-variants/hero`; spinner, Cancel
disabled, Escape and backdrop blocked; 204 -> row removed, toast `Variant “hero” deleted`, footer
recompute; deleting the last row shows Empty; failures 404 (reloads the list) / 500 -> toast.
14. **Reference card** — the 4 rows with badge words and icons, the exact JSON block text, the note;
absent in Loading/Empty/Error/Forbidden; one column below 640.
15. **Slide-over open/close** — desktop slide-over vs mobile full page (back arrow, no x), titles
"Add variant" / "Edit variant: hero", focus on Name, pristine close without dialog, dirty close
via Cancel/x/back/Escape opens "Discard changes?" with the right text, Keep editing / Discard
changes behaviours, focus return to the opener.
16. **Create: pristine** — all labels, placeholders, helps, defaults (Fit Cover, Format Original,
Quality 80, Generate off, Enabled on), "Add variant" disabled, overlay "Set a width or height to
see a preview.".
17. **Fit help, Examples-driven** — three helps.
18. **Field errors, Examples-driven** — each name/width/height/quality row of the feature; blur and
submit; revalidation on input; boundary values accepted; several errors at once: no request.
19. **No dimension** — copy under Width, error border on Height, default help hidden, no request.
20. **PNG** — Quality hidden, body without "quality", quality restored after switching back.
21. **Quality slider/number sync**.
22. **Create request** — exact POST body for the hero values; the `201` flow inserts the row in
name order without a second GET, footer recomputed, toast `Variant “hero” added`.
23. **Saving** — fields disabled, "Saving…", Escape/x/backdrop blocked.
24. **409** — inline message with the name, values kept, no alert and no toast, cleared on edit.
25. **Save error, Examples-driven** — ApiError(500) and NetworkError: alert copy; Try again repeats the
request; values kept.
26. **Other statuses, Examples-driven** — 400, 403, 404 toasts.
27. **422, Examples-driven** — the four server sentences and their inline/alert mapping.
28. **Edit: pristine** — locked Name with help, stored values, warning alert text with 244, Save
disabled.
29. **Edit: dirty and save** — quality 82 -> 70: estimate "about 71 KB", Save enabled, PUT body
without "name", 200 with purged_files 244 -> row updated in place, toast "Variant “hero” saved. 244 files
purged."; purged_files 0 -> "Variant “hero” saved.".
30. **Preview layout** — heading, sample text, labels, hint, chips "Original" and the name, handle size
(44 px class on mobile).
31. **ImageCompare keyboard, Examples-driven** — role/type range, min 0 max 100 step 1, value 50,
aria-label "Compare original and hero", aria-valuetext "50% hero"; every key row of the feature,
including clamping; valuetext and clip-path/divider position follow the value; pointer input sets it.
32. **ImageCompare empty** — no dimensions: no variant layer, divider or handle; overlay text.
33. **Output size and estimate, Examples-driven** — the six size rows and the four estimate rows;
monotonic quality; never above 3.9 MB.
34. **URL** — URL text with and without a name, Copy disabled without a name, click writes the
displayed URL, label "Copied" with fake timers and back to "Copy", a rejected clipboard keeps "Copy".
35. **French** — `setLocale('fr')`; every key used on the page exists in messages.ts (a catalogue
test) and the page renders without missing keys.
36. **Vocabulary** — no rendered text contains "thumbnail preset", "version" or "rendition".
## Visual (task visual:check -- image-variants, desktop and mobile, light and dark)
Reference: docs/design/screens/image-variants.md, board "Image variants" in
`docs/design/source/RenewCMS Screens Image variants.dc.html`, exact values in
`docs/design/source/VariantsFrame.dc.html` and `AppFrame.dc.html`. Add each state to
`web/visual/states.ts` with the board's data. The gate is hard: every non-skipped capture within 10 %,
both viewports, both themes. All states below use the board's sample data; none is clock-sensitive,
so none is skipped.
Sample data (from the board): the 6 variants of the Background (thumb 412 files 21 MB, card 318/58 MB,
hero 244/84 MB, square 140/19 MB, og 96/9 MB, print 74/21 MB), board row order as listed in
VariantsFrame (thumb, card, hero, square, og, print); (the board orders by creation, the API by name;
the mock must return the board order for the visual capture only). Session name "Marta Alves".
Preview sample: north-wall.jpg, 4032 × 3024, 3.9 MB.
- [ ] 1a Loaded (desktop table, mobile cards, reference card on desktop)
- [ ] 1b Loading
- [ ] 1c Empty with the two templates
- [ ] 1d Error loading
- [ ] 1e Forbidden (session without role, no nav item current)
- [ ] 1f Row menu (hero)
- [ ] 1g Row action in progress (hero "Purging…")
- [ ] 1h Purge confirm
- [ ] 1i Delete confirm
- [ ] 1j Delete in progress
- [ ] 1k Request reference (mobile scrolled 900 px; desktop shows it under the table)
- [ ] 2a Create: pristine
- [ ] 2b Create: dirty (hero, slider focused, mobile scrolled to the preview, estimate 96 KB)
- [ ] 2c Field errors (name "Hero banner", width 9000, height 630, quality 140)
- [ ] 2d No dimension (name "banner")
- [ ] 2e Name already taken (name "thumb", 400×300, estimate 18 KB)
- [ ] 2f Saving
- [ ] 2g Save error
- [ ] 2h Edit: pristine
- [ ] 2i Edit: dirty (quality 70, estimate 71 KB)
- [ ] 2j Unsaved changes (discard dialog over 2i)
- [ ] 2k PNG: no quality (estimate 310 KB)
- [ ] Layout, taken from the board frame (VariantsFrame.dc.html), not components.md: table grid
`minmax(0,1.1fr) 104px 76px 72px 68px 92px 92px 132px 44px`, 36 px header, 48 px rows; cards 12 px
padding with the name+switch line, the spec line, then count+menu; slide-over 960 px with a
440 px form column; compare box 3:2, chips 8 px from the top corners, handle 32 px (44 px on
mobile) centred on a 2 px divider; footer 64 px (72 px on mobile).
- [ ] Not drawn, no visual state: toggle failures, the purge/delete failure toasts, the 409-free
status toasts, the 422 mapping, the clipboard states "Copied" (add only if the board draws it: it
does not, so Vitest only), singular footer.
- [ ] Not covered by screenshots: keyboard and focus order (Tab reaches Add variant, tabs, each row's
switch and ellipsis; the slide-over focus starts on Name and is trapped; Escape rules), slider
keys by hand, hover/active, motion (slide-over, dialogs, skeleton pulse stopped under reduced
motion).
## Manual verification
- **Round trips against `task dev`**: create, edit (PUT with a purge), toggle, purge and delete a real
variant; confirm rows, counts and sizes after a reload match the API (needs generated files from a
real `/static/uploadedImages/<storedName>?v=<name>` request).
- **Role**: sign in as an owner and as an admin; confirm both reach the page. A session without a role
is not reproducible live (only owner/admin exist); covered by Vitest and the visual state.
- **Real clipboard** in a secure context (localhost): Copy then paste.
- **French**: switch the language and read every state, the dialogs and the toasts.
- **Real public URL**: paste the copied URL on a stored image and confirm it serves the variant.
+2 -2
View File
@@ -18,10 +18,10 @@ Feature: Media cleanup in the SPA: Settings card, purge dialog and Media Duplica
summary and try again." (stale fingerprint) or "A purge is already running." (busy)
And "owner or admin" is session.role in ('owner', 'admin'); only those two roles exist, so any
other role is a mocked value in tests
And /settings is the verified route of settings_page.feature; the Media cleanup card is its second
And /settings is the verified route of settings_page.feature; the Media cleanup card is its third
card, below Version and Updates, 20 px under the previous card, in a page at most 720 px wide
And the card is --ui-bg with a 1 px --ui-border, 20 px padding (16 px on mobile), 16 px gap, as
drawn in docs/design/source/SettingsFrame.dc.html
drawn in docs/design/source/AppFrame.dc.html
And the components are Nuxt UI v4 only (UCard, USwitch, UInputNumber, UFormField, UAlert, UButton,
UModal, USkeleton, useToast) and design tokens only
And PurgeDuplicatesDialog is one component shared by the card and the Media Duplicates tab: props
+1 -1
View File
@@ -61,7 +61,7 @@ Amendments to existing specs, in the same change: `SettingsView.spec.ts` and the
## Visual (`task visual:check -- settings` and `task visual:check -- media`, desktop and mobile, light and dark)
References: `docs/design/screens/settings.md` ("Media cleanup (second card)"), `docs/design/screens/media.md`, `docs/design/source/RenewCMS Screens Media cleanup.dc.html` and the frames `SettingsFrame.dc.html`, `MediaFrame.dc.html` (exact values). Add every state below to `web/visual/states.ts`, with the mocked `/v1` calls the state needs. The gate is hard: every capture within 10 %. Nothing is skipped: no state depends on the clock, because dates come from fixture timestamps (midday UTC, so the day is stable).
References: `docs/design/screens/settings.md` ("Media cleanup (third card)"), `docs/design/screens/media.md`, `docs/design/source/RenewCMS Screens Back-office.dc.html` and the frames `AppFrame.dc.html`, `MediaFrame.dc.html` (exact values). Add every state below to `web/visual/states.ts`, with the mocked `/v1` calls the state needs. The gate is hard: every capture within 10 %. Nothing is skipped: no state depends on the clock, because dates come from fixture timestamps (midday UTC, so the day is stable).
Board sample data to reuse:
- Last purge fixture: `last_purge_at` 14 Jun 2026, merged 3, articles 5; "on" has 30 days and next 14 Jul 2026 (`next_purge_at` "2026-07-14T09:00:00Z").
+3 -1
View File
@@ -8,6 +8,7 @@ require (
github.com/aws/aws-sdk-go-v2/config v1.33.6
github.com/aws/aws-sdk-go-v2/credentials v1.20.6
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0
github.com/gen2brain/webp v0.6.4
github.com/glebarez/sqlite v1.11.0
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
@@ -21,6 +22,7 @@ require (
golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0
golang.org/x/net v0.59.0
golang.org/x/sync v0.23.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.3
@@ -46,6 +48,7 @@ require (
github.com/aws/smithy-go v1.28.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
@@ -69,7 +72,6 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/segmentio/asm v1.2.1 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
+4
View File
@@ -47,10 +47,14 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvw
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU=
github.com/gen2brain/webp v0.6.4/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
+32
View File
@@ -2,6 +2,7 @@ package audit
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
"strconv"
"unicode/utf8"
)
@@ -28,3 +29,34 @@ func ArticleChanges(before article.Article, after article.Article) []Change {
}
return changes
}
func VariantChanges(before image.VariantSettings, after image.VariantSettings) []Change {
changes := []Change{}
changes = appendChange(changes, FieldWidth, image.DimensionText(before.Width), image.DimensionText(after.Width))
changes = appendChange(changes, FieldHeight, image.DimensionText(before.Height), image.DimensionText(after.Height))
changes = appendChange(changes, FieldFit, string(before.Fit), string(after.Fit))
changes = appendChange(changes, FieldFormat, string(before.Format), string(after.Format))
changes = appendChange(changes, FieldQuality, image.QualityText(before.Quality), image.QualityText(after.Quality))
return changes
}
func appendChange(changes []Change, field string, before string, after string) []Change {
if before == after {
return changes
}
return append(changes, TextChange(field, before, after))
}
func VariantParams(settings image.VariantSettings) map[string]any {
params := map[string]any{ParamFit: string(settings.Fit), ParamFormat: string(settings.Format)}
if settings.Width != nil {
params[ParamWidth] = *settings.Width
}
if settings.Height != nil {
params[ParamHeight] = *settings.Height
}
if settings.Quality != nil {
params[ParamQuality] = *settings.Quality
}
return params
}
+30
View File
@@ -76,6 +76,16 @@ const (
ActionMediaDelete = "media.delete"
ActionMediaMerge = "media.merge"
ActionMediaPurge = "media.purge"
ActionVariantCreate = "media.variant_create"
ActionVariantUpdate = "media.variant_update"
ActionVariantDelete = "media.variant_delete"
ActionVariantEnable = "media.variant_enable"
ActionVariantDisable = "media.variant_disable"
ActionVariantPurge = "media.variant_purge"
ActionVariantGenerateAll = "media.variant_generate_all"
ActionVariantPurgeImage = "media.variant_purge_image"
ActionVariantPurgeAll = "media.variant_purge_all"
ActionVariantPurgeUnused = "media.variant_purge_unused"
ActionUserRegister = "user.register"
ActionUserVerify = "user.verify"
ActionUserUpdateProfile = "user.update_profile"
@@ -94,6 +104,7 @@ const (
TargetUser = "user"
TargetArticle = "article"
TargetImage = "image"
TargetVariant = "image_variant"
TargetInvite = "invite"
TargetRelease = "release"
)
@@ -105,17 +116,36 @@ const (
ParamRemoved = "removed"
ParamFrom = "from"
ParamTo = "to"
ParamMaxGBFrom = "max_gb_from"
ParamMaxGBTo = "max_gb_to"
ParamMerged = "merged"
ParamArticlesUpdated = "articles_updated"
ParamFailedGroups = "failed_groups"
ParamUpdateAvailable = "update_available"
ParamKind = "kind"
ParamPurgedFiles = "purged_files"
ParamPurgedBytes = "purged_bytes"
ParamFailedFiles = "failed_files"
ParamOlderThanDays = "older_than_days"
ParamGenerated = "generated"
ParamFailed = "failed"
ParamCauses = "causes"
ParamWidth = "width"
ParamHeight = "height"
ParamFit = "fit"
ParamFormat = "format"
ParamQuality = "quality"
ParamTag = "tag"
FieldTitle = "title"
FieldOnline = "online"
FieldBody = "body"
FieldFilename = "filename"
FieldDisplayName = "display_name"
FieldWidth = "width"
FieldHeight = "height"
FieldFit = "fit"
FieldFormat = "format"
FieldQuality = "quality"
maxNameLength = 255
maxActionLength = 64
maxFailureReasonSize = 255
@@ -7,10 +7,12 @@ type Repository interface {
List(filter Filter) (Page, error)
Summary(query SummaryQuery) (Summary, error)
DeleteOlderThan(cutoff time.Time) (int, error)
DeleteOldest(limit int, before time.Time) (int, error)
SizeBytes() (int64, error)
}
type SettingsRepository interface {
Get() (Settings, error)
SaveRetention(days int) error
SaveRetention(days int, maxGB int) error
SaveLastCleanup(at time.Time, removed int) error
}
+31 -1
View File
@@ -7,6 +7,9 @@ const (
ForeverRetentionDays = 0
CleanupHour = 3
KeepRecentHours = 24
NoSizeLimitGB = 0
BytesPerGB = int64(1) << 30
RowOverheadBytes = int64(64)
)
func RetentionOptions() []int {
@@ -22,8 +25,23 @@ func ValidateRetentionDays(days int) error {
return ErrInvalidRetention
}
func SizeOptions() []int {
return []int{NoSizeLimitGB, 1, 5, 10, 25, 50}
}
func ValidateRetentionMaxGB(maxGB int) error {
for _, option := range SizeOptions() {
if option == maxGB {
return nil
}
}
return ErrInvalidRetention
}
type Settings struct {
RetentionDays int `json:"activity_retention_days"`
RetentionMaxGB int `json:"activity_retention_max_gb"`
LogSizeBytes int64 `json:"activity_log_size_bytes"`
LastCleanupAt *time.Time `json:"activity_last_cleanup_at"`
LastCleanupRemoved int `json:"activity_last_cleanup_removed"`
NextCleanupAt *time.Time `json:"activity_next_cleanup_at"`
@@ -33,6 +51,14 @@ func DefaultSettings() Settings {
return Settings{RetentionDays: DefaultRetentionDays}
}
func (s Settings) HasRetentionLimit() bool {
return s.RetentionDays != ForeverRetentionDays || s.RetentionMaxGB != NoSizeLimitGB
}
func (s Settings) MaxLogSizeBytes() int64 {
return int64(s.RetentionMaxGB) * BytesPerGB
}
func (s Settings) CleanupDue(now time.Time) bool {
todayRun := time.Date(now.Year(), now.Month(), now.Day(), CleanupHour, 0, 0, 0, now.Location())
if now.Before(todayRun) {
@@ -43,7 +69,7 @@ func (s Settings) CleanupDue(now time.Time) bool {
func (s Settings) WithNextCleanup(now time.Time) Settings {
s.NextCleanupAt = nil
if s.RetentionDays == ForeverRetentionDays {
if !s.HasRetentionLimit() {
return s
}
next := time.Date(now.Year(), now.Month(), now.Day(), CleanupHour, 0, 0, 0, now.Location())
@@ -62,6 +88,10 @@ func ClampCutoff(now time.Time, cutoff time.Time) time.Time {
return cutoff
}
func GuardCutoff(now time.Time) time.Time {
return ClampCutoff(now, now)
}
func RetentionCutoff(now time.Time, retentionDays int) time.Time {
return ClampCutoff(now, now.Add(-time.Duration(retentionDays)*24*time.Hour))
}
+4
View File
@@ -9,3 +9,7 @@ func NewTarget(targetType string, id uint32, name string) Target {
func ReleaseTarget(tag string) Target {
return Target{Type: TargetRelease, ID: tag, Name: tag}
}
func VariantTarget(name string) Target {
return Target{Type: TargetVariant, ID: name, Name: name}
}
+114
View File
@@ -0,0 +1,114 @@
package image
import (
"bufio"
"errors"
"io"
)
var ErrNotAGif = errors.New("not a gif")
const (
gifSignaturePrefix = "GIF"
gifHeaderLength = 6
gifScreenDescriptorSize = 7
gifImageDescriptorSize = 9
gifExtensionIntroducer = 0x21
gifImageSeparator = 0x2C
gifTrailer = 0x3B
gifColorTableFlag = 0x80
gifColorTableSizeMask = 0x07
gifColorTableEntryBytes = 3
gifFramesBeforeAnimation = 2
)
func IsAnimatedGIF(source io.Reader) (bool, error) {
reader := bufio.NewReader(source)
if err := skipGifPreamble(reader); err != nil {
return false, err
}
frames := 0
for {
marker, err := reader.ReadByte()
if err != nil {
return false, err
}
switch marker {
case gifTrailer:
return false, nil
case gifExtensionIntroducer:
err = skipGifExtension(reader)
case gifImageSeparator:
frames++
if frames >= gifFramesBeforeAnimation {
return true, nil
}
err = skipGifImage(reader)
default:
return false, ErrNotAGif
}
if err != nil {
return false, err
}
}
}
func skipGifPreamble(reader *bufio.Reader) error {
header := make([]byte, gifHeaderLength)
if _, err := io.ReadFull(reader, header); err != nil {
return err
}
if string(header[:len(gifSignaturePrefix)]) != gifSignaturePrefix {
return ErrNotAGif
}
screen := make([]byte, gifScreenDescriptorSize)
if _, err := io.ReadFull(reader, screen); err != nil {
return err
}
return skipGifColorTable(reader, screen[4])
}
func skipGifColorTable(reader *bufio.Reader, packed byte) error {
if packed&gifColorTableFlag == 0 {
return nil
}
entries := 1 << (int(packed&gifColorTableSizeMask) + 1)
_, err := reader.Discard(entries * gifColorTableEntryBytes)
return err
}
func skipGifExtension(reader *bufio.Reader) error {
if _, err := reader.ReadByte(); err != nil {
return err
}
return skipGifSubBlocks(reader)
}
func skipGifImage(reader *bufio.Reader) error {
descriptor := make([]byte, gifImageDescriptorSize)
if _, err := io.ReadFull(reader, descriptor); err != nil {
return err
}
if err := skipGifColorTable(reader, descriptor[8]); err != nil {
return err
}
if _, err := reader.ReadByte(); err != nil {
return err
}
return skipGifSubBlocks(reader)
}
func skipGifSubBlocks(reader *bufio.Reader) error {
for {
size, err := reader.ReadByte()
if err != nil {
return err
}
if size == 0 {
return nil
}
if _, err := reader.Discard(int(size)); err != nil {
return err
}
}
}
+39 -11
View File
@@ -99,10 +99,11 @@ func StoredName(imagePath string) string {
type Sort string
const (
SortNewest Sort = "newest"
SortOldest Sort = "oldest"
SortName Sort = "name"
SortLargest Sort = "largest"
SortNewest Sort = "newest"
SortOldest Sort = "oldest"
SortName Sort = "name"
SortLargest Sort = "largest"
SortTotalSize Sort = "total_size"
)
type Filter string
@@ -113,13 +114,22 @@ const (
FilterDuplicates Filter = "duplicates"
)
type VariantsFilter string
const (
VariantsAny VariantsFilter = ""
VariantsWith VariantsFilter = "with"
VariantsWithout VariantsFilter = "without"
)
type ListQuery struct {
Search string
Hash string
Filter Filter
Sort Sort
Limit int
Cursor string
Search string
Hash string
Filter Filter
Variants VariantsFilter
Sort Sort
Limit int
Cursor string
}
func NewListQuery() ListQuery {
@@ -133,7 +143,12 @@ func (q ListQuery) Validate() error {
return ErrInvalidQuery
}
switch q.Sort {
case SortNewest, SortOldest, SortName, SortLargest:
case SortNewest, SortOldest, SortName, SortLargest, SortTotalSize:
default:
return ErrInvalidQuery
}
switch q.Variants {
case VariantsAny, VariantsWith, VariantsWithout:
default:
return ErrInvalidQuery
}
@@ -175,6 +190,19 @@ func SortImages(images []Image, order Sort) {
})
}
func SortImagesByTotalSize(images []Image, totals map[string]VariantFileTotals) {
totalOf := func(img Image) int64 {
return img.Size + totals[StoredName(img.Path)].Bytes
}
sort.SliceStable(images, func(i, j int) bool {
a, b := images[i], images[j]
if totalOf(a) != totalOf(b) {
return totalOf(a) > totalOf(b)
}
return a.ID < b.ID
})
}
type Counts struct {
All int
DuplicateGroups int
@@ -14,6 +14,8 @@ type Repository interface {
Rename(id uint32, filename string) (Image, error)
ListDuplicates() ([]Image, error)
Counts() (Counts, error)
ExistsByStoredName(storedName string) (bool, error)
ListByStoredNames(storedNames []string) ([]Image, error)
ListMissingMetadata() ([]Image, error)
UpdateMetadata(id uint32, metadata Metadata) error
}
+76
View File
@@ -0,0 +1,76 @@
package image
import (
"errors"
"time"
)
var ErrCachePurgeScope = errors.New("cache purge scope is not valid")
type CachePurgeScope string
const (
CacheScopeAll CachePurgeScope = "all"
CacheScopeUnused CachePurgeScope = "unused"
CacheScopeVariant CachePurgeScope = "variant"
CacheScopeImage CachePurgeScope = "image"
)
const (
UnusedVariantDays = 30
RejectionWindowDays = 7
TopImagesLimit = 10
CachePurgeBatchSize = 500
RejectionDayLayout = "2006-01-02"
)
func UnusedCutoff(now time.Time) time.Time {
return now.UTC().AddDate(0, 0, -UnusedVariantDays)
}
func RejectionDay(at time.Time) time.Time {
utc := at.UTC()
return time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
}
func RejectionWindowStart(now time.Time) time.Time {
return RejectionDay(now).AddDate(0, 0, -(RejectionWindowDays - 1))
}
func RejectionPruneBefore(now time.Time) time.Time {
return RejectionDay(now).AddDate(0, 0, -RejectionWindowDays)
}
type UnusedFilter struct {
VariantNames []string
Cutoff time.Time
}
type VariantFileFilter struct {
VariantName string
SourceName string
Unused *UnusedFilter
}
type SourceTotals struct {
SourceName string
Files int
Bytes int64
}
type VariantHit struct {
VariantName string
SourceName string
At time.Time
}
type RejectionCount struct {
Day time.Time
Count int
}
type VariantUsageBatch struct {
Hits []VariantHit
Rejections []RejectionCount
PruneBefore time.Time
}
+285
View File
@@ -0,0 +1,285 @@
package image
import (
"crypto/sha256"
"encoding/hex"
"errors"
"math"
"regexp"
"slices"
"strconv"
"strings"
"time"
)
var ErrVariantNotFound = errors.New("variant not found")
var ErrVariantExists = errors.New("variant already exists")
var ErrVariantInvalid = errors.New("variant is not valid")
var ErrVariantFormatUnavailable = errors.New("variant format cannot be encoded")
var ErrUnknownVariant = errors.New("unknown variant")
var ErrUnsupportedParameter = errors.New("unsupported parameter")
var ErrSourceTooLarge = errors.New("source image is too large to generate this variant")
var ErrVariantSourceUnsupported = errors.New("variants are not generated for this format")
var ErrVariantSourceUnreadable = errors.New("the source image cannot be read")
type VariantFit string
const (
FitCover VariantFit = "cover"
FitContain VariantFit = "contain"
FitInside VariantFit = "inside"
)
type VariantFormat string
const (
FormatOriginal VariantFormat = "original"
FormatJPEG VariantFormat = "jpeg"
FormatPNG VariantFormat = "png"
FormatWebP VariantFormat = "webp"
)
const (
MinVariantNameLength = 2
MaxVariantNameLength = 32
MinVariantDimension = 1
MaxVariantDimension = 8000
MinVariantQuality = 1
MaxVariantQuality = 100
DefaultVariantQuality = 82
DefaultVariantFit = FitCover
DefaultVariantFormat = FormatOriginal
VariantQueryParameter = "v"
)
var variantNamePattern = regexp.MustCompile(`^[a-z0-9-]{2,32}$`)
var unsupportedQueryParameters = []string{"width", "height", "format", "quality"}
func VariantFits() []VariantFit {
return []VariantFit{FitCover, FitContain, FitInside}
}
func VariantFormats() []VariantFormat {
return []VariantFormat{FormatOriginal, FormatJPEG, FormatPNG, FormatWebP}
}
func UnsupportedQueryParameters() []string {
return append([]string(nil), unsupportedQueryParameters...)
}
func IsValidVariantName(name string) bool {
return variantNamePattern.MatchString(name)
}
type VariantSettings struct {
Width *int
Height *int
Fit VariantFit
Format VariantFormat
Quality *int
}
func (s VariantSettings) Normalized() (VariantSettings, error) {
normalized := s
if normalized.Fit == "" {
normalized.Fit = DefaultVariantFit
}
if normalized.Format == "" {
normalized.Format = DefaultVariantFormat
}
if !isKnownFit(normalized.Fit) || !isKnownFormat(normalized.Format) {
return VariantSettings{}, ErrVariantInvalid
}
if !hasValidDimensions(normalized.Width, normalized.Height) {
return VariantSettings{}, ErrVariantInvalid
}
quality, err := normalizedQuality(normalized.Format, normalized.Quality)
if err != nil {
return VariantSettings{}, err
}
normalized.Quality = quality
return normalized, nil
}
func (s VariantSettings) Fingerprint() string {
parts := []string{
"w=" + DimensionText(s.Width),
"h=" + DimensionText(s.Height),
"fit=" + string(s.Fit),
"format=" + string(s.Format),
"q=" + QualityText(s.Quality),
}
sum := sha256.Sum256([]byte(strings.Join(parts, ";")))
return hex.EncodeToString(sum[:])
}
func (s VariantSettings) IsSameAs(other VariantSettings) bool {
return s.Fingerprint() == other.Fingerprint()
}
func isKnownFit(fit VariantFit) bool {
for _, known := range VariantFits() {
if fit == known {
return true
}
}
return false
}
func isKnownFormat(format VariantFormat) bool {
for _, known := range VariantFormats() {
if format == known {
return true
}
}
return false
}
func hasValidDimensions(width *int, height *int) bool {
if width == nil && height == nil {
return false
}
return isValidDimension(width) && isValidDimension(height)
}
func isValidDimension(dimension *int) bool {
return dimension == nil || (*dimension >= MinVariantDimension && *dimension <= MaxVariantDimension)
}
func normalizedQuality(format VariantFormat, quality *int) (*int, error) {
if format == FormatPNG {
return nil, nil
}
if quality == nil {
defaultQuality := DefaultVariantQuality
return &defaultQuality, nil
}
if *quality < MinVariantQuality || *quality > MaxVariantQuality {
return nil, ErrVariantInvalid
}
kept := *quality
return &kept, nil
}
func DimensionText(dimension *int) string {
if dimension == nil {
return "auto"
}
return strconv.Itoa(*dimension)
}
func QualityText(quality *int) string {
if quality == nil {
return "none"
}
return strconv.Itoa(*quality)
}
type Variant struct {
Name string `json:"name"`
Width *int `json:"width"`
Height *int `json:"height"`
Fit VariantFit `json:"fit"`
Format VariantFormat `json:"format"`
Quality *int `json:"quality"`
GenerateOnUpload bool `json:"generate_on_upload"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func NewVariant(name string, settings VariantSettings, generateOnUpload bool, enabled bool, now time.Time) (Variant, error) {
if !IsValidVariantName(name) {
return Variant{}, ErrVariantInvalid
}
normalized, err := settings.Normalized()
if err != nil {
return Variant{}, err
}
variant := Variant{Name: name, GenerateOnUpload: generateOnUpload, Enabled: enabled, CreatedAt: now, UpdatedAt: now}
return variant.WithSettings(normalized), nil
}
func (v Variant) Settings() VariantSettings {
return VariantSettings{Width: v.Width, Height: v.Height, Fit: v.Fit, Format: v.Format, Quality: v.Quality}
}
func (v Variant) WithSettings(settings VariantSettings) Variant {
v.Width, v.Height, v.Fit, v.Format, v.Quality = settings.Width, settings.Height, settings.Fit, settings.Format, settings.Quality
return v
}
type VariantFile struct {
ID uint32
VariantName string
SourceName string
CachedName string
ContentType string
Size int64
Fingerprint string
}
type VariantFileTotals struct {
Files int
Bytes int64
}
func OutputSize(sourceWidth int, sourceHeight int, settings VariantSettings) (int, int) {
if sourceWidth < 1 || sourceHeight < 1 {
return 0, 0
}
if settings.Width == nil || settings.Height == nil {
return proportionalOutput(sourceWidth, sourceHeight, settings)
}
boxWidth, boxHeight := *settings.Width, *settings.Height
switch settings.Fit {
case FitContain:
return boxWidth, boxHeight
case FitInside:
return scaledOutput(sourceWidth, sourceHeight, math.Min(1, math.Min(ratio(boxWidth, sourceWidth), ratio(boxHeight, sourceHeight))))
default:
shrink := math.Min(1, math.Min(ratio(sourceWidth, boxWidth), ratio(sourceHeight, boxHeight)))
return scaledOutput(boxWidth, boxHeight, shrink)
}
}
func proportionalOutput(sourceWidth int, sourceHeight int, settings VariantSettings) (int, int) {
scale := 1.0
if settings.Width != nil {
scale = math.Min(scale, ratio(*settings.Width, sourceWidth))
}
if settings.Height != nil {
scale = math.Min(scale, ratio(*settings.Height, sourceHeight))
}
return scaledOutput(sourceWidth, sourceHeight, scale)
}
func scaledOutput(width int, height int, factor float64) (int, int) {
return scaledLength(width, factor), scaledLength(height, factor)
}
func scaledLength(length int, factor float64) int {
return max(1, int(math.Round(float64(length)*factor)))
}
func ratio(numerator int, denominator int) float64 {
return float64(numerator) / float64(denominator)
}
var sourceContentTypeFormats = map[string]VariantFormat{
"image/jpeg": FormatJPEG,
"image/png": FormatPNG,
"image/webp": FormatWebP,
"image/gif": FormatPNG,
}
func OutputFormat(format VariantFormat, sourceContentType string, encodable []VariantFormat) VariantFormat {
if format == FormatOriginal {
format = sourceContentTypeFormats[sourceContentType]
}
if slices.Contains(encodable, format) {
return format
}
return FormatPNG
}
@@ -0,0 +1,52 @@
package image
import (
"io"
"time"
)
type VariantRepository interface {
List() ([]Variant, error)
Get(name string) (Variant, error)
Create(variant Variant) (Variant, error)
Update(variant Variant) (Variant, error)
Delete(name string) error
}
type VariantFileRepository interface {
Find(variantName string, sourceName string) (VariantFile, bool, error)
Save(file VariantFile) (VariantFile, error)
ListByVariant(variantName string) ([]VariantFile, error)
ListBySource(sourceName string) ([]VariantFile, error)
Delete(id uint32) error
Totals() (map[string]VariantFileTotals, error)
TotalsBySource() (map[string]VariantFileTotals, error)
}
type VariantCacheRepository interface {
UnusedTotals(filter UnusedFilter) (VariantFileTotals, error)
TopSources(limit int) ([]SourceTotals, error)
ListPage(filter VariantFileFilter, afterID uint32, limit int) ([]VariantFile, error)
}
type VariantUsageRepository interface {
Record(batch VariantUsageBatch) error
RejectedSince(day time.Time) (int, error)
}
type VariantUsageTracker interface {
RecordHit(variantName string, sourceName string)
RecordRejection()
Flush() error
}
type RenderedVariant struct {
Content []byte
ContentType string
}
type VariantRenderer interface {
DeclaredSize(source io.Reader) (width int, height int, err error)
Render(source io.Reader, settings VariantSettings) (RenderedVariant, error)
EncodableFormats() []VariantFormat
}
+54
View File
@@ -0,0 +1,54 @@
package image
import (
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
)
const etagDigestLength = 32
type VariantRequestError struct {
cause error
Detail string
}
func NewUnknownVariantError(name string) error {
return VariantRequestError{cause: ErrUnknownVariant, Detail: name}
}
func NewUnsupportedParameterError(parameter string) error {
return VariantRequestError{cause: ErrUnsupportedParameter, Detail: parameter}
}
func (e VariantRequestError) Error() string {
return e.cause.Error() + ": " + e.Detail
}
func (e VariantRequestError) Unwrap() error {
return e.cause
}
func VariantRequestDetail(err error) string {
var requestError VariantRequestError
if errors.As(err, &requestError) {
return requestError.Detail
}
return ""
}
func VariantETag(settings VariantSettings, sourceName string) string {
digest := sha256.Sum256([]byte(settings.Fingerprint() + "|" + sourceName))
return `"` + hex.EncodeToString(digest[:])[:etagDigestLength] + `"`
}
func ETagMatches(ifNoneMatch string, etag string) bool {
for _, candidate := range strings.Split(ifNoneMatch, ",") {
trimmed := strings.TrimPrefix(strings.TrimSpace(candidate), "W/")
if trimmed == "*" || trimmed == etag {
return true
}
}
return false
}
@@ -0,0 +1,44 @@
package config
import (
"fmt"
"strconv"
)
const (
EnvImageVariantMaxSourceBytes = "IMAGE_VARIANT_MAX_SOURCE_BYTES"
DefaultImageVariantMaxSourceBytes = "10485760"
EnvImageVariantMaxSourcePixels = "IMAGE_VARIANT_MAX_SOURCE_PIXELS"
DefaultImageVariantMaxSourcePixels = "50000000"
EnvImageVariantMaxConcurrentRenders = "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS"
DefaultImageVariantMaxConcurrentRenders = "4"
)
func imageVariantMaxSourceBytesFromEnv() (int64, error) {
raw := envOrDefault(EnvImageVariantMaxSourceBytes, DefaultImageVariantMaxSourceBytes)
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil || value < 1 {
return 0, fmt.Errorf("%s must be a whole number of bytes greater than zero, got %q", EnvImageVariantMaxSourceBytes, raw)
}
return value, nil
}
func imageVariantMaxSourcePixelsFromEnv() (int64, error) {
raw := envOrDefault(EnvImageVariantMaxSourcePixels, DefaultImageVariantMaxSourcePixels)
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil || value < 1 {
return 0, fmt.Errorf("%s must be a whole number of pixels greater than zero, got %q", EnvImageVariantMaxSourcePixels, raw)
}
return value, nil
}
func imageVariantMaxConcurrentRendersFromEnv() (int, error) {
raw := envOrDefault(EnvImageVariantMaxConcurrentRenders, DefaultImageVariantMaxConcurrentRenders)
value, err := strconv.Atoi(raw)
if err != nil || value < 1 {
return 0, fmt.Errorf("%s must be a whole number of renders greater than zero, got %q", EnvImageVariantMaxConcurrentRenders, raw)
}
return value, nil
}
+40 -19
View File
@@ -22,22 +22,25 @@ type AuthRateLimitSettings struct {
}
type Settings struct {
Environment string
CorsAllowedOrigins []string
PublicURL string
UploadDir string
JWTSecret string
DBFile string
Database DatabaseSettings
Storage StorageSettings
DataDirPath string
Port string
Smtp SmtpSettings
AuthRateLimit AuthRateLimitSettings
TrustedProxies []string
Version string
UpdateFeedURL string
Container bool
Environment string
CorsAllowedOrigins []string
PublicURL string
UploadDir string
JWTSecret string
DBFile string
Database DatabaseSettings
Storage StorageSettings
DataDirPath string
Port string
Smtp SmtpSettings
AuthRateLimit AuthRateLimitSettings
TrustedProxies []string
Version string
UpdateFeedURL string
Container bool
ImageVariantMaxSourceBytes int64
ImageVariantMaxSourcePixels int64
ImageVariantMaxConcurrentRenders int
}
func (s Settings) DataDir() string {
@@ -77,6 +80,21 @@ func NewSettingsFromEnv() (Settings, error) {
return Settings{}, err
}
maxSourceBytes, err := imageVariantMaxSourceBytesFromEnv()
if err != nil {
return Settings{}, err
}
maxSourcePixels, err := imageVariantMaxSourcePixelsFromEnv()
if err != nil {
return Settings{}, err
}
maxConcurrentRenders, err := imageVariantMaxConcurrentRendersFromEnv()
if err != nil {
return Settings{}, err
}
return Settings{
Environment: os.Getenv("ENVIRONMENT"),
CorsAllowedOrigins: parseSemicolonSeparatedList(os.Getenv("CORS_ALLOWED_ORIGINS")),
@@ -99,9 +117,12 @@ func NewSettingsFromEnv() (Settings, error) {
MaxAttempts: authRateLimitMaxAttempts,
Window: time.Duration(authRateLimitWindowSeconds) * time.Second,
},
TrustedProxies: parseSemicolonSeparatedList(os.Getenv("TRUSTED_PROXIES")),
UpdateFeedURL: os.Getenv("UPDATE_FEED_URL"),
Container: strings.EqualFold(os.Getenv("RENEWCMS_CONTAINER"), "true"),
TrustedProxies: parseSemicolonSeparatedList(os.Getenv("TRUSTED_PROXIES")),
UpdateFeedURL: os.Getenv("UPDATE_FEED_URL"),
Container: strings.EqualFold(os.Getenv("RENEWCMS_CONTAINER"), "true"),
ImageVariantMaxSourceBytes: maxSourceBytes,
ImageVariantMaxSourcePixels: maxSourcePixels,
ImageVariantMaxConcurrentRenders: maxConcurrentRenders,
}, nil
}
@@ -0,0 +1,60 @@
package imaging
import (
domain "RenewCMS/internal/domain/image"
"bytes"
"image"
"image/jpeg"
"image/png"
"github.com/gen2brain/webp"
)
type encoder interface {
contentType() string
flattensTransparency() bool
encode(output *bytes.Buffer, source image.Image, quality int) error
}
type jpegEncoder struct{}
func (jpegEncoder) contentType() string { return "image/jpeg" }
func (jpegEncoder) flattensTransparency() bool { return true }
func (jpegEncoder) encode(output *bytes.Buffer, source image.Image, quality int) error {
return jpeg.Encode(output, source, &jpeg.Options{Quality: quality})
}
type pngEncoder struct{}
func (pngEncoder) contentType() string { return "image/png" }
func (pngEncoder) flattensTransparency() bool { return false }
func (pngEncoder) encode(output *bytes.Buffer, source image.Image, _ int) error {
return png.Encode(output, source)
}
type webpEncoder struct{}
func (webpEncoder) contentType() string { return "image/webp" }
func (webpEncoder) flattensTransparency() bool { return false }
func (webpEncoder) encode(output *bytes.Buffer, source image.Image, quality int) error {
return webp.Encode(output, source, webp.Options{Quality: quality})
}
var knownEncoders = map[domain.VariantFormat]encoder{
domain.FormatJPEG: jpegEncoder{},
domain.FormatPNG: pngEncoder{},
domain.FormatWebP: webpEncoder{},
}
var sourceFormats = map[string]domain.VariantFormat{
"jpeg": domain.FormatJPEG,
"png": domain.FormatPNG,
"webp": domain.FormatWebP,
"gif": domain.FormatPNG,
}
@@ -0,0 +1,72 @@
package imaging
import (
domain "RenewCMS/internal/domain/image"
"image"
"math"
)
type layout struct {
canvas image.Point
source image.Rectangle
content image.Rectangle
}
func layoutFor(source image.Point, settings domain.VariantSettings) layout {
outputWidth, outputHeight := domain.OutputSize(source.X, source.Y, settings)
output := image.Pt(outputWidth, outputHeight)
if settings.Width == nil || settings.Height == nil {
return wholeSourceLayout(source, output)
}
switch settings.Fit {
case domain.FitContain:
return containLayout(source, output)
case domain.FitInside:
return wholeSourceLayout(source, output)
default:
return coverLayout(source, output)
}
}
func wholeSourceLayout(source image.Point, output image.Point) layout {
return layout{canvas: output, source: image.Rectangle{Max: source}, content: image.Rectangle{Max: output}}
}
func coverLayout(source image.Point, output image.Point) layout {
return layout{canvas: output, source: centeredCrop(source, output), content: image.Rectangle{Max: output}}
}
func containLayout(source image.Point, box image.Point) layout {
fit := math.Min(1, math.Min(ratio(box.X, source.X), ratio(box.Y, source.Y)))
content := scaled(source, fit)
content = image.Pt(min(content.X, box.X), min(content.Y, box.Y))
origin := image.Pt((box.X-content.X)/2, (box.Y-content.Y)/2)
return layout{canvas: box, source: image.Rectangle{Max: source}, content: image.Rectangle{Min: origin, Max: origin.Add(content)}}
}
func centeredCrop(source image.Point, output image.Point) image.Rectangle {
crop := source
if source.X*output.Y >= source.Y*output.X {
crop.X = min(source.X, roundedDivision(source.Y*output.X, output.Y))
} else {
crop.Y = min(source.Y, roundedDivision(source.X*output.Y, output.X))
}
origin := image.Pt((source.X-crop.X)/2, (source.Y-crop.Y)/2)
return image.Rectangle{Min: origin, Max: origin.Add(crop)}
}
func ratio(numerator int, denominator int) float64 {
return float64(numerator) / float64(denominator)
}
func roundedDivision(numerator int, denominator int) int {
return int(math.Round(ratio(numerator, denominator)))
}
func scaled(size image.Point, factor float64) image.Point {
return image.Pt(scaledLength(size.X, factor), scaledLength(size.Y, factor))
}
func scaledLength(length int, factor float64) int {
return max(1, int(math.Round(float64(length)*factor)))
}
@@ -0,0 +1,102 @@
package imaging
import (
domain "RenewCMS/internal/domain/image"
"bytes"
"image"
"image/color"
"io"
"slices"
xdraw "golang.org/x/image/draw"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
_ "golang.org/x/image/webp"
)
type VariantRenderer struct {
encoders map[domain.VariantFormat]encoder
}
var _ domain.VariantRenderer = &VariantRenderer{}
func NewVariantRenderer() *VariantRenderer {
return NewVariantRendererEncoding(domain.FormatOriginal, domain.FormatJPEG, domain.FormatPNG, domain.FormatWebP)
}
func NewVariantRendererEncoding(formats ...domain.VariantFormat) *VariantRenderer {
encoders := map[domain.VariantFormat]encoder{}
for format, candidate := range knownEncoders {
if slices.Contains(formats, format) {
encoders[format] = candidate
}
}
return &VariantRenderer{encoders}
}
func (r *VariantRenderer) EncodableFormats() []domain.VariantFormat {
formats := []domain.VariantFormat{domain.FormatOriginal}
for _, format := range domain.VariantFormats() {
if _, encodable := r.encoders[format]; encodable {
formats = append(formats, format)
}
}
return formats
}
func (r *VariantRenderer) DeclaredSize(source io.Reader) (int, int, error) {
header, _, err := image.DecodeConfig(source)
if err != nil {
return 0, 0, err
}
return header.Width, header.Height, nil
}
func (r *VariantRenderer) Render(source io.Reader, settings domain.VariantSettings) (domain.RenderedVariant, error) {
decoded, sourceFormat, err := image.Decode(source)
if err != nil {
return domain.RenderedVariant{}, err
}
chosen := r.encoderFor(settings.Format, sourceFormat)
arranged := arrange(decoded, layoutFor(decoded.Bounds().Size(), settings), chosen.flattensTransparency())
var output bytes.Buffer
if err := chosen.encode(&output, arranged, qualityOf(settings)); err != nil {
return domain.RenderedVariant{}, err
}
return domain.RenderedVariant{Content: output.Bytes(), ContentType: chosen.contentType()}, nil
}
func (r *VariantRenderer) encoderFor(format domain.VariantFormat, sourceFormat string) encoder {
if format == domain.FormatOriginal {
format = sourceFormats[sourceFormat]
}
if chosen, encodable := r.encoders[format]; encodable {
return chosen
}
return pngEncoder{}
}
func qualityOf(settings domain.VariantSettings) int {
if settings.Quality == nil {
return domain.DefaultVariantQuality
}
return *settings.Quality
}
func arrange(decoded image.Image, plan layout, onWhite bool) image.Image {
canvas := image.NewRGBA(image.Rectangle{Max: plan.canvas})
if onWhite {
xdraw.Draw(canvas, canvas.Bounds(), image.NewUniform(color.White), image.Point{}, xdraw.Src)
}
sourceRegion := plan.source.Add(decoded.Bounds().Min)
if plan.source.Size() == plan.content.Size() {
xdraw.Draw(canvas, plan.content, decoded, sourceRegion.Min, xdraw.Over)
return canvas
}
xdraw.CatmullRom.Scale(canvas, plan.content, decoded, sourceRegion, xdraw.Over, nil)
return canvas
}
@@ -7,9 +7,29 @@ import (
)
func Migrate(db *gorm.DB) error {
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{})
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{})
if err != nil {
return err
}
if err := ensureImagePathIndex(db); err != nil {
return err
}
return BackfillUserRoles(db)
}
const (
imagePathIndexName = "idx_images_path"
mysqlDialectorName = "mysql"
imagePathIndexStatement = "CREATE INDEX " + imagePathIndexName + " ON images (path)"
mysqlImagePathStatement = "CREATE INDEX " + imagePathIndexName + " ON images (path(191))"
)
func ensureImagePathIndex(db *gorm.DB) error {
if db.Migrator().HasIndex(&models.Image{}, imagePathIndexName) {
return nil
}
if db.Dialector.Name() == mysqlDialectorName {
return db.Exec(mysqlImagePathStatement).Error
}
return db.Exec(imagePathIndexStatement).Error
}
@@ -83,6 +83,7 @@ func encodeJSON(value any, empty string) (string, error) {
func AuditSettingsToDomain(m models.AuditSettings) audit.Settings {
return audit.Settings{
RetentionDays: m.RetentionDays,
RetentionMaxGB: m.RetentionMaxGB,
LastCleanupAt: m.LastCleanupAt,
LastCleanupRemoved: m.LastCleanupRemoved,
}
@@ -91,6 +92,7 @@ func AuditSettingsToDomain(m models.AuditSettings) audit.Settings {
func AuditSettingsToModel(s audit.Settings) models.AuditSettings {
return models.AuditSettings{
RetentionDays: s.RetentionDays,
RetentionMaxGB: s.RetentionMaxGB,
LastCleanupAt: s.LastCleanupAt,
LastCleanupRemoved: s.LastCleanupRemoved,
}
@@ -0,0 +1,60 @@
package mappers
import (
"RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/models"
)
func VariantToDomain(variantModel models.ImageVariant) image.Variant {
return image.Variant{
Name: variantModel.Name,
Width: variantModel.Width,
Height: variantModel.Height,
Fit: image.VariantFit(variantModel.Fit),
Format: image.VariantFormat(variantModel.Format),
Quality: variantModel.Quality,
GenerateOnUpload: variantModel.GenerateOnUpload,
Enabled: variantModel.Enabled,
CreatedAt: variantModel.CreatedAt,
UpdatedAt: variantModel.UpdatedAt,
}
}
func VariantToModel(variantDomain image.Variant) models.ImageVariant {
return models.ImageVariant{
Name: variantDomain.Name,
Width: variantDomain.Width,
Height: variantDomain.Height,
Fit: string(variantDomain.Fit),
Format: string(variantDomain.Format),
Quality: variantDomain.Quality,
GenerateOnUpload: variantDomain.GenerateOnUpload,
Enabled: variantDomain.Enabled,
CreatedAt: variantDomain.CreatedAt,
UpdatedAt: variantDomain.UpdatedAt,
}
}
func VariantFileToDomain(fileModel models.ImageVariantFile) image.VariantFile {
return image.VariantFile{
ID: fileModel.ID,
VariantName: fileModel.VariantName,
SourceName: fileModel.SourceName,
CachedName: fileModel.CachedName,
ContentType: fileModel.ContentType,
Size: fileModel.Size,
Fingerprint: fileModel.Fingerprint,
}
}
func VariantFileToModel(fileDomain image.VariantFile) models.ImageVariantFile {
return models.ImageVariantFile{
ID: fileDomain.ID,
VariantName: fileDomain.VariantName,
SourceName: fileDomain.SourceName,
CachedName: fileDomain.CachedName,
ContentType: fileDomain.ContentType,
Size: fileDomain.Size,
Fingerprint: fileDomain.Fingerprint,
}
}
@@ -5,6 +5,7 @@ import "time"
type AuditSettings struct {
ID uint32 `gorm:"primaryKey"`
RetentionDays int `gorm:"not null"`
RetentionMaxGB int `gorm:"not null;default:0"`
LastCleanupAt *time.Time
LastCleanupRemoved int `gorm:"not null"`
}
@@ -0,0 +1,22 @@
package models
import "time"
type ImageVariantFile struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
VariantName string `gorm:"not null;size:32;uniqueIndex:idx_image_variant_files_pair,priority:1"`
SourceName string `gorm:"not null;size:191;uniqueIndex:idx_image_variant_files_pair,priority:2;index:idx_image_variant_files_source"`
CachedName string `gorm:"not null;size:191"`
ContentType string `gorm:"not null;size:64"`
Size int64 `gorm:"not null"`
Fingerprint string `gorm:"not null;size:64"`
CreatedAt time.Time
LastRequestedAt *time.Time
}
type ImageVariantRejectionDay struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
Day string `gorm:"not null;size:10;uniqueIndex:idx_image_variant_rejection_days_day"`
Count int `gorm:"not null"`
}
@@ -0,0 +1,16 @@
package models
import "time"
type ImageVariant struct {
Name string `gorm:"primaryKey;size:32"`
Width *int
Height *int
Fit string `gorm:"not null;size:16"`
Format string `gorm:"not null;size:16"`
Quality *int
GenerateOnUpload bool `gorm:"not null"`
Enabled bool `gorm:"not null"`
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"slices"
"strings"
"time"
@@ -186,3 +187,49 @@ func (a *AuditEventRepository) DeleteOlderThan(cutoff time.Time) (int, error) {
result := a.db.Where("created_at < ?", cutoff.UTC()).Delete(&entity.AuditEvent{})
return int(result.RowsAffected), result.Error
}
const (
deleteChunkSize = 500
payloadSizeSQL = `COALESCE(SUM(
OCTET_LENGTH(action) + OCTET_LENGTH(actor_name) + OCTET_LENGTH(target_type) +
OCTET_LENGTH(target_id) + OCTET_LENGTH(target_name) + COALESCE(OCTET_LENGTH(failure_reason), 0) +
COALESCE(OCTET_LENGTH(ip), 0) + COALESCE(OCTET_LENGTH(user_agent), 0) +
OCTET_LENGTH(params) + OCTET_LENGTH(changes)), 0) AS payload, COUNT(*) AS total`
)
type payloadRow struct {
Payload int64
Total int64
}
func (a *AuditEventRepository) SizeBytes() (int64, error) {
var row payloadRow
if err := a.db.Model(&entity.AuditEvent{}).Select(payloadSizeSQL).Scan(&row).Error; err != nil {
return 0, err
}
return row.Payload + row.Total*audit.RowOverheadBytes, nil
}
func (a *AuditEventRepository) DeleteOldest(limit int, before time.Time) (int, error) {
if limit < 1 {
return 0, nil
}
var ids []uint64
err := a.db.Model(&entity.AuditEvent{}).
Where("created_at < ?", before.UTC()).
Order("created_at ASC, id ASC").
Limit(limit).
Pluck("id", &ids).Error
if err != nil {
return 0, err
}
removed := 0
for chunk := range slices.Chunk(ids, deleteChunkSize) {
result := a.db.Where("id IN ?", chunk).Delete(&entity.AuditEvent{})
if result.Error != nil {
return removed, result.Error
}
removed += int(result.RowsAffected)
}
return removed, nil
}
@@ -34,9 +34,10 @@ func (a *AuditSettingsRepository) Get() (audit.Settings, error) {
return mappers.AuditSettingsToDomain(model), nil
}
func (a *AuditSettingsRepository) SaveRetention(days int) error {
return a.update(map[string]any{"retention_days": days}, func(settings *audit.Settings) {
func (a *AuditSettingsRepository) SaveRetention(days int, maxGB int) error {
return a.update(map[string]any{"retention_days": days, "retention_max_gb": maxGB}, func(settings *audit.Settings) {
settings.RetentionDays = days
settings.RetentionMaxGB = maxGB
})
}
@@ -101,6 +101,27 @@ func (i ImageRepository) Counts() (domain.Counts, error) {
return domain.Counts{All: int(all), DuplicateGroups: int(groups)}, nil
}
func (i ImageRepository) ExistsByStoredName(storedName string) (bool, error) {
var matches int64
err := i.db.Model(&entity.Image{}).Where("path = ?", domain.PublicPath(storedName)).Limit(1).Count(&matches).Error
return matches > 0, err
}
func (i ImageRepository) ListByStoredNames(storedNames []string) ([]domain.Image, error) {
if len(storedNames) == 0 {
return []domain.Image{}, nil
}
paths := make([]string, 0, len(storedNames))
for _, storedName := range storedNames {
paths = append(paths, domain.PublicPath(storedName))
}
var models []entity.Image
if err := i.db.Where("path IN ?", paths).Order("id asc").Find(&models).Error; err != nil {
return nil, err
}
return imagesToDomain(models), nil
}
func (i ImageRepository) duplicatedHashes() *gorm.DB {
return i.db.Model(&entity.Image{}).
Select("sha256").
@@ -0,0 +1,182 @@
package persistence
import (
domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type VariantFileRepository struct {
db *gorm.DB
}
var _ domain.VariantFileRepository = &VariantFileRepository{}
var _ domain.VariantCacheRepository = &VariantFileRepository{}
func NewVariantFileRepository(db *gorm.DB) *VariantFileRepository {
return &VariantFileRepository{db}
}
func (v *VariantFileRepository) Find(variantName string, sourceName string) (domain.VariantFile, bool, error) {
var model entity.ImageVariantFile
err := v.db.Where("variant_name = ? AND source_name = ?", variantName, sourceName).First(&model).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return domain.VariantFile{}, false, nil
}
if err != nil {
return domain.VariantFile{}, false, err
}
return mappers.VariantFileToDomain(model), true, nil
}
func (v *VariantFileRepository) Save(file domain.VariantFile) (domain.VariantFile, error) {
model := mappers.VariantFileToModel(file)
model.ID = 0
err := v.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "variant_name"}, {Name: "source_name"}},
DoUpdates: clause.AssignmentColumns([]string{"cached_name", "content_type", "size", "fingerprint"}),
}).Create(&model).Error
if err != nil {
return domain.VariantFile{}, err
}
saved, _, err := v.Find(file.VariantName, file.SourceName)
return saved, err
}
func (v *VariantFileRepository) ListByVariant(variantName string) ([]domain.VariantFile, error) {
return v.list("variant_name = ?", variantName)
}
func (v *VariantFileRepository) ListBySource(sourceName string) ([]domain.VariantFile, error) {
return v.list("source_name = ?", sourceName)
}
func (v *VariantFileRepository) list(condition string, value string) ([]domain.VariantFile, error) {
var found []entity.ImageVariantFile
if err := v.db.Where(condition, value).Order("id asc").Find(&found).Error; err != nil {
return nil, err
}
files := make([]domain.VariantFile, 0, len(found))
for _, model := range found {
files = append(files, mappers.VariantFileToDomain(model))
}
return files, nil
}
func (v *VariantFileRepository) Delete(id uint32) error {
return v.db.Delete(&entity.ImageVariantFile{}, id).Error
}
func (v *VariantFileRepository) TotalsBySource() (map[string]domain.VariantFileTotals, error) {
var rows []struct {
SourceName string
Files int
Bytes int64
}
err := v.db.Model(&entity.ImageVariantFile{}).
Select("source_name, COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes").
Group("source_name").
Scan(&rows).Error
if err != nil {
return nil, err
}
totals := make(map[string]domain.VariantFileTotals, len(rows))
for _, row := range rows {
totals[row.SourceName] = domain.VariantFileTotals{Files: row.Files, Bytes: row.Bytes}
}
return totals, nil
}
const (
unusedCondition = "variant_name IN ? AND COALESCE(last_requested_at, created_at) < ?"
topSourcesOrder = "bytes DESC, source_name ASC"
)
func (v *VariantFileRepository) UnusedTotals(filter domain.UnusedFilter) (domain.VariantFileTotals, error) {
if len(filter.VariantNames) == 0 {
return domain.VariantFileTotals{}, nil
}
var row struct {
Files int
Bytes int64
}
err := v.db.Model(&entity.ImageVariantFile{}).
Select("COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes").
Where(unusedCondition, filter.VariantNames, filter.Cutoff.UTC()).
Scan(&row).Error
if err != nil {
return domain.VariantFileTotals{}, err
}
return domain.VariantFileTotals{Files: row.Files, Bytes: row.Bytes}, nil
}
func (v *VariantFileRepository) TopSources(limit int) ([]domain.SourceTotals, error) {
var rows []struct {
SourceName string
Files int
Bytes int64
}
err := v.db.Model(&entity.ImageVariantFile{}).
Select("source_name, COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes").
Group("source_name").
Order(topSourcesOrder).
Limit(limit).
Scan(&rows).Error
if err != nil {
return nil, err
}
sources := make([]domain.SourceTotals, 0, len(rows))
for _, row := range rows {
sources = append(sources, domain.SourceTotals{SourceName: row.SourceName, Files: row.Files, Bytes: row.Bytes})
}
return sources, nil
}
func (v *VariantFileRepository) ListPage(filter domain.VariantFileFilter, afterID uint32, limit int) ([]domain.VariantFile, error) {
statement := v.db.Where("id > ?", afterID)
if filter.VariantName != "" {
statement = statement.Where("variant_name = ?", filter.VariantName)
}
if filter.SourceName != "" {
statement = statement.Where("source_name = ?", filter.SourceName)
}
if filter.Unused != nil {
if len(filter.Unused.VariantNames) == 0 {
return []domain.VariantFile{}, nil
}
statement = statement.Where(unusedCondition, filter.Unused.VariantNames, filter.Unused.Cutoff.UTC())
}
var found []entity.ImageVariantFile
if err := statement.Order("id asc").Limit(limit).Find(&found).Error; err != nil {
return nil, err
}
files := make([]domain.VariantFile, 0, len(found))
for _, model := range found {
files = append(files, mappers.VariantFileToDomain(model))
}
return files, nil
}
func (v *VariantFileRepository) Totals() (map[string]domain.VariantFileTotals, error) {
var rows []struct {
VariantName string
Files int
Bytes int64
}
err := v.db.Model(&entity.ImageVariantFile{}).
Select("variant_name, COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes").
Group("variant_name").
Scan(&rows).Error
if err != nil {
return nil, err
}
totals := make(map[string]domain.VariantFileTotals, len(rows))
for _, row := range rows {
totals[row.VariantName] = domain.VariantFileTotals{Files: row.Files, Bytes: row.Bytes}
}
return totals, nil
}
@@ -0,0 +1,65 @@
package persistence
import (
domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type VariantRepository struct {
db *gorm.DB
}
var _ domain.VariantRepository = &VariantRepository{}
func NewVariantRepository(db *gorm.DB) *VariantRepository {
return &VariantRepository{db}
}
func (v *VariantRepository) List() ([]domain.Variant, error) {
var found []entity.ImageVariant
if err := v.db.Order("name asc").Find(&found).Error; err != nil {
return nil, err
}
variants := make([]domain.Variant, 0, len(found))
for _, model := range found {
variants = append(variants, mappers.VariantToDomain(model))
}
return variants, nil
}
func (v *VariantRepository) Get(name string) (domain.Variant, error) {
var model entity.ImageVariant
if err := v.db.Where("name = ?", name).First(&model).Error; err != nil {
return domain.Variant{}, mapNotFoundErr(err, domain.ErrVariantNotFound)
}
return mappers.VariantToDomain(model), nil
}
func (v *VariantRepository) Create(variant domain.Variant) (domain.Variant, error) {
model := mappers.VariantToModel(variant)
result := v.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&model)
if result.Error != nil {
return domain.Variant{}, result.Error
}
if result.RowsAffected == 0 {
return domain.Variant{}, domain.ErrVariantExists
}
return mappers.VariantToDomain(model), nil
}
func (v *VariantRepository) Update(variant domain.Variant) (domain.Variant, error) {
model := mappers.VariantToModel(variant)
result := v.db.Model(&entity.ImageVariant{}).Where("name = ?", model.Name).Select("*").Omit("name", "created_at").UpdateColumns(&model)
if err := deleteOrNotFound(result, domain.ErrVariantNotFound); err != nil {
return domain.Variant{}, err
}
return v.Get(variant.Name)
}
func (v *VariantRepository) Delete(name string) error {
return deleteOrNotFound(v.db.Where("name = ?", name).Delete(&entity.ImageVariant{}), domain.ErrVariantNotFound)
}
@@ -0,0 +1,64 @@
package persistence
import (
domain "RenewCMS/internal/domain/image"
entity "RenewCMS/internal/infrastructure/persistence/models"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type VariantUsageRepository struct {
db *gorm.DB
}
var _ domain.VariantUsageRepository = &VariantUsageRepository{}
func NewVariantUsageRepository(db *gorm.DB) *VariantUsageRepository {
return &VariantUsageRepository{db}
}
func (v *VariantUsageRepository) Record(batch domain.VariantUsageBatch) error {
return v.db.Transaction(func(tx *gorm.DB) error {
for _, hit := range batch.Hits {
if err := recordHit(tx, hit); err != nil {
return err
}
}
for _, rejection := range batch.Rejections {
if err := recordRejections(tx, rejection); err != nil {
return err
}
}
if batch.PruneBefore.IsZero() {
return nil
}
return tx.Where("day < ?", batch.PruneBefore.UTC().Format(domain.RejectionDayLayout)).Delete(&entity.ImageVariantRejectionDay{}).Error
})
}
func recordHit(tx *gorm.DB, hit domain.VariantHit) error {
at := hit.At.UTC()
return tx.Model(&entity.ImageVariantFile{}).
Where("variant_name = ? AND source_name = ?", hit.VariantName, hit.SourceName).
Where("last_requested_at IS NULL OR last_requested_at < ?", at).
Update("last_requested_at", at).Error
}
func recordRejections(tx *gorm.DB, rejection domain.RejectionCount) error {
row := entity.ImageVariantRejectionDay{Day: rejection.Day.UTC().Format(domain.RejectionDayLayout), Count: rejection.Count}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "day"}},
DoUpdates: clause.Assignments(map[string]any{"count": gorm.Expr("? + ?", clause.Column{Table: clause.CurrentTable, Name: "count"}, rejection.Count)}),
}).Create(&row).Error
}
func (v *VariantUsageRepository) RejectedSince(day time.Time) (int, error) {
var total int
err := v.db.Model(&entity.ImageVariantRejectionDay{}).
Select("COALESCE(SUM(count), 0)").
Where("day >= ?", day.UTC().Format(domain.RejectionDayLayout)).
Scan(&total).Error
return total, err
}
@@ -0,0 +1,150 @@
package usage
import (
"RenewCMS/internal/domain/image"
"context"
"sync"
"time"
)
const FlushKeyLimit = 500
func FlushInterval() time.Duration {
return 30 * time.Second
}
type hitKey struct {
variantName string
sourceName string
}
type Tracker struct {
repository image.VariantUsageRepository
now func() time.Time
interval time.Duration
keyLimit int
flushing sync.Mutex
pending sync.Mutex
hits map[hitKey]time.Time
rejections map[time.Time]int
wake chan struct{}
}
var _ image.VariantUsageTracker = &Tracker{}
func NewTracker(repository image.VariantUsageRepository, now func() time.Time) *Tracker {
return NewTrackerWith(repository, now, FlushInterval(), FlushKeyLimit)
}
func NewTrackerWith(repository image.VariantUsageRepository, now func() time.Time, interval time.Duration, keyLimit int) *Tracker {
return &Tracker{
repository: repository,
now: now,
interval: interval,
keyLimit: keyLimit,
hits: map[hitKey]time.Time{},
rejections: map[time.Time]int{},
wake: make(chan struct{}, 1),
}
}
func (t *Tracker) RecordHit(variantName string, sourceName string) {
at := t.now().UTC()
t.pending.Lock()
defer t.pending.Unlock()
keepLatest(t.hits, hitKey{variantName, sourceName}, at)
if len(t.hits) >= t.keyLimit {
t.wakeLoop()
}
}
func (t *Tracker) RecordRejection() {
day := image.RejectionDay(t.now())
t.pending.Lock()
defer t.pending.Unlock()
t.rejections[day]++
}
func (t *Tracker) wakeLoop() {
select {
case t.wake <- struct{}{}:
default:
}
}
func keepLatest(hits map[hitKey]time.Time, key hitKey, at time.Time) {
if current, found := hits[key]; !found || at.After(current) {
hits[key] = at
}
}
func (t *Tracker) Flush() error {
t.flushing.Lock()
defer t.flushing.Unlock()
hits, rejections := t.take()
if len(hits) == 0 && len(rejections) == 0 {
return nil
}
batch := image.VariantUsageBatch{
Hits: hitsOf(hits),
Rejections: rejectionsOf(rejections),
PruneBefore: image.RejectionPruneBefore(t.now()),
}
if err := t.repository.Record(batch); err != nil {
t.restore(hits, rejections)
return err
}
return nil
}
func (t *Tracker) take() (map[hitKey]time.Time, map[time.Time]int) {
t.pending.Lock()
defer t.pending.Unlock()
hits, rejections := t.hits, t.rejections
t.hits, t.rejections = map[hitKey]time.Time{}, map[time.Time]int{}
return hits, rejections
}
func (t *Tracker) restore(hits map[hitKey]time.Time, rejections map[time.Time]int) {
t.pending.Lock()
defer t.pending.Unlock()
for key, at := range hits {
keepLatest(t.hits, key, at)
}
for day, count := range rejections {
t.rejections[day] += count
}
}
func hitsOf(hits map[hitKey]time.Time) []image.VariantHit {
listed := make([]image.VariantHit, 0, len(hits))
for key, at := range hits {
listed = append(listed, image.VariantHit{VariantName: key.variantName, SourceName: key.sourceName, At: at})
}
return listed
}
func rejectionsOf(rejections map[time.Time]int) []image.RejectionCount {
listed := make([]image.RejectionCount, 0, len(rejections))
for day, count := range rejections {
listed = append(listed, image.RejectionCount{Day: day, Count: count})
}
return listed
}
func (t *Tracker) Run(ctx context.Context) {
ticker := time.NewTicker(t.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
_ = t.Flush()
return
case <-ticker.C:
_ = t.Flush()
case <-t.wake:
_ = t.Flush()
}
}
}
@@ -8,11 +8,12 @@ import (
type GetActivityRetentionUseCase struct {
settings audit.SettingsRepository
events audit.Repository
now func() time.Time
}
func NewGetActivityRetentionUseCase(settings audit.SettingsRepository, now func() time.Time) *GetActivityRetentionUseCase {
return &GetActivityRetentionUseCase{settings, now}
func NewGetActivityRetentionUseCase(settings audit.SettingsRepository, events audit.Repository, now func() time.Time) *GetActivityRetentionUseCase {
return &GetActivityRetentionUseCase{settings, events, now}
}
func (g *GetActivityRetentionUseCase) GetActivityRetention() (audit.Settings, error) {
@@ -20,38 +21,80 @@ func (g *GetActivityRetentionUseCase) GetActivityRetention() (audit.Settings, er
if err != nil {
return audit.Settings{}, err
}
current.LogSizeBytes, err = g.events.SizeBytes()
if err != nil {
return audit.Settings{}, err
}
return current.WithNextCleanup(g.now()), nil
}
type UpdateActivityRetentionCommand struct {
Days *int
MaxGB *int
}
type UpdateActivityRetentionUseCase struct {
settings audit.SettingsRepository
events audit.Repository
recorder audit.Recorder
now func() time.Time
}
func NewUpdateActivityRetentionUseCase(settings audit.SettingsRepository, recorder audit.Recorder, now func() time.Time) *UpdateActivityRetentionUseCase {
return &UpdateActivityRetentionUseCase{settings, recorder, now}
func NewUpdateActivityRetentionUseCase(settings audit.SettingsRepository, events audit.Repository, recorder audit.Recorder, now func() time.Time) *UpdateActivityRetentionUseCase {
return &UpdateActivityRetentionUseCase{settings, events, recorder, now}
}
func (u *UpdateActivityRetentionUseCase) UpdateActivityRetention(ctx context.Context, days int) (audit.Settings, error) {
if err := audit.ValidateRetentionDays(days); err != nil {
return audit.Settings{}, err
func (u *UpdateActivityRetentionUseCase) UpdateActivityRetention(ctx context.Context, command UpdateActivityRetentionCommand) (audit.Settings, error) {
if command.Days == nil && command.MaxGB == nil {
return audit.Settings{}, audit.ErrInvalidRetention
}
if command.Days != nil {
if err := audit.ValidateRetentionDays(*command.Days); err != nil {
return audit.Settings{}, err
}
}
if command.MaxGB != nil {
if err := audit.ValidateRetentionMaxGB(*command.MaxGB); err != nil {
return audit.Settings{}, err
}
}
current, err := u.settings.Get()
if err != nil {
return audit.Settings{}, err
}
if current.RetentionDays == days {
size, err := u.events.SizeBytes()
if err != nil {
return audit.Settings{}, err
}
current.LogSizeBytes = size
updated := current
if command.Days != nil {
updated.RetentionDays = *command.Days
}
if command.MaxGB != nil {
updated.RetentionMaxGB = *command.MaxGB
}
daysChanged := updated.RetentionDays != current.RetentionDays
sizeChanged := updated.RetentionMaxGB != current.RetentionMaxGB
if !daysChanged && !sizeChanged {
return current.WithNextCleanup(u.now()), nil
}
if err := u.settings.SaveRetention(days); err != nil {
if err := u.settings.SaveRetention(updated.RetentionDays, updated.RetentionMaxGB); err != nil {
return audit.Settings{}, err
}
event := audit.NewEvent(audit.ActionRetentionChanged, audit.CategorySystem)
event.Params = map[string]any{audit.ParamFrom: current.RetentionDays, audit.ParamTo: days}
event.Params = map[string]any{}
if daysChanged {
event.Params[audit.ParamFrom] = current.RetentionDays
event.Params[audit.ParamTo] = updated.RetentionDays
}
if sizeChanged {
event.Params[audit.ParamMaxGBFrom] = current.RetentionMaxGB
event.Params[audit.ParamMaxGBTo] = updated.RetentionMaxGB
}
u.recorder.Record(ctx, event)
current.RetentionDays = days
return current.WithNextCleanup(u.now()), nil
return updated.WithNextCleanup(u.now()), nil
}
@@ -6,14 +6,22 @@ import (
"time"
)
const defaultCleanupBatchSize = 1000
type CleanupActivityUseCase struct {
events audit.Repository
settings audit.SettingsRepository
recorder audit.Recorder
events audit.Repository
settings audit.SettingsRepository
recorder audit.Recorder
batchSize int
}
func NewCleanupActivityUseCase(events audit.Repository, settings audit.SettingsRepository, recorder audit.Recorder) *CleanupActivityUseCase {
return &CleanupActivityUseCase{events, settings, recorder}
return &CleanupActivityUseCase{events, settings, recorder, defaultCleanupBatchSize}
}
func (c *CleanupActivityUseCase) WithBatchSize(batchSize int) *CleanupActivityUseCase {
c.batchSize = batchSize
return c
}
func (c *CleanupActivityUseCase) Tick(now time.Time) (audit.CleanupResult, bool, error) {
@@ -21,13 +29,23 @@ func (c *CleanupActivityUseCase) Tick(now time.Time) (audit.CleanupResult, bool,
if err != nil {
return audit.CleanupResult{}, false, err
}
if settings.RetentionDays == audit.ForeverRetentionDays || !settings.CleanupDue(now) {
if !settings.HasRetentionLimit() || !settings.CleanupDue(now) {
return audit.CleanupResult{}, false, nil
}
removed, err := c.events.DeleteOlderThan(audit.RetentionCutoff(now, settings.RetentionDays))
if err != nil {
return audit.CleanupResult{}, false, err
removed := 0
if settings.RetentionDays != audit.ForeverRetentionDays {
removed, err = c.events.DeleteOlderThan(audit.RetentionCutoff(now, settings.RetentionDays))
if err != nil {
return audit.CleanupResult{}, false, err
}
}
if settings.RetentionMaxGB != audit.NoSizeLimitGB {
trimmed, err := c.trimToSize(now, settings.MaxLogSizeBytes())
if err != nil {
return audit.CleanupResult{}, false, err
}
removed += trimmed
}
if err := c.settings.SaveLastCleanup(now, removed); err != nil {
return audit.CleanupResult{}, false, err
@@ -38,6 +56,28 @@ func (c *CleanupActivityUseCase) Tick(now time.Time) (audit.CleanupResult, bool,
return audit.CleanupResult{Removed: removed}, true, nil
}
func (c *CleanupActivityUseCase) trimToSize(now time.Time, limitBytes int64) (int, error) {
guard := audit.GuardCutoff(now)
removed := 0
for {
size, err := c.events.SizeBytes()
if err != nil {
return removed, err
}
if size <= limitBytes {
return removed, nil
}
batch, err := c.events.DeleteOldest(c.batchSize, guard)
if err != nil {
return removed, err
}
if batch == 0 {
return removed, nil
}
removed += batch
}
}
func (c *CleanupActivityUseCase) recordCleanup(removed int) {
event := audit.NewEvent(audit.ActionCleanup, audit.CategorySystem)
event.Actor = audit.SystemActor()
@@ -0,0 +1,58 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"context"
"time"
)
type CreateVariantCommand struct {
Name string
VariantDefinitionCommand
GenerateOnUpload *bool
Enabled *bool
}
type CreateVariantUseCase struct {
variants image.VariantRepository
renderer image.VariantRenderer
now func() time.Time
recorder audit.Recorder
}
func NewCreateVariantUseCase(variants image.VariantRepository, renderer image.VariantRenderer, now func() time.Time, recorder audit.Recorder) *CreateVariantUseCase {
return &CreateVariantUseCase{variants, renderer, now, recorder}
}
func (u *CreateVariantUseCase) EncodableFormats() []image.VariantFormat {
return u.renderer.EncodableFormats()
}
func (u *CreateVariantUseCase) Create(ctx context.Context, command CreateVariantCommand) (VariantSummary, error) {
variant, err := u.build(command)
if err != nil {
return VariantSummary{}, err
}
event := audit.NewEvent(audit.ActionVariantCreate, audit.CategoryMedia)
event.Target = audit.VariantTarget(variant.Name)
created, err := u.variants.Create(variant)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return VariantSummary{}, err
}
event.Params = audit.VariantParams(created.Settings())
u.recorder.Record(ctx, event)
return VariantSummary{Variant: created}, nil
}
func (u *CreateVariantUseCase) build(command CreateVariantCommand) (image.Variant, error) {
settings, err := command.settings(u.renderer.EncodableFormats())
if err != nil {
return image.Variant{}, err
}
generateOnUpload := command.GenerateOnUpload != nil && *command.GenerateOnUpload
enabled := command.Enabled == nil || *command.Enabled
return image.NewVariant(command.Name, settings, generateOnUpload, enabled, u.now())
}
@@ -12,8 +12,8 @@ type DeleteMediaUseCase struct {
recorder audit.Recorder
}
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, recorder audit.Recorder) *DeleteMediaUseCase {
return &DeleteMediaUseCase{imageRepository, mediaRemover{imageRepository, storage}, recorder}
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder) *DeleteMediaUseCase {
return &DeleteMediaUseCase{imageRepository, newMediaRemover(imageRepository, storage, variantFiles), recorder}
}
func (u *DeleteMediaUseCase) Delete(ctx context.Context, id uint32) error {
@@ -0,0 +1,41 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"context"
)
type DeleteVariantUseCase struct {
variants image.VariantRepository
cache variantCache
recorder audit.Recorder
}
func NewDeleteVariantUseCase(variants image.VariantRepository, files image.VariantFileRepository, storage image.Storage, recorder audit.Recorder) *DeleteVariantUseCase {
return &DeleteVariantUseCase{variants, variantCache{files, storage}, recorder}
}
func (u *DeleteVariantUseCase) Delete(ctx context.Context, name string) error {
if _, err := u.variants.Get(name); err != nil {
return err
}
event := audit.NewEvent(audit.ActionVariantDelete, audit.CategoryMedia)
event.Target = audit.VariantTarget(name)
purged, err := u.cache.purgeVariant(name)
if err == nil {
err = u.variants.Delete(name)
}
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return err
}
event.Params = variantPurgeParams(purged)
u.recorder.Record(ctx, event)
return nil
}
func variantPurgeParams(purged VariantPurgeResult) map[string]any {
return map[string]any{audit.ParamPurgedFiles: purged.Files, audit.ParamPurgedBytes: purged.Bytes}
}
@@ -0,0 +1,100 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"context"
"slices"
)
type GenerateAllResult struct {
Generated int
Failed int
Failures []VariantFailure
Items []ImageVariantView
}
type GenerateAllImageVariantsUseCase struct {
images image.Repository
variants image.VariantRepository
files image.VariantFileRepository
renderer image.VariantRenderer
generator *ServeVariantUseCase
recorder audit.Recorder
}
func NewGenerateAllImageVariantsUseCase(images image.Repository, variants image.VariantRepository, files image.VariantFileRepository, renderer image.VariantRenderer, generator *ServeVariantUseCase, recorder audit.Recorder) *GenerateAllImageVariantsUseCase {
return &GenerateAllImageVariantsUseCase{images, variants, files, renderer, generator, recorder}
}
func (u *GenerateAllImageVariantsUseCase) Generate(ctx context.Context, id uint32) (GenerateAllResult, error) {
img, err := u.images.Get(id)
if err != nil {
return GenerateAllResult{}, err
}
if isVectorSource(img) {
return GenerateAllResult{}, image.ErrVariantSourceUnsupported
}
enabled, err := enabledVariants(u.variants)
if err != nil {
return GenerateAllResult{}, err
}
storedName := image.StoredName(img.Path)
rows, err := currentFiles(u.files, storedName)
if err != nil {
return GenerateAllResult{}, err
}
result := GenerateAllResult{Failures: []VariantFailure{}}
var firstFailure error
for _, variant := range enabled {
if isCurrent(variant, rows) {
continue
}
if _, err := u.generator.Generate(ctx, variant, storedName); err != nil {
result.Failed++
result.Failures = append(result.Failures, VariantFailure{Name: variant.Name, Cause: generationCause(err)})
if firstFailure == nil {
firstFailure = err
}
continue
}
result.Generated++
}
refreshed, err := currentFiles(u.files, storedName)
if err != nil {
return GenerateAllResult{}, err
}
result.Items = viewsOf(enabled, img, refreshed, u.renderer.EncodableFormats())
u.record(ctx, img, result, firstFailure)
return result, nil
}
func (u *GenerateAllImageVariantsUseCase) record(ctx context.Context, img image.Image, result GenerateAllResult, firstFailure error) {
if result.Generated == 0 && result.Failed == 0 {
return
}
event := audit.NewEvent(audit.ActionVariantGenerateAll, audit.CategoryMedia)
event.Target = imageTarget(img)
event.Params = map[string]any{
audit.ParamGenerated: result.Generated,
audit.ParamFailed: result.Failed,
audit.ParamCauses: distinctCauses(result.Failures),
}
if result.Generated == 0 {
failFrom(ctx, u.recorder, event, reasonFor(firstFailure))
return
}
u.recorder.Record(ctx, event)
}
func distinctCauses(failures []VariantFailure) []string {
causes := []string{}
for _, failure := range failures {
if !slices.Contains(causes, failure.Cause) {
causes = append(causes, failure.Cause)
}
}
return causes
}
@@ -0,0 +1,44 @@
package useCases
import (
"RenewCMS/internal/domain/image"
"context"
)
type GenerateImageVariantUseCase struct {
images image.Repository
variants image.VariantRepository
files image.VariantFileRepository
renderer image.VariantRenderer
generator *ServeVariantUseCase
}
func NewGenerateImageVariantUseCase(images image.Repository, variants image.VariantRepository, files image.VariantFileRepository, renderer image.VariantRenderer, generator *ServeVariantUseCase) *GenerateImageVariantUseCase {
return &GenerateImageVariantUseCase{images, variants, files, renderer, generator}
}
func (u *GenerateImageVariantUseCase) Generate(ctx context.Context, id uint32, name string) (ImageVariantView, error) {
img, err := u.images.Get(id)
if err != nil {
return ImageVariantView{}, err
}
variant, err := u.variants.Get(name)
if err != nil {
return ImageVariantView{}, err
}
if !variant.Enabled || variant.Name != name {
return ImageVariantView{}, image.ErrVariantNotFound
}
if isVectorSource(img) {
return ImageVariantView{}, image.ErrVariantSourceUnsupported
}
storedName := image.StoredName(img.Path)
if _, err := u.generator.Generate(ctx, variant, storedName); err != nil {
return ImageVariantView{}, err
}
rows, err := currentFiles(u.files, storedName)
if err != nil {
return ImageVariantView{}, err
}
return viewOf(variant, img, rows, u.renderer.EncodableFormats()), nil
}
@@ -0,0 +1,37 @@
package useCases
import (
"RenewCMS/internal/domain/image"
"context"
"log"
)
type GenerateVariantsOnUploadUseCase struct {
variants image.VariantRepository
generator *ServeVariantUseCase
logger *log.Logger
}
func NewGenerateVariantsOnUploadUseCase(variants image.VariantRepository, generator *ServeVariantUseCase, logger *log.Logger) *GenerateVariantsOnUploadUseCase {
return &GenerateVariantsOnUploadUseCase{variants, generator, logger}
}
func (u *GenerateVariantsOnUploadUseCase) Generate(ctx context.Context, img image.Image) {
if isVectorSource(img) {
return
}
enabled, err := enabledVariants(u.variants)
if err != nil {
u.logger.Printf("listing variants to generate on upload failed: image=%d: %v", img.ID, err)
return
}
storedName := image.StoredName(img.Path)
for _, variant := range enabled {
if !variant.GenerateOnUpload {
continue
}
if _, err := u.generator.Generate(ctx, variant, storedName); err != nil {
u.logger.Printf("generating variant on upload failed: variant=%s image=%d: %v", variant.Name, img.ID, err)
}
}
}
@@ -0,0 +1,170 @@
package useCases
import (
"RenewCMS/internal/domain/image"
"sort"
"time"
)
type VariantCacheEntry struct {
Name string
Enabled bool
Files int
Bytes int64
}
type VariantCacheImage struct {
ID uint32
Filename string
URL string
Files int
Bytes int64
}
type VariantCacheStats struct {
GeneratedFiles int
DiskUsed int64
VariantsDefined int
VariantsEnabled int
VariantsDisabled int
RejectedRequests int
RejectedWindowDays int
Unused image.VariantFileTotals
UnusedDays int
ByVariant []VariantCacheEntry
TopImages []VariantCacheImage
}
type GetVariantCacheStatsUseCase struct {
variants image.VariantRepository
files image.VariantFileRepository
cache image.VariantCacheRepository
images image.Repository
usage image.VariantUsageRepository
tracker image.VariantUsageTracker
now func() time.Time
}
func NewGetVariantCacheStatsUseCase(variants image.VariantRepository, files image.VariantFileRepository, cache image.VariantCacheRepository, images image.Repository, usage image.VariantUsageRepository, tracker image.VariantUsageTracker, now func() time.Time) *GetVariantCacheStatsUseCase {
return &GetVariantCacheStatsUseCase{variants, files, cache, images, usage, tracker, now}
}
func (u *GetVariantCacheStatsUseCase) Get() (VariantCacheStats, error) {
_ = u.tracker.Flush()
variants, err := u.variants.List()
if err != nil {
return VariantCacheStats{}, err
}
totals, err := u.files.Totals()
if err != nil {
return VariantCacheStats{}, err
}
now := u.now()
unused, err := u.cache.UnusedTotals(image.UnusedFilter{VariantNames: enabledNames(variants), Cutoff: image.UnusedCutoff(now)})
if err != nil {
return VariantCacheStats{}, err
}
rejected, err := u.usage.RejectedSince(image.RejectionWindowStart(now))
if err != nil {
return VariantCacheStats{}, err
}
top, err := u.topImages()
if err != nil {
return VariantCacheStats{}, err
}
stats := VariantCacheStats{
VariantsDefined: len(variants),
RejectedRequests: rejected,
RejectedWindowDays: image.RejectionWindowDays,
Unused: unused,
UnusedDays: image.UnusedVariantDays,
ByVariant: cacheEntries(variants, totals),
TopImages: top,
}
for _, entry := range stats.ByVariant {
stats.GeneratedFiles += entry.Files
stats.DiskUsed += entry.Bytes
if entry.Enabled {
stats.VariantsEnabled++
}
}
stats.VariantsDisabled = stats.VariantsDefined - stats.VariantsEnabled
return stats, nil
}
func enabledNames(variants []image.Variant) []string {
names := []string{}
for _, variant := range variants {
if variant.Enabled {
names = append(names, variant.Name)
}
}
return names
}
func cacheEntries(variants []image.Variant, totals map[string]image.VariantFileTotals) []VariantCacheEntry {
entries := make([]VariantCacheEntry, 0, len(variants))
for _, variant := range variants {
own := totals[variant.Name]
entries = append(entries, VariantCacheEntry{Name: variant.Name, Enabled: variant.Enabled, Files: own.Files, Bytes: own.Bytes})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].Bytes != entries[j].Bytes {
return entries[i].Bytes > entries[j].Bytes
}
return entries[i].Name < entries[j].Name
})
return entries
}
func (u *GetVariantCacheStatsUseCase) topImages() ([]VariantCacheImage, error) {
limit := image.TopImagesLimit + 1
for {
sources, err := u.cache.TopSources(limit)
if err != nil {
return nil, err
}
ranked, err := u.rank(sources)
if err != nil {
return nil, err
}
if len(sources) < limit || topIsSettled(ranked, sources) {
return ranked[:min(len(ranked), image.TopImagesLimit)], nil
}
limit *= 2
}
}
func topIsSettled(ranked []VariantCacheImage, sources []image.SourceTotals) bool {
return len(ranked) >= image.TopImagesLimit && ranked[image.TopImagesLimit-1].Bytes > sources[len(sources)-1].Bytes
}
func (u *GetVariantCacheStatsUseCase) rank(sources []image.SourceTotals) ([]VariantCacheImage, error) {
names := make([]string, 0, len(sources))
for _, source := range sources {
names = append(names, source.SourceName)
}
found, err := u.images.ListByStoredNames(names)
if err != nil {
return nil, err
}
byName := make(map[string]image.Image, len(found))
for _, img := range found {
byName[image.StoredName(img.Path)] = img
}
ranked := make([]VariantCacheImage, 0, len(sources))
for _, source := range sources {
if img, known := byName[source.SourceName]; known {
ranked = append(ranked, VariantCacheImage{ID: img.ID, Filename: img.Filename, URL: image.PublicPath(source.SourceName), Files: source.Files, Bytes: source.Bytes})
}
}
sort.SliceStable(ranked, func(i, j int) bool {
if ranked[i].Bytes != ranked[j].Bytes {
return ranked[i].Bytes > ranked[j].Bytes
}
return ranked[i].ID < ranked[j].ID
})
return ranked, nil
}
@@ -0,0 +1,33 @@
package useCases
import "RenewCMS/internal/domain/image"
type ListImageVariantsUseCase struct {
images image.Repository
variants image.VariantRepository
files image.VariantFileRepository
renderer image.VariantRenderer
}
func NewListImageVariantsUseCase(images image.Repository, variants image.VariantRepository, files image.VariantFileRepository, renderer image.VariantRenderer) *ListImageVariantsUseCase {
return &ListImageVariantsUseCase{images, variants, files, renderer}
}
func (u *ListImageVariantsUseCase) List(id uint32) (ImageVariantList, error) {
img, err := u.images.Get(id)
if err != nil {
return ImageVariantList{}, err
}
if isVectorSource(img) {
return ImageVariantList{Unsupported: unsupportedSVG, Items: []ImageVariantView{}}, nil
}
enabled, err := enabledVariants(u.variants)
if err != nil {
return ImageVariantList{}, err
}
rows, err := currentFiles(u.files, image.StoredName(img.Path))
if err != nil {
return ImageVariantList{}, err
}
return ImageVariantList{Items: viewsOf(enabled, img, rows, u.renderer.EncodableFormats())}, nil
}
+48 -9
View File
@@ -16,6 +16,9 @@ type MediaItem struct {
UploadedBy string
UsedIn int
Duplicate bool
VariantCount int
VariantSize int64
}
type MediaCounts struct {
@@ -34,10 +37,11 @@ type ListMediaUseCase struct {
imageRepository image.Repository
articleRepository article.Repository
userRepository user.Repository
variantFiles image.VariantFileRepository
}
func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository) *ListMediaUseCase {
return &ListMediaUseCase{imageRepository, articleRepository, userRepository}
func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository, variantFiles image.VariantFileRepository) *ListMediaUseCase {
return &ListMediaUseCase{imageRepository, articleRepository, userRepository, variantFiles}
}
func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
@@ -61,16 +65,20 @@ func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
if err != nil {
return MediaPage{}, err
}
variantTotals, err := l.variantFiles.TotalsBySource()
if err != nil {
return MediaPage{}, err
}
images, err := l.imageRepository.List(image.SearchQuery{Search: strings.TrimSpace(query.Search), Hash: query.Hash})
if err != nil {
return MediaPage{}, err
}
image.SortImages(images, query.Sort)
sortImages(images, query.Sort, variantTotals)
matching := make([]image.Image, 0, len(images))
for _, img := range images {
if matchesFilter(query.Filter, usage(img), duplicateHashes[img.SHA256] && img.SHA256 != "") {
if matchesVariants(query.Variants, variantTotals[image.StoredName(img.Path)].Files) && matchesFilter(query.Filter, usage(img), duplicateHashes[img.SHA256] && img.SHA256 != "") {
matching = append(matching, img)
}
}
@@ -84,11 +92,14 @@ func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
if err != nil {
return MediaPage{}, err
}
own := variantTotals[image.StoredName(img.Path)]
items = append(items, MediaItem{
Image: img,
UploadedBy: uploadedBy,
UsedIn: usage(img),
Duplicate: img.SHA256 != "" && duplicateHashes[img.SHA256],
Image: img,
UploadedBy: uploadedBy,
UsedIn: usage(img),
Duplicate: img.SHA256 != "" && duplicateHashes[img.SHA256],
VariantCount: own.Files,
VariantSize: own.Bytes,
})
}
@@ -118,7 +129,16 @@ func (l *ListMediaUseCase) Describe(img image.Image) (MediaItem, error) {
duplicate = len(sameContent) > 1
}
return MediaItem{Image: img, UploadedBy: uploadedBy, UsedIn: len(articles), Duplicate: duplicate}, nil
rows, err := l.variantFiles.ListBySource(image.StoredName(img.Path))
if err != nil {
return MediaItem{}, err
}
var variantSize int64
for _, row := range rows {
variantSize += row.Size
}
return MediaItem{Image: img, UploadedBy: uploadedBy, UsedIn: len(articles), Duplicate: duplicate, VariantCount: len(rows), VariantSize: variantSize}, nil
}
func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
@@ -168,6 +188,25 @@ func (l *ListMediaUseCase) counts(usage func(image.Image) int) (MediaCounts, err
return MediaCounts{All: libraryCounts.All, Unused: unused, Duplicates: libraryCounts.DuplicateGroups}, nil
}
func sortImages(images []image.Image, order image.Sort, totals map[string]image.VariantFileTotals) {
if order == image.SortTotalSize {
image.SortImagesByTotalSize(images, totals)
return
}
image.SortImages(images, order)
}
func matchesVariants(filter image.VariantsFilter, variantFiles int) bool {
switch filter {
case image.VariantsWith:
return variantFiles > 0
case image.VariantsWithout:
return variantFiles == 0
default:
return true
}
}
func matchesFilter(filter image.Filter, usedIn int, duplicate bool) bool {
switch filter {
case image.FilterUnused:
@@ -0,0 +1,28 @@
package useCases
import "RenewCMS/internal/domain/image"
type ListVariantsUseCase struct {
variants image.VariantRepository
files image.VariantFileRepository
}
func NewListVariantsUseCase(variants image.VariantRepository, files image.VariantFileRepository) *ListVariantsUseCase {
return &ListVariantsUseCase{variants, files}
}
func (u *ListVariantsUseCase) List() ([]VariantSummary, error) {
variants, err := u.variants.List()
if err != nil {
return nil, err
}
totals, err := u.files.Totals()
if err != nil {
return nil, err
}
summaries := make([]VariantSummary, 0, len(variants))
for _, variant := range variants {
summaries = append(summaries, summarize(variant, totals))
}
return summaries, nil
}
@@ -24,8 +24,8 @@ type MergeMediaUseCase struct {
recorder audit.Recorder
}
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, recorder audit.Recorder) *MergeMediaUseCase {
return &MergeMediaUseCase{imageRepository, articleRepository, mediaRemover{imageRepository, storage}, recorder}
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder) *MergeMediaUseCase {
return &MergeMediaUseCase{imageRepository, articleRepository, newMediaRemover(imageRepository, storage, variantFiles), recorder}
}
type bodyRewrite struct {
@@ -0,0 +1,52 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"context"
)
type PurgeImageVariantUseCase struct {
images image.Repository
variants image.VariantRepository
files image.VariantFileRepository
cache variantCache
recorder audit.Recorder
}
func NewPurgeImageVariantUseCase(images image.Repository, variants image.VariantRepository, files image.VariantFileRepository, storage image.Storage, recorder audit.Recorder) *PurgeImageVariantUseCase {
return &PurgeImageVariantUseCase{images, variants, files, variantCache{files, storage}, recorder}
}
func (u *PurgeImageVariantUseCase) Purge(ctx context.Context, id uint32, name string) (VariantPurgeResult, error) {
img, err := u.images.Get(id)
if err != nil {
return VariantPurgeResult{}, err
}
variant, err := u.variants.Get(name)
if err != nil {
return VariantPurgeResult{}, err
}
if variant.Name != name {
return VariantPurgeResult{}, image.ErrVariantNotFound
}
event := audit.NewEvent(audit.ActionVariantPurgeImage, audit.CategoryMedia)
event.Target = imageTarget(img)
row, found, err := u.files.Find(name, image.StoredName(img.Path))
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return VariantPurgeResult{}, err
}
if !found {
return VariantPurgeResult{}, nil
}
purged, err := u.cache.purge([]image.VariantFile{row})
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return VariantPurgeResult{}, err
}
event.Params = variantPurgeParams(purged)
u.recorder.Record(ctx, event)
return purged, nil
}
@@ -0,0 +1,36 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"context"
)
type PurgeVariantUseCase struct {
variants image.VariantRepository
cache variantCache
recorder audit.Recorder
}
func NewPurgeVariantUseCase(variants image.VariantRepository, files image.VariantFileRepository, storage image.Storage, recorder audit.Recorder) *PurgeVariantUseCase {
return &PurgeVariantUseCase{variants, variantCache{files, storage}, recorder}
}
func (u *PurgeVariantUseCase) Purge(ctx context.Context, name string) (VariantPurgeResult, error) {
if _, err := u.variants.Get(name); err != nil {
return VariantPurgeResult{}, err
}
event := audit.NewEvent(audit.ActionVariantPurge, audit.CategoryMedia)
event.Target = audit.VariantTarget(name)
purged, err := u.cache.purgeVariant(name)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return VariantPurgeResult{}, err
}
if purged.Files > 0 {
event.Params = variantPurgeParams(purged)
u.recorder.Record(ctx, event)
}
return purged, nil
}
Loaded 100 of 274 files, more files were not shown because too many files have changed in this diff. Show more