feat: image variants per image

This commit is contained in:
fsylvain committed 2026-10-08 19:57:51 +02:00
1 parent f567866161
commit 951fef18ba
92 files changed
+8695 -413

No files matched your search

+128 -116
View File
@@ -36,65 +36,70 @@ import (
) )
type UseCases struct { type UseCases struct {
apiKeyAuthOnce sync.Once apiKeyAuthOnce sync.Once
apiKeyAuthMiddleware func(http.Handler) http.Handler apiKeyAuthMiddleware func(http.Handler) http.Handler
Token *jwtauth.JWTAuth Token *jwtauth.JWTAuth
PurgeScheduler *scheduler.Runner PurgeScheduler *scheduler.Runner
ActivityCleanupScheduler *scheduler.Runner ActivityCleanupScheduler *scheduler.Runner
CreateArticleUseCase *useCases.CreateArticleUseCase CreateArticleUseCase *useCases.CreateArticleUseCase
GetArticleUseCase *useCases.GetArticleUseCase GetArticleUseCase *useCases.GetArticleUseCase
ListArticlesUseCase *useCases.ListArticlesUseCase ListArticlesUseCase *useCases.ListArticlesUseCase
UpdateArticleUseCase *useCases.UpdateArticleUseCase UpdateArticleUseCase *useCases.UpdateArticleUseCase
DeleteArticleUseCase *useCases.DeleteArticleUseCase DeleteArticleUseCase *useCases.DeleteArticleUseCase
GetUserUseCase *useCases.GetUserUseCase GetUserUseCase *useCases.GetUserUseCase
CreateUserUseCase *useCases.CreateUserUseCase CreateUserUseCase *useCases.CreateUserUseCase
AuthenticateUserUseCase *useCases.AuthenticateUserUseCase AuthenticateUserUseCase *useCases.AuthenticateUserUseCase
VerifyUserUseCase *useCases.VerifyUserUseCase VerifyUserUseCase *useCases.VerifyUserUseCase
CancelRegistrationUseCase *useCases.CancelRegistrationUseCase CancelRegistrationUseCase *useCases.CancelRegistrationUseCase
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
ResetPasswordUseCase *useCases.ResetPasswordUseCase ResetPasswordUseCase *useCases.ResetPasswordUseCase
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
ListUsersUseCase *useCases.ListUsersUseCase ListUsersUseCase *useCases.ListUsersUseCase
CreateInviteUseCase *useCases.CreateInviteUseCase CreateInviteUseCase *useCases.CreateInviteUseCase
CancelInviteUseCase *useCases.CancelInviteUseCase CancelInviteUseCase *useCases.CancelInviteUseCase
RenewInviteUseCase *useCases.RenewInviteUseCase RenewInviteUseCase *useCases.RenewInviteUseCase
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
GetInviteUseCase *useCases.GetInviteUseCase GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase AcceptInviteUseCase *useCases.AcceptInviteUseCase
SendMailUseCase *useCases.SendMailUseCase SendMailUseCase *useCases.SendMailUseCase
CreateImageUseCase *useCases.CreateImageUseCase CreateImageUseCase *useCases.CreateImageUseCase
OpenMediaUseCase *useCases.OpenMediaUseCase OpenMediaUseCase *useCases.OpenMediaUseCase
ListMediaUseCase *useCases.ListMediaUseCase ListMediaUseCase *useCases.ListMediaUseCase
RenameMediaUseCase *useCases.RenameMediaUseCase RenameMediaUseCase *useCases.RenameMediaUseCase
DeleteMediaUseCase *useCases.DeleteMediaUseCase DeleteMediaUseCase *useCases.DeleteMediaUseCase
MergeMediaUseCase *useCases.MergeMediaUseCase MergeMediaUseCase *useCases.MergeMediaUseCase
GetMediaUsageUseCase *useCases.GetMediaUsageUseCase GetMediaUsageUseCase *useCases.GetMediaUsageUseCase
GetPurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase GetPurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase
UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase
PreviewPurgeUseCase *useCases.PreviewPurgeUseCase PreviewPurgeUseCase *useCases.PreviewPurgeUseCase
RunPurgeUseCase *useCases.RunPurgeUseCase RunPurgeUseCase *useCases.RunPurgeUseCase
GetSettingsUseCase *useCases.GetSettingsUseCase GetSettingsUseCase *useCases.GetSettingsUseCase
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
RequestRestartUseCase *useCases.RequestRestartUseCase RequestRestartUseCase *useCases.RequestRestartUseCase
RecordRunningInfoUseCase *useCases.RecordRunningInfoUseCase RecordRunningInfoUseCase *useCases.RecordRunningInfoUseCase
CreateAPIKeyUseCase *useCases.CreateAPIKeyUseCase CreateAPIKeyUseCase *useCases.CreateAPIKeyUseCase
ListAPIKeysUseCase *useCases.ListAPIKeysUseCase ListAPIKeysUseCase *useCases.ListAPIKeysUseCase
RevokeAPIKeyUseCase *useCases.RevokeAPIKeyUseCase RevokeAPIKeyUseCase *useCases.RevokeAPIKeyUseCase
AuthenticateAPIKeyUseCase *useCases.AuthenticateAPIKeyUseCase AuthenticateAPIKeyUseCase *useCases.AuthenticateAPIKeyUseCase
ListActivityUseCase *useCases.ListActivityUseCase ListActivityUseCase *useCases.ListActivityUseCase
GetActivitySummaryUseCase *useCases.GetActivitySummaryUseCase GetActivitySummaryUseCase *useCases.GetActivitySummaryUseCase
ExportActivityUseCase *useCases.ExportActivityUseCase ExportActivityUseCase *useCases.ExportActivityUseCase
GetActivityRetentionUseCase *useCases.GetActivityRetentionUseCase GetActivityRetentionUseCase *useCases.GetActivityRetentionUseCase
UpdateActivityRetentionUseCase *useCases.UpdateActivityRetentionUseCase UpdateActivityRetentionUseCase *useCases.UpdateActivityRetentionUseCase
ListVariantsUseCase *useCases.ListVariantsUseCase ListVariantsUseCase *useCases.ListVariantsUseCase
CreateVariantUseCase *useCases.CreateVariantUseCase CreateVariantUseCase *useCases.CreateVariantUseCase
UpdateVariantUseCase *useCases.UpdateVariantUseCase UpdateVariantUseCase *useCases.UpdateVariantUseCase
SetVariantEnabledUseCase *useCases.SetVariantEnabledUseCase SetVariantEnabledUseCase *useCases.SetVariantEnabledUseCase
DeleteVariantUseCase *useCases.DeleteVariantUseCase DeleteVariantUseCase *useCases.DeleteVariantUseCase
PurgeVariantUseCase *useCases.PurgeVariantUseCase PurgeVariantUseCase *useCases.PurgeVariantUseCase
ServeVariantUseCase *useCases.ServeVariantUseCase ServeVariantUseCase *useCases.ServeVariantUseCase
ListImageVariantsUseCase *useCases.ListImageVariantsUseCase
GenerateImageVariantUseCase *useCases.GenerateImageVariantUseCase
GenerateAllImageVariantsUseCase *useCases.GenerateAllImageVariantsUseCase
PurgeImageVariantUseCase *useCases.PurgeImageVariantUseCase
GenerateVariantsOnUploadUseCase *useCases.GenerateVariantsOnUploadUseCase
} }
func (c *UseCases) RunSchedulers(ctx context.Context) { func (c *UseCases) RunSchedulers(ctx context.Context) {
@@ -285,64 +290,71 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge, recorder) purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge, recorder)
activityCleanup := useCases.NewCleanupActivityUseCase(repos.AuditRepo, repos.AuditSettingsRepo, recorder) activityCleanup := useCases.NewCleanupActivityUseCase(repos.AuditRepo, repos.AuditSettingsRepo, recorder)
serveVariant := useCases.NewServeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, repos.ImageRepo, imageStorage, repos.VariantRenderer, useCases.ServeVariantLimits{MaxSourceBytes: settings.ImageVariantMaxSourceBytes, MaxSourcePixels: settings.ImageVariantMaxSourcePixels, MaxConcurrentRenders: settings.ImageVariantMaxConcurrentRenders})
return &UseCases{ return &UseCases{
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()), PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()), ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil), Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder), CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer), GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer), ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo), GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder),
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher, recorder), VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher, recorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo), CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder), ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder), UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder),
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo), ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder), CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder),
RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo), ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo),
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher), GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, recorder), AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, recorder),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo), SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder), CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage), OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo), ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo, repos.VariantFileRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder), RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder), DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder),
MergeMediaUseCase: mergeMedia, MergeMediaUseCase: mergeMedia,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo), GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo), GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now), UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo), PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo),
RunPurgeUseCase: runPurge, RunPurgeUseCase: runPurge,
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version), GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now), GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now, recorder), CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now, recorder),
StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings, recorder), StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings, recorder),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now, 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), RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder), CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now), ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now, recorder), RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now, recorder),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now), AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now),
ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now), ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now),
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now), GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now),
ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now), ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, time.Now), GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, time.Now),
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, recorder, time.Now), UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, recorder, time.Now),
ListVariantsUseCase: useCases.NewListVariantsUseCase(repos.VariantRepo, repos.VariantFileRepo), ListVariantsUseCase: useCases.NewListVariantsUseCase(repos.VariantRepo, repos.VariantFileRepo),
CreateVariantUseCase: useCases.NewCreateVariantUseCase(repos.VariantRepo, repos.VariantRenderer, time.Now, recorder), CreateVariantUseCase: useCases.NewCreateVariantUseCase(repos.VariantRepo, repos.VariantRenderer, time.Now, recorder),
UpdateVariantUseCase: useCases.NewUpdateVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, 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), SetVariantEnabledUseCase: useCases.NewSetVariantEnabledUseCase(repos.VariantRepo, repos.VariantFileRepo, time.Now, recorder),
DeleteVariantUseCase: useCases.NewDeleteVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder), DeleteVariantUseCase: useCases.NewDeleteVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder),
PurgeVariantUseCase: useCases.NewPurgeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder), PurgeVariantUseCase: useCases.NewPurgeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder),
ServeVariantUseCase: useCases.NewServeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, repos.ImageRepo, imageStorage, repos.VariantRenderer, useCases.ServeVariantLimits{MaxSourceBytes: settings.ImageVariantMaxSourceBytes, MaxSourcePixels: settings.ImageVariantMaxSourcePixels, MaxConcurrentRenders: settings.ImageVariantMaxConcurrentRenders}), 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()),
} }
} }
+24 -1
View File
@@ -29,6 +29,11 @@ type Handler struct {
UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase
PreviewPurgeUseCase *useCases.PreviewPurgeUseCase PreviewPurgeUseCase *useCases.PreviewPurgeUseCase
RunPurgeUseCase *useCases.RunPurgeUseCase RunPurgeUseCase *useCases.RunPurgeUseCase
ListVariantsUseCase *useCases.ListImageVariantsUseCase
GenerateVariantUseCase *useCases.GenerateImageVariantUseCase
GenerateAllUseCase *useCases.GenerateAllImageVariantsUseCase
PurgeVariantUseCase *useCases.PurgeImageVariantUseCase
GenerateOnUploadUseCase *useCases.GenerateVariantsOnUploadUseCase
} }
type RenameRequest struct { type RenameRequest struct {
@@ -53,6 +58,9 @@ type mediaItemResponse struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UsedIn int `json:"used_in"` UsedIn int `json:"used_in"`
Duplicate bool `json:"duplicate"` Duplicate bool `json:"duplicate"`
VariantCount int `json:"variant_count"`
VariantSize int64 `json:"variant_size"`
} }
type countsResponse struct { type countsResponse struct {
@@ -78,7 +86,7 @@ type mergeResponse struct {
Merged int `json:"merged"` 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{ return &Handler{
CreateUseCase: create, CreateUseCase: create,
ListUseCase: list, ListUseCase: list,
@@ -90,6 +98,11 @@ func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUse
UpdatePurgeSettingsUseCase: updatePurgeSettings, UpdatePurgeSettingsUseCase: updatePurgeSettings,
PreviewPurgeUseCase: previewPurge, PreviewPurgeUseCase: previewPurge,
RunPurgeUseCase: runPurge, 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, CreatedAt: item.Image.CreatedAt,
UsedIn: item.UsedIn, UsedIn: item.UsedIn,
Duplicate: item.Duplicate, 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") { if values.Has("filter") {
query.Filter = domainImage.Filter(values.Get("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") { if values.Has("sort") {
query.Sort = domainImage.Sort(values.Get("sort")) query.Sort = domainImage.Sort(values.Get("sort"))
} }
@@ -219,6 +241,7 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
return return
} }
h.GenerateOnUploadUseCase.Generate(r.Context(), created)
h.writeItem(w, http.StatusCreated, 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})
}
+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 { func APIKeyAuth(authenticate *useCases.AuthenticateAPIKeyUseCase, maxRequests int, window time.Duration) func(http.Handler) http.Handler {
limiter := newKeyedRateLimiter(maxRequests, window, apiKeyRateLimitKey) limiter := newKeyedRateLimiter(maxRequests, window, apiKeyRateLimitKey)
+2
View File
@@ -51,5 +51,7 @@ const VariantInvalidErrorMessage = "The variant is not valid."
const VariantExistsErrorMessageFormat = "A variant named “%s” already exists. Choose another name." const VariantExistsErrorMessageFormat = "A variant named “%s” already exists. Choose another name."
const VariantFormatErrorMessageFormat = "Format must be %s." const VariantFormatErrorMessageFormat = "Format must be %s."
const VariantSourceTooLargeErrorMessage = "The source image is too large to generate this variant." 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 VariantUnknownMessage = "Unknown variant" const VariantUnknownMessage = "Unknown variant"
const VariantUnsupportedParameterMessage = "Unsupported parameter" const VariantUnsupportedParameterMessage = "Unsupported parameter"
+10 -1
View File
@@ -250,11 +250,12 @@ func NewAPIKeyRouter(container *UseCases, trustedProxies []string) http.Handler
func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler { func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter() 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(jwtauth.Verifier(container.Token))
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token)) r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
@@ -265,6 +266,14 @@ func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
r.Get("/purge-preview", mediaHandler.PreviewPurge) r.Get("/purge-preview", mediaHandler.PreviewPurge)
r.Post("/purge", mediaHandler.RunPurge) 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.Get("/", mediaHandler.List)
r.Post("/", mediaHandler.Upload) r.Post("/", mediaHandler.Upload)
r.Post("/merge", mediaHandler.Merge) r.Post("/merge", mediaHandler.Merge)
+34 -3
View File
@@ -23,7 +23,7 @@ for example `https://cms.example/v1/article/`.
| Group | Routes | | Group | Routes |
| --- | --- | | --- | --- |
| Articles | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} | | Articles | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
| Media | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin) | | 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 | | 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 | | Settings | GET /v1/settings |
@@ -62,7 +62,7 @@ never changes the response or the change itself.
| Category | Actions | | Category | Actions |
| --- | --- | | --- | --- |
| `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` | | `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` |
| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge` | | `media` | `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` |
| `users` | `user.register`, `user.verify`, `user.update_profile`, `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` | | `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` | | `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` |
@@ -73,6 +73,10 @@ kinds `text` and `body`; a body edit carries a signed character count delta and
change that leaves a value as it was (same body, same publish state, same file name, same display change that leaves a value as it was (same body, same publish state, same file name, same display
name) records nothing. Params: `media.merge` and `media.purge` and `system.media_purge` carry name) records nothing. Params: `media.merge` and `media.purge` and `system.media_purge` carry
`merged` and `articles_updated`, plus `failed_groups` when a group failed (still outcome `success`); `merged` and `articles_updated`, plus `failed_groups` when a group failed (still outcome `success`);
`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;
`system.update_checked` carries `update_available`; `system.restart_requested` carries `kind` and, `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 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 `ready` or `failed`, with the origin captured at request time; a refusal at the start is recorded
@@ -88,7 +92,10 @@ 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. 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 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 ## Image variants
Owners and admins manage named variants of the stored images. These routes are session only (no API Owners and admins manage named variants of the stored images. These routes are session only (no API
@@ -115,6 +122,30 @@ Errors are plain text: 400 for a body that is not valid JSON, 404 "Variant not f
named “thumb” already exists. Choose another name." and 422 with one sentence for the first invalid named “thumb” already exists. Choose another name." and 422 with one sentence for the first invalid
field (name, width and height, fit, format, quality). field (name, width and height, fit, format, quality).
### 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 ## Errors
Every error is a plain-text body with one sentence; the status is in the HTTP status line. The Every error is a plain-text body with one sentence; the status is in the HTTP status line. The
statuses are 400, 401, 403, 404, 409, 429 and 500. Messages are listed on the Integration page. statuses are 400, 401, 403, 404, 409, 429 and 500. Messages are listed on the Integration page.
@@ -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.
+5
View File
@@ -82,6 +82,8 @@ const (
ActionVariantEnable = "media.variant_enable" ActionVariantEnable = "media.variant_enable"
ActionVariantDisable = "media.variant_disable" ActionVariantDisable = "media.variant_disable"
ActionVariantPurge = "media.variant_purge" ActionVariantPurge = "media.variant_purge"
ActionVariantGenerateAll = "media.variant_generate_all"
ActionVariantPurgeImage = "media.variant_purge_image"
ActionUserRegister = "user.register" ActionUserRegister = "user.register"
ActionUserVerify = "user.verify" ActionUserVerify = "user.verify"
ActionUserUpdateProfile = "user.update_profile" ActionUserUpdateProfile = "user.update_profile"
@@ -121,6 +123,9 @@ const (
ParamKind = "kind" ParamKind = "kind"
ParamPurgedFiles = "purged_files" ParamPurgedFiles = "purged_files"
ParamPurgedBytes = "purged_bytes" ParamPurgedBytes = "purged_bytes"
ParamGenerated = "generated"
ParamFailed = "failed"
ParamCauses = "causes"
ParamWidth = "width" ParamWidth = "width"
ParamHeight = "height" ParamHeight = "height"
ParamFit = "fit" ParamFit = "fit"
+39 -11
View File
@@ -99,10 +99,11 @@ func StoredName(imagePath string) string {
type Sort string type Sort string
const ( const (
SortNewest Sort = "newest" SortNewest Sort = "newest"
SortOldest Sort = "oldest" SortOldest Sort = "oldest"
SortName Sort = "name" SortName Sort = "name"
SortLargest Sort = "largest" SortLargest Sort = "largest"
SortTotalSize Sort = "total_size"
) )
type Filter string type Filter string
@@ -113,13 +114,22 @@ const (
FilterDuplicates Filter = "duplicates" FilterDuplicates Filter = "duplicates"
) )
type VariantsFilter string
const (
VariantsAny VariantsFilter = ""
VariantsWith VariantsFilter = "with"
VariantsWithout VariantsFilter = "without"
)
type ListQuery struct { type ListQuery struct {
Search string Search string
Hash string Hash string
Filter Filter Filter Filter
Sort Sort Variants VariantsFilter
Limit int Sort Sort
Cursor string Limit int
Cursor string
} }
func NewListQuery() ListQuery { func NewListQuery() ListQuery {
@@ -133,7 +143,12 @@ func (q ListQuery) Validate() error {
return ErrInvalidQuery return ErrInvalidQuery
} }
switch q.Sort { 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: default:
return ErrInvalidQuery 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 { type Counts struct {
All int All int
DuplicateGroups int DuplicateGroups int
+63
View File
@@ -4,7 +4,9 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors" "errors"
"math"
"regexp" "regexp"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -17,6 +19,8 @@ var ErrVariantFormatUnavailable = errors.New("variant format cannot be encoded")
var ErrUnknownVariant = errors.New("unknown variant") var ErrUnknownVariant = errors.New("unknown variant")
var ErrUnsupportedParameter = errors.New("unsupported parameter") var ErrUnsupportedParameter = errors.New("unsupported parameter")
var ErrSourceTooLarge = errors.New("source image is too large to generate this variant") 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 type VariantFit string
@@ -220,3 +224,62 @@ type VariantFileTotals struct {
Files int Files int
Bytes int64 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
}
@@ -17,6 +17,7 @@ type VariantFileRepository interface {
ListBySource(sourceName string) ([]VariantFile, error) ListBySource(sourceName string) ([]VariantFile, error)
Delete(id uint32) error Delete(id uint32) error
Totals() (map[string]VariantFileTotals, error) Totals() (map[string]VariantFileTotals, error)
TotalsBySource() (map[string]VariantFileTotals, error)
} }
type RenderedVariant struct { type RenderedVariant struct {
@@ -13,43 +13,26 @@ type layout struct {
} }
func layoutFor(source image.Point, settings domain.VariantSettings) layout { 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 { if settings.Width == nil || settings.Height == nil {
return proportionalLayout(source, settings) return wholeSourceLayout(source, output)
} }
box := image.Pt(*settings.Width, *settings.Height)
switch settings.Fit { switch settings.Fit {
case domain.FitContain: case domain.FitContain:
return containLayout(source, box) return containLayout(source, output)
case domain.FitInside: case domain.FitInside:
return insideLayout(source, box) return wholeSourceLayout(source, output)
default: default:
return coverLayout(source, box) return coverLayout(source, output)
} }
} }
func proportionalLayout(source image.Point, settings domain.VariantSettings) layout {
scale := 1.0
if settings.Width != nil {
scale = math.Min(scale, ratio(*settings.Width, source.X))
}
if settings.Height != nil {
scale = math.Min(scale, ratio(*settings.Height, source.Y))
}
return wholeSourceLayout(source, scaled(source, scale))
}
func insideLayout(source image.Point, box image.Point) layout {
scale := math.Min(1, math.Min(ratio(box.X, source.X), ratio(box.Y, source.Y)))
return wholeSourceLayout(source, scaled(source, scale))
}
func wholeSourceLayout(source image.Point, output image.Point) layout { func wholeSourceLayout(source image.Point, output image.Point) layout {
return layout{canvas: output, source: image.Rectangle{Max: source}, content: image.Rectangle{Max: output}} return layout{canvas: output, source: image.Rectangle{Max: source}, content: image.Rectangle{Max: output}}
} }
func coverLayout(source image.Point, box image.Point) layout { func coverLayout(source image.Point, output image.Point) layout {
shrink := math.Min(1, math.Min(ratio(source.X, box.X), ratio(source.Y, box.Y)))
output := scaled(box, shrink)
return layout{canvas: output, source: centeredCrop(source, output), content: image.Rectangle{Max: output}} return layout{canvas: output, source: centeredCrop(source, output), content: image.Rectangle{Max: output}}
} }
@@ -70,6 +70,26 @@ func (v *VariantFileRepository) Delete(id uint32) error {
return v.db.Delete(&entity.ImageVariantFile{}, id).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
}
func (v *VariantFileRepository) Totals() (map[string]domain.VariantFileTotals, error) { func (v *VariantFileRepository) Totals() (map[string]domain.VariantFileTotals, error) {
var rows []struct { var rows []struct {
VariantName string VariantName string
@@ -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,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 UploadedBy string
UsedIn int UsedIn int
Duplicate bool Duplicate bool
VariantCount int
VariantSize int64
} }
type MediaCounts struct { type MediaCounts struct {
@@ -34,10 +37,11 @@ type ListMediaUseCase struct {
imageRepository image.Repository imageRepository image.Repository
articleRepository article.Repository articleRepository article.Repository
userRepository user.Repository userRepository user.Repository
variantFiles image.VariantFileRepository
} }
func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository) *ListMediaUseCase { func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository, variantFiles image.VariantFileRepository) *ListMediaUseCase {
return &ListMediaUseCase{imageRepository, articleRepository, userRepository} return &ListMediaUseCase{imageRepository, articleRepository, userRepository, variantFiles}
} }
func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) { 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 { if err != nil {
return MediaPage{}, err 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}) images, err := l.imageRepository.List(image.SearchQuery{Search: strings.TrimSpace(query.Search), Hash: query.Hash})
if err != nil { if err != nil {
return MediaPage{}, err return MediaPage{}, err
} }
image.SortImages(images, query.Sort) sortImages(images, query.Sort, variantTotals)
matching := make([]image.Image, 0, len(images)) matching := make([]image.Image, 0, len(images))
for _, img := range 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) matching = append(matching, img)
} }
} }
@@ -84,11 +92,14 @@ func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
if err != nil { if err != nil {
return MediaPage{}, err return MediaPage{}, err
} }
own := variantTotals[image.StoredName(img.Path)]
items = append(items, MediaItem{ items = append(items, MediaItem{
Image: img, Image: img,
UploadedBy: uploadedBy, UploadedBy: uploadedBy,
UsedIn: usage(img), UsedIn: usage(img),
Duplicate: img.SHA256 != "" && duplicateHashes[img.SHA256], 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 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) { 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 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 { func matchesFilter(filter image.Filter, usedIn int, duplicate bool) bool {
switch filter { switch filter {
case image.FilterUnused: case image.FilterUnused:
@@ -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
}
@@ -5,6 +5,7 @@ import (
"bytes" "bytes"
"context" "context"
"errors" "errors"
"fmt"
"io" "io"
"path" "path"
"slices" "slices"
@@ -263,7 +264,7 @@ func (u *ServeVariantUseCase) render(ctx context.Context, settings image.Variant
func (u *ServeVariantUseCase) checkDeclaredPixels(data []byte) error { func (u *ServeVariantUseCase) checkDeclaredPixels(data []byte) error {
width, height, err := u.renderer.DeclaredSize(bytes.NewReader(data)) width, height, err := u.renderer.DeclaredSize(bytes.NewReader(data))
if err != nil { if err != nil {
return err return fmt.Errorf("%w: %v", image.ErrVariantSourceUnreadable, err)
} }
if int64(width)*int64(height) > u.maxSourcePixels { if int64(width)*int64(height) > u.maxSourcePixels {
return image.ErrSourceTooLarge return image.ErrSourceTooLarge
@@ -0,0 +1,33 @@
package useCases
import (
"RenewCMS/internal/domain/image"
"context"
)
func (u *ServeVariantUseCase) Generate(ctx context.Context, variant image.Variant, storedName string) (image.VariantFile, error) {
source, err := u.storage.Open(storedName)
if err != nil {
return image.VariantFile{}, err
}
defer source.Content.Close()
if servedAsIs(storedName, source.Content) {
return image.VariantFile{}, image.ErrVariantSourceUnsupported
}
if source.Size > u.maxSourceBytes {
return image.VariantFile{}, image.ErrSourceTooLarge
}
settings := variant.Settings()
fingerprint := settings.Fingerprint()
served, existing, hit, err := u.lookupCached(variant.Name, storedName, fingerprint, image.VariantETag(settings, storedName))
if err != nil {
return image.VariantFile{}, err
}
if hit {
_ = served.Content.Close()
return existing, nil
}
return u.generated(ctx, variant, storedName, fingerprint, existing)
}
@@ -29,6 +29,9 @@ var failureReasons = []failureReason{
{image.ErrInvalidMergeRequest, "The merge request is not valid."}, {image.ErrInvalidMergeRequest, "The merge request is not valid."},
{image.ErrMergeCopyMissing, "One of the copies no longer exists."}, {image.ErrMergeCopyMissing, "One of the copies no longer exists."},
{image.ErrMergeHashMismatch, "The images are not identical copies."}, {image.ErrMergeHashMismatch, "The images are not identical copies."},
{image.ErrSourceTooLarge, "The source image is too large to generate this variant."},
{image.ErrVariantSourceUnsupported, "Variants aren't generated for this format."},
{image.ErrVariantSourceUnreadable, "The source image can't be read, so this variant can't be generated."},
{image.ErrVariantExists, "A variant with that name already exists."}, {image.ErrVariantExists, "A variant with that name already exists."},
{purge.ErrPurgeConflict, "The library changed since the preview. Review it again."}, {purge.ErrPurgeConflict, "The library changed since the preview. Review it again."},
{purge.ErrPurgeBusy, "A purge is already running."}, {purge.ErrPurgeBusy, "A purge is already running."},
@@ -0,0 +1,108 @@
package useCases
import (
"RenewCMS/internal/domain/image"
"errors"
"slices"
"strings"
)
const (
svgContentType = "image/svg+xml"
unsupportedSVG = "svg"
causeTooLarge = "too_large"
causeUnsupported = "unsupported_format"
causeFailed = "failed"
variantURLQuery = "?" + image.VariantQueryParameter + "="
)
type ImageVariantView struct {
Variant image.Variant
OutputWidth int
OutputHeight int
OutputFormat image.VariantFormat
Generated bool
Size int64
URL string
}
type ImageVariantList struct {
Unsupported string
Items []ImageVariantView
}
type VariantFailure struct {
Name string
Cause string
}
func isVectorSource(img image.Image) bool {
return img.ContentType == svgContentType
}
func enabledVariants(variants image.VariantRepository) ([]image.Variant, error) {
listed, err := variants.List()
if err != nil {
return nil, err
}
enabled := make([]image.Variant, 0, len(listed))
for _, variant := range listed {
if variant.Enabled {
enabled = append(enabled, variant)
}
}
slices.SortFunc(enabled, func(a, b image.Variant) int { return strings.Compare(a.Name, b.Name) })
return enabled, nil
}
func currentFiles(files image.VariantFileRepository, storedName string) (map[string]image.VariantFile, error) {
rows, err := files.ListBySource(storedName)
if err != nil {
return nil, err
}
byVariant := make(map[string]image.VariantFile, len(rows))
for _, row := range rows {
byVariant[row.VariantName] = row
}
return byVariant, nil
}
func isCurrent(variant image.Variant, rows map[string]image.VariantFile) bool {
row, found := rows[variant.Name]
return found && row.Fingerprint == variant.Settings().Fingerprint()
}
func viewOf(variant image.Variant, img image.Image, rows map[string]image.VariantFile, encodable []image.VariantFormat) ImageVariantView {
width, height := image.OutputSize(img.Width, img.Height, variant.Settings())
view := ImageVariantView{
Variant: variant,
OutputWidth: width,
OutputHeight: height,
OutputFormat: image.OutputFormat(variant.Format, img.ContentType, encodable),
URL: image.PublicPath(image.StoredName(img.Path)) + variantURLQuery + variant.Name,
}
if isCurrent(variant, rows) {
view.Generated = true
view.Size = rows[variant.Name].Size
}
return view
}
func viewsOf(variants []image.Variant, img image.Image, rows map[string]image.VariantFile, encodable []image.VariantFormat) []ImageVariantView {
views := make([]ImageVariantView, 0, len(variants))
for _, variant := range variants {
views = append(views, viewOf(variant, img, rows, encodable))
}
return views
}
func generationCause(err error) string {
switch {
case errors.Is(err, image.ErrSourceTooLarge):
return causeTooLarge
case errors.Is(err, image.ErrVariantSourceUnreadable):
return causeUnsupported
default:
return causeFailed
}
}
+37 -36
View File
@@ -104,42 +104,43 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
purgeSettings := newMockPurgeSettingsRepo() purgeSettings := newMockPurgeSettingsRepo()
restartJobs := &fakeJobStore{} restartJobs := &fakeJobStore{}
env.container = &api.UseCases{ env.container = &api.UseCases{
Token: env.token, Token: env.token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, renderer, recorder), CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, renderer, recorder),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer), GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer), ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, recorder), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, recorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users), GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, hasher, mail, "https://cms.example.com", recorder), CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, hasher, mail, "https://cms.example.com", recorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(env.users, hasher, recorder), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(env.users, hasher, recorder),
VerifyUserUseCase: useCases.NewVerifyUserUseCase(env.users, hasher, recorder), VerifyUserUseCase: useCases.NewVerifyUserUseCase(env.users, hasher, recorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(env.users), CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(env.users),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(env.users, hasher, mail, "https://cms.example.com", recorder), RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(env.users, hasher, mail, "https://cms.example.com", recorder),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(env.users, hasher, env.keys, clock, recorder), ResetPasswordUseCase: useCases.NewResetPasswordUseCase(env.users, hasher, env.keys, clock, recorder),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(env.users, recorder), UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(env.users, recorder),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(env.users, env.invites, hasher, mail, "https://cms.example.com", recorder), CreateInviteUseCase: useCases.NewCreateInviteUseCase(env.users, env.invites, hasher, mail, "https://cms.example.com", recorder),
CancelInviteUseCase: useCases.NewCancelInviteUseCase(env.invites, recorder), CancelInviteUseCase: useCases.NewCancelInviteUseCase(env.invites, recorder),
RenewInviteUseCase: useCases.NewRenewInviteUseCase(env.users, env.invites, hasher, mail, "https://cms.example.com", recorder), RenewInviteUseCase: useCases.NewRenewInviteUseCase(env.users, env.invites, hasher, mail, "https://cms.example.com", recorder),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, env.invites), ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, env.invites),
GetInviteUseCase: useCases.NewGetInviteUseCase(env.invites, hasher), GetInviteUseCase: useCases.NewGetInviteUseCase(env.invites, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(env.users, env.invites, hasher, recorder), AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(env.users, env.invites, hasher, recorder),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage(), recorder), CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage(), recorder),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users), GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, recorder), ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo()),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), newMockVariantFileRepo(), recorder), RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, recorder),
MergeMediaUseCase: mergeMedia, DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), newMockVariantFileRepo(), recorder),
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, mergeMedia, purgeSettings, clock, log.New(io.Discard, "", 0), recorder), MergeMediaUseCase: mergeMedia,
RequestRestartUseCase: useCases.NewRequestRestartUseCase(env.updates, restartJobs, &fakeHelperDetector{installed: true}, &fakeResultStore{}, &fakeRestartRequestStore{}, apiKeyRunningVersion, clock, recorder), RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, mergeMedia, purgeSettings, clock, log.New(io.Discard, "", 0), recorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles), RequestRestartUseCase: useCases.NewRequestRestartUseCase(env.updates, restartJobs, &fakeHelperDetector{installed: true}, &fakeResultStore{}, &fakeRestartRequestStore{}, apiKeyRunningVersion, clock, recorder),
GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion), GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), newSizeEvents(t), time.Now), GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion),
GetUpdateStatusUseCase: newUpdateStatusUseCase(env.updates, &fakeJobStore{}, &fakeHelperDetector{}, apiKeyRunningVersion), GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), newSizeEvents(t), time.Now),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(env.feed, env.updates, apiKeyRunningVersion, clock, recorder), GetUpdateStatusUseCase: newUpdateStatusUseCase(env.updates, &fakeJobStore{}, &fakeHelperDetector{}, apiKeyRunningVersion),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(env.keys, env.generator, clock, recorder), CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(env.feed, env.updates, apiKeyRunningVersion, clock, recorder),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(env.keys, env.users, clock), CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(env.keys, env.generator, clock, recorder),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(env.keys, env.users, clock, recorder), ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(env.keys, env.users, clock),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, clock), RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(env.keys, env.users, clock, recorder),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, clock),
} }
router := chi.NewRouter() router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(env.container, env.settings)) router.Mount("/v1", api.InitBackendRoutes(env.container, env.settings))
+31 -2
View File
@@ -8,8 +8,11 @@ import (
domain "RenewCMS/internal/domain/image" domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -42,6 +45,9 @@ type variantEnv struct {
storage *mockVariantStorage storage *mockVariantStorage
renderer *countingRenderer renderer *countingRenderer
audit *activityHarness audit *activityHarness
articles *mockArticleRepo
keys *mockAPIKeyRepo
logs *bytes.Buffer
token *jwtauth.JWTAuth token *jwtauth.JWTAuth
now time.Time now time.Time
admin http.Handler admin http.Handler
@@ -70,6 +76,9 @@ func newVariantEnvLimits(t *testing.T, renderer *countingRenderer, limits useCas
storage: newMockVariantStorage(), storage: newMockVariantStorage(),
renderer: renderer, renderer: renderer,
audit: newActivityHarness(t), audit: newActivityHarness(t),
articles: newMockArticleRepo(),
keys: newMockAPIKeyRepo(),
logs: &bytes.Buffer{},
token: jwtauth.New("HS256", []byte("test-secret"), nil), token: jwtauth.New("HS256", []byte("test-secret"), nil),
now: time.Date(2026, time.October, 8, 9, 0, 0, 0, time.UTC), now: time.Date(2026, time.October, 8, 9, 0, 0, 0, time.UTC),
} }
@@ -94,22 +103,42 @@ func newVariantEnvLimits(t *testing.T, renderer *countingRenderer, limits useCas
func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases { func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases {
clock := func() time.Time { return e.now } clock := func() time.Time { return e.now }
recorder := e.audit.recorder recorder := e.audit.recorder
serve := useCases.NewServeVariantUseCase(e.variants, e.files, e.images, e.storage, e.renderer, limits)
return &api.UseCases{ return &api.UseCases{
Token: e.token, Token: e.token,
GetUserUseCase: useCases.NewGetUserUseCase(e.users), GetUserUseCase: useCases.NewGetUserUseCase(e.users),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage), OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder), DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(e.images, newMockArticleRepo(), e.storage, e.files, recorder), MergeMediaUseCase: useCases.NewMergeMediaUseCase(e.images, e.articles, e.storage, e.files, recorder),
ListVariantsUseCase: useCases.NewListVariantsUseCase(e.variants, e.files), ListVariantsUseCase: useCases.NewListVariantsUseCase(e.variants, e.files),
CreateVariantUseCase: useCases.NewCreateVariantUseCase(e.variants, e.renderer, clock, recorder), CreateVariantUseCase: useCases.NewCreateVariantUseCase(e.variants, e.renderer, clock, recorder),
UpdateVariantUseCase: useCases.NewUpdateVariantUseCase(e.variants, e.files, e.storage, e.renderer, clock, recorder), UpdateVariantUseCase: useCases.NewUpdateVariantUseCase(e.variants, e.files, e.storage, e.renderer, clock, recorder),
SetVariantEnabledUseCase: useCases.NewSetVariantEnabledUseCase(e.variants, e.files, clock, recorder), SetVariantEnabledUseCase: useCases.NewSetVariantEnabledUseCase(e.variants, e.files, clock, recorder),
DeleteVariantUseCase: useCases.NewDeleteVariantUseCase(e.variants, e.files, e.storage, recorder), DeleteVariantUseCase: useCases.NewDeleteVariantUseCase(e.variants, e.files, e.storage, recorder),
PurgeVariantUseCase: useCases.NewPurgeVariantUseCase(e.variants, e.files, e.storage, recorder), PurgeVariantUseCase: useCases.NewPurgeVariantUseCase(e.variants, e.files, e.storage, recorder),
ServeVariantUseCase: useCases.NewServeVariantUseCase(e.variants, e.files, e.images, e.storage, e.renderer, limits), ServeVariantUseCase: serve,
CreateImageUseCase: useCases.NewCreateImageUseCase(e.images, e.storage, recorder),
ListMediaUseCase: useCases.NewListMediaUseCase(e.images, e.articles, e.users, e.files),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(e.images, recorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(e.images, e.articles),
ListImageVariantsUseCase: useCases.NewListImageVariantsUseCase(e.images, e.variants, e.files, e.renderer),
GenerateImageVariantUseCase: useCases.NewGenerateImageVariantUseCase(e.images, e.variants, e.files, e.renderer, serve),
GenerateAllImageVariantsUseCase: useCases.NewGenerateAllImageVariantsUseCase(e.images, e.variants, e.files, e.renderer, serve, recorder),
PurgeImageVariantUseCase: useCases.NewPurgeImageVariantUseCase(e.images, e.variants, e.files, e.storage, recorder),
GenerateVariantsOnUploadUseCase: useCases.NewGenerateVariantsOnUploadUseCase(e.variants, serve, log.New(e.logs, "", 0)),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, hashing.NewSHA256KeyGenerator(), e.users, clock),
ListActivityUseCase: useCases.NewListActivityUseCase(e.audit.probe, e.users, e.articles, e.images, e.keys, clock),
} }
} }
func (e *variantEnv) issueAPIKey() string {
generator := hashing.NewSHA256KeyGenerator()
raw, prefix, hashed := generator.Generate()
e.keys.Create(apikey.APIKey{UserID: variantOwnerId, Name: "ci", Prefix: prefix, HashedKey: hashed, CreatedAt: e.now})
return raw
}
func (e *variantEnv) cookie(userId uint32, tokenVersion uint32) *http.Cookie { func (e *variantEnv) cookie(userId uint32, tokenVersion uint32) *http.Cookie {
e.t.Helper() e.t.Helper()
_, signed, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion}) _, signed, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
@@ -0,0 +1,176 @@
package test
import (
domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
const uploadedFilename = "photo.png"
func (e *variantEnv) seedUploadVariants() {
seedVariant(e.variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
seedVariant(e.variants, "hero", 1200, 630, domain.FitCover, domain.FormatJPEG, 82)
seedVariant(e.variants, "og", 1200, 630, domain.FitCover, domain.FormatJPEG, 80)
seedVariant(e.variants, "card", 800, 0, domain.FitInside, domain.FormatJPEG, 80)
e.flagOnUpload("thumb", true)
e.flagOnUpload("hero", true)
e.flagOnUpload("og", true)
e.disable("og")
}
func (e *variantEnv) flagOnUpload(name string, flag bool) {
variant := e.variants.stored(name)
variant.GenerateOnUpload = flag
e.variants.variants[name] = variant
}
func (e *variantEnv) upload(filename string, contentType string, content []byte) *httptest.ResponseRecorder {
e.t.Helper()
request := newMultipartFileRequest(e.t, http.MethodPost, "/v1/media", filename, contentType, content)
request.AddCookie(e.cookie(variantOwnerId, 0))
recorder := httptest.NewRecorder()
e.admin.ServeHTTP(recorder, request)
return recorder
}
func storedNameOf(t *testing.T, recorder *httptest.ResponseRecorder) string {
t.Helper()
return strings.TrimPrefix(decodeBody(t, recorder)["url"].(string), domain.PublicPathPrefix)
}
func TestGenerateVariantsOnUpload(t *testing.T) {
t.Run("Flagged and enabled variants are generated before the response", func(t *testing.T) {
env := newVariantEnv(t)
env.seedUploadVariants()
recorder := env.upload(uploadedFilename, "image/png", solidPNG(t, 1000, 800))
stored := storedNameOf(t, recorder)
body := decodeBody(t, recorder)
thumb, hasThumb, _ := env.files.Find("thumb", stored)
hero, hasHero, _ := env.files.Find("hero", stored)
_, hasOg, _ := env.files.Find("og", stored)
_, hasCard, _ := env.files.Find("card", stored)
if recorder.Code != 201 || !hasThumb || !hasHero || hasOg || hasCard || body["variant_count"] != 2.0 || body["variant_size"] != float64(thumb.Size+hero.Size) {
t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String())
}
if action, _ := env.lastEvent(); env.events() != 1 || action != "media.upload" {
t.Errorf("events = %d, last = %s", env.events(), action)
}
})
t.Run("A failing variant never fails the upload", func(t *testing.T) {
env := newVariantEnv(t)
env.seedUploadVariants()
heroFingerprint := env.variants.stored("hero").Settings().Fingerprint()
env.renderer.failFor = func(settings domain.VariantSettings) error {
if settings.Fingerprint() == heroFingerprint {
return errors.New("hero broke")
}
return nil
}
recorder := env.upload(uploadedFilename, "image/png", solidPNG(t, 1000, 800))
stored := storedNameOf(t, recorder)
_, hasThumb, _ := env.files.Find("thumb", stored)
_, hasHero, _ := env.files.Find("hero", stored)
if recorder.Code != 201 || !hasThumb || hasHero || decodeBody(t, recorder)["variant_count"] != 1.0 || len(env.images.images) != 1 {
t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String())
}
if logged := env.logs.String(); !strings.Contains(logged, "variant=hero") || !strings.Contains(logged, "image=1") {
t.Errorf("log = %q", logged)
}
})
t.Run("A source beyond a cap still uploads", func(t *testing.T) {
env := newVariantEnvLimits(t, newCountingRenderer(), useCases.ServeVariantLimits{MaxSourceBytes: variantDefaultCap, MaxSourcePixels: 100, MaxConcurrentRenders: variantDefaultRenders})
env.seedUploadVariants()
recorder := env.upload(uploadedFilename, "image/png", solidPNG(t, 1000, 800))
if recorder.Code != 201 || decodeBody(t, recorder)["variant_count"] != 0.0 {
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
}
})
t.Run("An SVG upload generates nothing", func(t *testing.T) {
env := newVariantEnv(t)
env.seedUploadVariants()
recorder := env.upload("logo.svg", "image/svg+xml", []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`))
if recorder.Code != 201 || env.renderer.renderCount() != 0 || decodeBody(t, recorder)["variant_count"] != 0.0 {
t.Errorf("response = %d %q, renders = %d", recorder.Code, recorder.Body.String(), env.renderer.renderCount())
}
})
t.Run("No flagged variant means no render", func(t *testing.T) {
env := newVariantEnv(t)
env.seedUploadVariants()
env.flagOnUpload("thumb", false)
env.flagOnUpload("hero", false)
recorder := env.upload(uploadedFilename, "image/png", solidPNG(t, 1000, 800))
if recorder.Code != 201 || env.renderer.renderCount() != 0 {
t.Errorf("status = %d, renders = %d", recorder.Code, env.renderer.renderCount())
}
})
t.Run("A refused upload generates nothing", func(t *testing.T) {
env := newVariantEnv(t)
env.seedUploadVariants()
recorder := env.upload("doc.pdf", "application/pdf", []byte("%PDF-1.4"))
if recorder.Code != 415 || env.renderer.renderCount() != 0 {
t.Errorf("status = %d, renders = %d", recorder.Code, env.renderer.renderCount())
}
})
t.Run("Changing the flag does not backfill existing images: creating a variant", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", `{"name": "wide", "width": 900, "generate_on_upload": true}`)
if recorder.Code != 201 || env.renderer.renderCount() != 0 || env.files.rowCount() != 0 {
t.Errorf("status = %d, renders = %d", recorder.Code, env.renderer.renderCount())
}
})
t.Run("Changing the flag does not backfill existing images: updating a variant", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/thumb", `{"width": 400, "height": 300, "format": "jpeg", "quality": 80, "generate_on_upload": true}`)
if recorder.Code != 200 || env.renderer.renderCount() != 0 || env.files.rowCount() != 0 {
t.Errorf("status = %d %q, renders = %d", recorder.Code, recorder.Body.String(), env.renderer.renderCount())
}
})
t.Run("Uploading the same bytes again generates files for the new image only", func(t *testing.T) {
env := newVariantEnv(t)
env.seedUploadVariants()
content := solidPNG(t, 1000, 800)
first := env.upload(uploadedFilename, "image/png", content)
firstStored := storedNameOf(t, first)
firstRow, _, _ := env.files.Find("thumb", firstStored)
second := env.upload(uploadedFilename, "image/png", content)
secondStored := storedNameOf(t, second)
secondRow, hasSecond, _ := env.files.Find("thumb", secondStored)
keptRow, _, _ := env.files.Find("thumb", firstStored)
if second.Code != 201 || secondStored == firstStored || !hasSecond || secondRow.CachedName == firstRow.CachedName || keptRow != firstRow {
t.Errorf("first = %q, second = %q", firstStored, secondStored)
}
})
}
+240
View File
@@ -0,0 +1,240 @@
package test
import (
domain "RenewCMS/internal/domain/image"
"errors"
"net/http"
"testing"
)
const (
tooLargeReason = "The source image is too large to generate this variant."
genericReason = "Something went wrong on our side. Please try again."
)
func (e *variantEnv) seedFiveEnabled() {
seedVariant(e.variants, "card", 800, 0, domain.FitInside, domain.FormatJPEG, 80)
seedVariant(e.variants, "hero", 1200, 630, domain.FitCover, domain.FormatJPEG, 82)
seedVariant(e.variants, "og", 1200, 630, domain.FitCover, domain.FormatJPEG, 80)
seedVariant(e.variants, "square", 600, 600, domain.FitCover, domain.FormatJPEG, 85)
seedVariant(e.variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
e.seedPerImage(1, "north-wall.jpg", "a1b2.png", "image/png", 1000, 800, solidPNG(e.t, 1000, 800))
}
func (e *variantEnv) failHero(cause error) {
heroFingerprint := e.variants.stored("hero").Settings().Fingerprint()
e.renderer.failFor = func(settings domain.VariantSettings) error {
if settings.Fingerprint() == heroFingerprint {
return cause
}
return nil
}
}
func assertImageTarget(t *testing.T, event map[string]any, id string, name string) {
t.Helper()
target := event["target"].(map[string]any)
if target["type"] != "image" || target["id"] != id || target["name"] != name || event["category"] != "media" {
t.Errorf("target = %v, category = %v", target, event["category"])
}
}
func TestPerImageVariantAudit(t *testing.T) {
t.Run("Generate all is recorded with the counts", func(t *testing.T) {
env := newVariantEnv(t)
env.seedFiveEnabled()
env.send(http.MethodPost, "/v1/media/1/variants", "", variantOwnerId)
action, event := env.lastEvent()
params := event["params"].(map[string]any)
causes, _ := params["causes"].([]any)
if env.events() != 1 || action != "media.variant_generate_all" || event["outcome"] != "success" || params["generated"] != 5.0 || params["failed"] != 0.0 || causes == nil || len(causes) != 0 {
t.Errorf("event = %v", event)
}
assertImageTarget(t, event, "1", "north-wall.jpg")
})
t.Run("A partial failure is a success that names its causes", func(t *testing.T) {
env := newVariantEnv(t)
env.seedFiveEnabled()
env.failHero(domain.ErrSourceTooLarge)
env.send(http.MethodPost, "/v1/media/1/variants", "", variantOwnerId)
_, event := env.lastEvent()
params := event["params"].(map[string]any)
causes := params["causes"].([]any)
if env.events() != 1 || event["outcome"] != "success" || params["generated"] != 4.0 || params["failed"] != 1.0 || len(causes) != 1 || causes[0] != "too_large" {
t.Errorf("event = %v", event)
}
})
t.Run("Every variant failing is a failed event with a plain reason", func(t *testing.T) {
env := newVariantEnv(t)
env.seedFiveEnabled()
env.renderer.failFor = func(domain.VariantSettings) error { return domain.ErrSourceTooLarge }
recorder := env.send(http.MethodPost, "/v1/media/1/variants", "", variantOwnerId)
body := decodeBody(t, recorder)
_, event := env.lastEvent()
if recorder.Code != 200 || body["generated"] != 0.0 || body["failed"] != 5.0 || env.events() != 1 || event["action"] != "media.variant_generate_all" || event["outcome"] != "failed" || event["failure_reason"] != tooLargeReason {
t.Errorf("response = %d %q, event = %v", recorder.Code, recorder.Body.String(), event)
}
})
t.Run("The failure reasons of the other causes are plain sentences", func(t *testing.T) {
causes := map[string]error{
"Variants aren't generated for this format.": domain.ErrVariantSourceUnsupported,
"The source image can't be read, so this variant can't be generated.": domain.ErrVariantSourceUnreadable,
genericReason: errors.New("boom"),
}
for reason, cause := range causes {
env := newVariantEnv(t)
env.seedFiveEnabled()
env.renderer.failFor = func(domain.VariantSettings) error { return cause }
env.send(http.MethodPost, "/v1/media/1/variants", "", variantOwnerId)
if _, event := env.lastEvent(); event["failure_reason"] != reason {
t.Errorf("reason for %v = %v, want %q", cause, event["failure_reason"], reason)
}
}
})
noEvent := []struct {
name string
prepare func(env *variantEnv)
method string
path string
}{
{"all five are already generated", func(env *variantEnv) {
env.seedFiveEnabled()
env.send(http.MethodPost, "/v1/media/1/variants", "", variantOwnerId)
}, http.MethodPost, "/v1/media/1/variants"},
{"no variant is enabled", func(env *variantEnv) { env.seedPerImage(1, "north-wall.jpg", "a1b2.png", "image/png", 10, 10, nil) }, http.MethodPost, "/v1/media/1/variants"},
{"an SVG", func(env *variantEnv) {
env.seedFiveEnabled()
env.seedPerImage(1, "logo.svg", "e5f6.svg", "image/svg+xml", 0, 0, nil)
}, http.MethodPost, "/v1/media/1/variants"},
{"a single generation", func(env *variantEnv) { env.seedFiveEnabled() }, http.MethodPost, "/v1/media/1/variants/thumb"},
{"an unknown image", func(env *variantEnv) { env.seedFiveEnabled() }, http.MethodPost, "/v1/media/9999/variants"},
{"a listing", func(env *variantEnv) { env.seedFiveEnabled() }, http.MethodGet, "/v1/media/1/variants"},
}
for _, row := range noEvent {
t.Run("Nothing was done, nothing is recorded: "+row.name, func(t *testing.T) {
env := newVariantEnv(t)
row.prepare(env)
before := env.events()
env.send(row.method, row.path, "", variantOwnerId)
if env.events() != before {
t.Errorf("events = %d, want %d", env.events(), before)
}
})
}
t.Run("Nothing was done, nothing is recorded: a public generation", func(t *testing.T) {
env := newVariantEnv(t)
env.seedFiveEnabled()
recorder := env.get("/static/uploadedImages/a1b2.png?v=thumb", nil)
if recorder.Code != 200 || env.events() != 0 {
t.Errorf("status = %d, events = %d", recorder.Code, env.events())
}
})
t.Run("Purging the files of one variant of an image is recorded", func(t *testing.T) {
env := newVariantEnv(t)
env.seedFiveEnabled()
env.seedCurrentFile("thumb", "a1b2.png", 18432)
env.send(http.MethodDelete, "/v1/media/1/variants/thumb", "", variantOwnerId)
action, event := env.lastEvent()
params := event["params"].(map[string]any)
if env.events() != 1 || action != "media.variant_purge_image" || event["outcome"] != "success" || params["purged_files"] != 1.0 || params["purged_bytes"] != 18432.0 {
t.Errorf("event = %v", event)
}
assertImageTarget(t, event, "1", "north-wall.jpg")
})
purgeNoEvent := []struct{ name, path string }{
{"nothing is generated", "/v1/media/1/variants/thumb"},
{"the variant is unknown", "/v1/media/1/variants/missing"},
{"the image is unknown", "/v1/media/9999/variants/thumb"},
}
for _, row := range purgeNoEvent {
t.Run("A purge that removes nothing, or finds nothing, is not recorded: "+row.name, func(t *testing.T) {
env := newVariantEnv(t)
env.seedFiveEnabled()
env.send(http.MethodDelete, row.path, "", variantOwnerId)
if env.events() != 0 {
t.Errorf("events = %d", env.events())
}
})
}
t.Run("A purge that fails on storage is recorded as failed", func(t *testing.T) {
env := newVariantEnv(t)
env.seedFiveEnabled()
env.seedCurrentFile("thumb", "a1b2.png", 18432)
env.storage.failDelete["cached-thumb-a1b2.png"] = errVariantStorageFailure
recorder := env.send(http.MethodDelete, "/v1/media/1/variants/thumb", "", variantOwnerId)
action, event := env.lastEvent()
if recorder.Code != 500 || env.events() != 1 || action != "media.variant_purge_image" || event["outcome"] != "failed" || event["failure_reason"] != genericReason {
t.Errorf("status = %d, event = %v", recorder.Code, event)
}
})
t.Run("A failing audit write never changes the response", func(t *testing.T) {
env := newVariantEnv(t)
env.seedFiveEnabled()
env.audit.probe.appendErr = errDiskFull
recorder := env.send(http.MethodPost, "/v1/media/1/variants", "", variantOwnerId)
if recorder.Code != 200 || decodeBody(t, recorder)["generated"] != 5.0 || env.files.rowCount() != 5 {
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
}
})
t.Run("The new events read back through the existing activity API", func(t *testing.T) {
env := newVariantEnv(t)
env.seedFiveEnabled()
env.send(http.MethodPost, "/v1/media/1/variants", "", variantOwnerId)
env.send(http.MethodDelete, "/v1/media/1/variants/thumb", "", variantOwnerId)
recorder := env.send(http.MethodGet, "/v1/activity?category=media", "", variantOwnerId)
items := itemsOf(t, recorder)
actions := []any{}
for _, item := range items {
actions = append(actions, item["action"])
if item["target"].(map[string]any)["type"] != "image" || item["params"] == nil {
t.Errorf("item = %v", item)
}
}
if recorder.Code != 200 || len(items) != 2 || actions[0] != "media.variant_purge_image" || actions[1] != "media.variant_generate_all" {
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
}
})
t.Run("An upload that generates files records only the upload", func(t *testing.T) {
env := newVariantEnv(t)
env.seedUploadVariants()
recorder := env.upload(uploadedFilename, "image/png", solidPNG(t, 1000, 800))
if action, _ := env.lastEvent(); recorder.Code != 201 || env.events() != 1 || action != "media.upload" {
t.Errorf("status = %d, events = %d", recorder.Code, env.events())
}
})
}
@@ -0,0 +1,138 @@
package test
import (
domain "RenewCMS/internal/domain/image"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"strings"
"testing"
)
func TestOutputGeometry(t *testing.T) {
width := func(value int) *int { return &value }
rows := []struct {
name string
sw, sh int
settings domain.VariantSettings
wantW, wantH int
}{
{"cover with one side", 4032, 3024, domain.VariantSettings{Width: width(800), Fit: domain.FitCover}, 800, 600},
{"cover with both sides keeps the box ratio", 4032, 3024, domain.VariantSettings{Width: width(1000), Height: width(1000), Fit: domain.FitCover}, 1000, 1000},
{"cover shrinks the box to fit the source", 300, 200, domain.VariantSettings{Width: width(400), Height: width(300), Fit: domain.FitCover}, 267, 200},
{"contain is the box", 300, 200, domain.VariantSettings{Width: width(400), Height: width(300), Fit: domain.FitContain}, 400, 300},
{"inside never upscales", 300, 200, domain.VariantSettings{Width: width(1200), Height: width(1200), Fit: domain.FitInside}, 300, 200},
{"inside keeps the source ratio", 4032, 3024, domain.VariantSettings{Width: width(1000), Height: width(1000), Fit: domain.FitInside}, 1000, 750},
{"height only", 4032, 3024, domain.VariantSettings{Height: width(2400), Fit: domain.FitInside}, 3200, 2400},
{"unknown source size", 0, 0, domain.VariantSettings{Width: width(10), Fit: domain.FitCover}, 0, 0},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
gotW, gotH := domain.OutputSize(row.sw, row.sh, row.settings)
if gotW != row.wantW || gotH != row.wantH {
t.Errorf("size = %d x %d, want %d x %d", gotW, gotH, row.wantW, row.wantH)
}
})
}
}
func TestOutputFormat(t *testing.T) {
all := []domain.VariantFormat{domain.FormatOriginal, domain.FormatJPEG, domain.FormatPNG, domain.FormatWebP}
withoutWebP := []domain.VariantFormat{domain.FormatOriginal, domain.FormatJPEG, domain.FormatPNG}
rows := []struct {
format domain.VariantFormat
contentType string
encodable []domain.VariantFormat
want domain.VariantFormat
}{
{domain.FormatOriginal, "image/jpeg", all, domain.FormatJPEG},
{domain.FormatOriginal, "image/png", all, domain.FormatPNG},
{domain.FormatOriginal, "image/webp", all, domain.FormatWebP},
{domain.FormatOriginal, "image/webp", withoutWebP, domain.FormatPNG},
{domain.FormatOriginal, "image/gif", all, domain.FormatPNG},
{domain.FormatWebP, "image/jpeg", withoutWebP, domain.FormatPNG},
{domain.FormatJPEG, "image/png", all, domain.FormatJPEG},
}
for _, row := range rows {
if got := domain.OutputFormat(row.format, row.contentType, row.encodable); got != row.want {
t.Errorf("%s over %s = %s, want %s", row.format, row.contentType, got, row.want)
}
}
}
func TestMediaListQueryVariants(t *testing.T) {
t.Run("The variants filter and the total_size sort are valid", func(t *testing.T) {
query := domain.NewListQuery()
query.Variants, query.Sort = domain.VariantsWith, domain.SortTotalSize
if err := query.Validate(); err != nil {
t.Errorf("err = %v", err)
}
})
t.Run("Any other variants filter is invalid", func(t *testing.T) {
query := domain.NewListQuery()
query.Variants = "bogus"
if err := query.Validate(); !errors.Is(err, domain.ErrInvalidQuery) {
t.Errorf("err = %v", err)
}
})
}
func TestVariantFileRepositoryTotalsBySource(t *testing.T) {
repo := persistence.NewVariantFileRepository(openActivityDatabase(t))
save := func(variant string, source string, size int64) {
_, _ = repo.Save(domain.VariantFile{VariantName: variant, SourceName: source, CachedName: variant + source, ContentType: "image/jpeg", Size: size, Fingerprint: "f"})
}
save("hero", "a.png", 1000)
save("thumb", "a.png", 500)
save("hero", "b.png", 2000)
totals, err := repo.TotalsBySource()
if err != nil || len(totals) != 2 || totals["a.png"].Files != 2 || totals["a.png"].Bytes != 1500 || totals["b.png"].Files != 1 || totals["b.png"].Bytes != 2000 {
t.Errorf("totals = %+v, err = %v", totals, err)
}
}
func TestPerImageVariantLayering(t *testing.T) {
t.Run("The new use cases import no persistence, mailer, models, gorm, net/http, chi or api package", func(t *testing.T) {
forbidden := []string{"persistence", "mailer", "models", "gorm", "net/http", "go-chi", "RenewCMS/api"}
files := []string{"ListImageVariants.go", "GenerateImageVariant.go", "GenerateAllImageVariants.go", "PurgeImageVariant.go", "GenerateVariantsOnUpload.go", "ServeVariantGenerate.go", "imageVariantView.go", "ListMedia.go"}
for _, name := range files {
for _, path := range importsOf(t, "internal/infrastructure/useCases/"+name) {
for _, fragment := range forbidden {
if strings.Contains(path, fragment) {
t.Errorf("%s imports %s", name, path)
}
}
}
}
})
t.Run("The media handler imports no persistence, mailer, models or gorm package", func(t *testing.T) {
for _, file := range []string{"api/handlers/media/mediaHandler.go", "api/handlers/media/variantsHandler.go"} {
for _, path := range importsOf(t, file) {
for _, fragment := range []string{"persistence", "mailer", "models", "gorm"} {
if strings.Contains(path, fragment) {
t.Errorf("%s imports %s", file, path)
}
}
}
}
})
t.Run("Only api/dependecyInjection.go constructs the new use cases", func(t *testing.T) {
composition := readRepositoryFile(t, "api/dependecyInjection.go")
for _, constructor := range []string{"NewListImageVariantsUseCase(", "NewGenerateImageVariantUseCase(", "NewGenerateAllImageVariantsUseCase(", "NewPurgeImageVariantUseCase(", "NewGenerateVariantsOnUploadUseCase("} {
if !strings.Contains(composition, constructor) {
t.Errorf("%s is not wired in the composition root", constructor)
}
if strings.Contains(readRepositoryFile(t, "api/router.go"), constructor) {
t.Errorf("api/router.go constructs %s", constructor)
}
}
})
}
+781
View File
@@ -0,0 +1,781 @@
package test
import (
"RenewCMS/api/pkg/apperr"
domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors"
"net/http"
"net/http/httptest"
"runtime"
"strconv"
"strings"
"sync"
"testing"
"time"
)
const (
listedStoredName = "a1b2.jpg"
svgStoredName = "e5f6.svg"
imageNotFoundBody = "Image not found"
internalErrorBody = "Internal server error"
unreadableBodyBytes = "not an image at all"
)
func (e *variantEnv) seedPerImage(id uint32, filename string, storedName string, contentType string, width int, height int, content []byte) domain.Image {
img := domain.Image{
ID: id,
Path: domain.PublicPath(storedName),
Filename: filename,
Size: int64(len(content)),
ContentType: contentType,
Width: width,
Height: height,
SHA256: "hash-" + strconv.Itoa(int(id)),
CreatedAt: mockImageEpoch.Add(time.Duration(id) * time.Minute),
}
e.images.images[id] = img
if id > e.images.nextID {
e.images.nextID = id
}
if content != nil {
e.storage.seed(storedName, content)
}
return img
}
func (e *variantEnv) seedCurrentFile(variantName string, storedName string, size int64) domain.VariantFile {
cached := "cached-" + variantName + "-" + storedName
e.storage.seed(cached, []byte("generated"))
saved, _ := e.files.Save(domain.VariantFile{
VariantName: variantName,
SourceName: storedName,
CachedName: cached,
ContentType: "image/webp",
Size: size,
Fingerprint: e.variants.stored(variantName).Settings().Fingerprint(),
})
return saved
}
func (e *variantEnv) disable(name string) {
variant := e.variants.stored(name)
variant.Enabled = false
e.variants.variants[name] = variant
}
func (e *variantEnv) seedListingVariants() {
seedVariant(e.variants, "card", 800, 0, domain.FitInside, domain.FormatWebP, 80)
seedVariant(e.variants, "hero", 1200, 630, domain.FitCover, domain.FormatWebP, 82)
seedVariant(e.variants, "og", 1200, 630, domain.FitCover, domain.FormatJPEG, 80)
seedVariant(e.variants, "print", 0, 2400, domain.FitInside, domain.FormatPNG, 0)
seedVariant(e.variants, "square", 600, 600, domain.FitCover, domain.FormatJPEG, 85)
seedVariant(e.variants, "thumb", 400, 300, domain.FitCover, domain.FormatWebP, 80)
e.disable("print")
}
func (e *variantEnv) seedListingBackground() {
e.seedListingVariants()
e.seedPerImage(1, "north-wall.jpg", listedStoredName, "image/jpeg", 4032, 3024, nil)
e.images.images[1] = withSize(e.images.images[1], 4089446)
e.seedPerImage(2, "logo.svg", svgStoredName, "image/svg+xml", 0, 0, nil)
e.seedCurrentFile("thumb", listedStoredName, 18432)
e.seedCurrentFile("card", listedStoredName, 65536)
e.seedCurrentFile("hero", listedStoredName, 98304)
e.seedCurrentFile("square", listedStoredName, 83968)
e.seedCurrentFile("print", listedStoredName, 3250586)
}
func withSize(img domain.Image, size int64) domain.Image {
img.Size = size
return img
}
func (e *variantEnv) seedGenerateBackground() {
e.seedVariantsForGeneration()
e.seedPerImage(1, "north-wall.jpg", "a1b2.png", "image/png", 1000, 800, encodePNG(e.t, variantSolidImage(1000, 800, variantRed)))
}
func (e *variantEnv) seedVariantsForGeneration() {
seedVariant(e.variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
seedVariant(e.variants, "hero", 1200, 630, domain.FitCover, domain.FormatJPEG, 82)
seedVariant(e.variants, "off", 300, 0, domain.FitInside, domain.FormatPNG, 0)
e.disable("off")
}
func itemsOf(t *testing.T, recorder *httptest.ResponseRecorder) []map[string]any {
t.Helper()
raw, ok := decodeBody(t, recorder)["items"].([]any)
if !ok {
t.Fatalf("items missing in %q", recorder.Body.String())
}
items := make([]map[string]any, 0, len(raw))
for _, entry := range raw {
items = append(items, entry.(map[string]any))
}
return items
}
func itemNames(items []map[string]any) []string {
names := make([]string, 0, len(items))
for _, item := range items {
names = append(names, item["name"].(string))
}
return names
}
func itemNamed(t *testing.T, items []map[string]any, name string) map[string]any {
t.Helper()
for _, item := range items {
if item["name"] == name {
return item
}
}
t.Fatalf("no item named %q in %v", name, itemNames(items))
return nil
}
func TestPerImageVariantRoutesAreGuarded(t *testing.T) {
rows := []struct {
method string
path string
credentials string
status int
body string
}{
{http.MethodGet, "/v1/media/1/variants", "none", 401, apperr.UnauthorizedErrorMessage},
{http.MethodGet, "/v1/media/1/variants", "stale", 401, apperr.UnauthorizedErrorMessage},
{http.MethodGet, "/v1/media/1/variants", "unverified", 403, apperr.VerificationRequiredErrorMessage},
{http.MethodGet, "/v1/media/1/variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodPost, "/v1/media/1/variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodPost, "/v1/media/1/variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodDelete, "/v1/media/1/variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodGet, "/v1/media/1/variants", "apikey", 401, apperr.UnauthorizedErrorMessage},
{http.MethodPost, "/v1/media/1/variants", "apikey", 401, apperr.UnauthorizedErrorMessage},
}
for _, row := range rows {
t.Run("Every per-image variant route is guarded: "+row.method+" "+row.path+" "+row.credentials, func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
variantCalls, fileCalls, opens := env.variants.calls, env.files.calls, env.storage.opens
request := httptest.NewRequest(row.method, row.path, nil)
switch row.credentials {
case "stale":
request.AddCookie(env.cookie(variantStaleId, 0))
case "unverified":
request.AddCookie(env.cookie(variantUnverifiedId, 0))
case "plain":
request.AddCookie(env.cookie(variantPlainUserId, 0))
case "apikey":
request.Header.Set("Authorization", "Bearer "+env.issueAPIKey())
}
recorder := httptest.NewRecorder()
env.admin.ServeHTTP(recorder, request)
assertStatusAndBody(t, recorder, row.status, row.body)
if env.variants.calls != variantCalls || env.files.calls != fileCalls || env.storage.opens != opens || env.storage.putCount() != 0 || env.renderer.renderCount() != 0 || env.events() != 0 {
t.Error("a variant, index row, blob or activity event was read or changed")
}
})
}
for name, userId := range map[string]uint32{"the owner": variantOwnerId, "an admin": variantAdminId} {
t.Run("Owners and admins are both allowed: "+name, func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
recorder := env.send(http.MethodGet, "/v1/media/1/variants", "", userId)
if recorder.Code != http.StatusOK {
t.Errorf("status = %d", recorder.Code)
}
})
}
}
func TestPerImageVariantMalformedAndUnknown(t *testing.T) {
rows := []struct {
method string
id string
suffix string
status int
body string
}{
{http.MethodGet, "abc", "", 400, apperr.IdUint32ErrorMessage},
{http.MethodPost, "abc", "", 400, apperr.IdUint32ErrorMessage},
{http.MethodGet, "9999", "", 404, imageNotFoundBody},
{http.MethodPost, "9999", "", 404, imageNotFoundBody},
{http.MethodPost, "9999", "/hero", 404, imageNotFoundBody},
{http.MethodDelete, "9999", "/hero", 404, imageNotFoundBody},
}
for _, row := range rows {
t.Run("A malformed id or an unknown image: "+row.method+" "+row.id+row.suffix, func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
recorder := env.sendAsAdmin(row.method, "/v1/media/"+row.id+"/variants"+row.suffix, "")
assertStatusAndBody(t, recorder, row.status, row.body)
if env.events() != 0 {
t.Error("an activity event was recorded")
}
})
}
}
func TestPerImageVariantListing(t *testing.T) {
t.Run("The enabled variants, in name order, with their output and what is cached", func(t *testing.T) {
env := newVariantEnv(t)
env.seedListingBackground()
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
if recorder.Code != 200 || decodeBody(t, recorder)["unsupported"] != nil {
t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String())
}
items := itemsOf(t, recorder)
if got := strings.Join(itemNames(items), ","); got != "card,hero,og,square,thumb" {
t.Fatalf("names = %s", got)
}
expected := []struct {
name, format string
width, height any
outWidth, outHeight float64
generated bool
size any
}{
{"card", "webp", 800.0, nil, 800, 600, true, 65536.0},
{"hero", "webp", 1200.0, 630.0, 1200, 630, true, 98304.0},
{"og", "jpeg", 1200.0, 630.0, 1200, 630, false, nil},
{"square", "jpeg", 600.0, 600.0, 600, 600, true, 83968.0},
{"thumb", "webp", 400.0, 300.0, 400, 300, true, 18432.0},
}
for _, want := range expected {
item := itemNamed(t, items, want.name)
if item["width"] != want.width || item["height"] != want.height || item["output_width"] != want.outWidth || item["output_height"] != want.outHeight || item["output_format"] != want.format || item["generated"] != want.generated || item["size"] != want.size {
t.Errorf("%s = %v", want.name, item)
}
}
if itemNamed(t, items, "thumb")["url"] != "/static/uploadedImages/a1b2.jpg?v=thumb" || itemNamed(t, items, "card")["fit"] != "inside" || itemNamed(t, items, "hero")["format"] != "webp" {
t.Errorf("thumb = %v", itemNamed(t, items, "thumb"))
}
if !strings.Contains(recorder.Body.String(), `"height":null`) || !strings.Contains(recorder.Body.String(), `"size":null`) {
t.Errorf("raw body = %q", recorder.Body.String())
}
})
t.Run("A row whose fingerprint is stale is not generated", func(t *testing.T) {
env := newVariantEnv(t)
env.seedListingBackground()
row, _, _ := env.files.Find("hero", listedStoredName)
row.Fingerprint = "older-quality"
_, _ = env.files.Save(row)
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
hero := itemNamed(t, itemsOf(t, recorder), "hero")
if hero["generated"] != false || hero["size"] != nil {
t.Errorf("hero = %v", hero)
}
})
formats := []struct {
contentType string
webp bool
want string
}{
{"image/jpeg", true, "jpeg"},
{"image/png", true, "png"},
{"image/webp", true, "webp"},
{"image/webp", false, "png"},
}
for _, row := range formats {
t.Run("A variant in the original format takes the format of the source: "+row.contentType+" webp "+strconv.FormatBool(row.webp), func(t *testing.T) {
renderer := newCountingRenderer()
if !row.webp {
renderer = newCountingRendererWithoutWebP()
}
env := newVariantEnvWith(t, renderer, variantDefaultCap)
seedVariant(env.variants, "keep", 100, 0, domain.FitInside, domain.FormatOriginal, 80)
env.seedPerImage(1, "photo", "src.bin", row.contentType, 400, 300, nil)
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
if got := itemNamed(t, itemsOf(t, recorder), "keep")["output_format"]; got != row.want {
t.Errorf("output_format = %v, want %s", got, row.want)
}
})
}
geometry := []struct {
sw, sh, w, h int
fit domain.VariantFit
ow, oh float64
}{
{4032, 3024, 800, 0, domain.FitCover, 800, 600},
{4032, 3024, 0, 2400, domain.FitInside, 3200, 2400},
{4032, 3024, 8000, 8000, domain.FitCover, 3024, 3024},
{300, 200, 800, 0, domain.FitCover, 300, 200},
{300, 200, 400, 300, domain.FitCover, 267, 200},
{300, 200, 400, 300, domain.FitContain, 400, 300},
{300, 200, 1200, 1200, domain.FitInside, 300, 200},
}
for _, row := range geometry {
name := strconv.Itoa(row.sw) + "x" + strconv.Itoa(row.sh) + " into " + strconv.Itoa(row.w) + "x" + strconv.Itoa(row.h) + " " + string(row.fit)
t.Run("The output size never exceeds the source: "+name, func(t *testing.T) {
env := newVariantEnv(t)
variant := seedVariant(env.variants, "probe", row.w, row.h, row.fit, domain.FormatPNG, 0)
env.seedPerImage(1, "photo", "src.png", "image/png", row.sw, row.sh, nil)
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
item := itemNamed(t, itemsOf(t, recorder), "probe")
if item["output_width"] != row.ow || item["output_height"] != row.oh {
t.Fatalf("output = %v x %v", item["output_width"], item["output_height"])
}
rendered, err := imaging.NewVariantRenderer().Render(bytes.NewReader(encodePNG(t, variantSolidImage(row.sw, row.sh, variantRed))), variant.Settings())
if err != nil {
t.Fatal(err)
}
width, height := decodedSize(t, rendered.Content)
if float64(width) != row.ow || float64(height) != row.oh {
t.Errorf("renderer produced %d x %d", width, height)
}
})
}
t.Run("A source that no variant can be generated for answers an empty list", func(t *testing.T) {
env := newVariantEnv(t)
env.seedListingBackground()
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/2/variants", "")
assertStatusAndBody(t, recorder, 200, `{"unsupported":"svg","items":[]}`)
})
t.Run("No enabled variant answers an empty array, not null", func(t *testing.T) {
env := newVariantEnv(t)
env.seedListingBackground()
for _, name := range []string{"card", "hero", "og", "square", "thumb"} {
env.disable(name)
}
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
if recorder.Code != 200 || !strings.Contains(recorder.Body.String(), `"items":[]`) || !strings.Contains(recorder.Body.String(), `"unsupported":null`) {
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
}
})
t.Run("Listing reads nothing it should not change", func(t *testing.T) {
env := newVariantEnv(t)
env.seedListingBackground()
rows := env.files.rowCount()
env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
if env.storage.opens != 0 || env.storage.putCount() != 0 || env.renderer.renderCount() != 0 || env.files.saves != 5 || env.files.rowCount() != rows || env.events() != 0 {
t.Error("the listing opened a blob, rendered, wrote a row or recorded an event")
}
})
}
func TestPerImageVariantGenerateOne(t *testing.T) {
t.Run("The first generation renders, stores and indexes the file", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
body := decodeBody(t, recorder)
row, found, _ := env.files.Find("thumb", "a1b2.png")
if recorder.Code != 200 || body["name"] != "thumb" || body["generated"] != true || body["output_width"] != 400.0 || body["output_height"] != 300.0 || body["output_format"] != "jpeg" || !found || body["size"] != float64(row.Size) {
t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String())
}
stored, _ := env.storage.Open(row.CachedName)
if stored.Size != row.Size || env.storage.putCount() != 1 || env.files.rowCount() != 1 || len(env.storage.deletedNames()) != 0 || !env.storage.has("a1b2.png") || env.events() != 0 {
t.Error("the generation changed more than the one blob and row")
}
})
t.Run("The public url then serves that file without rendering", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
first := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
row, _, _ := env.files.Find("thumb", "a1b2.png")
public := env.get("/static/uploadedImages/a1b2.png?v=thumb", nil)
if first.Code != 200 || public.Code != 200 || !bytes.Equal(public.Body.Bytes(), env.storage.blobs[row.CachedName]) || env.renderer.renderCount() != 1 {
t.Errorf("public = %d, renders = %d", public.Code, env.renderer.renderCount())
}
})
t.Run("A file generated by the public url is a hit for the admin route", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
env.get("/static/uploadedImages/a1b2.png?v=thumb", nil)
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
if recorder.Code != 200 || env.renderer.renderCount() != 1 || env.storage.putCount() != 1 {
t.Errorf("status = %d, renders = %d", recorder.Code, env.renderer.renderCount())
}
})
t.Run("Generating a variant that is already generated renders nothing", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
first := decodeBody(t, env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", ""))
again := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
if again.Code != 200 || decodeBody(t, again)["size"] != first["size"] || env.renderer.renderCount() != 1 {
t.Errorf("status = %d, renders = %d", again.Code, env.renderer.renderCount())
}
})
t.Run("A public request and an admin request at the same time render once", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
env.renderer.onRender = func() {
for env.storage.openCountOf("a1b2.png") < 3 {
runtime.Gosched()
}
}
codes := make([]int, 2)
start := make(chan struct{})
var finished sync.WaitGroup
finished.Add(2)
go func() {
defer finished.Done()
<-start
codes[0] = env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "").Code
}()
go func() {
defer finished.Done()
<-start
codes[1] = env.get("/static/uploadedImages/a1b2.png?v=thumb", nil).Code
}()
close(start)
finished.Wait()
if codes[0] != 200 || codes[1] != 200 || env.renderer.renderCount() != 1 || env.storage.putCount() != 1 {
t.Errorf("codes = %v, renders = %d, puts = %d", codes, env.renderer.renderCount(), env.storage.putCount())
}
})
refused := []struct{ name, variant string }{{"unknown", "missing"}, {"disabled", "off"}, {"wrongly cased", "Thumb"}}
for _, row := range refused {
t.Run("A request that cannot be served: "+row.name, func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/"+row.variant, "")
assertStatusAndBody(t, recorder, 404, variantNotFound)
if env.renderer.renderCount() != 0 || env.storage.putCount() != 0 {
t.Error("something was rendered or stored")
}
})
}
t.Run("An SVG answers 422", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
env.seedPerImage(2, "logo.svg", svgStoredName, "image/svg+xml", 0, 0, []byte("<svg/>"))
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/2/variants/thumb", "")
assertStatusAndBody(t, recorder, 422, apperr.VariantSourceUnsupportedErrorMessage)
if env.renderer.renderCount() != 0 || env.storage.putCount() != 0 {
t.Error("something was rendered or stored")
}
})
t.Run("A source beyond the byte cap answers 413", func(t *testing.T) {
env := newVariantEnvWith(t, newCountingRenderer(), 10)
env.seedGenerateBackground()
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
assertStatusAndBody(t, recorder, 413, apperr.VariantSourceTooLargeErrorMessage)
if env.storage.putCount() != 0 {
t.Error("a blob was stored")
}
})
t.Run("A source beyond the pixel cap answers 413", func(t *testing.T) {
env := newVariantEnvLimits(t, newCountingRenderer(), useCases.ServeVariantLimits{MaxSourceBytes: variantDefaultCap, MaxSourcePixels: 100, MaxConcurrentRenders: variantDefaultRenders})
env.seedGenerateBackground()
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
assertStatusAndBody(t, recorder, 413, apperr.VariantSourceTooLargeErrorMessage)
if env.storage.putCount() != 0 {
t.Error("a blob was stored")
}
})
t.Run("A source that cannot be decoded answers 415", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
env.storage.seed("a1b2.png", []byte(unreadableBodyBytes))
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
assertStatusAndBody(t, recorder, 415, apperr.VariantSourceUnreadableErrorMessage)
if env.storage.putCount() != 0 {
t.Error("a blob was stored")
}
})
t.Run("Any other render failure is a generic 500 and is not cached", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
env.renderer.failFirst = true
first := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
puts, rows := env.storage.putCount(), env.files.rowCount()
second := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
assertStatusAndBody(t, first, 500, internalErrorBody)
if puts != 0 || rows != 0 || second.Code != 200 {
t.Errorf("puts = %d, rows = %d, second = %d", puts, rows, second.Code)
}
})
t.Run("A generation does not change the original, the other variants or other images", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
env.seedPerImage(3, "other.png", "c3d4.png", "image/png", 10, 10, solidPNG(t, 10, 10))
other := env.seedCurrentFile("hero", "c3d4.png", 5)
before := env.images.images[1]
env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
kept, _, _ := env.files.Find("hero", "c3d4.png")
if env.images.images[1] != before || kept != other || env.files.rowCount() != 2 {
t.Error("the original, another variant or another image changed")
}
})
}
func TestPerImageVariantGenerateAll(t *testing.T) {
seedFive := func(env *variantEnv) {
seedVariant(env.variants, "card", 800, 0, domain.FitInside, domain.FormatJPEG, 80)
seedVariant(env.variants, "hero", 1200, 630, domain.FitCover, domain.FormatJPEG, 82)
seedVariant(env.variants, "og", 1200, 630, domain.FitCover, domain.FormatJPEG, 80)
seedVariant(env.variants, "print", 0, 2400, domain.FitInside, domain.FormatPNG, 0)
seedVariant(env.variants, "square", 600, 600, domain.FitCover, domain.FormatJPEG, 85)
seedVariant(env.variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
env.disable("print")
env.seedPerImage(1, "north-wall.jpg", "a1b2.png", "image/png", 1000, 800, encodePNG(env.t, variantSolidImage(1000, 800, variantRed)))
}
t.Run("Generate all renders every enabled variant and answers the refreshed list", func(t *testing.T) {
env := newVariantEnv(t)
seedFive(env)
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
body := decodeBody(t, recorder)
items := itemsOf(t, recorder)
if recorder.Code != 200 || body["generated"] != 5.0 || body["failed"] != 0.0 || !strings.Contains(recorder.Body.String(), `"failures":[]`) || len(items) != 5 {
t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String())
}
for _, item := range items {
if item["generated"] != true || item["size"] == nil {
t.Errorf("item = %v", item)
}
}
if env.storage.putCount() != 5 || env.files.rowCount() != 5 || len(env.files.rowsOf("print")) != 0 {
t.Error("expected exactly five blobs and rows, none for the disabled variant")
}
})
t.Run("Variants already generated are skipped and not counted", func(t *testing.T) {
env := newVariantEnv(t)
seedFive(env)
env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/card", "")
env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
generated := decodeBody(t, recorder)["generated"]
if generated != 3.0 || env.renderer.renderCount() != 5 {
t.Errorf("generated = %v, renders = %d", generated, env.renderer.renderCount())
}
for _, item := range itemsOf(t, recorder) {
if item["generated"] != true {
t.Errorf("item = %v", item)
}
}
})
failures := []struct {
name string
fail error
cause string
}{
{"ErrSourceTooLarge", domain.ErrSourceTooLarge, "too_large"},
{"ErrVariantSourceUnreadable", domain.ErrVariantSourceUnreadable, "unsupported_format"},
{"a renderer error that wraps no sentinel", errors.New("boom"), "failed"},
}
for _, row := range failures {
t.Run("A failing variant is reported and the others are still generated: "+row.name, func(t *testing.T) {
env := newVariantEnv(t)
seedFive(env)
heroFingerprint := env.variants.stored("hero").Settings().Fingerprint()
env.renderer.failFor = func(settings domain.VariantSettings) error {
if settings.Fingerprint() == heroFingerprint {
return row.fail
}
return nil
}
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
body := decodeBody(t, recorder)
failed, _ := body["failures"].([]any)
if recorder.Code != 200 || body["generated"] != 4.0 || body["failed"] != 1.0 || len(failed) != 1 || failed[0].(map[string]any)["name"] != "hero" || failed[0].(map[string]any)["cause"] != row.cause {
t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String())
}
for _, item := range itemsOf(t, recorder) {
if (item["name"] == "hero") == (item["generated"] == true) {
t.Errorf("item = %v", item)
}
}
})
}
t.Run("Nothing to generate answers zero", func(t *testing.T) {
env := newVariantEnv(t)
seedFive(env)
env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
rendered := env.renderer.renderCount()
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
body := decodeBody(t, recorder)
if recorder.Code != 200 || body["generated"] != 0.0 || body["failed"] != 0.0 || env.renderer.renderCount() != rendered {
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
}
})
t.Run("No enabled variant answers zero", func(t *testing.T) {
env := newVariantEnv(t)
seedFive(env)
for _, name := range []string{"card", "hero", "og", "square", "thumb"} {
env.disable(name)
}
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
body := decodeBody(t, recorder)
if recorder.Code != 200 || body["generated"] != 0.0 || body["failed"] != 0.0 || !strings.Contains(recorder.Body.String(), `"items":[]`) {
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
}
})
t.Run("An SVG answers 422", func(t *testing.T) {
env := newVariantEnv(t)
seedFive(env)
env.seedPerImage(2, "logo.svg", svgStoredName, "image/svg+xml", 0, 0, []byte("<svg/>"))
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/2/variants", "")
assertStatusAndBody(t, recorder, 422, apperr.VariantSourceUnsupportedErrorMessage)
})
}
func TestPerImageVariantPurge(t *testing.T) {
seedBoth := func(env *variantEnv) {
seedVariant(env.variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
seedVariant(env.variants, "hero", 1200, 630, domain.FitCover, domain.FormatJPEG, 82)
env.seedPerImage(1, "north-wall.jpg", "a1b2.jpg", "image/jpeg", 100, 100, nil)
env.seedPerImage(3, "other.jpg", "c3d4.jpg", "image/jpeg", 100, 100, nil)
for _, source := range []string{"a1b2.jpg", "c3d4.jpg"} {
env.seedCurrentFile("thumb", source, 18432)
env.seedCurrentFile("hero", source, 98304)
}
}
t.Run("Purging a generated variant removes its file and its row", func(t *testing.T) {
env := newVariantEnv(t)
seedBoth(env)
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/thumb", "")
assertStatusAndBody(t, recorder, 200, `{"purged_files":1,"purged_bytes":18432}`)
if got := strings.Join(env.storage.deletedNames(), ","); got != "cached-thumb-a1b2.jpg" {
t.Errorf("deleted = %s", got)
}
if _, found, _ := env.files.Find("thumb", "a1b2.jpg"); found || env.files.rowCount() != 3 {
t.Error("the row is still there, or another row went with it")
}
})
t.Run("The next public request generates the file again", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/thumb", "")
puts := env.storage.putCount()
public := env.get("/static/uploadedImages/a1b2.png?v=thumb", nil)
if public.Code != 200 || env.storage.putCount() != puts+1 {
t.Errorf("status = %d, puts = %d", public.Code, env.storage.putCount())
}
})
t.Run("Purging a variant that is not generated answers zero", func(t *testing.T) {
env := newVariantEnv(t)
seedBoth(env)
row, _, _ := env.files.Find("thumb", "a1b2.jpg")
_ = env.files.Delete(row.ID)
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/thumb", "")
assertStatusAndBody(t, recorder, 200, `{"purged_files":0,"purged_bytes":0}`)
})
t.Run("A disabled variant keeps its files and they can be purged", func(t *testing.T) {
env := newVariantEnv(t)
seedBoth(env)
env.disable("thumb")
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/thumb", "")
if decodeBody(t, recorder)["purged_files"] != 1.0 {
t.Errorf("body = %q", recorder.Body.String())
}
})
t.Run("An unknown variant answers 404", func(t *testing.T) {
env := newVariantEnv(t)
seedBoth(env)
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/missing", "")
assertStatusAndBody(t, recorder, 404, variantNotFound)
})
t.Run("A blob that cannot be deleted keeps its row and the call fails", func(t *testing.T) {
env := newVariantEnv(t)
seedBoth(env)
env.storage.failDelete["cached-thumb-a1b2.jpg"] = errVariantStorageFailure
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/thumb", "")
assertStatusAndBody(t, recorder, 500, internalErrorBody)
if _, found, _ := env.files.Find("thumb", "a1b2.jpg"); !found {
t.Error("the index row was removed")
}
})
}
+26
View File
@@ -7,6 +7,7 @@ import (
"bytes" "bytes"
"errors" "errors"
"io" "io"
"log"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -114,6 +115,8 @@ type mockVariantFileRepo struct {
deleted []uint32 deleted []uint32
saves int saves int
calls int calls int
totalsBySourceCalls int
} }
var _ domain.VariantFileRepository = &mockVariantFileRepo{} var _ domain.VariantFileRepository = &mockVariantFileRepo{}
@@ -195,6 +198,19 @@ func (m *mockVariantFileRepo) Totals() (map[string]domain.VariantFileTotals, err
return totals, nil return totals, nil
} }
func (m *mockVariantFileRepo) TotalsBySource() (map[string]domain.VariantFileTotals, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
m.totalsBySourceCalls++
totals := map[string]domain.VariantFileTotals{}
for _, row := range m.rows {
current := totals[row.SourceName]
totals[row.SourceName] = domain.VariantFileTotals{Files: current.Files + 1, Bytes: current.Bytes + row.Size}
}
return totals, nil
}
func (m *mockVariantFileRepo) rowsOf(variantName string) []domain.VariantFile { func (m *mockVariantFileRepo) rowsOf(variantName string) []domain.VariantFile {
rows, _ := m.ListByVariant(variantName) rows, _ := m.ListByVariant(variantName)
return rows return rows
@@ -290,6 +306,7 @@ type countingRenderer struct {
calls atomic.Int32 calls atomic.Int32
failFirst bool failFirst bool
onRender func() onRender func()
failFor func(settings domain.VariantSettings) error
} }
var _ domain.VariantRenderer = &countingRenderer{} var _ domain.VariantRenderer = &countingRenderer{}
@@ -307,6 +324,11 @@ func (c *countingRenderer) Render(source io.Reader, settings domain.VariantSetti
if c.onRender != nil { if c.onRender != nil {
c.onRender() c.onRender()
} }
if c.failFor != nil {
if err := c.failFor(settings); err != nil {
return domain.RenderedVariant{}, err
}
}
if c.failFirst && call == 1 { if c.failFirst && call == 1 {
return domain.RenderedVariant{}, errors.New("render failure") return domain.RenderedVariant{}, errors.New("render failure")
} }
@@ -371,6 +393,10 @@ func newInertServeVariantUseCase(storage domain.Storage) *useCases.ServeVariantU
return useCases.NewServeVariantUseCase(newMockVariantRepo(), newMockVariantFileRepo(), newMockImageRepo(), storage, newCountingRenderer(), defaultServeLimits(variantDefaultCap)) return useCases.NewServeVariantUseCase(newMockVariantRepo(), newMockVariantFileRepo(), newMockImageRepo(), storage, newCountingRenderer(), defaultServeLimits(variantDefaultCap))
} }
func newInertGenerateOnUploadUseCase() *useCases.GenerateVariantsOnUploadUseCase {
return useCases.NewGenerateVariantsOnUploadUseCase(newMockVariantRepo(), newInertServeVariantUseCase(newMockVariantStorage()), log.New(io.Discard, "", 0))
}
func (m *mockVariantStorage) openCountOf(storedName string) int { func (m *mockVariantStorage) openCountOf(storedName string) int {
m.mutex.Lock() m.mutex.Lock()
defer m.mutex.Unlock() defer m.mutex.Unlock()
+18 -17
View File
@@ -63,23 +63,24 @@ func newMediaEnv(t *testing.T) *mediaEnv {
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder) merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
now := func() time.Time { return env.clock } now := func() time.Time { return env.clock }
container := &api.UseCases{ container := &api.UseCases{
Token: env.token, Token: env.token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder), CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}), GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}), ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, discardRecorder), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users), GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder), CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users), GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, discardRecorder), ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo()),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, newMockVariantFileRepo(), discardRecorder), RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, discardRecorder),
MergeMediaUseCase: merge, DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, newMockVariantFileRepo(), discardRecorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles), MergeMediaUseCase: merge,
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(env.purgeSettings), GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(env.purgeSettings, now), GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(env.purgeSettings),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(env.images, env.articles), UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(env.purgeSettings, now),
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder), PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(env.images, env.articles),
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder),
} }
router := chi.NewRouter() router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{})) router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
+266
View File
@@ -0,0 +1,266 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/article"
domain "RenewCMS/internal/domain/image"
"net/http"
"net/url"
"slices"
"strings"
"testing"
)
const libraryCounts = `{"all":5,"unused":3,"duplicates":1}`
func (e *variantEnv) saveRow(variantName string, storedName string, size int64) {
e.files.Save(domain.VariantFile{VariantName: variantName, SourceName: storedName, CachedName: "cached-" + variantName + "-" + storedName, ContentType: "image/webp", Size: size, Fingerprint: "f"})
e.storage.seed("cached-"+variantName+"-"+storedName, []byte("generated"))
}
func (e *variantEnv) seedLibrary() {
library := []struct {
id uint32
filename string
storedName string
contentType string
size int64
sha string
}{
{1, "north-wall.jpg", "a1b2.jpg", "image/jpeg", 4089446, "hash-north"},
{2, "floorboards.jpg", "f2f2.jpg", "image/jpeg", 2516582, "hash-floor"},
{3, "floorboards-copy.jpg", "f3f3.jpg", "image/jpeg", 2516582, "hash-floor"},
{4, "window-frame.jpg", "w4w4.jpg", "image/jpeg", 812345, "hash-window"},
{5, "logo.svg", "e5f6.svg", "image/svg+xml", 1204, "hash-logo"},
}
for _, entry := range library {
img := e.seedPerImage(entry.id, entry.filename, entry.storedName, entry.contentType, 100, 100, nil)
img.Size, img.SHA256 = entry.size, entry.sha
e.images.images[entry.id] = img
}
e.articles.articles[1] = article.Article{ID: 1, Title: "Walls", Body: "see /static/uploadedImages/a1b2.jpg and /static/uploadedImages/f2f2.jpg", IsOnline: true}
for variantName, size := range map[string]int64{"thumb": 18432, "card": 65536, "hero": 98304, "square": 83968} {
e.saveRow(variantName, "a1b2.jpg", size)
}
e.saveRow("thumb", "f2f2.jpg", 41000)
for _, variantName := range []string{"old-a", "old-b", "old-c"} {
e.saveRow(variantName, "w4w4.jpg", 1000000)
}
}
func (e *variantEnv) listIds(query string) ([]float64, map[string]any) {
e.t.Helper()
recorder := e.send(http.MethodGet, "/v1/media/"+query, "", variantOwnerId)
if recorder.Code != 200 {
e.t.Fatalf("list %q = %d %q", query, recorder.Code, recorder.Body.String())
}
body := decodeBody(e.t, recorder)
ids := []float64{}
for _, item := range body["items"].([]any) {
ids = append(ids, item.(map[string]any)["id"].(float64))
}
return ids, body
}
func itemWithId(t *testing.T, body map[string]any, id float64) map[string]any {
t.Helper()
for _, item := range body["items"].([]any) {
if item.(map[string]any)["id"] == id {
return item.(map[string]any)
}
}
t.Fatalf("no item %v", id)
return nil
}
func TestMediaListCarriesVariantFields(t *testing.T) {
t.Run("Every item carries variant_count and variant_size", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
ids, body := env.listIds("")
if !slices.Equal(ids, []float64{5, 4, 3, 2, 1}) {
t.Fatalf("order = %v", ids)
}
expected := map[float64][2]float64{1: {4, 266240}, 2: {1, 41000}, 3: {0, 0}, 4: {3, 3000000}, 5: {0, 0}}
for id, want := range expected {
item := itemWithId(t, body, id)
if item["variant_count"] != want[0] || item["variant_size"] != want[1] {
t.Errorf("item %v = %v", id, item)
}
}
if itemWithId(t, body, 2)["duplicate"] != true || itemWithId(t, body, 1)["used_in"] != 1.0 {
t.Error("duplicate or used_in changed")
}
})
t.Run("The list reads the totals of all sources in one call", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
env.listIds("")
if env.files.totalsBySourceCalls != 1 {
t.Errorf("calls = %d", env.files.totalsBySourceCalls)
}
})
t.Run("A rename answers the same fields", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
recorder := env.send(http.MethodPatch, "/v1/media/1", `{"filename": "north-wall-01"}`, variantOwnerId)
body := decodeBody(t, recorder)
if recorder.Code != 200 || body["variant_count"] != 4.0 || body["variant_size"] != 266240.0 {
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
}
})
}
func TestMediaListVariantsFilterAndSort(t *testing.T) {
filters := map[string][]float64{"with": {1, 2, 4}, "without": {3, 5}}
for variants, want := range filters {
t.Run("variants narrows the list and the counts stay library-wide: "+variants, func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
ids, body := env.listIds("?variants=" + variants)
slices.Sort(ids)
counts := body["counts"].(map[string]any)
if !slices.Equal(ids, want) || counts["all"] != 5.0 || counts["unused"] != 3.0 || counts["duplicates"] != 1.0 {
t.Errorf("ids = %v, counts = %v", ids, counts)
}
})
}
t.Run("variants, filter, q and sort combine", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
unused, _ := env.listIds("?variants=with&filter=unused")
searched, _ := env.listIds("?variants=with&q=floorboards")
if !slices.Equal(unused, []float64{4}) || !slices.Equal(searched, []float64{2}) {
t.Errorf("unused = %v, searched = %v", unused, searched)
}
})
t.Run("sort=total_size orders by original plus variants, largest first, then by id", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
ids, _ := env.listIds("?sort=total_size")
if !slices.Equal(ids, []float64{1, 4, 2, 3, 5}) {
t.Errorf("order = %v", ids)
}
})
t.Run("sort=largest is unchanged and sorts by the original alone", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
ids, _ := env.listIds("?sort=largest")
if !slices.Equal(ids, []float64{1, 2, 3, 4, 5}) {
t.Errorf("order = %v", ids)
}
})
t.Run("Equal totals fall back to the lowest id first", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
third := env.images.images[3]
third.Size = 2557582
env.images.images[3] = third
ids, _ := env.listIds("?sort=total_size")
if !slices.Equal(ids, []float64{1, 4, 2, 3, 5}) {
t.Errorf("order = %v", ids)
}
})
t.Run("variants=with and sort=total_size page through the list", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
first, firstBody := env.listIds("?variants=with&sort=total_size&limit=2")
cursor, _ := firstBody["next_cursor"].(string)
second, secondBody := env.listIds("?variants=with&sort=total_size&limit=2&cursor=" + url.QueryEscape(cursor))
if !slices.Equal(first, []float64{1, 4}) || cursor == "" || !slices.Equal(second, []float64{2}) || secondBody["next_cursor"] != nil {
t.Errorf("first = %v (%q), second = %v (%v)", first, cursor, second, secondBody["next_cursor"])
}
})
t.Run("variants=with on a library with an empty cache answers an empty list", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
for _, row := range env.files.rows {
_ = env.files.Delete(row.ID)
}
recorder := env.send(http.MethodGet, "/v1/media/?variants=with", "", variantOwnerId)
if recorder.Code != 200 || !strings.Contains(recorder.Body.String(), `"items":[]`) || !strings.Contains(recorder.Body.String(), `"counts":`+libraryCounts) {
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
}
})
for _, query := range []string{"variants=bogus", "variants=all", "variants=With", "variants=", "sort=Total_size", "sort=total"} {
t.Run("A malformed query answers 400: "+query, func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
recorder := env.send(http.MethodGet, "/v1/media/?"+query, "", variantOwnerId)
assertStatusAndBody(t, recorder, 400, apperr.MediaQueryErrorMessage)
})
}
}
func TestMediaLibraryVariantConsistency(t *testing.T) {
t.Run("The number shown by a delete is the number a delete removes", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
env.storage.seed("a1b2.jpg", []byte("original"))
recorder := env.send(http.MethodDelete, "/v1/media/1", "", variantOwnerId)
deleted := env.storage.deletedNames()
slices.Sort(deleted)
expected := []string{"a1b2.jpg", "cached-card-a1b2.jpg", "cached-hero-a1b2.jpg", "cached-square-a1b2.jpg", "cached-thumb-a1b2.jpg"}
if recorder.Code != 204 || !slices.Equal(deleted, expected) || len(env.files.rowsOf("hero")) != 0 || env.files.rowCount() != 4 {
t.Errorf("status = %d, deleted = %v, rows = %d", recorder.Code, deleted, env.files.rowCount())
}
})
t.Run("An article that uses a variant url still counts as using the image", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
env.articles.articles[1] = article.Article{ID: 1, Title: "Walls", Body: "![x](/static/uploadedImages/a1b2.jpg?v=hero)", IsOnline: true}
usage := env.send(http.MethodGet, "/v1/media/1/usage", "", variantOwnerId)
_, body := env.listIds("")
if usage.Code != 200 || !strings.Contains(usage.Body.String(), `"id":1`) || itemWithId(t, body, 1)["used_in"] != 1.0 {
t.Errorf("usage = %d %q", usage.Code, usage.Body.String())
}
})
t.Run("Merging keeps the variant parameter of the switched url", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
env.articles.articles[1] = article.Article{ID: 1, Title: "Floors", Body: "![x](/static/uploadedImages/f3f3.jpg?v=hero)", IsOnline: true}
recorder := env.send(http.MethodPost, "/v1/media/merge", `{"keep_id": 2, "merge_ids": [3]}`, variantOwnerId)
if recorder.Code != 200 || !strings.Contains(env.articles.articles[1].Body, "/static/uploadedImages/f2f2.jpg?v=hero") {
t.Errorf("status = %d, body = %q", recorder.Code, env.articles.articles[1].Body)
}
})
}
+19 -18
View File
@@ -128,24 +128,25 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := jwtauth.New("HS256", []byte("test-secret"), nil)
return &api.UseCases{ return &api.UseCases{
Token: token, Token: token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder), CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}), ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(userRepo), GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder), CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(userRepo), CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(userRepo),
ListUsersUseCase: useCases.NewListUsersUseCase(userRepo), ListUsersUseCase: useCases.NewListUsersUseCase(userRepo),
SendMailUseCase: useCases.NewSendMailUseCase(mailRepo), SendMailUseCase: useCases.NewSendMailUseCase(mailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo, newMockImageStorage(), discardRecorder), CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo, newMockImageStorage(), discardRecorder),
ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo), GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo, discardRecorder), ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo, newMockVariantFileRepo()),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder), RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo, discardRecorder),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder), DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo), MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo),
} }
} }
+6 -4
View File
@@ -4,6 +4,7 @@ import { t } from '@/i18n'
import { computed } from 'vue' import { computed } from 'vue'
import ActivityActor from '@/components/ActivityActor.vue' import ActivityActor from '@/components/ActivityActor.vue'
import ActivityBadge from '@/components/ActivityBadge.vue' import ActivityBadge from '@/components/ActivityBadge.vue'
import ActivityDetailLine from '@/components/ActivityDetailLine.vue'
import ActivitySentence from '@/components/ActivitySentence.vue' import ActivitySentence from '@/components/ActivitySentence.vue'
import { ACTIVITY_SKELETON_CARDS } from '@/composables/useActivity' import { ACTIVITY_SKELETON_CARDS } from '@/composables/useActivity'
import { categoryIcon, outcomeBadge, rowSurface, type ActivityItem } from '@/lib/activity' import { categoryIcon, outcomeBadge, rowSurface, type ActivityItem } from '@/lib/activity'
@@ -96,10 +97,11 @@ function cardClass(item: ActivityItem) {
:name="categoryIcon(item.category)" :name="categoryIcon(item.category)"
class="mt-0.5 size-4 shrink-0 text-muted" class="mt-0.5 size-4 shrink-0 text-muted"
/> />
<span <span class="flex min-w-0 flex-1 flex-col">
class="line-clamp-3 min-w-0 flex-1 text-md wrap-anywhere text-default" <span class="line-clamp-3 text-md wrap-anywhere text-default">
> <ActivitySentence :item="item" />
<ActivitySentence :item="item" /> </span>
<ActivityDetailLine :item="item" wrap />
</span> </span>
</div> </div>
<div class="flex flex-wrap items-center gap-1.5"> <div class="flex flex-wrap items-center gap-1.5">
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ActivityItem } from '@/lib/activity'
import { detailLine } from '@/lib/activitySentence'
const props = defineProps<{ item: ActivityItem; wrap?: boolean }>()
const line = computed(() => detailLine(props.item))
</script>
<template>
<span
v-if="line"
data-testid="activity-detail-line"
class="block text-xs/5 text-muted"
:class="wrap ? 'wrap-anywhere' : 'truncate'"
>{{ line }}</span
>
</template>
@@ -5,6 +5,7 @@ import { computed, ref, watch } from 'vue'
import ActivityActor from '@/components/ActivityActor.vue' import ActivityActor from '@/components/ActivityActor.vue'
import ActivityBadge from '@/components/ActivityBadge.vue' import ActivityBadge from '@/components/ActivityBadge.vue'
import ActivityDiff from '@/components/ActivityDiff.vue' import ActivityDiff from '@/components/ActivityDiff.vue'
import ActivityDetailLine from '@/components/ActivityDetailLine.vue'
import ActivitySentence from '@/components/ActivitySentence.vue' import ActivitySentence from '@/components/ActivitySentence.vue'
import { import {
categoryIcon, categoryIcon,
@@ -99,6 +100,7 @@ function onOpenChange(isOpen: boolean) {
> >
<ActivitySentence :item="current" /> <ActivitySentence :item="current" />
</p> </p>
<ActivityDetailLine :item="current" wrap />
<div class="flex flex-wrap items-center gap-1.5"> <div class="flex flex-wrap items-center gap-1.5">
<ActivityBadge v-bind="outcomeBadge(current.outcome)" /> <ActivityBadge v-bind="outcomeBadge(current.outcome)" />
<ActivityBadge <ActivityBadge
+6 -2
View File
@@ -4,6 +4,7 @@ import { t } from '@/i18n'
import { computed } from 'vue' import { computed } from 'vue'
import ActivityActor from '@/components/ActivityActor.vue' import ActivityActor from '@/components/ActivityActor.vue'
import ActivityBadge from '@/components/ActivityBadge.vue' import ActivityBadge from '@/components/ActivityBadge.vue'
import ActivityDetailLine from '@/components/ActivityDetailLine.vue'
import ActivitySentence from '@/components/ActivitySentence.vue' import ActivitySentence from '@/components/ActivitySentence.vue'
import ActivityTargetCell from '@/components/ActivityTargetCell.vue' import ActivityTargetCell from '@/components/ActivityTargetCell.vue'
import { ACTIVITY_SKELETON_ROWS } from '@/composables/useActivity' import { ACTIVITY_SKELETON_ROWS } from '@/composables/useActivity'
@@ -124,8 +125,11 @@ function rowClass(item: ActivityItem) {
:name="categoryIcon(item.category)" :name="categoryIcon(item.category)"
class="size-4 shrink-0 text-muted" class="size-4 shrink-0 text-muted"
/> />
<span class="truncate text-default"> <span class="flex min-w-0 flex-col">
<ActivitySentence :item="item" /> <span class="truncate text-default">
<ActivitySentence :item="item" />
</span>
<ActivityDetailLine :item="item" />
</span> </span>
<UIcon <UIcon
v-if="item.security" v-if="item.security"
+142 -11
View File
@@ -1,13 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n' import { t } from '@/i18n'
import { computed, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3' import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
import VariantSelect from '@/components/variants/VariantSelect.vue'
import { articleImageId } from '@/lib/articleImage' import { articleImageId } from '@/lib/articleImage'
import { mediaRevision, withMediaRevision } from '@/lib/mediaRevision' import { mediaRevision, withMediaRevision } from '@/lib/mediaRevision'
import { variantOfSrc, withVariant } from '@/lib/variants/blockVariant'
import { formatDimensions } from '@/lib/variants/variantFormat'
import { bareOriginalOption, variantOption } from '@/lib/variants/variantOptions'
import { useVariantCatalogStore } from '@/stores/variantCatalog'
const props = defineProps(nodeViewProps) const props = defineProps(nodeViewProps)
const catalog = useVariantCatalogStore()
const imageId = computed(() => articleImageId(props.node)) const imageId = computed(() => articleImageId(props.node))
const showDeleteButton = computed(() => props.selected && imageId.value !== null) const showDeleteButton = computed(() => props.selected && imageId.value !== null)
interface ImageNodeAttrs { interface ImageNodeAttrs {
@@ -31,11 +37,41 @@ const imageAttrs = computed(() => {
}) })
const failed = ref(false) const failed = ref(false)
const picking = ref(false)
const pickButton = ref<{ $el: HTMLElement } | null>(null)
const displayAttrs = computed(() => ({ const displayAttrs = computed(() => ({
...imageAttrs.value, ...imageAttrs.value,
src: withMediaRevision(imageAttrs.value.src, mediaRevision.value), src: withMediaRevision(imageAttrs.value.src, mediaRevision.value),
})) }))
const variantName = computed(() => variantOfSrc(imageAttrs.value.src))
const variant = computed(() =>
variantName.value === null ? undefined : catalog.find(variantName.value),
)
const verdict = computed<'deleted' | 'disabled' | null>(() => {
if (variantName.value === null || !catalog.loaded) {
return null
}
if (!variant.value) {
return 'deleted'
}
return variant.value.enabled ? null : 'disabled'
})
const chip = computed(() =>
variant.value
? `${variant.value.name} · ${formatDimensions(variant.value.width, variant.value.height)}`
: null,
)
const warningTitle = computed(() =>
verdict.value === 'deleted'
? t("Variant '{0}' no longer exists.", [variantName.value ?? ''])
: t("Variant '{0}' is disabled.", [variantName.value ?? '']),
)
const pickOptions = computed(() => [
bareOriginalOption(),
...catalog.enabled.map((entry) => variantOption(entry)),
])
watch( watch(
() => displayAttrs.value.src, () => displayAttrs.value.src,
() => { () => {
@@ -49,18 +85,96 @@ function onDeleteClick() {
} }
props.extension.options.onRequestRemove(imageId.value) props.extension.options.onRequestRemove(imageId.value)
} }
function rewrite(name: string) {
const src = imageAttrs.value.src
if (src) {
props.updateAttributes({ src: withVariant(src, name) })
}
picking.value = false
}
async function onPickOpenChange(open: boolean) {
if (open) {
return
}
picking.value = false
await nextTick()
pickButton.value?.$el?.focus()
}
</script> </script>
<template> <template>
<NodeViewWrapper <NodeViewWrapper
as="div" as="div"
class="relative block" class="relative block"
:class="[failed ? 'w-full' : 'w-fit', { 'outline-2 outline-primary rounded-md': selected }]" :class="[
failed || verdict ? 'w-full' : 'w-fit',
{ 'outline-2 outline-primary rounded-md': selected },
]"
data-testid="article-editor-image" data-testid="article-editor-image"
@dragstart.prevent @dragstart.prevent
> >
<div <div
v-if="failed" v-if="verdict"
data-testid="article-editor-variant-warning"
class="flex flex-col border border-warning font-sans text-sm"
>
<div class="relative aspect-video overflow-hidden border border-default">
<div
class="variant-placeholder absolute inset-0 flex items-center justify-center bg-secondary-200 text-muted opacity-50 dark:bg-secondary-300"
>
<UIcon name="i-lucide-image-off" class="size-10" />
</div>
<span
v-if="chip"
data-testid="variant-chip"
class="absolute top-2 left-2 inline-flex h-5 items-center border border-accented bg-default px-1.5 font-mono text-xs font-medium text-highlighted"
>{{ chip }}</span
>
</div>
<UAlert
role="alert"
color="warning"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-triangle-alert"
:title="warningTitle"
:description="t('This image returns an error on the public site.')"
:ui="{ actions: 'mt-2 w-full flex-col items-stretch gap-2' }"
>
<template #actions>
<div class="flex">
<UButton
ref="pickButton"
color="neutral"
variant="outline"
icon="i-lucide-layers"
data-testid="pick-another-variant"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs font-medium text-highlighted ring-0 md:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@mousedown.prevent
@click="picking = true"
>
{{ t('Pick another variant') }}
</UButton>
</div>
<VariantSelect
v-if="picking"
:open="true"
:options="pickOptions"
:model-value="undefined"
:placeholder="t('Variant')"
class="max-w-80"
@update:open="onPickOpenChange"
@update:model-value="rewrite"
/>
</template>
</UAlert>
</div>
<div
v-else-if="failed"
data-testid="article-editor-image-missing" data-testid="article-editor-image-missing"
class="flex min-h-40 flex-col items-center justify-center gap-2 border border-dashed border-accented bg-elevated p-6 text-center font-sans text-sm text-muted" class="flex min-h-40 flex-col items-center justify-center gap-2 border border-dashed border-accented bg-elevated p-6 text-center font-sans text-sm text-muted"
> >
@@ -70,15 +184,22 @@ function onDeleteClick() {
}}</span> }}</span>
<span>{{ t('It was deleted from the library. Remove it from the article.') }}</span> <span>{{ t('It was deleted from the library. Remove it from the article.') }}</span>
</div> </div>
<img <div v-else class="relative">
v-else <img
v-bind="displayAttrs" v-bind="displayAttrs"
draggable="false" draggable="false"
class="block max-w-full" class="block max-w-full"
@error="failed = true" @error="failed = true"
/> />
<span
v-if="chip"
data-testid="variant-chip"
class="absolute top-2 left-2 inline-flex h-5 items-center border border-accented bg-default px-1.5 font-mono text-xs font-medium text-highlighted"
>{{ chip }}</span
>
</div>
<UButton <UButton
v-if="showDeleteButton || (failed && imageId !== null)" v-if="showDeleteButton || ((failed || verdict) && imageId !== null)"
data-testid="remove-image-button" data-testid="remove-image-button"
icon="i-lucide-trash-2" icon="i-lucide-trash-2"
color="neutral" color="neutral"
@@ -92,3 +213,13 @@ function onDeleteClick() {
</UButton> </UButton>
</NodeViewWrapper> </NodeViewWrapper>
</template> </template>
<style scoped>
.variant-placeholder {
background-image: repeating-linear-gradient(
135deg,
color-mix(in srgb, var(--ui-text-muted) 12%, transparent) 0 1px,
transparent 1px 12px
);
}
</style>
@@ -4,6 +4,7 @@ import { t } from '@/i18n'
import { computed } from 'vue' import { computed } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue' import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MediaBadge from '@/components/MediaBadge.vue' import MediaBadge from '@/components/MediaBadge.vue'
import VariantRemovalNote from '@/components/variants/VariantRemovalNote.vue'
import type { MediaItem } from '@/composables/useMedia' import type { MediaItem } from '@/composables/useMedia'
import { pluralArticles } from '@/lib/mediaFormat' import { pluralArticles } from '@/lib/mediaFormat'
@@ -20,6 +21,12 @@ const emit = defineEmits<{ close: [confirmed: boolean] }>()
const total = computed(() => props.items.length) const total = computed(() => props.items.length)
const imageLabel = computed(() => t(total.value === 1 ? '{0} image' : '{0} images', [total.value])) const imageLabel = computed(() => t(total.value === 1 ? '{0} image' : '{0} images', [total.value]))
const inUse = computed(() => props.items.filter((item) => item.used_in > 0)) const inUse = computed(() => props.items.filter((item) => item.used_in > 0))
const variantCount = computed(() =>
props.items.reduce((sum, item) => sum + (item.variant_count ?? 0), 0),
)
const variantBytes = computed(() =>
props.items.reduce((sum, item) => sum + (item.variant_size ?? 0), 0),
)
const articleCount = computed(() => inUse.value.reduce((sum, item) => sum + item.used_in, 0)) const articleCount = computed(() => inUse.value.reduce((sum, item) => sum + item.used_in, 0))
const description = computed(() => { const description = computed(() => {
const usedCount = inUse.value.length const usedCount = inUse.value.length
@@ -79,6 +86,7 @@ const description = computed(() => {
<MediaBadge v-else kind="unused" :label="t('Unused')" /> <MediaBadge v-else kind="unused" :label="t('Unused')" />
</li> </li>
</ul> </ul>
<VariantRemovalNote :count="variantCount" :bytes="variantBytes" />
</template> </template>
</ConfirmDialog> </ConfirmDialog>
</template> </template>
+2
View File
@@ -4,6 +4,7 @@ import { t } from '@/i18n'
import { computed } from 'vue' import { computed } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue' import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue' import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue'
import VariantRemovalNote from '@/components/variants/VariantRemovalNote.vue'
import type { MediaItem, MediaUsageItem } from '@/composables/useMedia' import type { MediaItem, MediaUsageItem } from '@/composables/useMedia'
import { pluralArticles } from '@/lib/mediaFormat' import { pluralArticles } from '@/lib/mediaFormat'
@@ -58,6 +59,7 @@ const description = computed(() =>
<MediaArticleStatusBadge :published="article.published" /> <MediaArticleStatusBadge :published="article.published" />
</li> </li>
</ul> </ul>
<VariantRemovalNote :count="item.variant_count" :bytes="item.variant_size" />
</template> </template>
</ConfirmDialog> </ConfirmDialog>
</template> </template>
+25 -2
View File
@@ -8,6 +8,8 @@ import { ApiError, useApi } from '@/composables/useApi'
import { useLibraryList } from '@/composables/useLibraryList' import { useLibraryList } from '@/composables/useLibraryList'
import { classifyMediaError, type MediaItem } from '@/composables/useMedia' import { classifyMediaError, type MediaItem } from '@/composables/useMedia'
import { oldestOf } from '@/composables/useMediaUpload' import { oldestOf } from '@/composables/useMediaUpload'
import { useCanManageVariants } from '@/composables/useCanManageVariants'
import { useVariantCatalogStore } from '@/stores/variantCatalog'
import { hashFile } from '@/lib/fileHash' import { hashFile } from '@/lib/fileHash'
import { import {
IMAGE_TYPE_REJECTION_TEXT, IMAGE_TYPE_REJECTION_TEXT,
@@ -17,6 +19,7 @@ import {
validateImageFile, validateImageFile,
} from '@/lib/imageUpload' } from '@/lib/imageUpload'
import { libraryMatchText } from '@/lib/mediaFormat' import { libraryMatchText } from '@/lib/mediaFormat'
import { insertedUrl } from '@/lib/variants/variantOptions'
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles' import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
import { import {
ASPECT_RATIOS, ASPECT_RATIOS,
@@ -55,7 +58,7 @@ const props = withDefaults(
const emit = defineEmits<{ const emit = defineEmits<{
uploaded: [item: MediaItem] uploaded: [item: MediaItem]
inserted: [item: MediaItem, alt: string] inserted: [item: MediaItem, alt: string, src: string]
close: [] close: []
}>() }>()
@@ -102,7 +105,9 @@ const tab = ref<Tab>('library')
const existing = ref<MediaItem | null>(null) const existing = ref<MediaItem | null>(null)
const selected = ref<MediaItem | null>(null) const selected = ref<MediaItem | null>(null)
const alt = ref('') const alt = ref('')
const variant = ref('')
let tabTouched = false let tabTouched = false
let variantsRequested = false
const naturalWidth = ref(0) const naturalWidth = ref(0)
const naturalHeight = ref(0) const naturalHeight = ref(0)
@@ -117,6 +122,8 @@ const sourceBitmap = ref<ImageBitmap | null>(null)
const { progress, start, abort } = useImageUpload() const { progress, start, abort } = useImageUpload()
const { request } = useApi() const { request } = useApi()
const library = useLibraryList() const library = useLibraryList()
const canManageVariants = useCanManageVariants()
const catalog = useVariantCatalogStore()
let closeTimer: ReturnType<typeof setTimeout> | null = null let closeTimer: ReturnType<typeof setTimeout> | null = null
const uploadPercent = computed(() => { const uploadPercent = computed(() => {
@@ -418,11 +425,22 @@ function selectItem(item: MediaItem) {
} }
selected.value = item selected.value = item
alt.value = '' alt.value = ''
variant.value = ''
requestVariants()
}
function requestVariants() {
if (variantsRequested || !canManageVariants.value) {
return
}
variantsRequested = true
catalog.load()
} }
async function searchLibrary(query: string) { async function searchLibrary(query: string) {
selected.value = null selected.value = null
alt.value = '' alt.value = ''
variant.value = ''
await library.search(query) await library.search(query)
} }
@@ -430,7 +448,7 @@ function insertSelected() {
if (!selected.value) { if (!selected.value) {
return return
} }
emit('inserted', selected.value, alt.value) emit('inserted', selected.value, alt.value, insertedUrl(selected.value, variant.value))
open.value = false open.value = false
} }
@@ -710,6 +728,8 @@ watch(open, (isOpen) => {
existing.value = null existing.value = null
selected.value = null selected.value = null
alt.value = '' alt.value = ''
variant.value = ''
variantsRequested = false
tabTouched = false tabTouched = false
library.reset() library.reset()
if (props.initialFile) { if (props.initialFile) {
@@ -768,6 +788,9 @@ onUnmounted(() => {
<MediaPicker <MediaPicker
v-if="onLibraryTab" v-if="onLibraryTab"
v-model:alt="alt" v-model:alt="alt"
v-model:variant="variant"
:variants="catalog.status === 'ready' ? catalog.enabled : []"
:variants-ready="catalog.status === 'ready'"
:items="library.items.value" :items="library.items.value"
:status="library.status.value" :status="library.status.value"
:show-skeleton="library.showSkeleton.value" :show-skeleton="library.showSkeleton.value"
+5 -1
View File
@@ -3,7 +3,7 @@ import { computed } from 'vue'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
kind: 'unused' | 'duplicate' | 'used' | 'online' | 'draft' kind: 'unused' | 'duplicate' | 'used' | 'online' | 'draft' | 'variants'
label: string label: string
size?: 'sm' | 'md' size?: 'sm' | 'md'
}>(), }>(),
@@ -21,6 +21,10 @@ const badges = {
icon: 'i-lucide-copy', icon: 'i-lucide-copy',
class: 'border border-(--rc-info-soft) bg-(--rc-info-soft) text-info', class: 'border border-(--rc-info-soft) bg-(--rc-info-soft) text-info',
}, },
variants: {
icon: 'i-lucide-layers',
class: 'border border-(--rc-info-soft) bg-(--rc-info-soft) text-info',
},
used: { icon: undefined, class: OUTLINE }, used: { icon: undefined, class: OUTLINE },
online: { online: {
icon: 'i-lucide-globe', icon: 'i-lucide-globe',
+7 -1
View File
@@ -4,7 +4,8 @@ import { t } from '@/i18n'
import MediaBadge from '@/components/MediaBadge.vue' import MediaBadge from '@/components/MediaBadge.vue'
defineProps<{ defineProps<{
item: { duplicate: boolean; used_in: number } item: { duplicate: boolean; used_in: number; variant_count?: number }
variants?: boolean
}>() }>()
</script> </script>
@@ -12,4 +13,9 @@ defineProps<{
<MediaBadge v-if="item.duplicate" kind="duplicate" :label="t('Duplicate')" /> <MediaBadge v-if="item.duplicate" kind="duplicate" :label="t('Duplicate')" />
<MediaBadge v-if="item.used_in > 0" kind="used" :label="t('Used in {0}', [item.used_in])" /> <MediaBadge v-if="item.used_in > 0" kind="used" :label="t('Used in {0}', [item.used_in])" />
<MediaBadge v-else kind="unused" :label="t('Unused')" /> <MediaBadge v-else kind="unused" :label="t('Unused')" />
<MediaBadge
v-if="variants && (item.variant_count ?? 0) > 0"
kind="variants"
:label="item.variant_count === 1 ? t('1 variant') : t('{0} variants', [item.variant_count])"
/>
</template> </template>
+179 -108
View File
@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n' import { t } from '@/i18n'
import { computed } from 'vue' import { computed, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core' import { useMediaQuery } from '@vueuse/core'
import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue' import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue'
import MediaThumb from '@/components/MediaThumb.vue' import MediaThumb from '@/components/MediaThumb.vue'
import MediaVariantsTab from '@/components/variants/MediaVariantsTab.vue'
import type { MediaItem, MediaUsageItem } from '@/composables/useMedia' import type { MediaItem, MediaUsageItem } from '@/composables/useMedia'
import { import {
formatDimensions, formatDimensions,
@@ -14,6 +15,8 @@ import {
joinNames, joinNames,
pluralArticles, pluralArticles,
} from '@/lib/mediaFormat' } from '@/lib/mediaFormat'
import { PANEL_TAB_TRIGGER } from '@/lib/tabStyles'
import { useCanManageVariants } from '@/composables/useCanManageVariants'
const DESKTOP_QUERY = '(min-width: 48rem)' const DESKTOP_QUERY = '(min-width: 48rem)'
const ACTION_BUTTON = const ACTION_BUTTON =
@@ -31,10 +34,46 @@ const emit = defineEmits<{
rename: [] rename: []
delete: [] delete: []
'review-duplicates': [] 'review-duplicates': []
gone: []
}>() }>()
const open = defineModel<boolean>('open', { default: false }) const open = defineModel<boolean>('open', { default: false })
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useMediaQuery(DESKTOP_QUERY)
const canManageVariants = useCanManageVariants()
const panelTab = ref<'details' | 'variants'>('details')
const variantsVisited = ref(false)
const tabItems = computed(() => [
{
get label() {
return t('Details')
},
value: 'details',
},
{
get label() {
return t('Variants')
},
value: 'variants',
},
])
const tabModel = computed({
get: () => panelTab.value,
set: (value: string) => {
panelTab.value = value === 'variants' ? 'variants' : 'details'
if (panelTab.value === 'variants') {
variantsVisited.value = true
}
},
})
watch(
() => [open.value, props.item?.id],
() => {
panelTab.value = 'details'
variantsVisited.value = false
},
)
const closeButton = computed(() => ({ const closeButton = computed(() => ({
color: 'neutral' as const, color: 'neutral' as const,
@@ -124,126 +163,158 @@ const duplicateAlert = computed(() => {
}" }"
> >
<template #body> <template #body>
<div v-if="item" class="flex flex-col gap-5"> <div v-if="item" class="flex flex-col gap-4">
<MediaThumb <div
:item="item" v-if="canManageVariants"
size="xl" data-testid="panel-tabs"
class="aspect-4/3 w-full border border-default md:aspect-16/10" class="-mx-4 -mt-4 h-11 border-b border-default px-4 md:-mx-5 md:-mt-5 md:mb-1 md:h-10 md:px-5"
/> >
<UTabs
<div class="flex gap-2 max-md:flex-wrap"> v-model="tabModel"
<UButton :items="tabItems"
variant="link"
color="neutral" color="neutral"
variant="outline" :content="false"
icon="i-lucide-link" class="-mb-px"
:class="ACTION_BUTTON" :ui="{
@click="emit('copy-url')" list: 'gap-5 border-b-0 p-0',
> trigger: PANEL_TAB_TRIGGER,
{{ t('Copy URL') }} }"
</UButton> />
<UButton
color="neutral"
variant="outline"
icon="i-lucide-pencil"
:disabled="offline"
:class="ACTION_BUTTON"
@click="emit('rename')"
>
{{ t('Rename') }}
</UButton>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-trash-2"
:disabled="offline"
:class="ACTION_BUTTON"
class="text-error"
@click="emit('delete')"
>
{{ t('Delete') }}
</UButton>
</div> </div>
<UAlert <MediaVariantsTab
v-if="duplicateAlert" v-if="canManageVariants && variantsVisited"
data-testid="duplicate-alert" v-show="panelTab === 'variants'"
color="info" :key="item.id"
variant="soft" :item="item"
orientation="vertical" @gone="emit('gone')"
:close="false" />
icon="i-lucide-copy"
:title="duplicateAlert.title" <div v-show="panelTab === 'details'" class="flex flex-col gap-5">
:description="duplicateAlert.description" <MediaThumb
:ui="{ actions: 'mt-2 w-full', description: 'text-wrap' }" :item="item"
> size="xl"
<template #actions> class="aspect-4/3 w-full border border-default md:aspect-16/10"
/>
<div class="flex gap-2 max-md:flex-wrap">
<UButton <UButton
color="neutral" color="neutral"
variant="outline" variant="outline"
icon="i-lucide-arrow-right" icon="i-lucide-link"
block :class="ACTION_BUTTON"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7" @click="emit('copy-url')"
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('review-duplicates')"
> >
{{ t('Review duplicates') }} {{ t('Copy URL') }}
</UButton> </UButton>
</template> <UButton
</UAlert> color="neutral"
variant="outline"
icon="i-lucide-pencil"
:disabled="offline"
:class="ACTION_BUTTON"
@click="emit('rename')"
>
{{ t('Rename') }}
</UButton>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-trash-2"
:disabled="offline"
:class="ACTION_BUTTON"
class="text-error"
@click="emit('delete')"
>
{{ t('Delete') }}
</UButton>
</div>
<dl class="grid grid-cols-[110px_minmax(0,1fr)] gap-x-3 gap-y-2 text-toned"> <UAlert
<template v-for="row in metadata" :key="row.label"> v-if="duplicateAlert"
<dt class="text-muted">{{ row.label }}</dt> data-testid="duplicate-alert"
<dd class="m-0 text-highlighted">{{ row.value }}</dd> color="info"
</template> variant="soft"
<dt class="text-muted">URL</dt> orientation="vertical"
<dd class="m-0 truncate font-mono text-xs/5 text-highlighted"> :close="false"
{{ displayUrl }} icon="i-lucide-copy"
</dd> :title="duplicateAlert.title"
</dl> :description="duplicateAlert.description"
:ui="{ actions: 'mt-2 w-full', description: 'text-wrap' }"
<section class="flex flex-col gap-2"> >
<h3 class="text-base font-semibold text-highlighted"> <template #actions>
{{ <UButton
item.used_in > 0 color="neutral"
? t('Used in {0}', [pluralArticles(item.used_in)]) variant="outline"
: t('Not used in any article') icon="i-lucide-arrow-right"
}} block
</h3> class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
<template v-if="item.used_in > 0"> :ui="{ leadingIcon: 'size-3.5' }"
<ul v-if="usage.length > 0" class="border border-default"> @click="emit('review-duplicates')"
<li
v-for="(article, index) in usage"
:key="article.id"
data-testid="usage-row"
class="flex min-h-12 items-center gap-2 px-3 md:min-h-10"
:class="{ 'border-t border-muted': index > 0 }"
> >
<RouterLink {{ t('Review duplicates') }}
:to="`/article/${article.id}/edit`" </UButton>
class="min-w-0 flex-1 truncate font-medium text-highlighted underline underline-offset-3" </template>
> </UAlert>
{{ article.title }}
</RouterLink> <dl class="grid grid-cols-[110px_minmax(0,1fr)] gap-x-3 gap-y-2 text-toned">
<MediaArticleStatusBadge :published="article.published" size="md" /> <template v-for="row in metadata" :key="row.label">
<UIcon <dt class="text-muted">{{ row.label }}</dt>
name="i-lucide-arrow-up-right" <dd class="m-0 text-highlighted">{{ row.value }}</dd>
class="size-3.5 shrink-0 text-muted" </template>
/> <dt class="text-muted">URL</dt>
</li> <dd class="m-0 truncate font-mono text-xs/5 text-highlighted">
</ul> {{ displayUrl }}
<p class="text-xs text-muted"> </dd>
</dl>
<section class="flex flex-col gap-2">
<h3 class="text-base font-semibold text-highlighted">
{{ {{
t( item.used_in > 0
'Counted from the text of your articles each time you open this page.', ? t('Used in {0}', [pluralArticles(item.used_in)])
) : t('Not used in any article')
}} }}
</h3>
<template v-if="item.used_in > 0">
<ul v-if="usage.length > 0" class="border border-default">
<li
v-for="(article, index) in usage"
:key="article.id"
data-testid="usage-row"
class="flex min-h-12 items-center gap-2 px-3 md:min-h-10"
:class="{ 'border-t border-muted': index > 0 }"
>
<RouterLink
:to="`/article/${article.id}/edit`"
class="min-w-0 flex-1 truncate font-medium text-highlighted underline underline-offset-3"
>
{{ article.title }}
</RouterLink>
<MediaArticleStatusBadge
:published="article.published"
size="md"
/>
<UIcon
name="i-lucide-arrow-up-right"
class="size-3.5 shrink-0 text-muted"
/>
</li>
</ul>
<p class="text-xs text-muted">
{{
t(
'Counted from the text of your articles each time you open this page.',
)
}}
</p>
</template>
<p v-else class="text-muted">
{{ t('This image is safe to delete. No article shows it.') }}
</p> </p>
</template> </section>
<p v-else class="text-muted"> </div>
{{ t('This image is safe to delete. No article shows it.') }}
</p>
</section>
</div> </div>
</template> </template>
</USlideover> </USlideover>
+15 -1
View File
@@ -6,7 +6,7 @@ import { computed } from 'vue'
const QUERY_MAX_LENGTH = 40 const QUERY_MAX_LENGTH = 40
const props = defineProps<{ const props = defineProps<{
variant: 'first-run' | 'no-results' | 'no-duplicates' variant: 'first-run' | 'no-results' | 'no-duplicates' | 'no-variants'
query?: string query?: string
filterLabel?: string filterLabel?: string
offline?: boolean offline?: boolean
@@ -42,6 +42,20 @@ const current = computed(() => {
action: t('Back to all images'), action: t('Back to all images'),
} }
} }
if (props.variant === 'no-variants') {
return {
icon: 'i-lucide-layers',
get title() {
return t('No images with variants')
},
get description() {
return t(
'Variants are generated when an image is requested with ?v=<name>, or from its Variants tab.',
)
},
action: t('Show all images'),
}
}
return { return {
icon: 'i-lucide-search', icon: 'i-lucide-search',
get title() { get title() {
+8 -4
View File
@@ -6,7 +6,7 @@ import MediaCardBadges from '@/components/MediaCardBadges.vue'
import MediaCheckbox from '@/components/MediaCheckbox.vue' import MediaCheckbox from '@/components/MediaCheckbox.vue'
import MediaThumb from '@/components/MediaThumb.vue' import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia' import type { MediaItem } from '@/composables/useMedia'
import { accessibleName, formatDimensions, formatSize } from '@/lib/mediaFormat' import { accessibleName, formatDimensions, formatSize, totalMeta } from '@/lib/mediaFormat'
const SELECTED_CARD = const SELECTED_CARD =
'border-primary bg-secondary-200 text-primary-900 inset-ring-1 inset-ring-primary dark:bg-secondary-300' 'border-primary bg-secondary-200 text-primary-900 inset-ring-1 inset-ring-primary dark:bg-secondary-300'
@@ -16,6 +16,7 @@ const props = defineProps<{
items: MediaItem[] items: MediaItem[]
columns: number columns: number
selected: number[] selected: number[]
totalSize?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -108,12 +109,15 @@ function onKeyup(event: KeyboardEvent) {
<span <span
class="truncate text-xs/5" class="truncate text-xs/5"
:class="selected.includes(item.id) ? 'text-primary-600' : 'text-muted'" :class="selected.includes(item.id) ? 'text-primary-600' : 'text-muted'"
>{{ formatSize(item.size) }} · >{{
{{ formatDimensions(item.width, item.height) }}</span totalSize
? totalMeta(item)
: `${formatSize(item.size)} · ${formatDimensions(item.width, item.height)}`
}}</span
> >
</span> </span>
<span class="flex flex-wrap gap-1"> <span class="flex flex-wrap gap-1">
<MediaCardBadges :item="item" /> <MediaCardBadges :item="item" variants />
</span> </span>
</span> </span>
</button> </button>
+11
View File
@@ -4,7 +4,9 @@ import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core' import { useMediaQuery } from '@vueuse/core'
import MediaThumb from '@/components/MediaThumb.vue' import MediaThumb from '@/components/MediaThumb.vue'
import VariantPickerField from '@/components/variants/VariantPickerField.vue'
import type { LibraryStatus } from '@/composables/useLibraryList' import type { LibraryStatus } from '@/composables/useLibraryList'
import type { ImageVariant } from '@/composables/useImageVariants'
import type { MediaItem } from '@/composables/useMedia' import type { MediaItem } from '@/composables/useMedia'
import { accessibleName, formatDimensions, formatSize } from '@/lib/mediaFormat' import { accessibleName, formatDimensions, formatSize } from '@/lib/mediaFormat'
@@ -34,6 +36,8 @@ const props = defineProps<{
firstNewId: number | null firstNewId: number | null
inArticleIds: number[] inArticleIds: number[]
selected: MediaItem | null selected: MediaItem | null
variants?: ImageVariant[]
variantsReady?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -45,6 +49,7 @@ const emit = defineEmits<{
}>() }>()
const alt = defineModel<string>('alt', { default: '' }) const alt = defineModel<string>('alt', { default: '' })
const variant = defineModel<string>('variant', { default: '' })
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useMediaQuery(DESKTOP_QUERY)
const grid = ref<HTMLElement | null>(null) const grid = ref<HTMLElement | null>(null)
@@ -323,6 +328,12 @@ onBeforeUnmount(clearDebounce)
> >
</div> </div>
</div> </div>
<VariantPickerField
v-model:variant="variant"
:image="selected"
:variants="variants ?? []"
:ready="variantsReady ?? false"
/>
<UFormField <UFormField
:label="t('Alt text (optional)')" :label="t('Alt text (optional)')"
help="Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration." help="Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration."
+35 -6
View File
@@ -2,11 +2,20 @@
import { t, formatNumber } from '@/i18n' import { t, formatNumber } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import MediaVariantsFilter from '@/components/MediaVariantsFilter.vue'
import type { MediaCounts } from '@/composables/useMedia' import type { MediaCounts } from '@/composables/useMedia'
import type { MediaControls, MediaFilter, MediaSort, MediaView } from '@/lib/mediaQuery' import type {
MediaControls,
MediaFilter,
MediaSort,
MediaVariantsFilter as VariantsFilterValue,
MediaView,
} from '@/lib/mediaQuery'
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles' import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
const SEARCH_DEBOUNCE_MS = 120 const SEARCH_DEBOUNCE_MS = 120
const DESKTOP_QUERY = '(min-width: 48rem)'
const SORT_LABELS: Record<MediaSort, string> = { const SORT_LABELS: Record<MediaSort, string> = {
get newest() { get newest() {
@@ -21,6 +30,9 @@ const SORT_LABELS: Record<MediaSort, string> = {
get largest() { get largest() {
return t('Largest') return t('Largest')
}, },
get total_size() {
return t('Largest total size (original + variants)')
},
} }
const VIEW_OPTIONS: { value: MediaView; icon: string; label: string }[] = [ const VIEW_OPTIONS: { value: MediaView; icon: string; label: string }[] = [
@@ -54,10 +66,12 @@ const emit = defineEmits<{
'update:q': [value: string] 'update:q': [value: string]
'update:filter': [value: MediaFilter] 'update:filter': [value: MediaFilter]
'update:sort': [value: MediaSort] 'update:sort': [value: MediaSort]
'update:variants': [value: VariantsFilterValue]
'update:view': [value: MediaView] 'update:view': [value: MediaView]
purge: [] purge: []
}>() }>()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const searchText = ref(props.controls.q) const searchText = ref(props.controls.q)
let debounceTimer: ReturnType<typeof setTimeout> | null = null let debounceTimer: ReturnType<typeof setTimeout> | null = null
@@ -128,9 +142,13 @@ const tab = computed({
}, },
}) })
function sortLabel(value: MediaSort) {
return value === 'total_size' && !isDesktop.value ? t('Largest total size') : SORT_LABELS[value]
}
const sortItems = computed(() => const sortItems = computed(() =>
(Object.keys(SORT_LABELS) as MediaSort[]).map((value) => ({ (Object.keys(SORT_LABELS) as MediaSort[]).map((value) => ({
label: SORT_LABELS[value], label: sortLabel(value),
type: 'checkbox' as const, type: 'checkbox' as const,
checked: props.controls.sort === value, checked: props.controls.sort === value,
onUpdateChecked: () => emit('update:sort', value), onUpdateChecked: () => emit('update:sort', value),
@@ -197,19 +215,30 @@ const sortItems = computed(() =>
</template> </template>
</UInput> </UInput>
<MediaVariantsFilter
:model-value="controls.variants"
class="order-3 w-full md:order-none md:mb-1.5 md:w-auto"
@update:model-value="emit('update:variants', $event)"
/>
<div <div
class="order-3 flex flex-wrap gap-x-2 gap-y-3 md:order-none md:mb-1.5 md:flex-nowrap md:gap-2" class="order-4 flex flex-wrap gap-x-2 gap-y-3 md:order-none md:mb-1.5 md:flex-nowrap md:gap-2"
> >
<UDropdownMenu :items="sortItems" :content="{ align: 'start' }"> <UDropdownMenu
:items="sortItems"
:content="{ align: 'start' }"
:ui="{ content: 'md:w-85' }"
>
<UButton <UButton
color="neutral" color="neutral"
variant="outline" variant="outline"
trailing-icon="i-lucide-chevron-down" trailing-icon="i-lucide-chevron-down"
:aria-label="t('Sort')" :aria-label="t('Sort')"
class="h-11 flex-1 justify-between border border-accented bg-default px-2.5 text-sm font-normal text-highlighted ring-0 md:h-8 md:w-32 md:flex-none" class="h-11 flex-1 justify-between border border-accented bg-default px-2.5 text-sm font-normal text-highlighted ring-0 md:h-8 md:flex-none"
:class="controls.sort === 'total_size' ? 'md:w-auto' : 'md:w-32'"
:ui="{ trailingIcon: 'size-3.5 text-muted' }" :ui="{ trailingIcon: 'size-3.5 text-muted' }"
> >
{{ SORT_LABELS[controls.sort] }} {{ sortLabel(controls.sort) }}
</UButton> </UButton>
</UDropdownMenu> </UDropdownMenu>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { t } from '@/i18n'
import type { MediaVariantsFilter } from '@/lib/mediaQuery'
const SEGMENT =
'h-10.5 flex-1 justify-center rounded-none px-2.5 text-xs font-medium whitespace-nowrap ring-0 md:h-7.5 md:flex-none md:px-3 md:text-sm'
defineProps<{ modelValue: MediaVariantsFilter }>()
const emit = defineEmits<{ 'update:modelValue': [value: MediaVariantsFilter] }>()
const OPTIONS: { value: MediaVariantsFilter; label: () => string }[] = [
{ value: 'all', label: () => t('All') },
{ value: 'with', label: () => t('With variants') },
{ value: 'without', label: () => t('Without') },
]
</script>
<template>
<div
role="group"
:aria-label="t('Variants')"
data-testid="variants-filter"
class="flex shrink-0 border border-accented bg-default"
>
<UButton
v-for="option in OPTIONS"
:key="option.value"
:color="option.value === modelValue ? 'primary' : 'neutral'"
:variant="option.value === modelValue ? 'solid' : 'ghost'"
:aria-pressed="option.value === modelValue"
:class="[SEGMENT, option.value === modelValue ? '' : 'text-toned']"
@click="emit('update:modelValue', option.value)"
>
{{ option.label() }}
</UButton>
</div>
</template>
+2 -1
View File
@@ -82,7 +82,8 @@ export const Host = defineComponent({
return { return {
open, open,
onUploaded: (item: MediaItem) => emit('uploaded', item), onUploaded: (item: MediaItem) => emit('uploaded', item),
onInserted: (item: MediaItem, alt: string) => emit('inserted', item, alt), onInserted: (item: MediaItem, alt: string, src: string) =>
emit('inserted', item, alt, src),
onClose: () => emit('close'), onClose: () => emit('close'),
} }
}, },
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedToast } from '@/i18n/presentation'
import VariantImageRow from '@/components/variants/VariantImageRow.vue'
import VariantOriginalBlock from '@/components/variants/VariantOriginalBlock.vue'
import VariantPreviewDialog from '@/components/variants/VariantPreviewDialog.vue'
import { useImageVariantRows } from '@/composables/useImageVariantRows'
import type { MediaItem } from '@/composables/useMedia'
import type { MediaVariantItem } from '@/composables/useMediaVariants'
const ROOMY_QUERY = '(min-width: 40rem)'
const SKELETON_ROWS = 5
const TOAST_DURATION_MS = 5000
const SETTINGS_VARIANTS_PATH = '/settings/media/variants'
const RETRY_BUTTON =
'h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7'
const props = defineProps<{ item: MediaItem }>()
const emit = defineEmits<{ gone: [] }>()
const toast = useLocalizedToast()
const roomy = useMediaQuery(ROOMY_QUERY)
const rows = useImageVariantRows(props.item.id, () => emit('gone'))
const previewOpen = ref(false)
const previewTarget = ref<MediaVariantItem | null>(null)
let previewOpener: HTMLElement | null = null
const total = computed(() => rows.items.value.length)
const showList = computed(
() => rows.status.value === 'ready' && rows.unsupported.value === null && total.value > 0,
)
const showEmpty = computed(
() => rows.status.value === 'ready' && rows.unsupported.value === null && total.value === 0,
)
const generateAllDisabled = computed(() => rows.allGenerated.value || rows.anyBusy.value)
onMounted(rows.load)
async function copyUrl(variant: MediaVariantItem) {
try {
await navigator.clipboard.writeText(`${window.location.origin}${variant.url}`)
} catch {
return
}
toast.add(() => ({
id: `media-variant-url-copied-${props.item.id}-${variant.name}`,
color: 'success' as const,
icon: 'i-lucide-circle-check',
get title() {
return t('URL copied')
},
get description() {
return t('Paste it wherever you need the image address.')
},
duration: TOAST_DURATION_MS,
}))
}
function openPreview(variant: MediaVariantItem, opener: HTMLElement) {
previewOpener = opener
previewTarget.value = variant
previewOpen.value = true
}
async function onPreviewOpenChange(open: boolean) {
previewOpen.value = open
if (open) {
return
}
await nextTick()
previewOpener?.focus()
}
</script>
<template>
<div data-testid="variants-tab" class="flex flex-col gap-4">
<section
v-if="rows.status.value === 'loading' && rows.showSkeleton.value"
:aria-label="t('Loading variants')"
aria-busy="true"
class="border border-default"
>
<div
v-for="row in SKELETON_ROWS"
:key="row"
data-testid="variant-skeleton-row"
class="flex flex-col gap-2 px-3 py-2.5"
:class="row > 1 ? 'border-t border-muted' : ''"
>
<USkeleton class="h-3.25 w-[30%] rounded-none bg-accented" />
<USkeleton class="h-2.75 w-[55%] rounded-none bg-accented" />
</div>
</section>
<UAlert
v-else-if="rows.status.value === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load variants')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
:class="RETRY_BUTTON"
:ui="{ leadingIcon: 'size-3.5' }"
@click="rows.load"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<template v-else-if="rows.status.value === 'ready'">
<VariantOriginalBlock :item="item" />
<UAlert
v-if="rows.unsupported.value !== null"
color="info"
variant="soft"
:close="false"
icon="i-lucide-info"
:title="t('Variants aren\'t generated for this format.')"
:description="t('SVG images are served as they are.')"
/>
<UEmpty
v-else-if="showEmpty"
variant="naked"
icon="i-lucide-layers"
:title="t('No variants defined')"
:description="
t('Define a variant in Settings to get resized copies of this image.')
"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-7',
header: 'gap-3',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-[0.9375rem] font-semibold',
description: 'max-w-95 text-muted text-pretty',
}"
>
<template #actions>
<RouterLink
:to="SETTINGS_VARIANTS_PATH"
class="inline-flex min-h-11 items-center gap-1 font-medium text-highlighted underline underline-offset-3 sm:min-h-5"
>
{{ t('Settings → Media → Variants') }}
<UIcon name="i-lucide-arrow-up-right" class="size-3.5" />
</RouterLink>
</template>
</UEmpty>
<template v-else-if="showList">
<div class="flex items-center gap-3">
<div class="flex flex-1 flex-col">
<span class="font-semibold text-highlighted">{{
t('{0} of {1} generated', [rows.generatedCount.value, total])
}}</span>
<span
v-if="rows.generatedCount.value === 0 && !rows.anyBusy.value"
class="text-xs/5 text-muted"
>{{ t('Nothing generated yet.') }}</span
>
<span v-if="rows.anyBusy.value" class="text-xs/5 text-info">{{
t('Generating variants…')
}}</span>
</div>
<UButton
color="neutral"
variant="outline"
:icon="rows.anyBusy.value ? 'i-lucide-loader-circle' : 'i-lucide-zap'"
:disabled="generateAllDisabled"
class="h-11 border border-accented bg-default px-3 text-sm font-medium text-highlighted ring-0 sm:h-8"
:ui="{
leadingIcon: rows.anyBusy.value ? 'size-4 animate-spin' : 'size-4',
}"
@click="rows.generateAll"
>
{{ rows.anyBusy.value ? t('Generating…') : t('Generate all') }}
</UButton>
</div>
<ul class="border border-default">
<VariantImageRow
v-for="(row, index) in rows.rows.value"
:key="row.item.name"
:row="row"
:original-size="item.size"
:roomy="roomy"
:first="index === 0"
@copy="copyUrl(row.item)"
@preview="openPreview(row.item, $event)"
@purge="rows.purge(row.item.name)"
@generate="rows.generateOne(row.item.name)"
/>
</ul>
</template>
</template>
<p role="status" aria-live="polite" data-testid="variants-announcer" class="sr-only">
{{ rows.announcement.value }}
</p>
<VariantPreviewDialog
v-if="previewTarget"
:open="previewOpen"
:variant="previewTarget"
:original="item"
@update:open="onPreviewOpenChange"
/>
</div>
</template>
@@ -0,0 +1,140 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import VariantStatusBadge from '@/components/variants/VariantStatusBadge.vue'
import type { VariantRow } from '@/composables/useImageVariantRows'
import type { VariantFormat } from '@/composables/useImageVariants'
import { causeText } from '@/lib/variants/variantCauses'
import { formatLabel, formatVariantSize } from '@/lib/variants/variantFormat'
import { signedPercent } from '@/lib/variants/variantSaving'
type ActionKey = 'copy' | 'preview' | 'purge' | 'generate' | 'retry'
const props = defineProps<{
row: VariantRow
originalSize: number
roomy: boolean
first: boolean
}>()
const emit = defineEmits<{
copy: [element: HTMLElement]
preview: [element: HTMLElement]
purge: []
generate: []
}>()
const ACTIONS: Record<ActionKey, { icon: string; label: () => string }> = {
copy: { icon: 'i-lucide-link', label: () => t('Copy URL') },
preview: { icon: 'i-lucide-eye', label: () => t('Preview') },
purge: { icon: 'i-lucide-eraser', label: () => t('Purge') },
generate: { icon: 'i-lucide-zap', label: () => t('Generate now') },
retry: { icon: 'i-lucide-rotate-cw', label: () => t('Retry') },
}
const ACTIONS_BY_STATUS: Record<VariantRow['status'], ActionKey[]> = {
generated: ['copy', 'preview', 'purge'],
missing: ['copy', 'generate'],
busy: [],
failed: ['retry'],
}
const item = computed(() => props.row.item)
const generated = computed(() => props.row.status === 'generated')
const actions = computed(() =>
ACTIONS_BY_STATUS[props.row.status].map((key) => ({ key, ...ACTIONS[key] })),
)
const meta = computed(() => {
const base = `${item.value.output_width}×${item.value.output_height} · ${formatLabel(item.value.output_format as VariantFormat)}`
if (!generated.value || item.value.size === null) {
return base
}
return `${base} · ${formatVariantSize(item.value.size)} ·`
})
const saving = computed(() =>
t('{0} vs original', [signedPercent(props.originalSize, item.value.size ?? 0)]),
)
function run(key: ActionKey, event: Event) {
const element = event.currentTarget as HTMLElement
if (key === 'copy') {
emit('copy', element)
} else if (key === 'preview') {
emit('preview', element)
} else if (key === 'purge') {
emit('purge')
} else {
emit('generate')
}
}
</script>
<template>
<li
data-testid="variant-row"
:data-variant="item.name"
class="flex flex-col gap-2 px-3 py-2.5 sm:gap-1"
:class="[
row.status === 'busy' ? 'bg-elevated' : 'bg-default',
first ? '' : 'border-t border-muted',
]"
>
<div class="flex items-center gap-2">
<span class="font-mono text-sm font-medium text-highlighted">{{ item.name }}</span>
<VariantStatusBadge :status="row.status" />
<span class="flex-1" />
<div v-if="roomy && actions.length > 0" class="flex gap-1">
<template v-for="action in actions" :key="action.key">
<UButton
v-if="action.key === 'retry'"
color="neutral"
variant="outline"
:icon="action.icon"
class="h-7 gap-1.5 border border-accented bg-default px-2.5 text-xs font-medium text-highlighted ring-0"
:ui="{ leadingIcon: 'size-3.5' }"
@click="run(action.key, $event)"
>
{{ action.label() }}
</UButton>
<UTooltip v-else :text="action.label()">
<UButton
color="neutral"
variant="outline"
:icon="action.icon"
:aria-label="action.label()"
class="size-7 justify-center border border-accented bg-default p-0 text-highlighted ring-0"
:ui="{ leadingIcon: 'size-4' }"
@click="run(action.key, $event)"
/>
</UTooltip>
</template>
</div>
</div>
<div class="flex flex-wrap gap-1 text-xs/5 text-toned">
<span>{{ meta }}</span>
<span v-if="generated" class="font-medium text-success">{{ saving }}</span>
</div>
<div
v-if="row.status === 'failed' && row.cause"
class="flex items-center gap-1.5 text-xs/5 text-error"
>
<UIcon name="i-lucide-circle-alert" class="size-3.5 shrink-0" />
<span>{{ causeText(row.cause) }}</span>
</div>
<div v-if="!roomy && actions.length > 0" class="flex flex-wrap gap-2">
<UButton
v-for="action in actions"
:key="action.key"
color="neutral"
variant="outline"
:icon="action.icon"
class="h-11 gap-1.5 border border-accented bg-default px-3 text-sm font-medium text-highlighted ring-0"
:ui="{ leadingIcon: 'size-4' }"
@click="run(action.key, $event)"
>
{{ action.label() }}
</UButton>
</div>
</li>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { t } from '@/i18n'
import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia'
import { formatDimensions, formatSize, formatType } from '@/lib/mediaFormat'
defineProps<{ item: MediaItem }>()
</script>
<template>
<div
data-testid="variants-original"
class="flex items-center gap-3 border border-default bg-muted p-3"
>
<MediaThumb :item="item" size="md" class="h-13.5 w-18 border border-default" />
<div class="flex min-w-0 flex-col">
<span class="text-xs/5 text-muted">{{ t('Original · kept untouched') }}</span>
<span class="truncate font-semibold text-highlighted">{{ item.filename }}</span>
<span class="text-xs/5 text-toned">
{{ formatType(item.content_type) }} ·
{{ formatDimensions(item.width, item.height) }} · {{ formatSize(item.size) }}
</span>
</div>
</div>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { ref } from 'vue'
import MediaThumb from '@/components/MediaThumb.vue'
defineProps<{
src: string
width: number
height: number
contentType: string
sha256: string
}>()
const failed = ref(false)
</script>
<template>
<MediaThumb
v-if="failed"
data-testid="pane-placeholder"
:item="{ url: '', content_type: contentType, sha256 }"
size="lg"
class="shrink-0"
:style="{ width: `${width}px`, height: `${height}px` }"
/>
<img
v-else
:src="src"
alt=""
:width="width"
:height="height"
class="block max-w-none shrink-0"
@error="failed = true"
/>
</template>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import VariantSelect from '@/components/variants/VariantSelect.vue'
import type { ImageVariant } from '@/composables/useImageVariants'
import type { MediaItem } from '@/composables/useMedia'
import {
buildOptions,
insertedUrl,
isVector,
ORIGINAL_OPTION_VALUE,
} from '@/lib/variants/variantOptions'
const LINK_TOKEN = '\u0000'
const SETTINGS_VARIANTS_PATH = '/settings/media/variants'
const props = defineProps<{
image: MediaItem
variants: ImageVariant[]
ready: boolean
}>()
const variant = defineModel<string>('variant', { default: ORIGINAL_OPTION_VALUE })
const options = computed(() => buildOptions(props.image, props.variants))
const vector = computed(() => isVector(props.image))
const noVariants = computed(() => props.ready && !vector.value && props.variants.length === 0)
const url = computed(() => insertedUrl(props.image, variant.value))
const helpParts = computed(() =>
t('No variants defined. Add one in {0}.', [LINK_TOKEN]).split(LINK_TOKEN),
)
</script>
<template>
<UFormField
:label="t('Variant')"
name="variant"
:ui="{
label: 'font-medium text-highlighted',
help: 'text-xs/5 text-muted',
}"
data-testid="variant-field"
>
<VariantSelect v-model="variant" :options="options" />
<template #help>
<span v-if="noVariants" class="flex items-center gap-1.5">
<UIcon name="i-lucide-info" class="size-3.5 shrink-0" />
<span data-testid="variant-help"
>{{ helpParts[0]
}}<RouterLink
:to="SETTINGS_VARIANTS_PATH"
class="font-medium text-highlighted underline underline-offset-3"
>{{ t('Settings → Media → Variants') }}</RouterLink
>{{ helpParts[1] }}</span
>
</span>
<span v-else data-testid="variant-url" class="font-mono">{{ url }}</span>
</template>
</UFormField>
</template>
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import VariantPaneImage from '@/components/variants/VariantPaneImage.vue'
import type { MediaItem } from '@/composables/useMedia'
import type { MediaVariantItem } from '@/composables/useMediaVariants'
import type { VariantFormat } from '@/composables/useImageVariants'
import { formatSize, formatType } from '@/lib/mediaFormat'
import { formatLabel, formatVariantSize } from '@/lib/variants/variantFormat'
import { savesPercent } from '@/lib/variants/variantSaving'
const VARIANT_TONE_HASH = '1'
const PANE =
'flex overflow-auto border border-default bg-muted h-45 items-start justify-start sm:h-75 sm:items-center sm:justify-center'
const props = defineProps<{ variant: MediaVariantItem; original: MediaItem }>()
const open = defineModel<boolean>('open', { default: false })
const variantSize = computed(() => props.variant.size ?? 0)
const variantTitle = computed(
() =>
`${props.variant.name} · ${props.variant.output_width}×${props.variant.output_height} · ${formatLabel(props.variant.output_format as VariantFormat)}`,
)
const variantSub = computed(() =>
t('{0} · saves {1}%', [
formatVariantSize(variantSize.value),
savesPercent(props.original.size, variantSize.value),
]),
)
const originalTitle = computed(
() =>
`${t('Original')} · ${props.original.width ?? 0}×${props.original.height ?? 0} · ${formatType(props.original.content_type)}`,
)
</script>
<template>
<UModal
v-model:open="open"
:title="t('Preview: {0}', [variant.name])"
:description="t('Both images are shown at actual size. Scroll to pan.')"
:ui="{
content:
'sm:top-20 sm:translate-y-0 sm:max-w-220 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0 divide-y-0 shadow-(--shadow-modal) ring-0',
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
close: 'top-5 end-6',
title: 'text-lg/6 font-semibold',
description: 'sr-only',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
}"
>
<template #body>
<div class="flex flex-col gap-3">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="flex min-w-0 flex-col gap-2">
<div :class="PANE" data-testid="preview-variant-pane">
<VariantPaneImage
:src="variant.url"
:width="variant.output_width"
:height="variant.output_height"
:content-type="original.content_type"
:sha256="VARIANT_TONE_HASH"
/>
</div>
<div class="flex flex-col">
<span class="font-semibold text-highlighted">{{ variantTitle }}</span>
<span class="text-xs/5 text-toned">{{ variantSub }}</span>
</div>
</div>
<div class="flex min-w-0 flex-col gap-2">
<div :class="PANE" data-testid="preview-original-pane">
<VariantPaneImage
:src="original.url"
:width="original.width ?? 0"
:height="original.height ?? 0"
:content-type="original.content_type"
:sha256="original.sha256"
/>
</div>
<div class="flex flex-col">
<span class="font-semibold text-highlighted">{{ originalTitle }}</span>
<span class="text-xs/5 text-toned">{{
formatSize(original.size)
}}</span>
</div>
</div>
</div>
<p class="text-xs/5 text-muted">
{{ t('Both images are shown at actual size. Scroll to pan.') }}
</p>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
color="neutral"
variant="outline"
block
class="h-11 justify-center border border-accented bg-default text-sm text-highlighted ring-0 sm:h-8 sm:w-auto sm:px-3"
@click="open = false"
>
{{ t('Close') }}
</UButton>
</div>
</template>
</UModal>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { removalNote } from '@/lib/mediaFormat'
defineProps<{ count: number; bytes: number }>()
</script>
<template>
<UAlert
v-if="count > 0"
data-testid="variant-removal-note"
color="warning"
variant="soft"
icon="i-lucide-layers"
:title="removalNote(count, bytes)"
:ui="{
root: 'items-center px-3 py-2.5',
title: 'font-medium text-highlighted',
}"
/>
</template>
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { ORIGINAL_OPTION_VALUE, type VariantOption } from '@/lib/variants/variantOptions'
const ORIGINAL_ITEM_VALUE = '@original'
const props = defineProps<{
options: VariantOption[]
modelValue?: string
id?: string
placeholder?: string
ariaLabel?: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const open = defineModel<boolean>('open', { default: false })
function toItemValue(value: string) {
return value === ORIGINAL_OPTION_VALUE ? ORIGINAL_ITEM_VALUE : value
}
const items = computed(() =>
props.options.map((option) => ({ ...option, value: toItemValue(option.value) })),
)
const current = computed(() =>
props.modelValue === undefined ? undefined : toItemValue(props.modelValue),
)
function choose(value: string) {
emit('update:modelValue', value === ORIGINAL_ITEM_VALUE ? ORIGINAL_OPTION_VALUE : value)
}
</script>
<template>
<USelectMenu
v-model:open="open"
:model-value="current"
:items="items"
value-key="value"
label-key="label"
:id="id"
:placeholder="placeholder"
:search-input="false"
:aria-label="ariaLabel ?? t('Variant')"
color="neutral"
variant="outline"
data-testid="variant-select"
class="w-full"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default px-3 text-lg ring-0 data-[state=open]:border-primary data-[state=open]:ring-1 data-[state=open]:ring-primary md:h-10 md:text-sm',
trailingIcon: 'size-4 text-muted',
content: 'min-w-(--reka-combobox-trigger-width)',
item: 'min-h-11 ps-2 text-lg md:min-h-9 md:text-sm',
itemLeadingIcon: 'hidden',
itemTrailingIcon: 'hidden',
}"
@update:model-value="choose($event as string)"
>
<template #item-leading="{ item }">
<span class="flex size-4 items-center justify-center">
<UIcon
v-if="(item as VariantOption).value === current"
name="i-lucide-check"
class="size-3.5"
/>
</span>
</template>
</USelectMenu>
</template>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { RowStatus } from '@/composables/useImageVariantRows'
const props = defineProps<{ status: RowStatus }>()
const BADGES = {
generated: {
icon: 'i-lucide-circle-check',
class: 'border border-(--rc-success-soft) bg-(--rc-success-soft) text-success',
label: () => t('Generated'),
},
missing: {
icon: 'i-lucide-circle-dashed',
class: 'border border-accented bg-transparent text-toned',
label: () => t('Not generated'),
},
busy: {
icon: 'i-lucide-loader-circle',
class: 'border border-(--rc-info-soft) bg-(--rc-info-soft) text-info',
label: () => t('Generating'),
},
failed: {
icon: 'i-lucide-circle-x',
class: 'border border-(--rc-error-soft) bg-(--rc-error-soft) text-error',
label: () => t('Failed'),
},
} as const
const badge = computed(() => BADGES[props.status])
</script>
<template>
<UBadge
color="neutral"
variant="soft"
:icon="badge.icon"
:class="badge.class"
:data-status="status"
:ui="{
base: 'h-5 gap-1 px-1.5 py-0 text-xs whitespace-nowrap',
leadingIcon: status === 'busy' ? 'size-3 animate-spin' : 'size-3',
}"
>
{{ badge.label() }}
</UBadge>
</template>
@@ -0,0 +1,7 @@
import { computed } from 'vue'
import { useSessionStore } from '@/stores/session'
export function useCanManageVariants() {
const session = useSessionStore()
return computed(() => session.session?.role === 'owner' || session.session?.role === 'admin')
}
+265
View File
@@ -0,0 +1,265 @@
import { computed, onBeforeUnmount, ref } from 'vue'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import { MEDIA_SKELETON_DELAY_MS, classifyMediaError } from '@/composables/useMedia'
import {
useMediaVariants,
type MediaVariantItem,
type VariantCause,
} from '@/composables/useMediaVariants'
import { formatSize } from '@/lib/mediaFormat'
import { causeOfStatus, causeText } from '@/lib/variants/variantCauses'
import { toastCopyFor } from '@/lib/variants/variantErrors'
export type RowStatus = 'generated' | 'missing' | 'busy' | 'failed'
export type TabStatus = 'loading' | 'ready' | 'error'
export interface VariantRow {
item: MediaVariantItem
status: RowStatus
cause: VariantCause | null
}
const TOAST_DURATION_MS = 5000
const NOT_FOUND = 404
export function useImageVariantRows(imageId: number, onGone: () => void) {
const api = useMediaVariants()
const toast = useLocalizedToast()
const items = ref<MediaVariantItem[]>([])
const unsupported = ref<string | null>(null)
const status = ref<TabStatus>('loading')
const showSkeleton = ref(false)
const busy = ref<string[]>([])
const failures = ref<Record<string, VariantCause>>({})
const announcement = ref('')
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let loadTicket = 0
const rows = computed<VariantRow[]>(() =>
items.value.map((item) => {
if (busy.value.includes(item.name)) {
return { item, status: 'busy', cause: null }
}
const cause = failures.value[item.name]
if (cause && !item.generated) {
return { item, status: 'failed', cause }
}
return { item, status: item.generated ? 'generated' : 'missing', cause: null }
}),
)
const generatedCount = computed(() => items.value.filter((item) => item.generated).length)
const anyBusy = computed(() => busy.value.length > 0)
const allGenerated = computed(
() => items.value.length > 0 && generatedCount.value === items.value.length,
)
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
async function load() {
loadTicket += 1
const ticket = loadTicket
status.value = 'loading'
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, MEDIA_SKELETON_DELAY_MS)
try {
const response = await api.list(imageId)
if (ticket !== loadTicket) {
return
}
items.value = response.items
unsupported.value = response.unsupported
status.value = 'ready'
} catch (error) {
if (ticket !== loadTicket) {
return
}
if (classifyMediaError(error) === 'not-found') {
onGone()
return
}
status.value = 'error'
} finally {
if (ticket === loadTicket) {
clearSkeletonTimer()
showSkeleton.value = false
}
}
}
function replaceItem(updated: MediaVariantItem) {
items.value = items.value.map((item) => (item.name === updated.name ? updated : item))
}
function clearFailure(name: string) {
const next = { ...failures.value }
delete next[name]
failures.value = next
}
function markBusy(names: string[]) {
busy.value = [...busy.value, ...names]
names.forEach(clearFailure)
}
function releaseBusy(names: string[]) {
busy.value = busy.value.filter((name) => !names.includes(name))
}
function successToast(id: string, title: () => string) {
toast.add(() => ({
id,
color: 'success' as const,
icon: 'i-lucide-circle-check',
get title() {
return title()
},
duration: TOAST_DURATION_MS,
}))
}
function failureToast(error: unknown) {
toast.add(() => ({
color: 'error' as const,
icon: 'i-lucide-circle-x',
get title() {
return toastCopyFor(error).title
},
get description() {
return toastCopyFor(error).description
},
}))
}
function causeToast(title: () => string, cause: VariantCause) {
toast.add(() => ({
color: 'error' as const,
icon: 'i-lucide-circle-x',
get title() {
return title()
},
get description() {
return `${causeText(cause)}.`
},
}))
}
function announce(text: string) {
announcement.value = text
}
async function generateOne(name: string) {
markBusy([name])
announce(t('Generating variants'))
try {
replaceItem(await api.generate(imageId, name))
successToast(`media-variant-generated-${imageId}-${name}`, () =>
t('Generated {0}', [name]),
)
announce(t('Generated {0}', [name]))
} catch (error) {
announce(t("Couldn't generate {0}.", [name]))
const cause = error instanceof ApiError ? causeOfStatus(error.status) : null
if (cause) {
failures.value = { ...failures.value, [name]: cause }
causeToast(() => t("Couldn't generate {0}.", [name]), cause)
return
}
failureToast(error)
if (error instanceof ApiError && error.status === NOT_FOUND) {
releaseBusy([name])
await load()
}
} finally {
releaseBusy([name])
}
}
function generatedTitle(count: number) {
return count === 1 ? t('Generated 1 variant') : t('Generated {0} variants', [count])
}
async function generateAll() {
const names = items.value.filter((item) => !item.generated).map((item) => item.name)
if (names.length === 0) {
return
}
markBusy(names)
announce(t('Generating variants'))
try {
const result = await api.generateAll(imageId)
items.value = result.items
const next = { ...failures.value }
result.failures.forEach((failure) => {
next[failure.name] = failure.cause
})
failures.value = next
if (result.generated > 0) {
successToast(`media-variants-generated-${imageId}`, () =>
generatedTitle(result.generated),
)
announce(generatedTitle(result.generated))
} else if (result.failures.length > 0) {
causeToast(() => t("Couldn't generate the variants."), result.failures[0]!.cause)
announce(t("Couldn't generate the variants."))
}
} catch (error) {
failureToast(error)
announce(t("Couldn't generate the variants."))
if (error instanceof ApiError && error.status === NOT_FOUND) {
releaseBusy(names)
await load()
}
} finally {
releaseBusy(names)
}
}
function purgedTitle(files: number, bytes: number) {
return files === 1
? t('Purged 1 file ({0})', [formatSize(bytes)])
: t('Purged {0} files ({1})', [files, formatSize(bytes)])
}
async function purge(name: string) {
try {
const result = await api.purge(imageId, name)
items.value = items.value.map((item) =>
item.name === name ? { ...item, generated: false, size: null } : item,
)
successToast(`media-variants-purged-${imageId}-${name}`, () =>
purgedTitle(result.purged_files, result.purged_bytes),
)
} catch (error) {
failureToast(error)
}
}
onBeforeUnmount(clearSkeletonTimer)
return {
items,
unsupported,
status,
showSkeleton,
rows,
generatedCount,
anyBusy,
allGenerated,
announcement,
load,
generateOne,
generateAll,
purge,
}
}
+2
View File
@@ -17,6 +17,8 @@ export interface MediaItem {
created_at: string created_at: string
used_in: number used_in: number
duplicate: boolean duplicate: boolean
variant_count: number
variant_size: number
} }
export interface MediaCounts { export interface MediaCounts {
+64
View File
@@ -0,0 +1,64 @@
import { useApi } from '@/composables/useApi'
export type VariantCause = 'too_large' | 'unsupported_format' | 'failed'
export interface MediaVariantItem {
name: string
width: number | null
height: number | null
fit: string
format: string
output_width: number
output_height: number
output_format: string
generated: boolean
size: number | null
url: string
}
export interface MediaVariantList {
unsupported: 'svg' | null
items: MediaVariantItem[]
}
export interface GenerateAllResult {
generated: number
failed: number
failures: { name: string; cause: VariantCause }[]
items: MediaVariantItem[]
}
export interface PurgedVariant {
purged_files: number
purged_bytes: number
}
function basePath(imageId: number) {
return `/v1/media/${imageId}/variants`
}
function namePath(imageId: number, name: string) {
return `${basePath(imageId)}/${encodeURIComponent(name)}`
}
export function useMediaVariants() {
const { request } = useApi()
function list(imageId: number) {
return request<MediaVariantList>(basePath(imageId))
}
function generate(imageId: number, name: string) {
return request<MediaVariantItem>(namePath(imageId, name), { method: 'POST' })
}
function generateAll(imageId: number) {
return request<GenerateAllResult>(basePath(imageId), { method: 'POST' })
}
function purge(imageId: number, name: string) {
return request<PurgedVariant>(namePath(imageId, name), { method: 'DELETE' })
}
return { list, generate, generateAll, purge }
}
+61
View File
@@ -1361,6 +1361,67 @@ export const messages = {
'Drag the divider, or focus it and use the arrow keys. The estimate comes from a sample; output is never larger than the source.': 'Drag the divider, or focus it and use the arrow keys. The estimate comes from a sample; output is never larger than the source.':
'Faites glisser le séparateur, ou donnez-lui le focus et utilisez les flèches. L’estimation vient d’un échantillon ; le résultat n’est jamais plus lourd que la source.', 'Faites glisser le séparateur, ou donnez-lui le focus et utilisez les flèches. L’estimation vient d’un échantillon ; le résultat n’est jamais plus lourd que la source.',
URL: 'URL', URL: 'URL',
'1 variant generated': '1 variante générée',
'{0} variants generated': '{0} variantes générées',
'Also removes 1 generated variant ({0})': 'Supprime aussi 1 variante générée ({0})',
'Also removes {0} generated variants ({1})': 'Supprime aussi {0} variantes générées ({1})',
'Both images are shown at actual size. Scroll to pan.':
'Les deux images sont affichées à leur taille réelle. Faites défiler pour les déplacer.',
'Define a variant in Settings to get resized copies of this image.':
'Définissez une variante dans les paramètres pour obtenir des copies redimensionnées de cette image.',
'Generate all': 'Tout générer',
'Generate now': 'Générer maintenant',
'Generated 1 variant': '1 variante générée',
'Generated {0} variants': '{0} variantes générées',
'Generated {0}': 'Variante {0} générée',
'Generating variants': 'Génération des variantes',
'Generating variants…': 'Génération des variantes…',
Generating: 'Génération',
'Generating…': 'Génération…',
'Generation failed': 'Échec de la génération',
'Image too large': 'Image trop grande',
'Largest total size (original + variants)': 'Plus grande taille totale (original + variantes)',
'Largest total size': 'Plus grande taille totale',
'No images with variants': 'Aucune image avec des variantes',
'No variants defined': 'Aucune variante définie',
'No variants defined. Add one in {0}.': 'Aucune variante définie. Ajoutez-en une dans {0}.',
'Not generated': 'Non générée',
'Nothing generated yet.': 'Rien n’a encore été généré.',
'Original · kept untouched': 'Original · conservé tel quel',
'Pick another variant': 'Choisir une autre variante',
'Preview: {0}': 'Aperçu : {0}',
Purge: 'Purger',
'Purged 1 file ({0})': '1 fichier purgé ({0})',
'Purged {0} files ({1})': '{0} fichiers purgés ({1})',
'Removed 1 file ({0})': '1 fichier supprimé ({0})',
'Removed {0} files ({1})': '{0} fichiers supprimés ({1})',
'SVG images are served as they are.': 'Les images SVG sont servies telles quelles.',
'Settings → Media → Variants': 'Paramètres → Médias → Variantes',
'Show all images': 'Afficher toutes les images',
'This image returns an error on the public site.':
'Cette image renvoie une erreur sur le site public.',
'Unsupported source format': 'Format source non pris en charge',
Variant: 'Variante',
'Variants are generated when an image is requested with ?v=<name>, or from its Variants tab.':
'Les variantes sont générées lorsqu’une image est demandée avec ?v=<nom>, ou depuis son onglet Variantes.',
'With variants': 'Avec variantes',
Without: 'Sans',
'{0} generated · {1} failed ({2})': '{0} générées · {1} en échec ({2})',
'{0} of {1} generated': '{0} sur {1} générées',
'{0} total · {1} + {2}': '{0} au total · {1} + {2}',
'{0} total': '{0} au total',
'{0} vs original': '{0} par rapport à l’original',
'{0} · saves {1}%': '{0} · économise {1} %',
'Generated all variants of {0}': 'A généré toutes les variantes de {0}',
'Failed to generate the variants of {0}': 'Échec de la génération des variantes de {0}',
'Purged the variants of {0}': 'A purgé les variantes de {0}',
'Failed to purge the variants of {0}': 'Échec de la purge des variantes de {0}',
"Couldn't generate {0}.": 'Impossible de générer {0}.',
"Couldn't generate the variants.": 'Impossible de générer les variantes.',
"Variants aren't generated for this format.":
'Les variantes ne sont pas générées pour ce format.',
"Variant '{0}' no longer exists.": 'La variante « {0} » n’existe plus.',
"Variant '{0}' is disabled.": 'La variante « {0} » est désactivée.',
} as const } as const
export type MessageKey = keyof typeof messages export type MessageKey = keyof typeof messages
@@ -230,6 +230,8 @@ describe('The catalogue is complete in both languages', () => {
'media.delete', 'media.delete',
'media.merge', 'media.merge',
'media.purge', 'media.purge',
'media.variant_generate_all',
'media.variant_purge_image',
'system.media_purge', 'system.media_purge',
'user.register', 'user.register',
'user.verify', 'user.verify',
@@ -1,12 +1,14 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { Editor, EditorContent } from '@tiptap/vue-3' import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import { ArticleImage, findArticleImagePos } from '../articleImage' import { ArticleImage, findArticleImagePos } from '../articleImage'
import { bumpMediaRevision } from '../mediaRevision' import { bumpMediaRevision } from '../mediaRevision'
function mountHarness(onRequestRemove = vi.fn()) { function mountHarness(onRequestRemove = vi.fn()) {
setActivePinia(createPinia())
let editor: Editor let editor: Editor
const Harness = defineComponent({ const Harness = defineComponent({
components: { EditorContent }, components: { EditorContent },
+48
View File
@@ -2,6 +2,8 @@ import { formatNumber, t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages' import type { MessageKey } from '@/i18n/messages'
import type { ActivityItem } from '@/lib/activity' import type { ActivityItem } from '@/lib/activity'
import { sizeLabel } from '@/lib/activityRetention' import { sizeLabel } from '@/lib/activityRetention'
import { formatSize } from '@/lib/mediaFormat'
import { causeText } from '@/lib/variants/variantCauses'
export interface SentenceSegment { export interface SentenceSegment {
text: string text: string
@@ -56,6 +58,14 @@ const CATALOGUE: Record<string, Template> = {
success: 'Ran the scheduled duplicate purge: {0}', success: 'Ran the scheduled duplicate purge: {0}',
failed: 'Failed to run the scheduled duplicate purge', failed: 'Failed to run the scheduled duplicate purge',
}, },
'media.variant_generate_all': {
success: 'Generated all variants of {0}',
failed: 'Failed to generate the variants of {0}',
},
'media.variant_purge_image': {
success: 'Purged the variants of {0}',
failed: 'Failed to purge the variants of {0}',
},
'user.register': { 'user.register': {
success: 'Registered the account {0}', success: 'Registered the account {0}',
failed: 'Failed to register the account {0}', failed: 'Failed to register the account {0}',
@@ -224,6 +234,44 @@ export function composeSentence(item: ActivityItem): SentenceSegment[] {
return segments.filter((segment) => segment.text !== '') return segments.filter((segment) => segment.text !== '')
} }
function causeList(value: unknown) {
const causes = Array.isArray(value) ? value.filter((cause) => typeof cause === 'string') : []
return [...new Set(causes)].map(causeText).join(', ')
}
function generateAllDetail(params: Record<string, unknown>) {
const generated = count(params.generated)
const failed = count(params.failed)
if (failed === 0) {
return generated === 1
? t('1 variant generated')
: t('{0} variants generated', [formatNumber(generated)])
}
return t('{0} generated · {1} failed ({2})', [
formatNumber(generated),
formatNumber(failed),
causeList(params.causes),
])
}
function purgeImageDetail(params: Record<string, unknown>) {
const files = count(params.purged_files)
const size = formatSize(count(params.purged_bytes))
return files === 1
? t('Removed 1 file ({0})', [size])
: t('Removed {0} files ({1})', [formatNumber(files), size])
}
export function detailLine(item: ActivityItem): string | null {
if (item.action === 'media.variant_generate_all') {
return generateAllDetail(item.params)
}
if (item.action === 'media.variant_purge_image') {
return purgeImageDetail(item.params)
}
return null
}
export function sentenceText(item: ActivityItem) { export function sentenceText(item: ActivityItem) {
return composeSentence(item) return composeSentence(item)
.map((segment) => segment.text) .map((segment) => segment.text)
+18
View File
@@ -36,6 +36,24 @@ export function formatSize(bytes: number) {
]) ])
} }
export function totalMeta(item: { size: number; variant_size: number; variant_count: number }) {
const total = formatSize(item.size + item.variant_size)
if (item.variant_count > 0) {
return t('{0} total · {1} + {2}', [
total,
formatSize(item.size),
formatSize(item.variant_size),
])
}
return t('{0} total', [total])
}
export function removalNote(count: number, bytes: number) {
return count === 1
? t('Also removes 1 generated variant ({0})', [formatSize(bytes)])
: t('Also removes {0} generated variants ({1})', [count, formatSize(bytes)])
}
export function formatDimensions(width: number | null, height: number | null) { export function formatDimensions(width: number | null, height: number | null) {
if (width === null || height === null) { if (width === null || height === null) {
return t(VECTOR_DIMENSIONS) return t(VECTOR_DIMENSIONS)
+11 -2
View File
@@ -1,13 +1,15 @@
import type { LocationQuery } from 'vue-router' import type { LocationQuery } from 'vue-router'
export type MediaFilter = 'all' | 'unused' | 'duplicates' export type MediaFilter = 'all' | 'unused' | 'duplicates'
export type MediaSort = 'newest' | 'oldest' | 'name' | 'largest' export type MediaSort = 'newest' | 'oldest' | 'name' | 'largest' | 'total_size'
export type MediaVariantsFilter = 'all' | 'with' | 'without'
export type MediaView = 'grid' | 'list' export type MediaView = 'grid' | 'list'
export interface MediaControls { export interface MediaControls {
q: string q: string
filter: MediaFilter filter: MediaFilter
sort: MediaSort sort: MediaSort
variants: MediaVariantsFilter
view: MediaView view: MediaView
} }
@@ -17,11 +19,13 @@ export const DEFAULT_CONTROLS: MediaControls = {
q: '', q: '',
filter: 'all', filter: 'all',
sort: 'newest', sort: 'newest',
variants: 'all',
view: 'grid', view: 'grid',
} }
const FILTERS: MediaFilter[] = ['all', 'unused', 'duplicates'] const FILTERS: MediaFilter[] = ['all', 'unused', 'duplicates']
const SORTS: MediaSort[] = ['newest', 'oldest', 'name', 'largest'] const SORTS: MediaSort[] = ['newest', 'oldest', 'name', 'largest', 'total_size']
const VARIANTS_FILTERS: MediaVariantsFilter[] = ['all', 'with', 'without']
const VIEWS: MediaView[] = ['grid', 'list'] const VIEWS: MediaView[] = ['grid', 'list']
function firstValue(value: LocationQuery[string] | undefined) { function firstValue(value: LocationQuery[string] | undefined) {
@@ -37,6 +41,7 @@ export function parseMediaControls(query: LocationQuery): MediaControls {
q: firstValue(query.q).trim(), q: firstValue(query.q).trim(),
filter: pick(FILTERS, firstValue(query.filter), DEFAULT_CONTROLS.filter), filter: pick(FILTERS, firstValue(query.filter), DEFAULT_CONTROLS.filter),
sort: pick(SORTS, firstValue(query.sort), DEFAULT_CONTROLS.sort), sort: pick(SORTS, firstValue(query.sort), DEFAULT_CONTROLS.sort),
variants: pick(VARIANTS_FILTERS, firstValue(query.variants), DEFAULT_CONTROLS.variants),
view: pick(VIEWS, firstValue(query.view), DEFAULT_CONTROLS.view), view: pick(VIEWS, firstValue(query.view), DEFAULT_CONTROLS.view),
} }
} }
@@ -52,6 +57,9 @@ export function toRouteQuery(controls: MediaControls) {
if (controls.sort !== DEFAULT_CONTROLS.sort) { if (controls.sort !== DEFAULT_CONTROLS.sort) {
query.sort = controls.sort query.sort = controls.sort
} }
if (controls.variants !== DEFAULT_CONTROLS.variants) {
query.variants = controls.variants
}
if (controls.view !== DEFAULT_CONTROLS.view) { if (controls.view !== DEFAULT_CONTROLS.view) {
query.view = controls.view query.view = controls.view
} }
@@ -63,6 +71,7 @@ export function toRequestQuery(controls: MediaControls) {
...(controls.q ? [`q=${encodeURIComponent(controls.q)}`] : []), ...(controls.q ? [`q=${encodeURIComponent(controls.q)}`] : []),
`filter=${controls.filter}`, `filter=${controls.filter}`,
`sort=${controls.sort}`, `sort=${controls.sort}`,
...(controls.variants === 'all' ? [] : [`variants=${controls.variants}`]),
`limit=${MEDIA_PAGE_LIMIT}`, `limit=${MEDIA_PAGE_LIMIT}`,
] ]
return parts.join('&') return parts.join('&')
+2
View File
@@ -1,2 +1,4 @@
export const UNDERLINE_TAB_TRIGGER = export const UNDERLINE_TAB_TRIGGER =
'h-9 px-0 py-0 data-[state=inactive]:text-toned in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:bottom-0 in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:h-0.5 max-md:h-11 max-md:text-[0.9375rem]' 'h-9 px-0 py-0 data-[state=inactive]:text-toned in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:bottom-0 in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:h-0.5 max-md:h-11 max-md:text-[0.9375rem]'
export const PANEL_TAB_TRIGGER = UNDERLINE_TAB_TRIGGER.replace('h-9', 'h-10')
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { withMediaRevision } from '@/lib/mediaRevision'
import { bodyHasVariantImage, variantOfSrc, withVariant } from '../blockVariant'
describe('Reading the variant of an image src', () => {
it.each([
['/static/uploadedImages/1.jpg?v=hero', 'hero'],
['/static/uploadedImages/1.jpg?v=hero&rev=3', 'hero'],
['/static/uploadedImages/1.jpg?rev=3&v=thumb#top', 'thumb'],
['/static/uploadedImages/1.jpg', null],
['/static/uploadedImages/1.jpg?rev=3', null],
['/static/uploadedImages/1.jpg?v=', null],
[undefined, null],
])('%s reads %s', (src, expected) => {
expect(variantOfSrc(src)).toBe(expected)
})
})
describe('Rewriting the variant of an image src', () => {
it.each([
['/static/uploadedImages/1.jpg?v=hero', 'thumb', '/static/uploadedImages/1.jpg?v=thumb'],
['/static/uploadedImages/1.jpg?v=hero', '', '/static/uploadedImages/1.jpg'],
['/static/uploadedImages/1.jpg', 'hero', '/static/uploadedImages/1.jpg?v=hero'],
[
'/static/uploadedImages/1.jpg?rev=3&v=hero#a',
'og',
'/static/uploadedImages/1.jpg?rev=3&v=og#a',
],
])('%s with %s becomes %s', (src, name, expected) => {
expect(withVariant(src, name)).toBe(expected)
})
})
describe('Detecting variant images in a body', () => {
it('finds an img whose src carries ?v=', () => {
expect(bodyHasVariantImage('<p>x</p><img src="/static/uploadedImages/1.jpg?v=hero">')).toBe(
true,
)
})
it('ignores plain images and text', () => {
expect(bodyHasVariantImage('<p>?v=hero</p><img src="/static/uploadedImages/1.jpg">')).toBe(
false,
)
})
})
describe('The cache-busting parameter coexists with the variant', () => {
it('is appended after ?v=', () => {
expect(withMediaRevision('/static/uploadedImages/1.jpg?v=hero', 3)).toBe(
'/static/uploadedImages/1.jpg?v=hero&rev=3',
)
})
})
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it } from 'vitest'
import { initializeLocale, setLocale } from '@/i18n'
import type { ImageVariant } from '@/composables/useImageVariants'
import { buildOptions, insertedUrl, originalOption, variantOption } from '../variantOptions'
const NORTH_WALL = {
url: '/static/uploadedImages/1.jpg',
width: 4032,
height: 3024,
content_type: 'image/jpeg',
}
function variant(overrides: Partial<ImageVariant>): ImageVariant {
return {
name: 'hero',
width: 1200,
height: 630,
fit: 'cover',
format: 'webp',
quality: 82,
generate_on_upload: false,
enabled: true,
generated_files: 0,
cache_size: 0,
...overrides,
}
}
afterEach(() => {
localStorage.clear()
initializeLocale()
})
describe('Option labels', () => {
it('reads the original from the image', () => {
expect(originalOption(NORTH_WALL).label).toBe('Original · 4032×3024 · JPEG')
expect(originalOption({ ...NORTH_WALL, content_type: 'image/webp' }).label).toBe(
'Original · 4032×3024 · WEBP',
)
expect(
originalOption({
...NORTH_WALL,
width: null,
height: null,
content_type: 'image/svg+xml',
}).label,
).toBe('Original · Vector · SVG')
})
it.each([
[variant({}), 'Hero · 1200×630 · WebP'],
[
variant({ name: 'card', width: 800, height: null, format: 'webp' }),
'Card · 800 × auto · WebP',
],
[
variant({ name: 'tall', width: null, height: 2400, format: 'png' }),
'Tall · auto × 2400 · PNG',
],
[variant({ name: 'keep', format: 'original' }), 'Keep · 1200×630 · Original'],
])('reads a variant', (entry, label) => {
expect(variantOption(entry).label).toBe(label)
})
it('offers only Original for a vector image', () => {
const options = buildOptions(
{ ...NORTH_WALL, width: null, height: null, content_type: 'image/svg+xml' },
[variant({})],
)
expect(options.map((option) => option.label)).toEqual(['Original · Vector · SVG'])
})
it('translates the original label', () => {
setLocale('fr')
expect(originalOption(NORTH_WALL).label).toBe('Original · 4032×3024 · JPEG')
})
})
describe('The inserted url', () => {
it('is the image url for Original and carries ?v= for a variant', () => {
expect(insertedUrl(NORTH_WALL, '')).toBe('/static/uploadedImages/1.jpg')
expect(insertedUrl(NORTH_WALL, 'hero')).toBe('/static/uploadedImages/1.jpg?v=hero')
})
})
+39
View File
@@ -0,0 +1,39 @@
const VARIANT_PARAMETER = 'v'
const HASH = '#'
const QUERY = '?'
function splitSrc(src: string) {
const hashAt = src.indexOf(HASH)
const withoutHash = hashAt === -1 ? src : src.slice(0, hashAt)
const hash = hashAt === -1 ? '' : src.slice(hashAt)
const queryAt = withoutHash.indexOf(QUERY)
if (queryAt === -1) {
return { base: withoutHash, query: '', hash }
}
return { base: withoutHash.slice(0, queryAt), query: withoutHash.slice(queryAt + 1), hash }
}
export function variantOfSrc(src: string | undefined | null) {
if (!src) {
return null
}
const name = new URLSearchParams(splitSrc(src).query).get(VARIANT_PARAMETER)
return name === null || name === '' ? null : name
}
export function withVariant(src: string, name: string) {
const { base, query, hash } = splitSrc(src)
const params = new URLSearchParams(query)
params.delete(VARIANT_PARAMETER)
const kept = params.toString()
const parts = [kept, name === '' ? '' : `${VARIANT_PARAMETER}=${encodeURIComponent(name)}`]
const next = parts.filter((part) => part !== '').join('&')
return `${base}${next === '' ? '' : QUERY + next}${hash}`
}
export function bodyHasVariantImage(html: string) {
const document = new DOMParser().parseFromString(html, 'text/html')
return Array.from(document.querySelectorAll('img')).some(
(image) => variantOfSrc(image.getAttribute('src')) !== null,
)
}
+26
View File
@@ -0,0 +1,26 @@
import { t } from '@/i18n'
import type { VariantCause } from '@/composables/useMediaVariants'
const PAYLOAD_TOO_LARGE = 413
const UNSUPPORTED_MEDIA_TYPE = 415
const UNPROCESSABLE = 422
export function causeText(cause: string) {
if (cause === 'too_large') {
return t('Image too large')
}
if (cause === 'unsupported_format') {
return t('Unsupported source format')
}
return t('Generation failed')
}
export function causeOfStatus(status: number): VariantCause | null {
if (status === PAYLOAD_TOO_LARGE) {
return 'too_large'
}
if (status === UNSUPPORTED_MEDIA_TYPE || status === UNPROCESSABLE) {
return 'unsupported_format'
}
return null
}
+71
View File
@@ -0,0 +1,71 @@
import { t } from '@/i18n'
import type { ImageVariant, VariantFormat } from '@/composables/useImageVariants'
import { formatType } from '@/lib/mediaFormat'
import { formatDimensions, formatLabel } from '@/lib/variants/variantFormat'
const SEPARATOR = ' · '
const VECTOR = 'Vector'
const SVG_TYPE = 'image/svg+xml'
const ORIGINAL_VALUE = ''
export interface VariantOption {
value: string
label: string
}
export interface OptionSource {
url: string
width: number | null
height: number | null
content_type: string
}
export const ORIGINAL_OPTION_VALUE = ORIGINAL_VALUE
export function isVector(source: OptionSource) {
return source.content_type === SVG_TYPE
}
function sourceSize(source: OptionSource) {
if (source.width === null || source.height === null) {
return t(VECTOR)
}
return `${source.width}×${source.height}`
}
export function capitalised(name: string) {
return name.charAt(0).toUpperCase() + name.slice(1)
}
export function originalOption(source: OptionSource): VariantOption {
return {
value: ORIGINAL_VALUE,
label: [t('Original'), sourceSize(source), formatType(source.content_type)].join(SEPARATOR),
}
}
export function bareOriginalOption(): VariantOption {
return { value: ORIGINAL_VALUE, label: t('Original') }
}
export function variantOption(variant: ImageVariant): VariantOption {
return {
value: variant.name,
label: [
capitalised(variant.name),
formatDimensions(variant.width, variant.height),
formatLabel(variant.format as VariantFormat),
].join(SEPARATOR),
}
}
export function buildOptions(source: OptionSource, variants: ImageVariant[]): VariantOption[] {
const offered = isVector(source) ? [] : variants.map(variantOption)
return [originalOption(source), ...offered]
}
export function insertedUrl(source: OptionSource, variantName: string) {
return variantName === ORIGINAL_VALUE
? source.url
: `${source.url}?v=${encodeURIComponent(variantName)}`
}
+23
View File
@@ -0,0 +1,23 @@
const MAX_MAGNITUDE = 99
const PERCENT = 100
export function savedPercent(originalBytes: number, variantBytes: number) {
if (originalBytes <= 0) {
return 0
}
const change = ((variantBytes - originalBytes) / originalBytes) * PERCENT
const rounded = Math.sign(change) * Math.round(Math.abs(change))
if (variantBytes > 0 && rounded < -MAX_MAGNITUDE) {
return -MAX_MAGNITUDE
}
return rounded
}
export function signedPercent(originalBytes: number, variantBytes: number) {
const percent = savedPercent(originalBytes, variantBytes)
return `${percent > 0 ? '+' : ''}${percent}%`
}
export function savesPercent(originalBytes: number, variantBytes: number) {
return Math.max(0, -savedPercent(originalBytes, variantBytes))
}
+43
View File
@@ -0,0 +1,43 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useImageVariants, type ImageVariant } from '@/composables/useImageVariants'
export type CatalogStatus = 'idle' | 'loading' | 'ready' | 'failed'
export const useVariantCatalogStore = defineStore('variantCatalog', () => {
const api = useImageVariants()
const items = ref<ImageVariant[]>([])
const status = ref<CatalogStatus>('idle')
const loaded = ref(false)
let pending: Promise<void> | null = null
const enabled = computed(() => items.value.filter((variant) => variant.enabled))
function find(name: string) {
return items.value.find((variant) => variant.name === name)
}
function load() {
if (pending) {
return pending
}
status.value = 'loading'
pending = api
.listVariants()
.then((listed) => {
items.value = listed
status.value = 'ready'
loaded.value = true
})
.catch(() => {
items.value = []
status.value = 'failed'
})
.finally(() => {
pending = null
})
return pending
}
return { items, status, loaded, enabled, find, load }
})
+10 -2
View File
@@ -20,7 +20,10 @@ import { useArticleDraft } from '@/composables/useArticleDraft'
import type { MediaItem } from '@/composables/useMedia' import type { MediaItem } from '@/composables/useMedia'
import { bodyImageCount, bodyImageIds } from '@/lib/bodyImageIds' import { bodyImageCount, bodyImageIds } from '@/lib/bodyImageIds'
import { formatSize } from '@/lib/mediaFormat' import { formatSize } from '@/lib/mediaFormat'
import { useCanManageVariants } from '@/composables/useCanManageVariants'
import { bodyHasVariantImage } from '@/lib/variants/blockVariant'
import { useSeenImagesStore } from '@/stores/seenImages' import { useSeenImagesStore } from '@/stores/seenImages'
import { useVariantCatalogStore } from '@/stores/variantCatalog'
const SKELETON_DELAY_MS = 150 const SKELETON_DELAY_MS = 150
const BODY_MAX_LENGTH = 10000 const BODY_MAX_LENGTH = 10000
@@ -36,6 +39,8 @@ const toast = useLocalizedToast()
const articleId = Number(route.params.id) const articleId = Number(route.params.id)
const draft = useArticleDraft(articleId) const draft = useArticleDraft(articleId)
const seenImages = useSeenImagesStore() const seenImages = useSeenImagesStore()
const variantCatalog = useVariantCatalogStore()
const canManageVariants = useCanManageVariants()
const loadState = ref<LoadState>('loading') const loadState = ref<LoadState>('loading')
const showSkeleton = ref(false) const showSkeleton = ref(false)
@@ -69,6 +74,9 @@ async function loadArticle() {
article.value = result article.value = result
draft.load(result.body, new Date(result.updated_at)) draft.load(result.body, new Date(result.updated_at))
loadState.value = 'loaded' loadState.value = 'loaded'
if (canManageVariants.value && bodyHasVariantImage(result.body)) {
variantCatalog.load()
}
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 404) { if (err instanceof ApiError && err.status === 404) {
loadState.value = 'notFound' loadState.value = 'notFound'
@@ -284,9 +292,9 @@ function onImageUploaded(item: MediaItem) {
announceImageAdded() announceImageAdded()
} }
function onImageInserted(item: MediaItem, alt: string) { function onImageInserted(item: MediaItem, alt: string, src: string) {
seenImages.remember([item]) seenImages.remember([item])
editorRef.value?.insertImage(item.url, alt, item.id) editorRef.value?.insertImage(src, alt, item.id)
announceImageAdded() announceImageAdded()
} }
+13 -4
View File
@@ -135,9 +135,15 @@ const nothingToShow = computed(
!emptyLibrary.value && !emptyLibrary.value &&
(isDuplicates.value ? groups.value.length === 0 : media.items.value.length === 0), (isDuplicates.value ? groups.value.length === 0 : media.items.value.length === 0),
) )
const emptyVariant = computed(() => const emptyVariant = computed(() => {
isDuplicates.value && controls.value.q === '' ? 'no-duplicates' : 'no-results', if (isDuplicates.value && controls.value.q === '') {
) return 'no-duplicates'
}
if (controls.value.variants === 'with' && controls.value.q === '' && !isDuplicates.value) {
return controls.value.filter === 'all' ? 'no-variants' : 'no-results'
}
return 'no-results'
})
const filterLabel = computed( const filterLabel = computed(
() => () =>
({ all: t('All'), unused: t('Unused'), duplicates: t('Duplicates') })[ ({ all: t('All'), unused: t('Unused'), duplicates: t('Duplicates') })[
@@ -203,7 +209,7 @@ function updateControls(patch: Partial<MediaControls>) {
} }
function clearFilters() { function clearFilters() {
updateControls({ q: '', filter: 'all' }) updateControls({ q: '', filter: 'all', variants: 'all' })
} }
function announce(source: () => string) { function announce(source: () => string) {
@@ -919,6 +925,7 @@ async function mergeAction() {
@update:q="updateControls({ q: $event })" @update:q="updateControls({ q: $event })"
@update:filter="updateControls({ filter: $event })" @update:filter="updateControls({ filter: $event })"
@update:sort="updateControls({ sort: $event })" @update:sort="updateControls({ sort: $event })"
@update:variants="updateControls({ variants: $event })"
@update:view="updateControls({ view: $event })" @update:view="updateControls({ view: $event })"
@purge="openPurge" @purge="openPurge"
/> />
@@ -985,6 +992,7 @@ async function mergeAction() {
:items="media.items.value" :items="media.items.value"
:columns="isDesktop ? DESKTOP_COLUMNS : MOBILE_COLUMNS" :columns="isDesktop ? DESKTOP_COLUMNS : MOBILE_COLUMNS"
:selected="gridSelected" :selected="gridSelected"
:total-size="controls.sort === 'total_size'"
@open="openPanel" @open="openPanel"
@toggle="toggleSelected" @toggle="toggleSelected"
/> />
@@ -1042,6 +1050,7 @@ async function mergeAction() {
@rename="renameOpen = true" @rename="renameOpen = true"
@delete="deleteOpen = true" @delete="deleteOpen = true"
@review-duplicates="reviewDuplicates" @review-duplicates="reviewDuplicates"
@gone="activeItem && handleGone(activeItem)"
/> />
<RenameMediaDialog <RenameMediaDialog
@@ -0,0 +1,175 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { useApi } from '@/composables/useApi'
import { initializeLocale, setLocale } from '@/i18n'
import { messages } from '@/i18n/messages'
import type { ActivityItem } from '@/lib/activity'
import { detailLine, sentenceText } from '@/lib/activitySentence'
import { mountActivity, rows, textOf, unmountActivity } from './activityHarness'
import { makeItem, pageOf, target } from './activityFixtures'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: vi.fn() }) }
})
const IMAGE = target('image', '1', 'north-wall.jpg')
function variantEvent(overrides: Partial<ActivityItem>) {
return makeItem({
id: 1,
category: 'media',
target: IMAGE,
action: 'media.variant_generate_all',
...overrides,
})
}
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(async () => {
unmountActivity()
vi.mocked(useApi).mockReset()
localStorage.clear()
initializeLocale()
await router.push('/login')
})
describe('The two new sentences', () => {
it.each([
['media.variant_generate_all', 'Generated all variants of north-wall.jpg'],
['media.variant_purge_image', 'Purged the variants of north-wall.jpg'],
])('reads %s as a sentence ending with the target', (action, sentence) => {
expect(sentenceText(variantEvent({ action }))).toBe(sentence)
})
it.each([
['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'],
])('reads %s failed', (action, sentence) => {
expect(sentenceText(variantEvent({ action, outcome: 'failed' }))).toBe(sentence)
})
})
describe('The detail line under the sentence', () => {
it.each([
[
'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)',
],
])('%s %j reads "%s"', (action, params, detail) => {
expect(detailLine(variantEvent({ action, params }))).toBe(detail)
})
it('has no detail line for other actions', () => {
expect(detailLine(makeItem({ id: 2, action: 'media.rename' }))).toBeNull()
})
it('shows the line in the row, under the sentence', async () => {
const events = [
variantEvent({ params: { generated: 4, failed: 1, causes: ['too_large'] } }),
makeItem({ id: 2, action: 'media.rename', category: 'media', target: IMAGE }),
]
await mountActivity({ list: () => pageOf(events) })
const [first, second] = rows()
const line = first!.querySelector<HTMLElement>('[data-testid="activity-detail-line"]')!
expect(textOf(line)).toBe('4 generated · 1 failed (Image too large)')
expect(line.className).toContain('text-xs')
expect(line.className).toContain('text-muted')
expect(second!.querySelector('[data-testid="activity-detail-line"]')).toBeNull()
})
it('shows the line in the details panel', async () => {
const events = [
variantEvent({
action: 'media.variant_purge_image',
params: { purged_files: 4, purged_bytes: 266240 },
}),
]
await mountActivity({ list: () => pageOf(events) })
rows()[0]!.click()
await flushPromises()
await vi.waitFor(() =>
expect(document.body.querySelector('[data-testid="activity-details"]')).toBeTruthy(),
)
const details = document.body.querySelector<HTMLElement>(
'[data-testid="activity-details"]',
)!
expect(textOf(details.querySelector('[data-testid="activity-detail-line"]'))).toBe(
'Removed 4 files (260 KB)',
)
})
})
describe('The French interface', () => {
it('translates the sentences and the detail lines', () => {
setLocale('fr')
expect(sentenceText(variantEvent({}))).toBe(
'A généré toutes les variantes de north-wall.jpg',
)
expect(
sentenceText(variantEvent({ action: 'media.variant_purge_image', outcome: 'failed' })),
).toBe('Échec de la purge des variantes de north-wall.jpg')
expect(
detailLine(
variantEvent({ params: { generated: 4, failed: 1, causes: ['too_large'] } }),
),
).toBe('4 générées · 1 en échec (Image trop grande)')
})
it.each([
'Generated all variants of {0}',
'Failed to generate the variants of {0}',
'Purged the variants of {0}',
'Failed to purge the variants of {0}',
'1 variant generated',
'{0} variants generated',
'{0} generated · {1} failed ({2})',
'Removed 1 file ({0})',
'Removed {0} files ({1})',
'Image too large',
'Unsupported source format',
])('has a French entry for %s', (key) => {
expect(messages).toHaveProperty([key])
expect(messages[key as keyof typeof messages]).not.toBe(key)
})
})
@@ -0,0 +1,666 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleEditor from '@/components/ArticleEditor.vue'
import RootComponent from '@/App.vue'
import { messages } from '@/i18n/messages'
import { fixture, textOf } from './mediaTestKit'
const FIXED_NOW = '2026-09-26T11:02:00Z'
const HERO_BODY =
'<p>Intro</p><img src="/static/uploadedImages/1.jpg?v=hero" alt="A wall" data-image-id="1">'
const TWO_HERO_BODY = `${HERO_BODY}<img src="/static/uploadedImages/1.jpg?v=hero" alt="Again" data-image-id="1">`
interface Reply {
status: number
body?: unknown
}
function variant(
name: string,
width: number | null,
height: number | null,
fit: string,
format: string,
enabled = true,
) {
return {
name,
width,
height,
fit,
format,
quality: 80,
generate_on_upload: false,
enabled,
generated_files: 0,
cache_size: 0,
}
}
const CATALOG = [
variant('card', 800, null, 'inside', 'webp'),
variant('hero', 1200, 630, 'cover', 'webp'),
variant('og', 1200, 630, 'cover', 'jpeg'),
variant('print', null, 2400, 'inside', 'png', false),
variant('square', 600, 600, 'cover', 'jpeg'),
variant('thumb', 400, 300, 'cover', 'webp'),
]
function reply(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
text: async () => '',
} as unknown as Response
}
function article(body: string): Article {
return {
id: 12,
title: 'How we price a bathroom renovation',
body,
is_online: false,
created_at: '2026-03-12T09:14:00Z',
updated_at: FIXED_NOW,
}
}
function libraryPage() {
const urls: Record<number, string> = {
1: '/static/uploadedImages/1.jpg',
2: '/static/uploadedImages/2.webp',
4: '/static/uploadedImages/4.svg',
}
const items = fixture().map((item) => ({ ...item, url: urls[item.id] ?? item.url }))
return { items, next_cursor: null, counts: { all: 12, unused: 2, duplicates: 2 } }
}
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', () => ({
useOverlay: () => ({ create: vi.fn() }),
useToast: () => ({ add: toastAdd }),
}))
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
let fetchMock: ReturnType<typeof vi.fn>
interface EditorOptions {
body?: string
role?: string
variants?: () => Reply | Promise<Reply>
}
async function mountEditor(options: EditorOptions = {}) {
setActivePinia(createPinia())
toastAdd = vi.fn()
const session = useSessionStore()
session.session = {
verified: true,
username: 'marta',
role: (options.role ?? 'owner') as never,
}
session.status = 'ready'
const variants = options.variants ?? (() => ({ status: 200, body: { items: CATALOG } }))
fetchMock = vi.fn(async (url: string) => {
if (url === '/v1/article/12?format=html') {
return reply(200, article(options.body ?? '<p>Intro</p>'))
}
if (url === '/v1/media/?limit=24') {
return reply(200, libraryPage())
}
if (url === '/v1/image-variants') {
const result = await variants()
return reply(result.status, result.body)
}
throw new Error(`Unexpected fetch call: ${url}`)
})
global.fetch = fetchMock as unknown as typeof fetch
await router.push('/article/12/edit')
const wrapper = mount(RootComponent, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
currentWrapper = wrapper
await flushPromises()
await settle()
return wrapper
}
async function settle() {
await new Promise((resolve) => setTimeout(resolve, 20))
await flushPromises()
}
function variantRequests() {
return fetchMock.mock.calls.filter(([url]) => url === '/v1/image-variants')
}
function writeRequests() {
return fetchMock.mock.calls.filter(([, init]) => (init as RequestInit | undefined)?.method)
}
function findButton(text: string, root: ParentNode = document.body) {
return Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(
(button) => textOf(button) === text,
)
}
async function openPicker(wrapper: Awaited<ReturnType<typeof mount>>) {
await wrapper
.findAll('button')
.find((button) => button.text() === 'Image')
?.trigger('click')
await flushPromises()
}
function tile(filename: string) {
return Array.from(document.body.querySelectorAll<HTMLButtonElement>('[data-picker-tile]')).find(
(element) => element.getAttribute('aria-label')?.startsWith(`${filename},`),
)!
}
async function selectTile(filename: string) {
tile(filename).click()
await flushPromises()
await settle()
}
function select() {
return document.body.querySelector<HTMLElement>('[data-testid="variant-select"]')
}
function selectText() {
return textOf(select())
}
interface SelectInstance {
type: { __name?: string }
parent: SelectInstance | null
props: { options: { value: string; label: string }[]; modelValue?: string }
emit: (event: string, ...args: unknown[]) => void
}
function variantSelect() {
let instance = (select() as unknown as { __vueParentComponent: SelectInstance | null })
.__vueParentComponent
while (instance && instance.type.__name !== 'VariantSelect') {
instance = instance.parent
}
return instance!
}
function optionLabels() {
return select() ? variantSelect().props.options.map((option) => option.label) : []
}
async function chooseOption(prefix: string) {
const instance = variantSelect()
const option = instance.props.options.find((entry) => entry.label.startsWith(prefix))!
instance.emit('update:modelValue', option.value)
await flushPromises()
await settle()
}
function urlLine() {
return document.body.querySelector<HTMLElement>('[data-testid="variant-url"]')
}
function editorBody(wrapper: Awaited<ReturnType<typeof mount>>) {
return wrapper.findComponent(ArticleEditor).props('modelValue')
}
beforeEach(() => {
localStorage.clear()
})
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
document.body.innerHTML = ''
await router.push('/login')
})
describe('The Variant select', () => {
it('Selecting an image shows the select with Original chosen', async () => {
const wrapper = await mountEditor()
await openPicker(wrapper)
expect(variantRequests()).toHaveLength(0)
await selectTile('north-wall.jpg')
expect(variantRequests()).toHaveLength(1)
const panel = document.body.querySelector<HTMLElement>('[data-testid="picker-panel"]')!
const order = [
panel.querySelector('[class*="size-14"]'),
panel.querySelector('label'),
select(),
urlLine(),
panel.querySelector('input[placeholder^="e.g. North wall"]'),
]
expect(order.every(Boolean)).toBe(true)
order.slice(0, -1).forEach((element, index) => {
expect(
element!.compareDocumentPosition(order[index + 1]!) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
})
expect(textOf(panel.querySelector('label'))).toBe('Variant')
expect(selectText()).toBe('Original · 4032×3024 · JPEG')
expect(textOf(urlLine())).toBe('/static/uploadedImages/1.jpg')
expect(findButton('Insert image')!.disabled).toBe(false)
})
it('The open list offers Original and the enabled variants', async () => {
const wrapper = await mountEditor()
await openPicker(wrapper)
await selectTile('north-wall.jpg')
expect(optionLabels()).toEqual([
'Original · 4032×3024 · JPEG',
'Card · 800 × auto · WebP',
'Hero · 1200×630 · WebP',
'Og · 1200×630 · JPEG',
'Square · 600×600 · JPEG',
'Thumb · 400×300 · WebP',
])
expect(optionLabels().some((label) => label.startsWith('Print'))).toBe(false)
expect(select()!.className).toContain('data-[state=open]:border-primary')
expect(select()!.className).toContain('data-[state=open]:ring-primary')
expect(variantSelect().props.modelValue).toBe('')
})
it('Choosing a variant changes the url line', async () => {
const wrapper = await mountEditor()
await openPicker(wrapper)
await selectTile('north-wall.jpg')
await chooseOption('Hero')
expect(selectText()).toBe('Hero · 1200×630 · WebP')
expect(textOf(urlLine())).toBe('/static/uploadedImages/1.jpg?v=hero')
})
it('The choice is reset when another image is selected', async () => {
const wrapper = await mountEditor()
await openPicker(wrapper)
await selectTile('north-wall.jpg')
await chooseOption('Hero')
await selectTile('kitchen-after.webp')
expect(selectText()).toBe('Original · 2400×1600 · WEBP')
expect(textOf(urlLine())).toBe('/static/uploadedImages/2.webp')
})
it('The variants list is not requested again within one opening', async () => {
const wrapper = await mountEditor()
await openPicker(wrapper)
await selectTile('north-wall.jpg')
await selectTile('kitchen-after.webp')
expect(variantRequests()).toHaveLength(1)
})
it.each([
['No variants defined', { status: 200, body: { items: [] } }],
[
'Only disabled variants count as none',
{
status: 200,
body: { items: CATALOG.map((entry) => ({ ...entry, enabled: false })) },
},
],
])('%s', async (_label, result) => {
const wrapper = await mountEditor({ variants: () => result })
await openPicker(wrapper)
await selectTile('north-wall.jpg')
expect(optionLabels()).toEqual(['Original · 4032×3024 · JPEG'])
const help = document.body.querySelector<HTMLElement>('[data-testid="variant-help"]')!
expect(help.textContent).toBe(
'No variants defined. Add one in Settings → Media → Variants.',
)
expect(help.querySelector('a')!.getAttribute('href')).toBe('/settings/media/variants')
expect(urlLine()).toBeNull()
})
it('An SVG offers only Original', async () => {
const wrapper = await mountEditor()
await openPicker(wrapper)
await selectTile('logo-renew.svg')
expect(optionLabels()).toEqual(['Original · Vector · SVG'])
expect(document.body.querySelector('[data-testid="variant-help"]')).toBeNull()
})
it.each([
['resolves with a 500 response', () => ({ status: 500 })],
['resolves with a 403 response', () => ({ status: 403 })],
['never reaches the server', () => Promise.reject(new TypeError('Failed to fetch'))],
])('The variants list fails: %s', async (_label, failure) => {
const wrapper = await mountEditor({ variants: failure as () => Reply })
await openPicker(wrapper)
await selectTile('north-wall.jpg')
expect(optionLabels()).toEqual(['Original · 4032×3024 · JPEG'])
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(findButton('Insert image')!.disabled).toBe(false)
})
it('sends no variants request for a session that is not owner or admin', async () => {
const wrapper = await mountEditor({ role: 'user' })
await openPicker(wrapper)
await selectTile('north-wall.jpg')
expect(variantRequests()).toHaveLength(0)
expect(selectText()).toBe('Original · 4032×3024 · JPEG')
})
it('exposes the select as a listbox trigger that the keyboard can reach', async () => {
const wrapper = await mountEditor()
await openPicker(wrapper)
await selectTile('north-wall.jpg')
const trigger = select()!
expect(trigger.tagName).toBe('BUTTON')
expect(trigger.getAttribute('aria-haspopup')).toBe('listbox')
expect(trigger.getAttribute('aria-label')).toBe('Variant')
expect(trigger.getAttribute('tabindex')).not.toBe('-1')
})
})
describe('Inserting a variant', () => {
it.each([
['Original', '/static/uploadedImages/1.jpg'],
['Hero', '/static/uploadedImages/1.jpg?v=hero'],
['Thumb', '/static/uploadedImages/1.jpg?v=thumb'],
])('The inserted url carries the variant: %s', async (option, src) => {
const wrapper = await mountEditor({ body: '<p>Intro</p>' })
await openPicker(wrapper)
await selectTile('north-wall.jpg')
await chooseOption(option)
const alt = document.body.querySelector<HTMLInputElement>(
'input[placeholder^="e.g. North wall"]',
)!
alt.value = 'A wall'
alt.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
findButton('Insert image')!.click()
await flushPromises()
await settle()
expect(editorBody(wrapper)).toContain(`<img src="${src}" alt="A wall" data-image-id="1">`)
expect(wrapper.text()).toContain('Unsaved changes')
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Image added', icon: 'i-lucide-circle-check' }),
)
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
expect(writeRequests()).toHaveLength(0)
})
it('can insert an image already in the article again with another variant', async () => {
const wrapper = await mountEditor({
body: '<p>Intro</p><img src="/static/uploadedImages/1.jpg" alt="" data-image-id="1">',
})
await openPicker(wrapper)
await selectTile('north-wall.jpg')
expect(textOf(tile('north-wall.jpg'))).toContain('In this article')
await chooseOption('Thumb')
findButton('Insert image')!.click()
await flushPromises()
await settle()
expect(editorBody(wrapper)).toContain('src="/static/uploadedImages/1.jpg?v=thumb"')
expect(editorBody(wrapper).match(/<img/g)).toHaveLength(2)
})
it('leaves the editor untouched when the dialog is cancelled after choosing a variant', async () => {
const wrapper = await mountEditor({ body: '<p>Intro</p>' })
await openPicker(wrapper)
await selectTile('north-wall.jpg')
await chooseOption('Hero')
findButton('Cancel')!.click()
await flushPromises()
expect(editorBody(wrapper)).not.toContain('<img')
expect(wrapper.text()).not.toContain('Unsaved changes')
expect(toastAdd).not.toHaveBeenCalled()
})
})
describe('A block whose variant was deleted or disabled', () => {
function block() {
return document.body.querySelector<HTMLElement>(
'[data-testid="article-editor-variant-warning"]',
)
}
function chips() {
return Array.from(
document.body.querySelectorAll<HTMLElement>('[data-testid="variant-chip"]'),
)
}
it('A deleted variant keeps the src and shows the warning', async () => {
const items = CATALOG.filter((entry) => entry.name !== 'hero')
const wrapper = await mountEditor({
body: HERO_BODY,
variants: () => ({ status: 200, body: { items } }),
})
const warning = block()!
expect(warning.className).toContain('border-warning')
expect(textOf(warning)).toContain("Variant 'hero' no longer exists.")
expect(textOf(warning)).toContain('This image returns an error on the public site.')
expect(findButton('Pick another variant', warning)).toBeDefined()
expect(warning.querySelector('[role="alert"]')).not.toBeNull()
expect(textOf(document.body)).not.toContain('This image is no longer available')
expect(editorBody(wrapper)).toContain('src="/static/uploadedImages/1.jpg?v=hero"')
expect(editorBody(wrapper)).toContain('data-image-id="1"')
expect(chips()).toHaveLength(0)
})
it('A disabled variant shows its own title and still its chip', async () => {
const items = CATALOG.map((entry) =>
entry.name === 'hero' ? { ...entry, enabled: false } : entry,
)
await mountEditor({ body: HERO_BODY, variants: () => ({ status: 200, body: { items } }) })
expect(textOf(block())).toContain("Variant 'hero' is disabled.")
expect(textOf(block())).toContain('This image returns an error on the public site.')
expect(chips().map(textOf)).toEqual(['hero · 1200×630'])
})
it('An enabled variant has no warning but a chip', async () => {
await mountEditor({ body: HERO_BODY })
expect(block()).toBeNull()
const [chip] = chips()
expect(textOf(chip)).toBe('hero · 1200×630')
for (const name of [
'top-2',
'left-2',
'h-5',
'font-mono',
'text-xs',
'font-medium',
'bg-default',
'border-accented',
]) {
expect(chip!.className).toContain(name)
}
})
it('A block without a variant is unchanged', async () => {
await mountEditor({
body: '<p>Intro</p><img src="/static/uploadedImages/1.jpg" alt="" data-image-id="1">',
})
expect(block()).toBeNull()
expect(chips()).toHaveLength(0)
expect(variantRequests()).toHaveLength(0)
})
it('The list is requested once for all blocks', async () => {
await mountEditor({
body: `${HERO_BODY}<img src="/static/uploadedImages/2.webp?v=thumb" alt="" data-image-id="2">`,
})
expect(variantRequests()).toHaveLength(1)
expect(chips().map(textOf)).toEqual(['hero · 1200×630', 'thumb · 400×300'])
})
it.each([
['resolves with a 500 response', () => ({ status: 500 })],
['resolves with a 403 response', () => ({ status: 403 })],
['never reaches the server', () => Promise.reject(new TypeError('Failed to fetch'))],
])('The variants list fails: %s', async (_label, failure) => {
await mountEditor({ body: HERO_BODY, variants: failure as () => Reply })
expect(block()).toBeNull()
expect(chips()).toHaveLength(0)
expect(document.body.querySelector('[data-testid="article-editor-image"]')).not.toBeNull()
})
it('keeps the Pick another variant button 44 px high on touch screens', async () => {
const items = CATALOG.filter((entry) => entry.name !== 'hero')
await mountEditor({ body: HERO_BODY, variants: () => ({ status: 200, body: { items } }) })
const button = findButton('Pick another variant', block()!)!
expect(button.className).toContain('h-11')
expect(button.className).toContain('md:h-7')
})
describe('Pick another variant', () => {
const GONE = CATALOG.filter((entry) => entry.name !== 'hero')
async function openPick() {
findButton('Pick another variant', block()!)!.click()
await flushPromises()
await settle()
}
it('opens the select on the block with Original and the enabled variants', async () => {
await mountEditor({
body: HERO_BODY,
variants: () => ({ status: 200, body: { items: GONE } }),
})
await openPick()
expect(optionLabels()).toEqual([
'Original',
'Card · 800 × auto · WebP',
'Og · 1200×630 · JPEG',
'Square · 600×600 · JPEG',
'Thumb · 400×300 · WebP',
])
expect(block()!.contains(select())).toBe(true)
expect(select()!.getAttribute('aria-expanded')).toBe('true')
})
it('Escape closes the select and returns focus to the button', async () => {
await mountEditor({
body: HERO_BODY,
variants: () => ({ status: 200, body: { items: GONE } }),
})
await openPick()
variantSelect().emit('update:open', false)
await settle()
expect(select()).toBeNull()
expect(document.activeElement).toBe(findButton('Pick another variant', block()!))
})
it.each([
['Thumb', 'src="/static/uploadedImages/1.jpg?v=thumb"', ['thumb · 400×300']],
['Original', 'src="/static/uploadedImages/1.jpg"', []],
])('Choosing %s rewrites the block', async (option, src, expectedChips) => {
const wrapper = await mountEditor({
body: HERO_BODY,
variants: () => ({ status: 200, body: { items: GONE } }),
})
const before = fetchMock.mock.calls.length
await openPick()
await chooseOption(option)
const body = editorBody(wrapper)
expect(body).toContain(src)
expect(body).toContain('data-image-id="1"')
expect(body).toContain('alt="A wall"')
expect(block()).toBeNull()
expect(chips().map(textOf)).toEqual(expectedChips)
expect(wrapper.text()).toContain('Unsaved changes')
expect(fetchMock.mock.calls.length).toBe(before)
})
it('rewrites only the clicked block', async () => {
const wrapper = await mountEditor({
body: TWO_HERO_BODY,
variants: () => ({ status: 200, body: { items: GONE } }),
})
const warnings = document.body.querySelectorAll(
'[data-testid="article-editor-variant-warning"]',
)
expect(warnings).toHaveLength(2)
findButton('Pick another variant', warnings[0] as HTMLElement)!.click()
await flushPromises()
await settle()
await chooseOption('Thumb')
const body = editorBody(wrapper)
expect(body).toContain('?v=thumb')
expect(body).toContain('?v=hero')
expect(
document.body.querySelectorAll('[data-testid="article-editor-variant-warning"]'),
).toHaveLength(1)
})
it('does not save the warning in the article', async () => {
const wrapper = await mountEditor({
body: HERO_BODY,
variants: () => ({ status: 200, body: { items: GONE } }),
})
const body = editorBody(wrapper)
expect(body).toContain('<img')
expect(body).not.toContain('This image returns an error')
expect(body).not.toContain('Pick another variant')
})
})
})
describe('French strings', () => {
it.each([
'Variant',
'Pick another variant',
"Variant '{0}' no longer exists.",
"Variant '{0}' is disabled.",
'This image returns an error on the public site.',
'No variants defined. Add one in {0}.',
'Settings → Media → Variants',
])('has a French entry for %s', (key) => {
expect(messages).toHaveProperty([key])
expect(messages[key as keyof typeof messages]).not.toBe(key)
})
})
@@ -0,0 +1,437 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import UBadge from '@nuxt/ui/components/Badge.vue'
import router from '@/router'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
callsTo,
cardFor,
cards,
checkboxFor,
dialog,
findButton,
fixture,
kit,
lastListPath,
listBody,
listCalls,
mountPage,
panel,
settle,
textOf,
unmountPage,
} from './mediaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => toasts }
})
function activate(element: Element) {
element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
}
function badgesOf(filename: string) {
return Array.from(cardFor(filename).querySelectorAll('[data-kind]')).map(textOf)
}
function metaOf(filename: string) {
return textOf(cardFor(filename).querySelector('.text-xs\\/5'))
}
function segment(label: string) {
return Array.from(
document.body.querySelectorAll<HTMLElement>('[data-testid="variants-filter"] button'),
).find((button) => textOf(button) === label)!
}
async function openSortMenu() {
const sort = document.body.querySelector<HTMLElement>('[aria-label="Sort"]')!
sort.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
sort.click()
await flushPromises()
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitemcheckbox"]'))
}
async function openPanelFor(filename: string) {
cardFor(filename).click()
await flushPromises()
}
beforeEach(() => {
toasts.add.mockClear()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
})
afterEach(unmountPage)
describe('The library badge', () => {
it('A card with generated variants carries the badge', async () => {
await mountPage()
expect(badgesOf('north-wall.jpg')).toEqual(['Used in 2', '4 variants'])
expect(badgesOf('kitchen-after.webp')).toEqual(['Used in 3', '5 variants'])
})
it.each([
['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']],
])('The badge text and its absence: %s', async (filename, expected) => {
await mountPage()
expect(badgesOf(filename)).toEqual(expected)
})
it('draws the badge with the info fill and the layers icon', async () => {
await mountPage()
const badge = cardFor('north-wall.jpg').querySelector('[data-kind="variants"]')!
expect(badge.className).toContain('bg-(--rc-info-soft)')
expect(badge.className).toContain('text-info')
expect(badge.className).toContain('h-5')
const component = kit
.wrapper!.findAllComponents(UBadge)
.find((entry) => entry.element === badge)
expect(component?.props('icon')).toBe('i-lucide-layers')
})
it('The accessible name of a card is unchanged', async () => {
await mountPage()
expect(cardFor('north-wall.jpg').getAttribute('aria-label')).toBe(
'north-wall.jpg, 3.9 MB, used in 2 articles',
)
})
it('List view shows no variants badge', async () => {
await mountPage({}, '/media?view=list')
const rows = Array.from(document.body.querySelectorAll('[data-testid="media-row"]'))
expect(rows).toHaveLength(12)
expect(rows.map(textOf).join(' ')).not.toMatch(/\d variants?/)
expect(document.body.querySelector('[data-kind="variants"]')).toBeNull()
})
it('List view on mobile shows no variants badge', async () => {
await mountPage({}, '/media?view=list', MOBILE_WIDTH)
expect(document.body.querySelector('[data-kind="variants"]')).toBeNull()
})
})
describe('The variants filter', () => {
it('sits between the search field and the sort menu with All selected', async () => {
await mountPage()
const control = document.body.querySelector<HTMLElement>('[data-testid="variants-filter"]')!
const toolbarOrder = Array.from(control.parentElement!.children).map(
(element) => element.getAttribute('data-testid') ?? element.tagName,
)
expect(toolbarOrder.indexOf('variants-filter')).toBeGreaterThan(
toolbarOrder.findIndex((id) => id === 'DIV' || id.includes('tabs')),
)
expect(Array.from(control.querySelectorAll('button')).map(textOf)).toEqual([
'All',
'With variants',
'Without',
])
expect(segment('All').getAttribute('aria-pressed')).toBe('true')
expect(segment('All').className).toContain('bg-primary')
expect(segment('With variants').getAttribute('aria-pressed')).toBe('false')
expect(control.className).toContain('border-accented')
})
it('On mobile the control is its own full-width row with 44 px segments', async () => {
await mountPage({}, '/media', MOBILE_WIDTH)
const control = document.body.querySelector<HTMLElement>('[data-testid="variants-filter"]')!
expect(control.className).toContain('w-full')
for (const button of control.querySelectorAll('button')) {
expect(button.className).toContain('h-10.5')
expect(button.className).toContain('flex-1')
expect(button.className).toContain('text-xs')
}
})
it.each([
['With variants', '?variants=with', 'filter=all&sort=newest&variants=with&limit=100'],
['Without', '?variants=without', 'filter=all&sort=newest&variants=without&limit=100'],
])('Choosing %s writes the URL and requests the list', async (label, search, request) => {
await mountPage()
segment(label).click()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(`/media${search}`)
expect(lastListPath()).toBe(`/v1/media/?${request}`)
})
it('shows the skeleton 150 ms after the request starts', async () => {
await mountPage()
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const never = new Promise(() => undefined)
const { kit } = await import('./mediaTestKit')
const original = kit.requestMock.getMockImplementation()!
kit.requestMock.mockImplementation((path: string, options?: RequestInit) =>
path.includes('variants=with') ? never : original(path, options),
)
segment('With variants').click()
await flushPromises()
expect(document.body.querySelector('[data-testid="media-skeleton"]')).toBeNull()
vi.advanceTimersByTime(150)
await flushPromises()
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
})
it('All removes the parameter', async () => {
await mountPage({}, '/media?variants=with')
segment('All').click()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/media')
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=newest&limit=100')
})
it.each([
[
'?variants=with',
'filter=all&sort=newest&variants=with',
'With variants',
'/media?variants=with',
],
[
'?variants=without&filter=unused',
'filter=unused&sort=newest&variants=without',
'Without',
'/media?filter=unused&variants=without',
],
['?variants=all', 'filter=all&sort=newest', 'All', '/media'],
['?variants=zzz', 'filter=all&sort=newest', 'All', '/media'],
])('The query string is the source of truth: %s', async (search, request, selected, url) => {
await mountPage({}, `/media${search}`)
expect(lastListPath()).toBe(`/v1/media/?${request}&limit=100`)
expect(segment(selected).getAttribute('aria-pressed')).toBe('true')
expect(router.currentRoute.value.fullPath).toBe(url)
})
it('With variants returns only the images that have cached files', async () => {
const ids = [1, 7, 9, 12, 5, 11, 2, 3, 10]
await mountPage(
{
list: () => listBody(ids.map((id) => fixture([id])[0]!)),
},
'/media?variants=with&sort=total_size',
)
expect(cards().map((card) => card.getAttribute('aria-label')!.split(',')[0])).toEqual([
'north-wall.jpg',
'IMG_4471.jpg',
'stone-wall-detail.jpg',
'floorboards.jpg',
'floorboards-copy.jpg',
'lime-plaster.jpg',
'kitchen-after.webp',
'kitchen-before.webp',
'bathroom-tiles.png',
])
expect(cards().every((card) => card.querySelector('[data-kind="variants"]'))).toBe(true)
})
it('Without variants returns the images with no cached file', async () => {
await mountPage(
{ list: () => listBody([4, 6, 8].map((id) => fixture([id])[0]!)) },
'/media?variants=without',
)
expect(cards()).toHaveLength(3)
expect(document.body.querySelector('[data-kind="variants"]')).toBeNull()
})
})
describe('Sorting by total size', () => {
it('The new option is last in the sort menu and the selected one carries a check', async () => {
await mountPage()
const items = await openSortMenu()
expect(items.map(textOf)).toEqual([
'Newest',
'Oldest',
'Name',
'Largest',
'Largest total size (original + variants)',
])
expect(items[0]!.getAttribute('aria-checked')).toBe('true')
})
it('labels the option "Largest total size" on mobile', async () => {
await mountPage({}, '/media', MOBILE_WIDTH)
const items = await openSortMenu()
expect(textOf(items.at(-1))).toBe('Largest total size')
})
it('Choosing it sends sort=total_size and the button reads the long label', async () => {
await mountPage()
const items = await openSortMenu()
activate(items.at(-1)!)
await flushPromises()
expect(router.currentRoute.value.query).toEqual({ sort: 'total_size' })
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=total_size&limit=100')
expect(textOf(document.body.querySelector('[aria-label="Sort"]'))).toBe(
'Largest total size (original + variants)',
)
})
it.each([
['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'],
])('The meta line shows the total while sorted by total size: %s', async (filename, meta) => {
await mountPage({}, '/media?sort=total_size')
expect(metaOf(filename)).toBe(meta)
})
it('Other sorts keep the usual meta line', async () => {
await mountPage()
expect(metaOf('north-wall.jpg')).toBe('3.9 MB · 4032 × 3024')
})
it('A stale sort value is dropped', async () => {
await mountPage({}, '/media?sort=total')
expect(router.currentRoute.value.fullPath).toBe('/media')
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=newest&limit=100')
})
})
describe('Nothing to show with the variants filter', () => {
it('With variants and an empty cache', async () => {
await mountPage({ list: () => listBody([]) }, '/media?variants=with')
const content = textOf(document.body)
expect(content).toContain('No images with variants')
expect(content).toContain(
'Variants are generated when an image is requested with ?v=<name>, or from its Variants tab.',
)
expect(findButton('Show all images')).toBeDefined()
expect(document.body.querySelector('[data-testid="variants-filter"]')).not.toBeNull()
})
it('Show all images resets the variants filter', async () => {
const handlers = {
list: (path: string) =>
path.includes('variants=with') ? listBody([]) : listBody(fixture()),
}
await mountPage(handlers, '/media?variants=with')
findButton('Show all images')!.click()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/media')
expect(segment('All').getAttribute('aria-pressed')).toBe('true')
expect(cards()).toHaveLength(12)
})
})
describe('Delete dialogs name the generated variants they also remove', () => {
it('A single delete of an image with variants', async () => {
await mountPage({
usage: () => [{ id: 10, title: 'How we price a bathroom', published: true }],
})
await openPanelFor('north-wall.jpg')
const before = callsTo('GET', /\/v1\/media\//).length
findButton('Delete', panel()!)!.click()
await flushPromises()
const content = textOf(dialog())
expect(content).toContain("Delete an image that's in use?")
expect(content).toContain('Also removes 4 generated variants (260 KB)')
expect(callsTo('GET', /\/v1\/media\//).length).toBe(before)
})
it.each([
['stone-wall-detail.jpg', 'Also removes 1 generated variant (60 KB)'],
['window-frame.jpg', null],
])('The note is singular for one variant and absent for none: %s', async (filename, note) => {
await mountPage()
await openPanelFor(filename)
findButton('Delete', panel()!)!.click()
await flushPromises()
if (note) {
expect(textOf(dialog())).toContain(note)
} else {
expect(textOf(dialog())).not.toContain('Also removes')
}
})
it('Bulk delete sums the selection', async () => {
await mountPage()
for (const name of ['north-wall.jpg', 'kitchen-after.webp', 'window-frame.jpg']) {
checkboxFor(name).click()
}
await flushPromises()
findButton(
'Delete',
document.body.querySelector<HTMLElement>('[data-testid="media-bulk-bar"]')!,
)!.click()
await flushPromises()
expect(textOf(dialog())).toContain('Also removes 9 generated variants (670 KB)')
expect(dialog()!.querySelectorAll('[data-testid="bulk-delete-row"]')).toHaveLength(3)
})
it('A bulk delete with no variant in the selection shows no note', async () => {
await mountPage()
for (const name of ['window-frame.jpg', 'IMG_2208.jpg']) {
checkboxFor(name).click()
}
await flushPromises()
findButton(
'Delete',
document.body.querySelector<HTMLElement>('[data-testid="media-bulk-bar"]')!,
)!.click()
await flushPromises()
expect(textOf(dialog())).not.toContain('Also removes')
})
it('The delete flow itself is unchanged and the list is requested again', async () => {
await mountPage({ remove: () => undefined })
await openPanelFor('window-frame.jpg')
const before = listCalls().length
findButton('Delete', panel()!)!.click()
await flushPromises()
findButton('Delete image', dialog()!)!.click()
await settle()
expect(callsTo('DELETE', /\/v1\/media\/8$/)).toHaveLength(1)
expect(listCalls().length).toBeGreaterThan(before)
})
})
@@ -0,0 +1,783 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import UButton from '@nuxt/ui/components/Button.vue'
import router from '@/router'
import { ApiError, NetworkError } from '@/composables/useApi'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
NORTH_WALL_BYTES,
callsTo,
cardFor,
cards,
deferred,
findButton,
kit,
listBody,
fixture,
mountPage,
panel,
press,
tab,
tabs,
textOf,
unmountPage,
variantItems,
variantList,
} from './mediaTestKit'
import { savedPercent, signedPercent } from '@/lib/variants/variantSaving'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => toasts }
})
const PARTLY = ['card', 'hero', 'square', 'thumb']
const SVG_LIST = { unsupported: 'svg', items: [] }
const writeText = vi.fn()
function mountTab(...[handlers, url, width, options]: Parameters<typeof mountPage>) {
return mountPage(handlers, url, width, { ...options, app: true })
}
function toastWith(id: string) {
return toasts.add.mock.calls.map(([content]) => content).find((content) => content.id === id)
}
function lastToast() {
return toasts.add.mock.calls.at(-1)![0]
}
async function openVariants(filename = 'north-wall.jpg') {
cardFor(filename).click()
await flushPromises()
activateTab('Variants')
await flushPromises()
}
function activateTab(label: string) {
const target = tab(label)
target.focus()
press(target, 'Enter')
}
function body() {
return panel()!.querySelector<HTMLElement>('[data-testid="variants-tab"]')!
}
function rows() {
return Array.from(body().querySelectorAll<HTMLElement>('[data-testid="variant-row"]'))
}
function rowOf(name: string) {
return rows().find((row) => row.getAttribute('data-variant') === name)!
}
function rowAction(name: string, label: string) {
return Array.from(rowOf(name).querySelectorAll<HTMLButtonElement>('button')).find(
(button) => (button.getAttribute('aria-label') ?? textOf(button)) === label,
)
}
function summary() {
return textOf(
Array.from(body().querySelectorAll('span')).find((span) =>
/of \d+ generated/.test(span.textContent ?? ''),
),
)
}
function generateAllButton() {
return findButton('Generate all', body()) ?? findButton('Generating…', body())
}
function variantRequests(method = 'GET') {
return callsTo(method, /\/v1\/media\/1\/variants$/)
}
beforeEach(() => {
toasts.add.mockClear()
writeText.mockReset().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
})
afterEach(unmountPage)
describe('Saving percent', () => {
it.each([
[18432, '-99%'],
[65536, '-98%'],
[98304, '-98%'],
[145408, '-96%'],
[3250586, '-21%'],
[4089446, '0%'],
[4500000, '+10%'],
])('Saving against the original for %i bytes reads %s', (size, text) => {
expect(signedPercent(NORTH_WALL_BYTES, size)).toBe(text)
})
it('never shows -100% for a non-empty file', () => {
expect(savedPercent(NORTH_WALL_BYTES, 1)).toBe(-99)
expect(savedPercent(NORTH_WALL_BYTES, 0)).toBe(-100)
})
it('rounds half away from zero', () => {
expect(savedPercent(200, 101)).toBe(-50)
expect(savedPercent(200, 301)).toBe(51)
expect(savedPercent(0, 10)).toBe(0)
})
})
describe('The panel tabs', () => {
it('gives an owner two tabs under the header with the first selected', async () => {
await mountTab({ variants: () => variantList() })
cardFor('north-wall.jpg').click()
await flushPromises()
const labels = tabs()
.map(textOf)
.filter((label) => label === 'Details' || label === 'Variants')
expect(labels).toEqual(['Details', 'Variants'])
expect(tab('Details').getAttribute('aria-selected')).toBe('true')
expect(tab('Variants').className).toContain('h-10')
expect(tab('Variants').className).toContain('max-md:h-11')
})
it('gives an admin the tabs too', async () => {
await mountTab({ variants: () => variantList() }, '/media', undefined, { role: 'admin' })
cardFor('north-wall.jpg').click()
await flushPromises()
expect(tab('Variants')).toBeDefined()
})
it('A session that is not owner or admin sees no tab and sends no request', async () => {
await mountTab({}, '/media', undefined, { role: 'user' })
cardFor('north-wall.jpg').click()
await flushPromises()
expect(tabs().filter((entry) => textOf(entry) === 'Variants')).toHaveLength(0)
expect(textOf(panel())).toContain('Copy URL')
expect(variantRequests()).toHaveLength(0)
})
it('Selecting the tab requests the variants once per open panel', async () => {
await mountTab({ variants: () => variantList(PARTLY) })
cardFor('north-wall.jpg').click()
await flushPromises()
expect(variantRequests()).toHaveLength(0)
activateTab('Variants')
await flushPromises()
activateTab('Details')
await flushPromises()
activateTab('Variants')
await flushPromises()
expect(variantRequests()).toHaveLength(1)
expect(textOf(panel())).toContain('Original · kept untouched')
})
it('keeps the Details body unchanged', async () => {
await mountTab({ variants: () => variantList(PARTLY) })
await openVariants()
activateTab('Details')
await flushPromises()
const details = panel()!.querySelector<HTMLElement>('dl')!
expect(textOf(details)).toContain('JPEG')
expect(details.closest<HTMLElement>('[style*="display: none"]')).toBeNull()
})
})
describe('Loading and failing', () => {
it('draws nothing at 149 ms and five skeleton rows with aria-busy at 150 ms', async () => {
const pending = deferred<unknown>()
await mountTab({ variants: () => pending.promise })
cardFor('north-wall.jpg').click()
await flushPromises()
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
activateTab('Variants')
await flushPromises()
vi.advanceTimersByTime(149)
await flushPromises()
expect(body().children).toHaveLength(1)
expect(body().querySelector('[aria-busy="true"]')).toBeNull()
vi.advanceTimersByTime(1)
await flushPromises()
const region = body().querySelector<HTMLElement>('[aria-busy="true"]')!
expect(region.getAttribute('aria-label')).toBe('Loading variants')
expect(region.querySelectorAll('[data-testid="variant-skeleton-row"]')).toHaveLength(5)
vi.useRealTimers()
})
it.each([
['status 500', () => Promise.reject(new ApiError(500))],
['a network failure', () => Promise.reject(new NetworkError())],
])('The variants request fails with %s and Try again sends it again', async (_label, fail) => {
await mountTab({ variants: fail })
await openVariants()
const alert = body().querySelector<HTMLElement>('[role="alert"]')!
expect(textOf(alert)).toContain("Couldn't load variants")
expect(textOf(alert)).toContain('Check your connection and try again.')
const retry = findButton('Try again', alert)!
expect(retry).toBeDefined()
retry.click()
await flushPromises()
expect(variantRequests()).toHaveLength(2)
})
})
describe('The list', () => {
it('shows the Original block, the summary and the list when partly generated', async () => {
await mountTab({ variants: () => variantList(PARTLY) })
await openVariants()
const children = Array.from(body().children)
expect(children[0]!.getAttribute('data-testid')).toBe('variants-original')
expect(textOf(children[0])).toBe(
'Original · kept untouched north-wall.jpg JPEG · 4032 × 3024 · 3.9 MB',
)
expect(summary()).toBe('4 of 5 generated')
expect(findButton('Generate all', body())).toBeDefined()
expect(rows().map((row) => row.getAttribute('data-variant'))).toEqual([
'card',
'hero',
'og',
'square',
'thumb',
])
})
it.each([
['thumb', '400×300 · WebP · 18 KB · -99% vs original'],
['card', '800×600 · WebP · 64 KB · -98% vs original'],
['hero', '1200×630 · WebP · 96 KB · -98% vs original'],
['square', '600×600 · JPEG · 82 KB · -98% vs original'],
])('A generated row: %s', async (name, meta) => {
await mountTab({ variants: () => variantList(PARTLY) })
await openVariants()
const row = rowOf(name)
expect(textOf(row)).toContain('Generated')
expect(textOf(row)).toContain(meta)
const saving = Array.from(row.querySelectorAll('span')).find((span) =>
span.textContent?.endsWith('vs original'),
)!
expect(saving.className).toContain('text-success')
expect(row.querySelector('.font-mono')!.className).toContain('font-medium')
})
it('A not generated row', async () => {
await mountTab({ variants: () => variantList(PARTLY) })
await openVariants()
const row = rowOf('og')
expect(textOf(row)).toContain('Not generated')
expect(textOf(row)).toContain('1200×630 · JPEG')
expect(textOf(row)).not.toContain('vs original')
expect(textOf(row)).not.toContain('KB')
})
it('Nothing generated yet', async () => {
await mountTab({ variants: () => variantList([]) })
await openVariants()
expect(textOf(body())).toContain('0 of 5 generated')
expect(textOf(body())).toContain('Nothing generated yet.')
expect(rows().every((row) => textOf(row).includes('Not generated'))).toBe(true)
})
it('No variants defined', async () => {
await mountTab({ variants: () => ({ unsupported: null, items: [] }) })
await openVariants()
expect(textOf(body())).toContain('No variants defined')
expect(textOf(body())).toContain(
'Define a variant in Settings to get resized copies of this image.',
)
const link = body().querySelector<HTMLAnchorElement>('a')!
expect(textOf(link)).toBe('Settings → Media → Variants')
expect(link.getAttribute('href')).toBe('/settings/media/variants')
expect(rows()).toHaveLength(0)
expect(generateAllButton()).toBeUndefined()
expect(body().querySelector('[data-testid="variants-original"]')).not.toBeNull()
})
it('An SVG source', async () => {
await mountTab({ variants: () => SVG_LIST })
cardFor('logo-renew.svg').click()
await flushPromises()
activateTab('Variants')
await flushPromises()
expect(textOf(body().firstElementChild)).toContain('SVG · Vector · 8 KB')
const alert = body().querySelector('[class*="bg-(--rc-info-soft)"], [data-slot="root"]')
expect(textOf(body())).toContain("Variants aren't generated for this format.")
expect(textOf(body())).toContain('SVG images are served as they are.')
expect(alert).not.toBeNull()
expect(rows()).toHaveLength(0)
expect(generateAllButton()).toBeUndefined()
})
})
describe('Row actions', () => {
it('shows icon buttons with accessible names on desktop', async () => {
await mountTab({ variants: () => variantList(PARTLY) })
await openVariants()
const generated = Array.from(rowOf('thumb').querySelectorAll('button')).map((button) =>
button.getAttribute('aria-label'),
)
expect(generated).toEqual(['Copy URL', 'Preview', 'Purge'])
const missing = Array.from(rowOf('og').querySelectorAll('button')).map((button) =>
button.getAttribute('aria-label'),
)
expect(missing).toEqual(['Copy URL', 'Generate now'])
expect(rowAction('thumb', 'Copy URL')!.className).toContain('size-7')
})
it('shows labelled 44 px buttons on their own line below 640 px', async () => {
await mountTab({ variants: () => variantList(PARTLY) }, '/media', MOBILE_WIDTH)
await openVariants()
const buttons = Array.from(rowOf('thumb').querySelectorAll('button'))
expect(buttons.map(textOf)).toEqual(['Copy URL', 'Preview', 'Purge'])
expect(buttons.every((button) => button.className.includes('h-11'))).toBe(true)
expect(textOf(rowAction('og', 'Generate now'))).toBe('Generate now')
})
it('disables Generate all when everything is generated', async () => {
await mountTab({ variants: () => variantList() })
await openVariants()
expect(generateAllButton()!.disabled).toBe(true)
})
it('shows an icon and a word for every status', async () => {
await mountTab({ variants: () => variantList(PARTLY) })
await openVariants()
const icons = kit
.wrapper!.findAllComponents({ name: 'Badge' })
.filter((badge) => badge.element.hasAttribute('data-status'))
.map((badge) => [badge.attributes('data-status'), badge.props('icon')])
expect(icons).toContainEqual(['generated', 'i-lucide-circle-check'])
expect(icons).toContainEqual(['missing', 'i-lucide-circle-dashed'])
})
})
describe('Generating', () => {
it('Generate now shows the row in flight and then generated with a toast', async () => {
const pending = deferred<unknown>()
await mountTab({
variants: () => variantList(PARTLY),
generate: () => pending.promise,
})
await openVariants()
rowAction('og', 'Generate now')!.click()
await flushPromises()
expect(callsTo('POST', /\/v1\/media\/1\/variants\/og$/)).toHaveLength(1)
expect(textOf(rowOf('og'))).toContain('Generating')
expect(rowOf('og').querySelectorAll('button')).toHaveLength(0)
expect(rowOf('og').className).toContain('bg-elevated')
expect(rowOf('hero').className).not.toContain('bg-elevated')
expect(textOf(body())).toContain('Generating variants…')
const all = generateAllButton()!
expect(textOf(all)).toBe('Generating…')
expect(all.disabled).toBe(true)
pending.resolve(variantItems().find((item) => item.name === 'og'))
await flushPromises()
expect(textOf(rowOf('og'))).toContain('Generated')
expect(summary()).toBe('5 of 5 generated')
expect(toastWith('media-variant-generated-1-og')).toMatchObject({
icon: 'i-lucide-circle-check',
title: 'Generated og',
})
})
it('Generate all shows the rows in flight and toasts the count', async () => {
const pending = deferred<unknown>()
await mountTab({
variants: () => variantList(['thumb']),
generateAll: () => pending.promise,
})
await openVariants()
generateAllButton()!.click()
await flushPromises()
expect(variantRequests('POST')).toHaveLength(1)
expect(
rows()
.filter((row) => textOf(row).includes('Generating'))
.map((row) => row.getAttribute('data-variant')),
).toEqual(['card', 'hero', 'og', 'square'])
expect(textOf(rowOf('thumb'))).toContain('Generated')
pending.resolve({ generated: 4, failed: 0, failures: [], items: variantItems() })
await flushPromises()
expect(rows().every((row) => textOf(row).includes('Generated'))).toBe(true)
expect(toastWith('media-variants-generated-1')).toMatchObject({
title: 'Generated 4 variants',
})
})
it('A single generated variant reads in the singular', async () => {
await mountTab({
variants: () => variantList(PARTLY),
generateAll: () => ({ generated: 1, failed: 0, failures: [], items: variantItems() }),
})
await openVariants()
generateAllButton()!.click()
await flushPromises()
expect(toastWith('media-variants-generated-1')).toMatchObject({
title: 'Generated 1 variant',
})
})
it('Generate all with a failure marks the row with its cause and Retry', async () => {
const items = variantItems().map((item) =>
item.name === 'hero' ? { ...item, generated: false, size: null } : item,
)
await mountTab({
variants: () => variantList(['thumb']),
generateAll: () => ({
generated: 4,
failed: 1,
failures: [{ name: 'hero', cause: 'too_large' }],
items,
}),
})
await openVariants()
generateAllButton()!.click()
await flushPromises()
const row = rowOf('hero')
expect(textOf(row)).toContain('Failed')
expect(textOf(row)).toContain('Image too large')
const retry = findButton('Retry', row)!
expect(retry.className).toContain('h-7')
expect(retry.className).toContain('text-xs')
expect(summary()).toBe('4 of 5 generated')
expect(toastWith('media-variants-generated-1')).toMatchObject({
title: 'Generated 4 variants',
})
})
it.each([
[413, 'Image too large'],
[415, 'Unsupported source format'],
])('A single generation fails with %i', async (status, cause) => {
await mountTab({
variants: () => variantList(PARTLY),
generate: () => Promise.reject(new ApiError(status, undefined, 'plain text body')),
})
await openVariants()
rowAction('og', 'Generate now')!.click()
await flushPromises()
expect(textOf(rowOf('og'))).toContain('Failed')
expect(textOf(rowOf('og'))).toContain(cause)
expect(findButton('Retry', rowOf('og'))).toBeDefined()
expect(lastToast()).toMatchObject({
color: 'error',
title: "Couldn't generate og.",
description: `${cause}.`,
})
expect(textOf(document.body)).not.toContain('plain text body')
})
it('does not remember a failed state when the panel is closed and opened again', async () => {
await mountTab({
variants: () => variantList(PARTLY),
generate: () => Promise.reject(new ApiError(413)),
})
await openVariants()
rowAction('og', 'Generate now')!.click()
await flushPromises()
expect(textOf(rowOf('og'))).toContain('Failed')
press(panel()!, 'Escape')
document.body
.querySelector<HTMLElement>('[role="dialog"] button[aria-label="Close"]')
?.click()
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 20))
await flushPromises()
await openVariants()
expect(textOf(rowOf('og'))).toContain('Not generated')
})
it('Retry sends the same request and shows Generating', async () => {
const pending = deferred<unknown>()
let calls = 0
await mountTab({
variants: () => variantList(PARTLY),
generate: () => (++calls === 1 ? Promise.reject(new ApiError(413)) : pending.promise),
})
await openVariants()
rowAction('og', 'Generate now')!.click()
await flushPromises()
findButton('Retry', rowOf('og'))!.click()
await flushPromises()
expect(callsTo('POST', /\/v1\/media\/1\/variants\/og$/)).toHaveLength(2)
expect(textOf(rowOf('og'))).toContain('Generating')
})
it.each([
['500', new ApiError(500), 'Something went wrong'],
['403', new ApiError(403), "You don't have access"],
['network failure', new NetworkError(), 'Something went wrong'],
])(
'Other failures of a generation: %s returns the row to Not generated',
async (_l, error, title) => {
await mountTab({
variants: () => variantList(PARTLY),
generate: () => Promise.reject(error),
})
await openVariants()
rowAction('og', 'Generate now')!.click()
await flushPromises()
expect(textOf(rowOf('og'))).toContain('Not generated')
expect(lastToast()).toMatchObject({ color: 'error', title })
},
)
it('a 404 returns the row, toasts and requests the tab again', async () => {
await mountTab({
variants: () => variantList(PARTLY),
generate: () => Promise.reject(new ApiError(404)),
})
await openVariants()
rowAction('og', 'Generate now')!.click()
await flushPromises()
expect(textOf(rowOf('og'))).toContain('Not generated')
expect(lastToast()).toMatchObject({
title: 'Not found',
description: 'This variant or image no longer exists. Refresh the page.',
})
expect(variantRequests()).toHaveLength(2)
})
it('closes the panel and drops the image when the second request is a 404', async () => {
let variantCalls = 0
let imageGone = false
await mountTab({
variants: () => {
if (++variantCalls === 1) {
return variantList(PARTLY)
}
imageGone = true
return Promise.reject(new ApiError(404))
},
generate: () => Promise.reject(new ApiError(404)),
list: () => listBody(fixture().filter((item) => !imageGone || item.id !== 1)),
})
await openVariants()
rowAction('og', 'Generate now')!.click()
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 20))
await flushPromises()
expect(cards().some((card) => textOf(card).includes('north-wall.jpg'))).toBe(false)
expect(toastWith('media-not-found-1')).toMatchObject({
title: 'That image no longer exists',
})
})
it('announces the progress in a polite live region', async () => {
const pending = deferred<unknown>()
await mountTab({ variants: () => variantList(PARTLY), generate: () => pending.promise })
await openVariants()
rowAction('og', 'Generate now')!.click()
await flushPromises()
const region = body().querySelector<HTMLElement>('[aria-live="polite"]')!
expect(textOf(region)).toBe('Generating variants')
pending.resolve(variantItems().find((item) => item.name === 'og'))
await flushPromises()
expect(textOf(region)).toBe('Generated og')
})
})
describe('Copy URL, Purge and Preview', () => {
it('copies the absolute variant url, also for a row that is not generated', async () => {
await mountTab({ variants: () => variantList(PARTLY) })
await openVariants()
rowAction('thumb', 'Copy URL')!.click()
await flushPromises()
rowAction('og', 'Copy URL')!.click()
await flushPromises()
expect(writeText).toHaveBeenNthCalledWith(
1,
`${window.location.origin}/static/uploadedImages/1.jpg?v=thumb`,
)
expect(writeText).toHaveBeenNthCalledWith(
2,
`${window.location.origin}/static/uploadedImages/1.jpg?v=og`,
)
expect(toastWith('media-variant-url-copied-1-thumb')).toMatchObject({
title: 'URL copied',
description: 'Paste it wherever you need the image address.',
})
})
it('shows no success toast when the clipboard rejects', async () => {
writeText.mockRejectedValue(new Error('denied'))
await mountTab({ variants: () => variantList(PARTLY) })
await openVariants()
rowAction('thumb', 'Copy URL')!.click()
await flushPromises()
expect(toastWith('media-variant-url-copied-1-thumb')).toBeUndefined()
})
it('Purge removes the file, updates the row and the summary, and toasts', async () => {
await mountTab({
variants: () => variantList(PARTLY),
purgeVariant: () => ({ purged_files: 1, purged_bytes: 18432 }),
})
await openVariants()
rowAction('thumb', 'Purge')!.click()
await flushPromises()
expect(callsTo('DELETE', /\/v1\/media\/1\/variants\/thumb$/)).toHaveLength(1)
expect(textOf(rowOf('thumb'))).toContain('Not generated')
expect(textOf(rowOf('thumb'))).not.toContain('KB')
expect(summary()).toBe('3 of 5 generated')
expect(toastWith('media-variants-purged-1-thumb')).toMatchObject({
title: 'Purged 1 file (18 KB)',
})
})
it.each([
[403, "You don't have access"],
[404, 'Not found'],
[500, 'Something went wrong'],
])('Purge fails with %i and the row stays Generated', async (status, title) => {
await mountTab({
variants: () => variantList(PARTLY),
purgeVariant: () => Promise.reject(new ApiError(status)),
})
await openVariants()
rowAction('thumb', 'Purge')!.click()
await flushPromises()
expect(textOf(rowOf('thumb'))).toContain('Generated')
expect(lastToast()).toMatchObject({ color: 'error', title })
})
it('Preview opens a dialog with both panes at actual size', async () => {
await mountTab({ variants: () => variantList(PARTLY) })
await openVariants()
rowAction('thumb', 'Preview')!.click()
await flushPromises()
const dialogs = Array.from(document.body.querySelectorAll<HTMLElement>('[role="dialog"]'))
const preview = dialogs.find((entry) => textOf(entry).includes('Preview: thumb'))!
const content = textOf(preview)
expect(content).toContain('thumb · 400×300 · WebP')
expect(content).toContain('18 KB · saves 99%')
expect(content).toContain('Original · 4032×3024 · JPEG')
expect(content).toContain('3.9 MB')
expect(content).toContain('Both images are shown at actual size. Scroll to pan.')
expect(findButton('Close', preview)).toBeDefined()
expect(preview.querySelector('[data-testid="preview-variant-pane"]')!.className).toContain(
'sm:h-75',
)
expect(preview.querySelector('[data-testid="preview-variant-pane"]')!.className).toContain(
'h-45',
)
const sources = Array.from(preview.querySelectorAll('img')).map((image) =>
image.getAttribute('src'),
)
expect(sources).toEqual(['/static/uploadedImages/1.jpg?v=thumb', '/media/north-wall.jpg'])
})
it('returns focus to the Preview button when the dialog closes', async () => {
await mountTab({ variants: () => variantList(PARTLY) })
await openVariants()
const opener = rowAction('thumb', 'Preview')!
opener.focus()
opener.click()
await flushPromises()
const preview = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="dialog"]'),
).find((entry) => textOf(entry).includes('Preview: thumb'))!
findButton('Close', preview)!.click()
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 20))
await flushPromises()
expect(document.activeElement).toBe(opener)
})
it('uses the sheet layout from the same markup below 640 px', async () => {
await mountTab({ variants: () => variantList(PARTLY) }, '/media', MOBILE_WIDTH)
await openVariants()
rowAction('thumb', 'Preview')!.click()
await flushPromises()
const preview = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="dialog"]'),
).find((entry) => textOf(entry).includes('Preview: thumb'))!
expect(preview.className).toContain('max-sm:!bottom-4')
expect(findButton('Close', preview)!.className).toContain('h-11')
})
})
describe('Vocabulary', () => {
it('never says thumbnail preset, version or rendition', async () => {
await mountTab({ variants: () => variantList(PARTLY) })
await openVariants()
expect(textOf(body())).not.toMatch(/thumbnail preset|version|rendition/i)
})
it('keeps the buttons buttons', async () => {
await mountTab({ variants: () => variantList(PARTLY) })
await openVariants()
expect(kit.wrapper!.findAllComponents(UButton).length).toBeGreaterThan(0)
expect(router.currentRoute.value.path).toBe('/media')
})
})
+105 -2
View File
@@ -1,7 +1,9 @@
import { vi } from 'vitest' import { vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils' import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin' import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import router from '@/router' import router from '@/router'
import { ApiError, useApi } from '@/composables/useApi' import { ApiError, useApi } from '@/composables/useApi'
import type { MediaItem } from '@/composables/useMedia' import type { MediaItem } from '@/composables/useMedia'
@@ -21,6 +23,8 @@ type Row = [
string, string,
boolean, boolean,
string, string,
number,
number,
] ]
const ROWS: Row[] = [ const ROWS: Row[] = [
@@ -36,6 +40,8 @@ const ROWS: Row[] = [
'2026-09-12T12:00:00Z', '2026-09-12T12:00:00Z',
false, false,
'0a', '0a',
4,
266240,
], ],
[ [
2, 2,
@@ -49,6 +55,8 @@ const ROWS: Row[] = [
'2026-09-08T12:00:00Z', '2026-09-08T12:00:00Z',
false, false,
'1b', '1b',
5,
419840,
], ],
[ [
3, 3,
@@ -62,6 +70,8 @@ const ROWS: Row[] = [
'2026-09-08T11:00:00Z', '2026-09-08T11:00:00Z',
false, false,
'2c', '2c',
5,
404480,
], ],
[ [
4, 4,
@@ -75,6 +85,8 @@ const ROWS: Row[] = [
'2026-09-05T12:00:00Z', '2026-09-05T12:00:00Z',
false, false,
'3d', '3d',
0,
0,
], ],
[ [
5, 5,
@@ -88,6 +100,8 @@ const ROWS: Row[] = [
'2026-09-02T12:00:00Z', '2026-09-02T12:00:00Z',
true, true,
'0e', '0e',
3,
245760,
], ],
[ [
6, 6,
@@ -101,6 +115,8 @@ const ROWS: Row[] = [
'2026-08-30T12:00:00Z', '2026-08-30T12:00:00Z',
true, true,
'0e', '0e',
0,
0,
], ],
[ [
7, 7,
@@ -114,6 +130,8 @@ const ROWS: Row[] = [
'2026-08-29T12:00:00Z', '2026-08-29T12:00:00Z',
true, true,
'2f', '2f',
2,
153600,
], ],
[ [
8, 8,
@@ -127,6 +145,8 @@ const ROWS: Row[] = [
'2026-08-28T12:00:00Z', '2026-08-28T12:00:00Z',
false, false,
'3a', '3a',
0,
0,
], ],
[ [
9, 9,
@@ -140,6 +160,8 @@ const ROWS: Row[] = [
'2026-08-21T12:00:00Z', '2026-08-21T12:00:00Z',
true, true,
'2f', '2f',
1,
61440,
], ],
[ [
10, 10,
@@ -153,6 +175,8 @@ const ROWS: Row[] = [
'2026-08-04T12:00:00Z', '2026-08-04T12:00:00Z',
false, false,
'1b', '1b',
3,
194560,
], ],
[ [
11, 11,
@@ -166,6 +190,8 @@ const ROWS: Row[] = [
'2026-07-19T12:00:00Z', '2026-07-19T12:00:00Z',
false, false,
'1c', '1c',
4,
286720,
], ],
[ [
12, 12,
@@ -179,6 +205,8 @@ const ROWS: Row[] = [
'2026-06-14T12:00:00Z', '2026-06-14T12:00:00Z',
true, true,
'0e', '0e',
5,
440320,
], ],
] ]
@@ -199,6 +227,8 @@ export function fixture(ids?: number[]): MediaItem[] {
created_at, created_at,
duplicate, duplicate,
sha256, sha256,
variant_count,
variant_size,
]) => ({ ]) => ({
id, id,
filename, filename,
@@ -212,6 +242,8 @@ export function fixture(ids?: number[]): MediaItem[] {
created_at, created_at,
used_in, used_in,
duplicate, duplicate,
variant_count,
variant_size,
}), }),
) )
} }
@@ -238,6 +270,10 @@ export interface Handlers {
merge?: (body: { keep_id: number; merge_ids: number[] }) => unknown merge?: (body: { keep_id: number; merge_ids: number[] }) => unknown
preview?: () => unknown preview?: () => unknown
purge?: (body: { fingerprint: string }) => unknown purge?: (body: { fingerprint: string }) => unknown
variants?: (id: number) => unknown
generateAll?: (id: number) => unknown
generate?: (id: number, name: string) => unknown
purgeVariant?: (id: number, name: string) => unknown
} }
export function libraryHandlers( export function libraryHandlers(
@@ -282,6 +318,28 @@ function dispatcher(handlers: Handlers) {
if (method === 'POST' && path === '/v1/media/purge') { if (method === 'POST' && path === '/v1/media/purge') {
return (handlers.purge ?? (() => undefined))(JSON.parse(String(options?.body))) return (handlers.purge ?? (() => undefined))(JSON.parse(String(options?.body)))
} }
const variantsList = /^\/v1\/media\/(\d+)\/variants$/.exec(path)
if (variantsList && method === 'GET') {
return (handlers.variants ?? (() => ({ unsupported: null, items: [] })))(
Number(variantsList[1]),
)
}
if (variantsList && method === 'POST') {
return (handlers.generateAll ?? (() => undefined))(Number(variantsList[1]))
}
const variantNamed = /^\/v1\/media\/(\d+)\/variants\/([\w-]+)$/.exec(path)
if (variantNamed && method === 'POST') {
return (handlers.generate ?? (() => undefined))(
Number(variantNamed[1]),
variantNamed[2]!,
)
}
if (variantNamed && method === 'DELETE') {
return (handlers.purgeVariant ?? (() => undefined))(
Number(variantNamed[1]),
variantNamed[2]!,
)
}
const usage = /^\/v1\/media\/(\d+)\/usage$/.exec(path) const usage = /^\/v1\/media\/(\d+)\/usage$/.exec(path)
if (method === 'GET' && usage) { if (method === 'GET' && usage) {
return (handlers.usage ?? (() => []))(Number(usage[1])) return (handlers.usage ?? (() => []))(Number(usage[1]))
@@ -324,7 +382,7 @@ export async function mountPage(
handlers: Handlers = {}, handlers: Handlers = {},
url = '/media', url = '/media',
width = DESKTOP_WIDTH, width = DESKTOP_WIDTH,
options: { realApi?: boolean; role?: string } = {}, options: { realApi?: boolean; role?: string; app?: boolean } = {},
) { ) {
setViewportWidth(width) setViewportWidth(width)
setActivePinia(createPinia()) setActivePinia(createPinia())
@@ -349,7 +407,10 @@ export async function mountPage(
}) })
} }
await router.push(url) await router.push(url)
kit.wrapper = mount(MediaView, { const root = options.app
? defineComponent({ render: () => h(UApp, null, { default: () => h(MediaView) }) })
: MediaView
kit.wrapper = mount(root, {
attachTo: document.body, attachTo: document.body,
global: { plugins: [router, uiPlugin] }, global: { plugins: [router, uiPlugin] },
}) })
@@ -599,3 +660,45 @@ export function uploadRows() {
export function rowTexts() { export function rowTexts() {
return uploadRows().map(textOf) return uploadRows().map(textOf)
} }
export const STORED_NAME = '1.jpg'
export const NORTH_WALL_BYTES = 4089446
const VARIANT_SPECS: [
string,
number | null,
number | null,
string,
string,
number,
number,
number,
][] = [
['card', 800, null, 'inside', 'webp', 800, 600, 65536],
['hero', 1200, 630, 'cover', 'webp', 1200, 630, 98304],
['og', 1200, 630, 'cover', 'jpeg', 1200, 630, 145408],
['square', 600, 600, 'cover', 'jpeg', 600, 600, 83968],
['thumb', 400, 300, 'cover', 'webp', 400, 300, 18432],
]
export function variantItems(generated: string[] = VARIANT_SPECS.map(([name]) => name)) {
return VARIANT_SPECS.map(
([name, width, height, fit, format, outputWidth, outputHeight, size]) => ({
name,
width,
height,
fit,
format,
output_width: outputWidth,
output_height: outputHeight,
output_format: format,
generated: generated.includes(name),
size: generated.includes(name) ? size : null,
url: `/static/uploadedImages/${STORED_NAME}?v=${name}`,
}),
)
}
export function variantList(generated?: string[]) {
return { unsupported: null, items: variantItems(generated) }
}
+44 -4
View File
@@ -6,7 +6,7 @@ import { build, preview } from 'vite'
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright' import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'
import { icons as lucide } from '@iconify-json/lucide' import { icons as lucide } from '@iconify-json/lucide'
import { getIconData, getIcons, iconToHTML, iconToSVG, replaceIDs } from '@iconify/utils' import { getIconData, getIcons, iconToHTML, iconToSVG, replaceIDs } from '@iconify/utils'
import { visualStates, type ApiMock, type VisualState } from './states' import { visualStates, type ApiMock, type DiffException, type VisualState } from './states'
type Viewport = 'desktop' | 'mobile' type Viewport = 'desktop' | 'mobile'
type Theme = 'light' | 'dark' type Theme = 'light' | 'dark'
@@ -151,6 +151,17 @@ const settleMs = 400
const diffThreshold = 24 const diffThreshold = 24
const maxDiffPercent = 10 const maxDiffPercent = 10
function budgetFor(cap: DiffException['maxPercent'] | undefined, viewport: Viewport) {
if (cap === undefined) {
return maxDiffPercent
}
return typeof cap === 'number' ? cap : cap[viewport]
}
function describeCap(cap: DiffException['maxPercent']) {
return typeof cap === 'number' ? `${cap}%` : `${cap.desktop}% desktop, ${cap.mobile}% mobile`
}
function slug(text: string) { function slug(text: string) {
return text return text
.toLowerCase() .toLowerCase()
@@ -239,11 +250,40 @@ function variantsScreen(): Screen {
alias: numberedStates('2', formNames), alias: numberedStates('2', formNames),
}, },
] ]
const libraryNames = [
'3a · Grid with badges',
'3b · Sort menu',
'3c · Filter: With variants, sorted by total size',
'3d · Filter: Without variants',
'3e · Filter: nothing to show',
'3f · Delete confirm',
]
const detailNames = [
'4a · Partly generated',
'4b · Nothing generated yet',
'4c · Generating',
'4d · Generation failed',
'4e · No variants defined',
'4f · Source is an SVG',
'4h · Preview dialog',
]
const editorNames = [
'6a · Variant select',
'6b · Select open',
'6c · No variants defined',
'6d · Variant deleted',
'6e · Variant disabled',
]
sources.push(
{ board: variantsBoard, group: 'Media library', states: libraryNames, alias: {} },
{ board: variantsBoard, group: 'Image detail', states: detailNames, alias: {} },
{ board: variantsBoard, group: 'Editor picker', states: editorNames, alias: {} },
)
return { return {
name: 'image-variants', name: 'image-variants',
board: variantsBoard, board: variantsBoard,
group: 'Variants list', group: 'Variants list',
states: [...listNames, ...formNames], states: [...listNames, ...formNames, ...libraryNames, ...detailNames, ...editorNames],
sources, sources,
} }
} }
@@ -686,7 +726,7 @@ async function main() {
) )
const result = await diff(browser, designPath, appPath, diffPath) const result = await diff(browser, designPath, appPath, diffPath)
const percent = result.ratio * 100 const percent = result.ratio * 100
const budget = setup.exception?.maxPercent ?? maxDiffPercent const budget = budgetFor(setup.exception?.maxPercent, viewport)
const problems = [ const problems = [
...(percent > budget ...(percent > budget
? [`${percent.toFixed(2)}% exceeds ${budget}% budget`] ? [`${percent.toFixed(2)}% exceeds ${budget}% budget`]
@@ -700,7 +740,7 @@ async function main() {
failures++ failures++
} }
const exception = setup.exception const exception = setup.exception
? ` [exception ${setup.exception.maxPercent}% approved by ${setup.exception.approvedBy}: ${setup.exception.reason}]` ? ` [exception ${describeCap(setup.exception.maxPercent)} approved by ${setup.exception.approvedBy}: ${setup.exception.reason}]`
: '' : ''
lines.push( lines.push(
`${problems.length ? 'FAIL' : 'PASS'} ${state} · ${viewport} · ${theme}: ${percent.toFixed(2)}% pixels differ → ${relative(repoRoot, directory)}${problems.length ? ` (${problems.join('; ')})` : ''}${exception}`, `${problems.length ? 'FAIL' : 'PASS'} ${state} · ${viewport} · ${theme}: ${percent.toFixed(2)}% pixels differ → ${relative(repoRoot, directory)}${problems.length ? ` (${problems.join('; ')})` : ''}${exception}`,
+384 -19
View File
@@ -10,7 +10,7 @@ export interface ApiMock {
} }
export interface DiffException { export interface DiffException {
maxPercent: number maxPercent: number | { desktop: number; mobile: number }
reason: string reason: string
approvedBy: string approvedBy: string
} }
@@ -1837,6 +1837,7 @@ function pickerApi(
'GET /v1/auth/me': mediaSession, 'GET /v1/auth/me': mediaSession,
'GET /v1/article/12': { status: 200, body: { ...editorArticle, body } }, 'GET /v1/article/12': { status: 200, body: { ...editorArticle, body } },
'GET /v1/media/': { status: 200, bodyFor: pickerList }, 'GET /v1/media/': { status: 200, bodyFor: pickerList },
'GET /v1/image-variants': { status: 403 },
...overrides, ...overrides,
} }
} }
@@ -3308,23 +3309,6 @@ const variantsStates: Record<string, VisualState> = {
await page.mouse.move(0, 0) await page.mouse.move(0, 0)
}, },
), ),
'Entry: sidebar': {
path: '/media',
origin: mediaOrigin,
api: mediaApi({ 'GET /v1/auth/me': variantsSession }),
steps: async (page) => {
if (isMobileViewport(page)) {
await page.locator('[data-slot="toggle"]').first().click()
}
await page
.locator(
'nav[aria-label="Main"]:visible a[href="/settings"] [data-slot="linkTrailing"]',
)
.click()
await page.mouse.move(0, 0)
await page.waitForTimeout(250)
},
},
'Request reference': { 'Request reference': {
...variantsListState({}, async (page) => { ...variantsListState({}, async (page) => {
if (isMobileViewport(page)) { if (isMobileViewport(page)) {
@@ -3428,6 +3412,383 @@ const variantsStates: Record<string, VisualState> = {
}), }),
} }
const variantBoardSizes: Record<string, number> = {
thumb: 18432,
card: 65536,
hero: 98304,
square: 83968,
og: 145408,
}
const variantBoardOutputs: Record<string, [number, number, string]> = {
thumb: [400, 300, 'webp'],
card: [800, 600, 'webp'],
hero: [1200, 630, 'webp'],
square: [600, 600, 'jpeg'],
og: [1200, 630, 'jpeg'],
}
const variantBoardNames = ['thumb', 'card', 'hero', 'square', 'og']
const variantLibraryTotals: Record<number, [number, number]> = {
1: [4, 266240],
2: [5, 419840],
3: [5, 404480],
5: [3, 245760],
7: [2, 153600],
9: [1, 61440],
10: [3, 194560],
11: [4, 286720],
12: [5, 440320],
}
const variantsMediaPath = '/media'
const variantsPanelId = 1
const variantsSvgId = 4
function variantLibraryItems(ids?: number[]) {
const order = (id: number) => (ids ? ids.indexOf(id) : id)
return mediaSamples
.filter((sample) => !ids || ids.includes(sample.id))
.sort((left, right) => order(left.id) - order(right.id))
.map((sample) => {
const [count, size] = variantLibraryTotals[sample.id] ?? [0, 0]
return {
...mediaItem({ ...sample, duplicate: false }),
variant_count: count,
variant_size: size,
}
})
}
function boardVariantItems(generated: string[], storedName = '1.jpg') {
return variantBoardNames.map((name) => {
const [width, height, format] = variantBoardOutputs[name]!
const isGenerated = generated.includes(name)
return {
name,
width,
height,
fit: 'cover',
format,
output_width: width,
output_height: height,
output_format: format,
generated: isGenerated,
size: isGenerated ? variantBoardSizes[name]! : null,
url: `/static/uploadedImages/${storedName}?v=${name}`,
}
})
}
function variantsMediaApi(
ids?: number[],
overrides: Record<string, ApiMock> = {},
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': variantsSession,
'GET /v1/media/': { status: 200, body: mediaList(variantLibraryItems(ids)) },
'GET /v1/media/1/usage': { status: 200, body: mediaNorthWallUsage },
...overrides,
}
}
function variantsMediaState(
overrides: Record<string, ApiMock> = {},
steps?: VisualState['steps'],
path = variantsMediaPath,
ids?: number[],
): VisualState {
return { path, api: variantsMediaApi(ids, overrides), steps }
}
async function openSortMenuOf(page: Page) {
await page.getByRole('button', { name: 'Sort', exact: true }).click()
await page.getByRole('menu').waitFor()
}
async function openImagePanel(page: Page, id = variantsPanelId) {
await page.locator(`[data-media-card][data-media-id="${id}"]`).click()
await page.getByRole('dialog').waitFor()
}
async function openVariantsTabOf(page: Page, id = variantsPanelId) {
await openImagePanel(page, id)
await page.getByRole('tab', { name: 'Variants', exact: true }).click()
await page.getByTestId('variants-tab').locator('[data-testid="variants-original"]').waitFor()
await page.mouse.move(0, 0)
}
function variantRow(page: Page, name: string) {
return page.locator(`[data-testid="variant-row"][data-variant="${name}"]`)
}
function variantsTabApi(
generated: string[],
overrides: Record<string, ApiMock> = {},
id = variantsPanelId,
) {
return variantsMediaApi(undefined, {
[`GET /v1/media/${id}/variants`]: {
status: 200,
body: { unsupported: null, items: boardVariantItems(generated) },
},
...overrides,
})
}
function variantsTabState(
generated: string[],
overrides: Record<string, ApiMock> = {},
steps?: VisualState['steps'],
): VisualState {
return {
path: variantsMediaPath,
api: variantsTabApi(generated, overrides),
steps: async (page) => {
await openVariantsTabOf(page)
await steps?.(page)
},
}
}
const variantsPartlyGenerated = ['thumb', 'card', 'hero', 'square']
const variantsFailedGeneration: ApiMock = {
status: 200,
body: {
generated: 2,
failed: 2,
failures: [
{ name: 'card', cause: 'unsupported_format' },
{ name: 'hero', cause: 'too_large' },
],
items: boardVariantItems(['thumb', 'square', 'og']),
},
}
const variantsPickerPath = '/article/12/edit'
function variantsBoardArticleBody(src: string | null) {
const image = src ? `<img src="${src}" alt="" data-image-id="1">` : ''
return `<p>${pickerIntro}</p>${image}<p>${pickerOutro}</p>`
}
function variantsPickerApi(catalog: unknown[]) {
return pickerApi(
{
'GET /v1/auth/me': variantsSession,
'GET /v1/media/': {
status: 200,
body: {
items: variantLibraryItems([1, 2, 3, 5]),
next_cursor: null,
counts: mediaFullCounts,
},
},
'GET /v1/image-variants': { status: 200, body: { items: catalog } },
},
variantsBoardArticleBody(null),
)
}
function variantsPickerState(
catalog: unknown[],
steps?: (page: Page) => Promise<void>,
): VisualState {
return {
path: variantsPickerPath,
api: variantsPickerApi(catalog),
steps: async (page) => {
await stubEditorImages(page)
await steps?.(page)
},
}
}
async function chooseBoardVariant(page: Page, label: string) {
await page.getByTestId('variant-select').click()
await page.getByRole('option', { name: label }).first().click()
await page.mouse.move(0, 0)
}
function variantsBlockState(catalog: unknown[]): VisualState {
return {
path: variantsPickerPath,
api: pickerApi(
{
'GET /v1/auth/me': variantsSession,
'GET /v1/image-variants': { status: 200, body: { items: catalog } },
},
variantsBoardArticleBody('/media/north-wall.jpg?v=hero'),
),
steps: async (page) => {
await page.getByTestId('article-editor-variant-warning').waitFor()
await page.mouse.move(0, 0)
},
}
}
const variantsWithoutHero = variantsBoardList.filter((entry) => entry.name !== 'hero')
const variantsHeroDisabled = variantsBoardList.map((entry) =>
entry.name === 'hero' ? { ...entry, enabled: false } : entry,
)
const variantsMediaStates: Record<string, VisualState> = {
'Entry: sidebar': {
path: '/media',
origin: mediaOrigin,
api: mediaApi({ 'GET /v1/auth/me': variantsSession }),
steps: async (page) => {
if (isMobileViewport(page)) {
await page.locator('[data-slot="toggle"]').first().click()
}
await page
.locator(
'nav[aria-label="Main"]:visible a[href="/settings"] [data-slot="linkTrailing"]',
)
.click()
await page.mouse.move(0, 0)
await page.waitForTimeout(250)
},
},
'3a · Grid with badges': variantsMediaState(),
'3b · Sort menu': variantsMediaState(
{},
async (page) => {
await openSortMenuOf(page)
await page.mouse.move(0, 0)
},
'/media?sort=total_size',
),
'3c · Filter: With variants, sorted by total size': variantsMediaState(
{},
undefined,
'/media?variants=with&sort=total_size',
[1, 7, 9, 12, 5, 11, 2, 3, 10],
),
'3d · Filter: Without variants': variantsMediaState(
{},
undefined,
'/media?variants=without',
[4, 6, 8],
),
'3e · Filter: nothing to show': variantsMediaState({}, undefined, '/media?variants=with', []),
'3f · Delete confirm': variantsMediaState({}, async (page) => {
await openImagePanel(page)
await page.getByRole('dialog').getByRole('button', { name: 'Delete', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}),
'4a · Partly generated': variantsTabState(variantsPartlyGenerated),
'4b · Nothing generated yet': variantsTabState([]),
'4c · Generating': variantsTabState(
['thumb'],
{
'POST /v1/media/1/variants/card': { status: 200, hang: true },
'POST /v1/media/1/variants/hero': { status: 200, hang: true },
},
async (page) => {
await variantRow(page, 'card').getByRole('button', { name: 'Generate now' }).click()
await variantRow(page, 'hero').getByRole('button', { name: 'Generate now' }).click()
await page.mouse.move(0, 0)
},
),
'4d · Generation failed': variantsTabState(
['thumb'],
{ 'POST /v1/media/1/variants': variantsFailedGeneration },
async (page) => {
await page.getByRole('button', { name: 'Generate all', exact: true }).click()
await variantRow(page, 'card').getByText('Failed').waitFor()
await page.mouse.move(0, 0)
},
),
'4e · No variants defined': {
path: variantsMediaPath,
api: variantsMediaApi(undefined, {
'GET /v1/media/1/variants': { status: 200, body: { unsupported: null, items: [] } },
}),
steps: async (page) => {
await openVariantsTabOf(page)
},
},
'4f · Source is an SVG': {
path: variantsMediaPath,
api: variantsMediaApi(undefined, {
'GET /v1/media/4/variants': { status: 200, body: { unsupported: 'svg', items: [] } },
'GET /v1/media/4/usage': { status: 200, body: [] },
}),
steps: async (page) => {
await openVariantsTabOf(page, variantsSvgId)
},
},
'4h · Preview dialog': variantsTabState(variantsPartlyGenerated, {}, async (page) => {
await variantRow(page, 'thumb').getByRole('button', { name: 'Preview' }).click()
await page.getByRole('dialog').filter({ hasText: 'Preview: thumb' }).waitFor()
await page.mouse.move(0, 0)
}),
}
const variantsEditorStates: Record<string, VisualState> = {
'6a · Variant select': variantsPickerState(variantsBoardList, async (page) => {
await openImageUploader(page)
await selectPickerTile(page, 'north-wall.jpg')
await chooseBoardVariant(page, 'Hero')
}),
'6b · Select open': variantsPickerState(variantsBoardList, async (page) => {
await openImageUploader(page)
await selectPickerTile(page, 'north-wall.jpg')
await page.getByTestId('variant-select').click()
await page.getByRole('listbox').waitFor()
await page.mouse.move(0, 0)
}),
'6c · No variants defined': variantsPickerState([], async (page) => {
await openImageUploader(page)
await selectPickerTile(page, 'north-wall.jpg')
await page.getByTestId('variant-help').waitFor()
await page.mouse.move(0, 0)
}),
'6d · Variant deleted': variantsBlockState(variantsWithoutHero),
'6e · Variant disabled': variantsBlockState(variantsHeroDisabled),
}
const boardPredatesPages = 'board predates current Media page and editor'
const variantExceptionCaps: Record<string, { desktop: number; mobile: number }> = {
'3a': { desktop: 28, mobile: 42 },
'3b': { desktop: 28, mobile: 42 },
'3c': { desktop: 28, mobile: 42 },
'3d': { desktop: 10, mobile: 39 },
'3e': { desktop: 10, mobile: 39 },
'3f': { desktop: 78, mobile: 54 },
'4a': { desktop: 15, mobile: 10 },
'4b': { desktop: 15, mobile: 10 },
'4c': { desktop: 15, mobile: 10 },
'4d': { desktop: 15, mobile: 10 },
'4e': { desktop: 15, mobile: 10 },
'4f': { desktop: 15, mobile: 10 },
'6a': { desktop: 29, mobile: 52 },
'6b': { desktop: 29, mobile: 52 },
'6c': { desktop: 29, mobile: 52 },
'6d': { desktop: 29, mobile: 52 },
'6e': { desktop: 29, mobile: 52 },
}
function withVariantExceptions(states: Record<string, VisualState>) {
return Object.fromEntries(
Object.entries(states).map(([name, state]) => {
const cap = variantExceptionCaps[name.slice(0, 2)]
return [
name,
cap
? {
...state,
exception: {
maxPercent: cap,
reason: boardPredatesPages,
approvedBy: 'user',
},
}
: state,
]
}),
)
}
export const visualStates: Record<string, Record<string, VisualState>> = { export const visualStates: Record<string, Record<string, VisualState>> = {
activity: activityStates, activity: activityStates,
profile: { profile: {
@@ -4566,7 +4927,11 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
steps: submitAcceptInvite('lea', 'limewash2026'), steps: submitAcceptInvite('lea', 'limewash2026'),
}, },
}, },
'image-variants': variantsStates, 'image-variants': {
...variantsStates,
...withVariantExceptions(variantsMediaStates),
...withVariantExceptions(variantsEditorStates),
},
media: { ...mediaStates, ...mediaPurgeStates }, media: { ...mediaStates, ...mediaPurgeStates },
'media-picker': pickerStates, 'media-picker': pickerStates,
} }