feat: image variants per image
This commit is contained in:
1 parent
f567866161
commit
951fef18ba
92 files changed
+8695
-413
No files matched your search
+128
-116
@@ -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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
@@ -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{}))
|
||||||
|
|||||||
@@ -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: "", 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: "", 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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('&')
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)}`
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user