Compare commits

...
Author SHA1 Message Date
fsylvain e10a5e57e2 docs: release notes for v2.5.0-beta 2026-10-01 05:32:13 +02:00
fsylvain ff0854f331 fix: deleted image wrongfully caching 2026-10-01 05:27:25 +02:00
fsylvain b3793d5dbb feat: media picker and image removal 2026-10-01 05:04:58 +02:00
fsylvain 008b0dfbb9 feat: media page T1 2026-10-01 01:36:35 +02:00
fsylvain 67d456baf6 feat(SPA): media page T0 2026-09-30 19:45:47 +02:00
fsylvain 0d99544579 feat(API): media library 2026-09-30 18:35:28 +02:00
fsylvain 95ae043d3e chore: groundwork for media library feature 2026-09-30 17:48:15 +02:00
190 changed files with 18693 additions and 2666 deletions

No files matched your search

+18 -6
View File
@@ -45,7 +45,11 @@ type UseCases struct {
AcceptInviteUseCase *useCases.AcceptInviteUseCase AcceptInviteUseCase *useCases.AcceptInviteUseCase
SendMailUseCase *useCases.SendMailUseCase SendMailUseCase *useCases.SendMailUseCase
CreateImageUseCase *useCases.CreateImageUseCase CreateImageUseCase *useCases.CreateImageUseCase
DeleteImageUseCase *useCases.DeleteImageUseCase ListMediaUseCase *useCases.ListMediaUseCase
RenameMediaUseCase *useCases.RenameMediaUseCase
DeleteMediaUseCase *useCases.DeleteMediaUseCase
MergeMediaUseCase *useCases.MergeMediaUseCase
GetMediaUsageUseCase *useCases.GetMediaUsageUseCase
GetSettingsUseCase *useCases.GetSettingsUseCase GetSettingsUseCase *useCases.GetSettingsUseCase
} }
@@ -63,7 +67,7 @@ func SQLiteDSN(dbName string) string {
return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbName, sqliteBusyTimeoutMs) return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbName, sqliteBusyTimeoutMs)
} }
func getDb(dbName string) *gorm.DB { func getDb(dbName string, uploadDir string) *gorm.DB {
if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil { if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil {
panic("Unable to create necessary subdirectories: " + err.Error()) panic("Unable to create necessary subdirectories: " + err.Error())
} }
@@ -72,7 +76,7 @@ func getDb(dbName string) *gorm.DB {
panic("Unable to open the database: " + err.Error()) panic("Unable to open the database: " + err.Error())
} }
if err := db.AutoMigrate(&models.Article{}, &models.User{}, &models.Invite{}); err != nil { if err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}); err != nil {
panic("Failed to migrate database: " + err.Error()) panic("Failed to migrate database: " + err.Error())
} }
@@ -80,6 +84,10 @@ func getDb(dbName string) *gorm.DB {
panic("Failed to backfill user roles: " + err.Error()) panic("Failed to backfill user roles: " + err.Error())
} }
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
panic("Failed to backfill image metadata: " + err.Error())
}
return db return db
} }
@@ -102,7 +110,7 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo), GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo), ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, repos.ImageRepo), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo), GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL), CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher),
@@ -120,13 +128,17 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher), AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo), SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo), CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo),
DeleteImageUseCase: useCases.NewDeleteImageUseCase(repos.ImageRepo), ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version), GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
} }
} }
func InitContainer(settings config.Settings) (*UseCases, func() error) { func InitContainer(settings config.Settings) (*UseCases, func() error) {
db := getDb(settings.DBFile) db := getDb(settings.DBFile, settings.UploadDir)
repos := getRepositories(db, settings) repos := getRepositories(db, settings)
return getUseCases(repos, settings), closeDb(db) return getUseCases(repos, settings), closeDb(db)
} }
-121
View File
@@ -1,121 +0,0 @@
package image
import (
"RenewCMS/api/pkg/apperr"
domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
type Handler struct {
CreateUseCase *useCases.CreateImageUseCase
GetUseCase *useCases.GetArticleUseCase
UpdateUseCase *useCases.UpdateArticleUseCase
DeleteUseCase *useCases.DeleteImageUseCase
}
func NewHandler(create *useCases.CreateImageUseCase, get *useCases.GetArticleUseCase, update *useCases.UpdateArticleUseCase, deleteUseCase *useCases.DeleteImageUseCase) *Handler {
return &Handler{
CreateUseCase: create,
GetUseCase: get,
UpdateUseCase: update,
DeleteUseCase: deleteUseCase,
}
}
func writeInternalServerError(w http.ResponseWriter) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
func writeArticleError(w http.ResponseWriter, err error) {
if errors.Is(err, domainArticle.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return
}
writeInternalServerError(w)
}
func (h *Handler) PostImage(w http.ResponseWriter, r *http.Request) {
articleId, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
if _, err := h.GetUseCase.GetArticle(uint32(articleId)); err != nil {
writeArticleError(w, err)
return
}
file, fileHeader, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
newImage, err := h.CreateUseCase.CreateImage(domainImage.ImageInput{
Content: file,
Filename: fileHeader.Filename,
Size: fileHeader.Size,
ContentType: fileHeader.Header.Get("Content-Type"),
})
if err != nil {
if errors.Is(err, domainImage.ErrImageTypeNotAllowed) || errors.Is(err, domainImage.ErrImageTooLarge) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeInternalServerError(w)
return
}
if err := h.UpdateUseCase.AddImage(uint32(articleId), newImage.ID); err != nil {
writeArticleError(w, err)
return
}
responseJson, err := json.Marshal(map[string]any{"id": newImage.ID, "location": newImage.Path})
if err != nil {
writeInternalServerError(w)
return
}
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(responseJson)
}
func (h *Handler) DeleteImage(w http.ResponseWriter, r *http.Request) {
articleId, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
imageId, err := strconv.ParseUint(chi.URLParam(r, "imageId"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
if _, err := h.GetUseCase.GetArticle(uint32(articleId)); err != nil {
writeArticleError(w, err)
return
}
if err := h.DeleteUseCase.DeleteImage(uint32(imageId)); err != nil {
if errors.Is(err, domainImage.ErrImageNotFound) {
http.Error(w, apperr.ImageNotFoundErrorMessage, http.StatusNotFound)
return
}
writeInternalServerError(w)
return
}
w.WriteHeader(http.StatusNoContent)
}
+317
View File
@@ -0,0 +1,317 @@
package media
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/upload"
validator "RenewCMS/api/pkg/validator"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
type Handler struct {
CreateUseCase *useCases.CreateImageUseCase
ListUseCase *useCases.ListMediaUseCase
RenameUseCase *useCases.RenameMediaUseCase
DeleteUseCase *useCases.DeleteMediaUseCase
MergeUseCase *useCases.MergeMediaUseCase
UsageUseCase *useCases.GetMediaUsageUseCase
}
type RenameRequest struct {
Filename string `json:"filename" validate:"required"`
}
type MergeRequest struct {
KeepID uint32 `json:"keep_id" validate:"required,gt=0"`
MergeIDs []uint32 `json:"merge_ids" validate:"required,min=1,dive,gt=0"`
}
type mediaItemResponse struct {
ID uint32 `json:"id"`
Filename string `json:"filename"`
URL string `json:"url"`
Size int64 `json:"size"`
ContentType string `json:"content_type"`
Width *int `json:"width"`
Height *int `json:"height"`
SHA256 string `json:"sha256"`
UploadedBy string `json:"uploaded_by"`
CreatedAt time.Time `json:"created_at"`
UsedIn int `json:"used_in"`
Duplicate bool `json:"duplicate"`
}
type countsResponse struct {
All int `json:"all"`
Unused int `json:"unused"`
Duplicates int `json:"duplicates"`
}
type listResponse struct {
Items []mediaItemResponse `json:"items"`
NextCursor *string `json:"next_cursor"`
Counts countsResponse `json:"counts"`
}
type usageResponse struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Published bool `json:"published"`
}
type mergeResponse struct {
ArticlesUpdated int `json:"articles_updated"`
Merged int `json:"merged"`
}
func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUseCase, rename *useCases.RenameMediaUseCase, deleteUseCase *useCases.DeleteMediaUseCase, merge *useCases.MergeMediaUseCase, usage *useCases.GetMediaUsageUseCase) *Handler {
return &Handler{
CreateUseCase: create,
ListUseCase: list,
RenameUseCase: rename,
DeleteUseCase: deleteUseCase,
MergeUseCase: merge,
UsageUseCase: usage,
}
}
func writeInternalServerError(w http.ResponseWriter) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
writeInternalServerError(w)
return
}
w.WriteHeader(status)
_, _ = w.Write(body)
}
func writeImageError(w http.ResponseWriter, err error) {
if errors.Is(err, domainImage.ErrImageNotFound) {
http.Error(w, apperr.ImageNotFoundErrorMessage, http.StatusNotFound)
return
}
writeInternalServerError(w)
}
func parseImageId(w http.ResponseWriter, r *http.Request) (uint32, bool) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return 0, false
}
return uint32(id), true
}
func dimension(value int) *int {
if value == 0 {
return nil
}
return &value
}
func toResponse(item useCases.MediaItem) mediaItemResponse {
return mediaItemResponse{
ID: item.Image.ID,
Filename: item.Image.Filename,
URL: item.Image.Path,
Size: item.Image.Size,
ContentType: item.Image.ContentType,
Width: dimension(item.Image.Width),
Height: dimension(item.Image.Height),
SHA256: item.Image.SHA256,
UploadedBy: item.UploadedBy,
CreatedAt: item.Image.CreatedAt,
UsedIn: item.UsedIn,
Duplicate: item.Duplicate,
}
}
func parseListQuery(r *http.Request) (domainImage.ListQuery, error) {
values, err := url.ParseQuery(r.URL.RawQuery)
if err != nil {
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
}
query := domainImage.NewListQuery()
query.Search = values.Get("q")
query.Hash = strings.ToLower(values.Get("hash"))
query.Cursor = values.Get("cursor")
if values.Has("filter") {
query.Filter = domainImage.Filter(values.Get("filter"))
}
if values.Has("sort") {
query.Sort = domainImage.Sort(values.Get("sort"))
}
if values.Has("limit") {
limit, err := strconv.Atoi(values.Get("limit"))
if err != nil {
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
}
query.Limit = limit
}
return query, nil
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
query, err := parseListQuery(r)
if err != nil {
http.Error(w, apperr.MediaQueryErrorMessage, http.StatusBadRequest)
return
}
page, err := h.ListUseCase.List(query)
if err != nil {
if errors.Is(err, domainImage.ErrInvalidQuery) {
http.Error(w, apperr.MediaQueryErrorMessage, http.StatusBadRequest)
return
}
writeInternalServerError(w)
return
}
items := make([]mediaItemResponse, 0, len(page.Items))
for _, item := range page.Items {
items = append(items, toResponse(item))
}
response := listResponse{
Items: items,
Counts: countsResponse{All: page.Counts.All, Unused: page.Counts.Unused, Duplicates: page.Counts.Duplicates},
}
if page.NextCursor != "" {
response.NextCursor = &page.NextCursor
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
userId, _ := claims.UserId(r)
input, closeFile, ok := upload.ReadImageInput(w, r, userId)
if !ok {
return
}
defer closeFile()
created, err := h.CreateUseCase.CreateImage(input)
if err != nil {
upload.WriteCreateError(w, err)
return
}
h.writeItem(w, http.StatusCreated, created)
}
func (h *Handler) writeItem(w http.ResponseWriter, status int, img domainImage.Image) {
item, err := h.ListUseCase.Describe(img)
if err != nil {
writeInternalServerError(w)
return
}
writeJSON(w, status, toResponse(item))
}
func (h *Handler) Rename(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
var request RenameRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, apperr.MediaFilenameErrorMessage, http.StatusBadRequest)
return
}
renamed, err := h.RenameUseCase.Rename(id, request.Filename)
if err != nil {
if errors.Is(err, domainImage.ErrInvalidFilename) {
http.Error(w, apperr.MediaFilenameErrorMessage, http.StatusBadRequest)
return
}
writeImageError(w, err)
return
}
h.writeItem(w, http.StatusOK, renamed)
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
if err := h.DeleteUseCase.Delete(id); err != nil {
writeImageError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Usage(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
articles, err := h.UsageUseCase.Usage(id)
if err != nil {
writeImageError(w, err)
return
}
response := make([]usageResponse, 0, len(articles))
for _, a := range articles {
response = append(response, usageResponse{ID: a.ID, Title: a.Title, Published: a.IsOnline})
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) Merge(w http.ResponseWriter, r *http.Request) {
var request MergeRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.MediaMergeRequestErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, apperr.MediaMergeRequestErrorMessage, http.StatusBadRequest)
return
}
result, err := h.MergeUseCase.Merge(request.KeepID, request.MergeIDs)
if err != nil {
writeMergeError(w, err)
return
}
writeJSON(w, http.StatusOK, mergeResponse{ArticlesUpdated: result.ArticlesUpdated, Merged: result.Merged})
}
func writeMergeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domainImage.ErrInvalidMergeRequest):
http.Error(w, apperr.MediaMergeRequestErrorMessage, http.StatusBadRequest)
case errors.Is(err, domainImage.ErrMergeCopyMissing), errors.Is(err, domainImage.ErrMergeHashMismatch):
http.Error(w, apperr.MediaMergeConflictErrorMessage, http.StatusConflict)
default:
writeImageError(w, err)
}
}
@@ -12,7 +12,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
const cacheControl = "public, max-age=31536000, immutable" const cacheControl = "public, no-cache"
const svgContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; sandbox" const svgContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
const fallbackContentType = "application/octet-stream" const fallbackContentType = "application/octet-stream"
+4
View File
@@ -25,3 +25,7 @@ const InviteInvalidErrorMessage = "This invite link doesn't work."
const InviteExpiredErrorMessage = "This invite has expired." const InviteExpiredErrorMessage = "This invite has expired."
const OwnerRequiredErrorMessage = "Only the owner can do this." const OwnerRequiredErrorMessage = "Only the owner can do this."
const UsernameTakenErrorMessage = "That username is taken. Try another." const UsernameTakenErrorMessage = "That username is taken. Try another."
const MediaQueryErrorMessage = "The media query is invalid."
const MediaFilenameErrorMessage = "Enter a filename."
const MediaMergeConflictErrorMessage = "Some of the images to merge changed. Refresh and try again."
const MediaMergeRequestErrorMessage = "Choose an image to keep and at least one other image to merge into it."
+49
View File
@@ -0,0 +1,49 @@
package upload
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/image"
"errors"
"net/http"
)
const multipartOverheadAllowance = 1 << 20
const fileField = "file"
func ReadImageInput(w http.ResponseWriter, r *http.Request, uploadedById uint32) (image.ImageInput, func(), bool) {
r.Body = http.MaxBytesReader(w, r.Body, image.MaxImageSize+multipartOverheadAllowance)
file, header, err := r.FormFile(fileField)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
http.Error(w, image.ErrImageTooLarge.Error(), http.StatusRequestEntityTooLarge)
return image.ImageInput{}, nil, false
}
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return image.ImageInput{}, nil, false
}
input := image.ImageInput{
Content: file,
Filename: header.Filename,
Size: header.Size,
ContentType: header.Header.Get("Content-Type"),
UploadedByID: uploadedById,
}
return input, func() { _ = file.Close() }, true
}
func WriteCreateError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, image.ErrImageTypeNotAllowed):
http.Error(w, err.Error(), http.StatusUnsupportedMediaType)
case errors.Is(err, image.ErrImageTooLarge):
http.Error(w, err.Error(), http.StatusRequestEntityTooLarge)
case errors.Is(err, image.ErrImageUnreadable):
http.Error(w, err.Error(), http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+20 -4
View File
@@ -4,7 +4,7 @@ import (
"RenewCMS/api/handlers/article" "RenewCMS/api/handlers/article"
"RenewCMS/api/handlers/auth" "RenewCMS/api/handlers/auth"
"RenewCMS/api/handlers/frontend" "RenewCMS/api/handlers/frontend"
"RenewCMS/api/handlers/image" "RenewCMS/api/handlers/media"
"RenewCMS/api/handlers/settings" "RenewCMS/api/handlers/settings"
"RenewCMS/api/handlers/staticimages" "RenewCMS/api/handlers/staticimages"
"RenewCMS/api/handlers/user" "RenewCMS/api/handlers/user"
@@ -35,7 +35,6 @@ func NewArticleRouter(container *UseCases) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase) articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase)
imageHandler := image.NewHandler(container.CreateImageUseCase, container.GetArticleUseCase, container.UpdateArticleUseCase, container.DeleteImageUseCase)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Get("/", articleHandler.ListArticles) r.Get("/", articleHandler.ListArticles)
@@ -50,8 +49,6 @@ func NewArticleRouter(container *UseCases) http.Handler {
r.Patch("/{id}", articleHandler.UpdateArticleBody) r.Patch("/{id}", articleHandler.UpdateArticleBody)
r.Patch("/{id}/publish", articleHandler.PublishArticle) r.Patch("/{id}/publish", articleHandler.PublishArticle)
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle) r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
r.Post("/{id}/image", imageHandler.PostImage)
r.Delete("/{id}/image/{imageId}", imageHandler.DeleteImage)
}) })
return r return r
} }
@@ -132,6 +129,24 @@ func NewSettingsRouter(container *UseCases) http.Handler {
return r return r
} }
func NewMediaRouter(container *UseCases) http.Handler {
r := chi.NewRouter()
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase)
r.Use(jwtauth.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", mediaHandler.List)
r.Post("/", mediaHandler.Upload)
r.Post("/merge", mediaHandler.Merge)
r.Patch("/{id}", mediaHandler.Rename)
r.Delete("/{id}", mediaHandler.Delete)
r.Get("/{id}/usage", mediaHandler.Usage)
return r
}
func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux { func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r := chi.NewRouter() r := chi.NewRouter()
@@ -146,6 +161,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
}) })
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies)) r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
r.Mount("/settings", NewSettingsRouter(container)) r.Mount("/settings", NewSettingsRouter(container))
r.Mount("/media", NewMediaRouter(container))
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies)) r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
return r return r
+6
View File
@@ -26,8 +26,14 @@ Nothing under `internal/` imports `api/`.
| Package | Role | | Package | Role |
|---|---| |---|---|
| `api/handlers/user` | Authenticated user endpoints: profile update (`UpdateMe`) and the users list with invitations (`CreateInvite`, `ListUsers`). | | `api/handlers/user` | Authenticated user endpoints: profile update (`UpdateMe`) and the users list with invitations (`CreateInvite`, `ListUsers`). |
| `api/handlers/media` | The media library under `/v1/media` (list, upload, rename, delete, merge, usage). Images belong to the library, not to an article: deleting an article never deletes an image, and an image's "used in" is read from article bodies each time. |
| `api/pkg/upload` | Multipart `file` reading and the mapping of upload errors (415, 413, 400) shared by both upload routes. |
| `api/pkg/publicuser` | `PublicUser`, the JSON shape of a user with no password, codes or token version. `publicuser.New` converts a domain `user.User`, so handlers never serialise the domain struct directly. | | `api/pkg/publicuser` | `PublicUser`, the JSON shape of a user with no password, codes or token version. `publicuser.New` converts a domain `user.User`, so handlers never serialise the domain struct directly. |
### Image metadata backfill
At boot, `BackfillImageMetadata` fills `filename`, `size`, `content_type`, `width`, `height` and `sha256` for image rows that lack them, reading the files in `UPLOAD_DIR`. It is idempotent, and a row whose file is missing or unreadable is left as it is without stopping the boot. The old `article_id` column and the `article_images` table are kept and never written.
## Frontend ↔ backend ## Frontend ↔ backend
``` ```
+3
View File
@@ -29,6 +29,8 @@ The visual spec of record for the back-office SPA and the transactional emails,
| [Articles](screens/articles.md) | `/article` | 14 | `source/RenewCMS Screens Back-office.dc.html` | | [Articles](screens/articles.md) | `/article` | 14 | `source/RenewCMS Screens Back-office.dc.html` |
| [New article](screens/new-article.md) | `modal from anywhere · /article/new` | 5 | `source/RenewCMS Screens Back-office.dc.html` | | [New article](screens/new-article.md) | `modal from anywhere · /article/new` | 5 | `source/RenewCMS Screens Back-office.dc.html` |
| [Editor](screens/editor.md) | `/article/:id/edit` | 22 | `source/RenewCMS Screens Back-office.dc.html` | | [Editor](screens/editor.md) | `/article/:id/edit` | 22 | `source/RenewCMS Screens Back-office.dc.html` |
| [Media](screens/media.md) | `/media` | 41 | `source/RenewCMS Screens Back-office.dc.html` |
| [Media picker](screens/media-picker.md) | `/article/:id/edit` (dialog) | 13 | `source/RenewCMS Screens Back-office.dc.html` |
| [Integration](screens/integration.md) | `/integration` | 2 | `source/RenewCMS Screens Back-office.dc.html` | | [Integration](screens/integration.md) | `/integration` | 2 | `source/RenewCMS Screens Back-office.dc.html` |
| [Users](screens/users.md) | `/users` | 17 | `source/RenewCMS Screens Back-office.dc.html` | | [Users](screens/users.md) | `/users` | 17 | `source/RenewCMS Screens Back-office.dc.html` |
| [Settings](screens/settings.md) | `/settings` | 1 | `source/RenewCMS Screens Back-office.dc.html` | | [Settings](screens/settings.md) | `/settings` | 1 | `source/RenewCMS Screens Back-office.dc.html` |
@@ -38,6 +40,7 @@ The visual spec of record for the back-office SPA and the transactional emails,
## Other boards ## Other boards
- `source/MediaFrame.dc.html`: the frame that draws the Media and Media picker states (the Back-office board imports it). Like `AppFrame.dc.html`, it holds the exact values.
- `source/RenewCMS Prototype.dc.html`: the clickable flows (first run, edit and publish, password reset, delete, session expiry mid-edit). - `source/RenewCMS Prototype.dc.html`: the clickable flows (first run, edit and publish, password reset, delete, session expiry mid-edit).
- `source/RenewCMS Handoff.dc.html`: the handoff page the first markdown here was generated from. It predates the Users and Profile screens and the invitation email, so it does not list them. Those are written by hand from the Back-office board, `AppFrame.dc.html`, the Prototype and `emails/invitation.html`. - `source/RenewCMS Handoff.dc.html`: the handoff page the first markdown here was generated from. It predates the Users and Profile screens and the invitation email, so it does not list them. Those are written by hand from the Back-office board, `AppFrame.dc.html`, the Prototype and `emails/invitation.html`.
- `source/RenewCMS Components Product.dc.html`: not regenerated for the new screens either. The matching component changes are in `components.md`, under "Added with the Users and Profile screens" and "Added with roles, invite actions and Settings". - `source/RenewCMS Components Product.dc.html`: not regenerated for the new screens either. The matching component changes are in `components.md`, under "Added with the Users and Profile screens" and "Added with roles, invite actions and Settings".
+20
View File
@@ -764,3 +764,23 @@ Cancel goes through `ConfirmDialog` with `tone: 'danger'`: title "Cancel this in
### SettingsPage ### SettingsPage
A page header (title "Settings", subtitle "Workspace information."), then one card holding a "Version" row. See [screens/settings.md](screens/settings.md). A page header (title "Settings", subtitle "Workspace information."), then one card holding a "Version" row. See [screens/settings.md](screens/settings.md).
## Added with Media
Exact values live in `source/MediaFrame.dc.html`. Copy and states are in [media](screens/media.md) and [media-picker](screens/media-picker.md).
| Component | Nuxt UI base | Props | Emits | Used in |
|---|---|---|---|---|
| MediaToolbar | UTabs, UInput, USelectMenu, UButtonGroup | tab, q, sort, view, counts | update:tab, update:q, update:sort, update:view | Media |
| MediaGrid | custom (buttons in a grid) | items, selected: Set, loading | open(id), toggle(id) | Media |
| MediaTable, MediaCardList | UTable, custom list | same as MediaGrid | same as MediaGrid | Media (md and up, below md) |
| MediaBulkBar | custom, UButton | count | delete, clear | Media |
| MediaDetailPanel | USlideover | id | close, rename, delete, copy-url, review-duplicates | Media |
| MediaUploadPanel | UProgress, UButton | rows | use-existing(row), upload-anyway(row), retry(row), cancel, dismiss | Media |
| DuplicateGroups | custom cards | groups | merge(group, keepId) | Media |
| MediaThumb | custom | item, size | none | Media, picker |
| MediaBadge | UBadge | kind: 'unused'\|'duplicate'\|'used'\|'online'\|'draft', label | none | Media, picker |
| RenameDialog, MergeDialog, DeleteMediaDialog | ConfirmDialog, UForm | item(s) | done | Media |
| MediaPicker | UTabs inside ImageUploader | articleId | picked(item, alt) | Editor |
ImageUploader (changed): it gains the Upload and Library tabs and the "Already in your library" card. The remove-image confirm becomes neutral and only detaches the image from the article.
+2 -1
View File
@@ -14,7 +14,8 @@ A global `router.beforeEach` guard reads `meta.auth`. On the first navigation it
| `/home` | DashboardShell | verified | HomePage | GET /v1/article/ | | `/home` | DashboardShell | verified | HomePage | GET /v1/article/ |
| `/article` | DashboardShell | verified | ArticlesPage | GET /v1/article/ · PATCH /{id}/publish\|unpublish · DELETE /{id} | | `/article` | DashboardShell | verified | ArticlesPage | GET /v1/article/ · PATCH /{id}/publish\|unpublish · DELETE /{id} |
| `/article/new` | DashboardShell | verified | ArticlesPage + NewArticleModal | POST /v1/article/ | | `/article/new` | DashboardShell | verified | ArticlesPage + NewArticleModal | POST /v1/article/ |
| `/article/:id/edit` | DashboardShell | verified | EditorPage | GET /{id} · PATCH /{id} · PATCH /{id}/publish\|unpublish · POST /{id}/image · DELETE /{id} | | `/article/:id/edit` | DashboardShell | verified | EditorPage | GET /{id} · PATCH /{id} · PATCH /{id}/publish\|unpublish · GET /v1/media/ · POST /v1/media/ · DELETE /{id} |
| `/media` | DashboardShell | verified | MediaPage | GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage |
| `/integration` | DashboardShell | verified | IntegrationPage | none | | `/integration` | DashboardShell | verified | IntegrationPage | none |
| `/users` | DashboardShell | verified | UsersPage + InviteUserModal | GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id · POST /v1/user/invite/:id/renew | | `/users` | DashboardShell | verified | UsersPage + InviteUserModal | GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id · POST /v1/user/invite/:id/renew |
| `/settings` | DashboardShell | verified | SettingsPage | GET /v1/settings | | `/settings` | DashboardShell | verified | SettingsPage | GET /v1/settings |
+4 -4
View File
@@ -88,7 +88,7 @@ Rows update when the server responds, not optimistically. Search, tab and sort a
- Breadcrumbs: Articles (mobile title "Articles") - Breadcrumbs: Articles (mobile title "Articles")
- Modal: **Delete this article?** - Modal: **Delete this article?**
- text ""Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone." - text ""Winter checklist for timber windows" will be deleted. Its images stay in your library. This can't be undone."
- cancel "Cancel" - cancel "Cancel"
- confirm "Delete article" (danger) - confirm "Delete article" (danger)
@@ -98,7 +98,7 @@ Rows update when the server responds, not optimistically. Search, tab and sort a
- Breadcrumbs: Articles (mobile title "Articles") - Breadcrumbs: Articles (mobile title "Articles")
- Modal: **Delete this article?** - Modal: **Delete this article?**
- text ""Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone." - text ""Winter checklist for timber windows" will be deleted. Its images stay in your library. This can't be undone."
- cancel "Cancel" - cancel "Cancel"
- confirm "Deleting…" (danger), loading, not dismissible - confirm "Deleting…" (danger), loading, not dismissible
@@ -108,7 +108,7 @@ Rows update when the server responds, not optimistically. Search, tab and sort a
- Breadcrumbs: Articles (mobile title "Articles") - Breadcrumbs: Articles (mobile title "Articles")
- Modal: **Delete this article?** - Modal: **Delete this article?**
- text ""Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone." - text ""Winter checklist for timber windows" will be deleted. Its images stay in your library. This can't be undone."
- Alert: "Couldn't delete the article, so it's still there. Try again in a moment." - Alert: "Couldn't delete the article, so it's still there. Try again in a moment."
- cancel "Cancel" - cancel "Cancel"
- confirm "Delete article" (danger) - confirm "Delete article" (danger)
@@ -118,5 +118,5 @@ Rows update when the server responds, not optimistically. Search, tab and sort a
**Trigger:** 204: the row is removed and focus moves to the next row. This is also where a delete from the editor ends up. **Trigger:** 204: the row is removed and focus moves to the next row. This is also where a delete from the editor ends up.
- Breadcrumbs: Articles (mobile title "Articles") - Breadcrumbs: Articles (mobile title "Articles")
- Toast (success, circle-check): **Article deleted** ""Winter checklist for timber windows" and its images are gone." - Toast (success, circle-check): **Article deleted** ""Winter checklist for timber windows" is gone."
- Tab counts (All, Online, Drafts): 5, 4, 1 - Tab counts (All, Online, Drafts): 5, 4, 1
+1 -1
View File
@@ -183,7 +183,7 @@ On mobile, Save and Publish sit in a sticky bottom bar and the details panel ope
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article") - Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Modal: **Delete this article?** - Modal: **Delete this article?**
- text ""How we price a bathroom renovation" and its 3 images will be deleted. This can't be undone." - text ""How we price a bathroom renovation" will be deleted. Its images stay in your library. This can't be undone."
- cancel "Cancel" - cancel "Cancel"
- confirm "Delete article" (danger) - confirm "Delete article" (danger)
- Editor: status "draft", save "saved" - Editor: status "draft", save "saved"
+56
View File
@@ -0,0 +1,56 @@
# Media picker (editor)
- **Route:** `/article/:id/edit` (inside the editor's existing "Add an image" dialog)
- **API:** GET /v1/media/?q=&limit=&cursor= · POST /v1/media/ (same as the Media page)
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Media picker", drawn by `source/MediaFrame.dc.html`.
The existing "Add an image" dialog gains two tabs, Upload and Library. The Upload tab is the current flow unchanged, so it still offers crop and resize, and a new image lands in the library and is inserted as before. The Library tab opens on the Library tab when the library has images and on Upload when it is empty. Images load 24 at a time with "Load more". Newest first, and images already in this article carry "In this article". Alt text is set here and lives only in the article. The dialog is 640 px wide on desktop and a bottom sheet on mobile.
This supersedes the upload-only "Add an image" dialog described in [editor](editor.md), and the "Remove this image?" confirmation from `features/editor_image_removal.feature`.
## Dialog
Title "Add an image", `x` to close, 640 px wide on desktop (bottom sheet on mobile). Two tabs under the title: **Upload** (`upload` icon) and **Library** (`images` icon). The dialog opens on Library when the library has images and on Upload when it is empty; dropping a file onto the editor opens Upload with the file provided, as before. Footer: "Cancel", and on Library a primary "Insert image", disabled until an image is selected.
**Upload tab.** The current flow unchanged (drop zone "Drop an image here, or choose a file", help "PNG, JPEG, WEBP, SVG · max 10 MB", crop and resize, rejected, uploading and failed states). Two changes: the new image lands in the library and is inserted as before, and a chosen file whose content matches an existing image sends nothing and shows an info-bordered card: thumbnail, "Already in your library", `north-wall.jpg matches an image uploaded on 12 Sep 2026 by Marta Alves.`, with "Use existing" (primary) and "Upload anyway". "Use existing" goes to the alt text step for the existing image; "Upload anyway" continues to crop and upload.
**Library tab.** Search field ("Search by filename"), the line "Newest first · 48 images", a grid of square tiles (4 columns on desktop, 3 on mobile; scrolls inside the dialog) with the filename under each, and "Load more" below (24 images per page). Tiles are buttons labelled with filename, size and use; Enter or Space selects (single selection). A tile of an image already in this article carries a sand "In this article" tag. The selected tile gets a primary outline and a check. Once one is selected, the grid shrinks and a panel appears below it: 56 px thumbnail, filename, `dimensions · size`, and a field "Alt text (optional)" with placeholder "e.g. North wall of the kitchen before plastering" and 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." Alt text lives only in the article, never on the image.
| Library state | Copy |
|---|---|
| Empty | `images` icon, "Your library is empty" / "Images you upload appear here, ready to use in any article." / "Upload an image" (switches to Upload) |
| No results | `search` icon, `No images match "wall"` / "Check the spelling, or search for a shorter part of the filename." / "Clear search" |
| Failed | error alert "Couldn't load your library" / "Check your connection and try again." / "Try again" |
| Loading more | button reads "Loading…" with a loader, skeleton tiles at the end |
Insert (either tab): the image goes in at the cursor with the chosen alt text (empty alt means decorative), the draft becomes "Unsaved changes", and a toast appears: `circle-check`, "Image added" / "Save to keep it in the article." The dialog then closes.
## Removing an image from the article
The "Remove image" button on a selected image in the editor (and Backspace or Delete on it) now opens a neutral (not red) confirm, because nothing is deleted:
- Title: "Remove this image from the article?"
- Description: "It's removed from this article only. The file stays in your library and in any other article that uses it."
- Body: thumbnail, filename, "Stays in the library · 3.9 MB"
- Buttons: "Cancel" / "Remove image"
- Done: the image leaves the body, the draft becomes "Unsaved changes", and a toast appears: `circle-check`, "Image removed" / "It's still in your library."
No API call is made. Removing is a body edit only.
## States
| State | Trigger and behaviour |
|---|---|
| Upload tab | The image button in the toolbar. Identical to the current "Add an image" content; only the tabs are new. Rejected, uploading and failed states are unchanged. |
| Upload tab: already in your library | A chosen file has the same content as an existing image. Nothing is sent. "Use existing" inserts the existing image (the alt text step follows); "Upload anyway" continues to crop and upload as usual. |
| Library: loading | The Library tab was opened, or a search changed. Skeleton tiles after 150 ms. |
| Library: nothing selected | Newest first. Tiles are buttons labelled with filename, size and use, and Enter or Space selects. Images already in this article carry "In this article". "Insert image" is disabled. |
| Library: selected | One image selected (single selection). Below the grid: thumbnail, filename, dimensions and size, and an optional alt text field. "Insert image" turns on. |
| Library: alt text entered | Alt text is optional. Empty alt text is inserted as a decorative image. It is not saved on the image. |
| Library: loading more | "Load more" pressed. The button shows progress and new skeleton tiles appear at the end. Focus stays on the button until the new tiles arrive, then moves to the first new tile. |
| Library: empty | Zero images. "Upload an image" switches to the Upload tab. |
| Library: no results | Search matched nothing. "Clear search" resets it. |
| Library: failed to load | A 500 or network failure. The error shows in the dialog and Try again repeats the request. A 429 uses the same countdown alert as the Media page, and a 401 is handled globally. |
| Inserted | Insert image from either tab. The image goes in at the cursor with the chosen alt text, and the article now has unsaved changes. |
| Remove image: confirm | Remove image on an image in the article. It now only removes it from this article, and the copy says the file stays in the library. The confirm button is neutral, not red, because nothing is deleted. Title: "Remove this image from the article?" |
| Removed | Confirmed. The image leaves the article body, which now has unsaved changes. |
+159
View File
@@ -0,0 +1,159 @@
# Media
- **Route:** `/media` (verified users)
- **API:** GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Media", drawn by `source/MediaFrame.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
The library of every uploaded image. It replaces "images belong to one article": an image belongs to the library and can be used in any number of articles. Deleting an article never deletes images. The sidebar gains **Media** (`images` icon) between Articles and Users, breadcrumb "Media".
New route for verified users, in the sidebar between Articles and Users with the images icon. Images belong to the library, not to an article: one image can be used in many articles, and deleting an article never deletes images. Uploads from this page are not cropped or resized, and the file is stored as chosen. "Used in" is read from article bodies each time the list loads. Duplicates are images with identical content, whatever their filenames. Alt text is not stored on the image; it is set when inserting into an article. Search, filter, sort and view are stored in the query string. Keyboard: grid items are buttons in one tab stop each; Enter opens the detail panel, Space toggles selection, and Escape closes the panel or clears the selection. Selection changes are announced ("north-wall.jpg selected, 1 selected").
## Layout
Header: title "Media", subtitle "Every image you've uploaded, ready to use in any article.", primary "Upload" button (`upload` icon) on the right. On mobile the button is a full-width 48 px button pinned to the bottom (it turns into "Try again in 0:42" while rate limited, and is replaced by the bulk bar while items are selected).
Toolbar: tabs **All**, **Unused** and **Duplicates** (count badge of duplicate groups, only while groups exist), then search ("Search by filename"), sort and the grid/list toggle. Sort options: Newest (default), Oldest, Name, Largest. Below md the toolbar stacks: search, tabs, then sort and toggle.
Grid (default): 5 columns on desktop, 2 on mobile, 16 px gap (10 px on mobile). Each card is one button: 4:3 thumbnail (checkerboard behind transparent PNG and SVG), filename (medium, ellipsis), `size · dimensions` (SVG dimensions read "Vector"), then badges. The accessible name reads "north-wall.jpg, 3.9 MB, used in 2 articles" or "…, unused". A "Select all" row with the image count sits above the grid.
| Badge | Look |
|---|---|
| Unused | warning-soft fill, `unlink` icon |
| Duplicate | info-soft fill, `copy` icon |
| Used in N | outline, toned text |
List view: table from md up, columns Filename (with 40 px thumbnail and Duplicate badge), Dimensions, Size, Type, Used in (underlined count, opens the detail panel), Uploaded by, Uploaded (sorted, `arrow-down`); 56 px rows, 36 px header, first column is the checkbox. Below md it is a card list with a 44 px checkbox column, a 56 px thumbnail, filename, `size · dimensions · type`, badges plus `uploader · date`, and a trailing `chevron-right`.
Selection: selected items take the sand fill (`--rc-sand`) with a primary outline. The checkbox shows on hover, on keyboard focus, on mobile, and whenever anything is selected. The bulk bar is sticky at the bottom (400 px wide on desktop, full width above the bottom edge on mobile): inverted fill, "3 selected", a danger "Delete" button (`trash-2`) and an inverted "Clear".
Detail panel: right-hand slideover 460 px wide on desktop (`x` to close), full screen on mobile with an `arrow-left`. Title is the filename. Body: 16:10 preview (4:3 on mobile), actions Copy URL (`link`), Rename (`pencil`), Delete (`trash-2`, error text), an info alert for duplicates, a metadata list (Type, Size, Dimensions, Uploaded, Uploaded by, URL in mono), then the "Used in" section.
Detail panel copy:
| Where | Copy |
|---|---|
| Duplicate alert | "Identical to 2 other images" / "floorboards-copy.jpg and IMG_2208.jpg have the same content." / action "Review duplicates" (`arrow-right`) |
| Used | "Used in 2 articles", then one row per article: title (underlined link to the editor), badge "Online" (`globe`, inverted) or "Draft" (`pencil-line`, outline), `arrow-up-right`. Footnote: "Counted from the text of your articles each time you open this page." |
| Not used | "Not used in any article" / "This image is safe to delete. No article shows it." |
## Dialogs
All dialogs use the existing ConfirmDialog look (`UModal`, `sm:max-w-md`, bottom sheet on mobile, cancel first). While a request runs the confirm button shows a `loader-circle`, both buttons are disabled and the dialog cannot be dismissed. A failure shows an error alert at the top of the dialog body and the dialog stays open.
| Dialog | Title | Description | Body | Cancel / Confirm (pending) |
|---|---|---|---|---|
| Rename | "Rename image" | "Only the filename changes. The URL stays the same, so articles that use this image keep working." | Field "Filename" with the extension fixed as a mono suffix (`.jpg`); help "Letters, numbers, dashes and underscores. The .jpg ending stays."; error "Enter a filename." on empty or only symbols | "Cancel" / "Rename" ("Renaming…") |
| Delete, unused | "Delete this image?" | ""window-frame.jpg" will be deleted from your library. No article uses it. This can't be undone." | none | "Cancel" / "Delete image" ("Deleting…"), danger |
| Delete, in use | "Delete an image that's in use?" | ""north-wall.jpg" is used in 2 articles. If you delete it, they will show a broken image. This can't be undone." | list of affected articles with Online or Draft badges | "Cancel" / "Delete image" ("Deleting…"), danger |
| Bulk delete | "Delete 3 images?" | "2 of them are used in 4 articles, which will show a broken image. The other one isn't used anywhere. This can't be undone." | one row per image: filename and "Used in N" or "Unused" | "Cancel" / "Delete 3 images" ("Deleting…"), danger |
| Merge | "Merge into floorboards.jpg?" | "Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can't be undone." | the kept image: thumbnail, filename, "Kept · used in 4 articles now" | "Cancel" / "Merge 2 copies" ("Merging…"), danger |
Dialog errors: rename "Couldn't rename the image. Your text is still here. Try again in a moment."; delete "Couldn't delete the image, so it's still there. Try again in a moment."; merge (500) "Couldn't merge the copies. Nothing was changed. Try again in a moment."; merge (409) "One of these images changed, so nothing was merged. The list is up to date now. Check it and try again."
## Upload panel
A panel above the images while files are in flight and until dismissed. Header: "Uploading 6 images" with "2 of 6 done" and "Cancel all" (after it settles: "Upload finished" / "5 images" and "Dismiss"; if nothing was added: "Nothing was uploaded"). Files upload two at a time, one row each: 40 px thumbnail, filename, size, then per status:
| Status | Row |
|---|---|
| Waiting | "Waiting" |
| Uploading | "Uploading 1.1 of 2.6 MB" with a 4 px progress bar and a cancel `x` |
| Added | `circle-check` "Added to your library" |
| Duplicate | "Already in your library" / `Same as "floorboards.jpg", uploaded 14 Jun 2026 by Inès Duarte.` / "Use existing" and "Upload anyway" |
| Rejected | `file-x` title, then "Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image." Titles: `scan.tif is 14.2 MB` (too large), `notes.pdf isn't an image` (type), `broken.png couldn't be read` with "The server couldn't read this image. Save it again as a PNG or JPEG, then try again." (400) |
| Failed | `circle-x` "Upload failed" / "The server didn't accept the file. Nothing was added to your library." / "Try again" (`rotate-cw`) |
Summary line when settled: "2 uploaded · 1 already in your library · 2 need attention". Type and size are checked in the browser before anything is sent (PNG, JPEG, WEBP, SVG; 10 MB), by MIME type and by extension, exactly as the editor's uploader does. Dropping files onto the page shows a dashed primary-bordered area "Drop images to upload" / "PNG, JPEG, WEBP, SVG · max 10 MB each".
## Duplicates view
Groups stacked with 16 px gap. Each group is a bordered card: header "Group 1" and "3 identical images · 2.4 MB each · 3600 × 2400"; one column per copy (stacked on mobile) with thumbnail, filename, "Uploaded 14 Jun 2026 by Inès Duarte", underlined "Used in 4 articles", and a "Merge into this one" button (`merge` icon). The suggested copy has the sand fill, a primary outline, a "Suggested" tag and a primary button; the others are outline. Footer: "Suggested: the most used copy, then the oldest. Choosing another copy switches articles to it." Empty: `copy` icon, "No duplicates" / "Every image in your library is different." / "Back to all images".
## Other copy
| Where | Copy |
|---|---|
| Empty library | `images` icon, "No images yet" / "Upload your first image. You can use it in any article." / primary "Upload an image"; under it "PNG, JPEG, WEBP, SVG · max 10 MB each" |
| No results | `search` icon, `No images match "wall"` / "Nothing matches in Unused. Check the spelling, or clear the filters." / "Clear filters" |
| List error | error alert "Couldn't load your images" / "Check your connection and try again." / "Try again" |
| Rate limited | info alert `timer` "Too many uploads" / "For your security, wait a moment before uploading again."; the Upload button reads "Try again in 0:42" and is disabled |
| Offline banner | `wifi-off` "You're offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection." |
Toasts (success 5 s unless noted):
| Trigger | Icon, title, description |
|---|---|
| Copy URL | `circle-check` "URL copied" / "Paste it wherever you need the image address." |
| Rename | `circle-check` "Image renamed" / `It's now "north-wall-01.jpg". The URL is the same.` |
| Delete | `circle-check` "Image deleted" / `"window-frame.jpg" is gone from your library.` |
| Bulk delete | `circle-check` "3 images deleted" / "They're gone from your library." |
| Bulk delete, partial | error, stays until retried or dismissed: `circle-x` "Couldn't delete 1 image" / "2 images were deleted. window-frame.jpg is still in your library. Try again." / "Retry" |
| Merge | `circle-check` "Copies merged" / "2 copies merged. 5 articles updated." |
| 404 | info `info` "That image no longer exists" / "It was deleted somewhere else, so we removed it from the library." |
## Status to copy
| Status | Where | Copy |
|---|---|---|
| 200 / 201 | Upload, rename, merge | Upload: row reads "Added to your library". Rename and merge: toasts above. |
| 204 | Delete, bulk delete | Toasts above. |
| 400 | Upload row | "broken.png couldn't be read" / "The server couldn't read this image. Save it again as a PNG or JPEG, then try again." In a dialog: "Something in the form isn't valid. Check the highlighted fields." |
| 401 | any | Global: /login?redirect=/media&reason=expired. No local copy. |
| 403 | any, unverified | Global: /register/pending. |
| 404 | rename, delete, merge, panel | The info toast above; the panel or dialog closes. |
| 409 | merge | Dialog alert above. |
| 413 | upload row | "scan.tif is 14.2 MB" / "Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image." |
| 415 | upload row | "notes.pdf isn't an image" / same description. |
| 429 | upload | Rate-limited alert above, counting down Retry-After. |
| 500 / network | list, upload row, rename, delete, merge | The copy for each place above. |
| Offline | whole page | Banner above. |
## Keyboard and accessibility
Grid items are buttons, one tab stop each. Enter opens the detail panel, Space toggles selection, arrow keys move focus across the grid, Escape closes the panel or clears the selection. Focus returns to the item when the panel closes, and moves to the next item after a delete. Selection changes are announced ("north-wall.jpg selected, 1 selected"), as are upload completion and the merge result. Search, filter, sort and view live in the query string.
## States
| State | Trigger and behaviour |
|---|---|
| Grid, many images | Default view. 12 images, newest first. Each item is a button labelled like "north-wall.jpg, 3.9 MB, used in 2 articles". Badges: "Unused" (warm), "Duplicate" (info), "Used in N" (outline). The Duplicates tab carries a count of groups only while duplicates exist. |
| Grid, few images | Three images. Cards keep their size instead of stretching. Checkerboard shows behind the SVG. |
| List view | The list icon in the toggle. A table from md up (Filename, Dimensions, Size, Type, Used in, Uploaded by, Uploaded) and a card list below md. The "Used in" count opens the article list in the detail panel. |
| Loading | The first load, a retry, or a change of search or filter. Skeleton cards appear after 150 ms. |
| Empty library, first run | Zero images. Toolbar and tabs are hidden; one call to action. Copy: "No images yet" / "Upload your first image. You can use it in any article." / "Upload an image". |
| No results | Search or filter matched nothing. "Clear filters" resets the search and goes back to All. |
| Hover, focus and keyboard | Hover (second item) darkens the border, adds the pop shadow and reveals the checkbox. Keyboard focus (third item) shows the 2 px primary ring with a 2 px offset and reveals the checkbox. Tab moves between items, Enter opens the detail panel, Space toggles selection, and arrow keys move focus across the grid. |
| Selection and bulk bar | Checkbox on any item, or Space. Selected items take the sand fill with a primary outline, and Select all shows a partial state. The bar is sticky at the bottom (above the bottom edge on mobile, replacing the Upload button). Announced: "3 selected". Escape or Clear empties it. |
| Bulk delete: confirm | Delete in the bulk bar. The dialog says how many of the selection are in use and lists them. The confirm button carries the count. |
| Bulk delete: in progress | Requests in flight. The dialog can't be dismissed. |
| Bulk delete: partly failed | Some requests failed. Deleted images leave the grid, failed ones stay selected, and the toast stays until retried or dismissed. |
| Bulk delete: done | 204 for all. Focus moves to the next item. |
| Detail panel: in use | Click or Enter on an item. Right-hand slideover on desktop, full screen on mobile with a back arrow. Actions: Copy URL, Rename, Delete. "Used in 2 articles" lists title, Online or Draft, and links to the editor. Focus returns to the item on close. |
| Detail panel: unused | Used in nothing. The section reads "Not used in any article" and says the image is safe to delete. |
| Detail panel: duplicate | The image has identical copies. An info alert links to the duplicates view. |
| Copy URL | Copy URL. Clipboard write succeeded. Success toasts last 5 s. |
| Rename: dialog | Rename in the panel. The field holds the name without its extension, and the .jpg ending is fixed. The description says the URL stays the same. |
| Rename: saving | PATCH in flight. Field and buttons disabled, dialog can't be dismissed. |
| Rename: validation | Empty or only symbols, on submit or blur. Nothing is sent. |
| Rename: failed | 500 or network failure. The error shows inside the dialog. |
| Rename: done | 200: the panel title and list update in place. |
| Delete: unused image | Delete in the panel. Plain confirm. Says what happens and that it can't be undone. |
| Delete: image in use | Stronger confirm. Lists the affected articles and states that they will show a broken image. |
| Delete: in progress | DELETE in flight. The dialog can't be dismissed. |
| Delete: failed | 500 or network failure. The error shows inside the dialog, as in the article delete. |
| Delete: done | 204: the panel closes, the item leaves the grid and focus moves to the next item. |
| Image already gone (404) | Any action on an image that was deleted elsewhere. The panel closes and the item leaves the list. |
| Upload: drag over the page | Files dragged onto the page. A dashed drop area covers the content. Dropping starts the upload straight away; there is no crop step on this page. |
| Upload: several files, mixed | Upload button or drop, several files. Each file has its own row and progress. Files upload two at a time. A row can end as added, "Already in your library", rejected in the browser, or failed. Rejected and failed rows stay until dismissed. |
| Upload: summary | All rows settled. The summary counts each outcome. "Use existing" selects the existing image and drops the row; "Upload anyway" stores a second copy, which then appears under Duplicates. "Try again" retries one file. Announced when it finishes. |
| Upload: rejected files (413, 400) | Type and size are checked in the browser first, so nothing is sent. A server 413 or 400 for a file that got through shows the same row with its own message. |
| Upload: too many requests (429) | Rate limited. Retry-After is counted down in the button. It re-enables at zero. Already-added files are kept. |
| Duplicates: groups | The Duplicates tab. Groups of identical images side by side, each with its uploader, upload date and "Used in N articles". The suggested copy to keep is the most used, then the oldest. |
| Duplicates: empty | No groups. The Duplicates tab loses its count badge. |
| Merge: confirm | "Merge into this one" on floorboards.jpg. The dialog says articles using the other copies will switch to this image and the other copies will be deleted, with the article count. |
| Merge: in progress | Request in flight. The dialog can't be dismissed. |
| Merge: failed | 500 or network failure. Nothing is switched or deleted. |
| Merge: conflict (409) | A copy was deleted, or a new copy appeared, since the page loaded. |
| Merge: done | 200: the group leaves the list. When it was the last one, "No duplicates" shows. |
| List failed to load | A 500 or network failure on the list request. The alert replaces the images; Try again repeats the request. |
| Offline | navigator.onLine is false. Loaded images stay browsable. Upload, Rename, Delete and Merge are disabled, and the banner says why. |
+1 -1
View File
@@ -343,7 +343,7 @@ class Component extends DCLogic {
const s = this.props.s || {} const s = this.props.s || {}
const m = !!this.props.mobile const m = !!this.props.mobile
const k = s.kind || 'home' const k = s.kind || 'home'
const navItems = [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['users', 'users', 'Users'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']] const navItems = [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['users', 'users', 'Users'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
const nav = navItems.map(([key, icon, t]) => ({ key, icon, t, bg: s.nav === key ? 'var(--rc-sand)' : 'transparent', fg: s.nav === key ? '#111111' : 'var(--ui-text-toned)', fw: s.nav === key ? 500 : 400 })) const nav = navItems.map(([key, icon, t]) => ({ key, icon, t, bg: s.nav === key ? 'var(--rc-sand)' : 'transparent', fg: s.nav === key ? '#111111' : 'var(--ui-text-toned)', fw: s.nav === key ? 500 : 400 }))
const crumbs = (s.crumbs || []).map((t, i, a) => ({ t, sep: i > 0, fg: i === a.length - 1 ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', fw: i === a.length - 1 ? 500 : 400 })) const crumbs = (s.crumbs || []).map((t, i, a) => ({ t, sep: i > 0, fg: i === a.length - 1 ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', fw: i === a.length - 1 ? 500 : 400 }))
const L = s.list || {} const L = s.list || {}
+276
View File
@@ -0,0 +1,276 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<div style="display:contents">{{ view }}</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:1440,&quot;height&quot;:900},&quot;s&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;object&quot;},&quot;mobile&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;boolean&quot;}}">
class Component extends DCLogic {
renderVals() {
const s = this.props.s || {}, m = !!this.props.mobile, h = React.createElement
const P = t => Object.fromEntries(t.split(';').filter(x => x.trim()).map(x => { const i = x.indexOf(':'); const k = x.slice(0, i).trim(); return [k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase()), x.slice(i + 1).trim()] }))
const D = (t, ...c) => h('div', { style: P(t) }, ...c)
const S = (t, ...c) => h('span', { style: P(t) }, ...c)
const I = (n, z = 16, t = '') => h('span', { 'aria-hidden': 'true', style: { display: 'inline-block', flexShrink: 0, width: z, height: z, backgroundColor: 'currentColor', WebkitMask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', mask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', ...(t ? P(t) : {}) } })
const bh = m ? 44 : 32
const B = (label, o = {}) => {
const k = o.k || 'outline'
const bg = k === 'primary' ? 'var(--ui-primary)' : k === 'danger' ? 'var(--ui-error)' : k === 'inv' ? 'transparent' : 'var(--ui-bg)'
const fg = k === 'primary' || k === 'danger' ? 'var(--ui-bg)' : k === 'del' ? 'var(--ui-error)' : k === 'inv' ? 'var(--ui-text-inverted)' : 'var(--ui-text-highlighted)'
const bd = k === 'primary' || k === 'danger' ? bg : k === 'inv' ? 'var(--ui-text-inverted)' : 'var(--ui-border-accented)'
return S('height:' + (o.h || bh) + 'px;padding:0 ' + (o.px || 12) + 'px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + bg + ';color:' + fg + ';border:1px solid ' + bd + ';font-weight:500;font-size:' + (o.fs || 13) + 'px;white-space:nowrap;opacity:' + (o.dis ? 0.4 : 1) + ';' + (o.full ? 'flex:1;' : '') + (o.ring ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : ''), o.icon && I(o.icon, o.z || 16), label)
}
const CHECK = 'conic-gradient(var(--ui-bg-accented) 25%, var(--ui-bg) 0 50%, var(--ui-bg-accented) 0 75%, var(--ui-bg) 0) 0 0/16px 16px'
const TONES = ['var(--rc-sand)', 'var(--ui-bg-accented)', 'color-mix(in srgb, var(--ui-border-accented) 35%, var(--ui-bg))', 'var(--ui-bg-elevated)']
const mark = h('svg', { width: 28, height: 28, viewBox: '0 0 16 16', shapeRendering: 'crispEdges' }, h('rect', { width: 16, height: 16, fill: '#111' }), h('path', { fill: '#fff', fillRule: 'evenodd', d: 'M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z' }))
const Th = (im, t = '', z = 24) => D('position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);background:' + (im.alpha ? CHECK : 'repeating-linear-gradient(135deg, rgb(128 128 128 / 0.12) 0 1px, transparent 1px 12px), ' + TONES[im.tone % 4]) + ';' + t, im.svg ? mark : I('image', z, 'opacity:0.55'))
const BADGE = { unused: ['background:var(--rc-warning-soft);color:var(--ui-warning)', 'unlink'], dup: ['background:var(--rc-info-soft);color:var(--ui-info)', 'copy'], used: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)'], on: ['background:var(--ui-bg-inverted);color:var(--ui-text-inverted)', 'globe'], draft: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', 'pencil-line'] }
const Bd = (t, k, sz = 20) => S('height:' + sz + 'px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:500;white-space:nowrap;' + BADGE[k][0], BADGE[k][1] && I(BADGE[k][1], 12), t)
const Cb = (on, z = 20, ind) => S('width:' + z + 'px;height:' + z + 'px;flex-shrink:0;display:flex;align-items:center;justify-content:center;border:1px solid ' + (on || ind ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on || ind ? 'var(--ui-primary)' : 'var(--ui-bg)') + ';color:var(--ui-bg)', (on || ind) && I(ind ? 'minus' : 'check', z - 6))
const Alert = (tone, icon, title, text, action, actionEl) => D('display:flex;gap:10px;padding:12px 14px;background:var(--rc-' + tone + '-soft);color:var(--ui-' + tone + ')', S('display:flex;padding-top:2px', I(icon)), D('flex:1;display:flex;flex-direction:column;gap:8px', D('display:flex;flex-direction:column;gap:2px', S('font-weight:600', title), S('color:var(--ui-text-toned)', text)), action && B(action, { icon: 'rotate-cw', h: m ? 44 : 28, fs: 12, z: 14, ...(actionEl || {}) })))
const Empty = (icon, title, text, action, primary, pad = 56) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + pad + 'px 24px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--ui-bg-elevated);color:var(--ui-text-toned)', I(icon, 20)), S('font-weight:600;font-size:15px;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted);max-width:320px', text), action && B(action, { k: primary ? 'primary' : 'outline', icon: primary ? 'upload' : null, px: 16 }))
const Note = t => S('align-self:flex-start;height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px \'IBM Plex Mono\',monospace', t)
const IM = [
{ id: 1, n: 'north-wall.jpg', sz: '3.9 MB', dim: '4032 × 3024', ty: 'JPEG', used: 2, by: 'Marta Alves', d: '12 Sep 2026', tone: 0 },
{ id: 2, n: 'kitchen-after.webp', sz: '1.2 MB', dim: '2400 × 1600', ty: 'WEBP', used: 3, by: 'Inès Duarte', d: '8 Sep 2026', tone: 1 },
{ id: 3, n: 'kitchen-before.webp', sz: '1.1 MB', dim: '2400 × 1600', ty: 'WEBP', used: 1, by: 'Inès Duarte', d: '8 Sep 2026', tone: 2 },
{ id: 4, n: 'logo-renew.svg', sz: '8 KB', dim: 'Vector', ty: 'SVG', used: 5, by: 'jonas', d: '5 Sep 2026', alpha: true, svg: true, tone: 3 },
{ id: 5, n: 'floorboards-copy.jpg', sz: '2.4 MB', dim: '3600 × 2400', ty: 'JPEG', used: 3, by: 'Marta Alves', d: '2 Sep 2026', dup: true, tone: 0 },
{ id: 6, n: 'IMG_2208.jpg', sz: '2.4 MB', dim: '3600 × 2400', ty: 'JPEG', used: 2, by: 'jonas', d: '30 Aug 2026', dup: true, tone: 0 },
{ id: 7, n: 'IMG_4471.jpg', sz: '3.1 MB', dim: '3600 × 2400', ty: 'JPEG', used: 2, by: 'Marta Alves', d: '29 Aug 2026', dup: true, tone: 2 },
{ id: 8, n: 'window-frame.jpg', sz: '1.8 MB', dim: '3000 × 2000', ty: 'JPEG', used: 0, by: 'Inès Duarte', d: '28 Aug 2026', tone: 3 },
{ id: 9, n: 'stone-wall-detail.jpg', sz: '3.1 MB', dim: '3600 × 2400', ty: 'JPEG', used: 1, by: 'jonas', d: '21 Aug 2026', dup: true, tone: 2 },
{ id: 10, n: 'bathroom-tiles.png', sz: '860 KB', dim: '1800 × 1200', ty: 'PNG', used: 0, by: 'Marta Alves', d: '4 Aug 2026', alpha: true, tone: 1 },
{ id: 11, n: 'lime-plaster.jpg', sz: '2.1 MB', dim: '3200 × 2133', ty: 'JPEG', used: 2, by: 'Inès Duarte', d: '19 Jul 2026', tone: 1 },
{ id: 12, n: 'floorboards.jpg', sz: '2.4 MB', dim: '3600 × 2400', ty: 'JPEG', used: 4, by: 'Inès Duarte', d: '14 Jun 2026', dup: true, tone: 0 }
]
const byId = id => IM.find(x => x.id === id)
const AR = [['How we price a bathroom renovation', 1], ['Winter checklist for timber windows', 0], ['Before and after: the Rue Verte kitchen', 1], ['Why we reuse original floorboards', 1], ['Choosing lime plaster for old stone walls', 0]]
const usedList = n => AR.slice(0, Math.min(n, 5))
const ids = s.ids || IM.map(x => x.id)
const imgs = ids.map(byId)
const sel = s.sel || []
const pl = n => n + (n === 1 ? ' article' : ' articles')
const label = im => im.n + ', ' + im.sz + ', ' + (im.used ? 'used in ' + pl(im.used) : 'unused')
const usedBadge = im => im.used ? Bd('Used in ' + im.used, 'used') : Bd('Unused', 'unused')
// ---------- Media page pieces
const Card = im => {
const on = sel.includes(im.id), hov = s.hover === im.id, foc = s.focus === im.id
const showCb = m || on || hov || foc || sel.length > 0
return D('position:relative;display:flex;flex-direction:column;background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? '#111111' : 'var(--ui-text)') + ';border:1px solid ' + (on ? 'var(--ui-primary)' : hov ? 'var(--ui-border-accented)' : 'var(--ui-border)') + ';' + (on ? 'box-shadow:inset 0 0 0 1px var(--ui-primary);' : hov ? 'box-shadow:var(--rc-shadow-pop);' : '') + (foc ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : ''),
Th(im, 'aspect-ratio:4/3;border-bottom:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border)')),
showCb && S('position:absolute;top:0;left:0;width:' + (m ? 44 : 36) + 'px;height:' + (m ? 44 : 36) + 'px;display:flex;align-items:center;justify-content:center', Cb(on, m ? 22 : 20)),
D('padding:10px 12px;display:flex;flex-direction:column;gap:6px;min-width:0', D('display:flex;flex-direction:column;gap:2px;min-width:0', S('font-weight:500;color:' + (on ? '#111111' : 'var(--ui-text-highlighted)') + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', im.n), S('font-size:12px;color:' + (on ? '#444444' : 'var(--ui-text-muted)') + ';white-space:nowrap;overflow:hidden;text-overflow:ellipsis', im.sz + ' · ' + im.dim)),
D('display:flex;flex-wrap:wrap;gap:4px', im.dup && Bd('Duplicate', 'dup'), usedBadge(im))))
}
const Grid = () => D('display:grid;grid-template-columns:repeat(' + (m ? 2 : 5) + ',minmax(0,1fr));gap:' + (m ? 10 : 16) + 'px', ...imgs.map(Card))
const Skel = () => D('display:grid;grid-template-columns:repeat(' + (m ? 2 : 5) + ',minmax(0,1fr));gap:' + (m ? 10 : 16) + 'px', ...Array.from({ length: m ? 6 : 10 }, () => D('background:var(--ui-bg);border:1px solid var(--ui-border)', S('display:block;aspect-ratio:4/3;background:var(--ui-bg-accented)'), D('padding:10px 12px;display:flex;flex-direction:column;gap:8px', S('display:block;width:70%;height:13px;background:var(--ui-bg-accented)'), S('display:block;width:45%;height:11px;background:var(--ui-bg-accented)'), S('display:block;width:60px;height:20px;background:var(--ui-bg-accented)')))))
const usedLink = im => im.used ? S('text-decoration:underline;text-underline-offset:3px;color:var(--ui-text-highlighted)', pl(im.used)) : Bd('Unused', 'unused')
const Table = () => {
const cols = '36px minmax(0,1.7fr) 110px 76px 64px 120px 130px 100px'
const hd = D('display:grid;grid-template-columns:' + cols + ';height:36px;align-items:center;padding:0 12px 0 0;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', S('display:flex;justify-content:center', Cb(false, 18, sel.length > 0 && sel.length < imgs.length)), S('', 'Filename'), S('', 'Dimensions'), S('', 'Size'), S('', 'Type'), S('', 'Used in'), S('', 'Uploaded by'), S('display:flex;align-items:center;gap:4px;color:var(--ui-text-highlighted)', 'Uploaded', I('arrow-down', 12)))
return D('background:var(--ui-bg);border:1px solid var(--ui-border)', hd, ...imgs.map(im => {
const on = sel.includes(im.id), hov = s.hover === im.id
return D('display:grid;grid-template-columns:' + cols + ';height:56px;align-items:center;padding:0 12px 0 0;border-top:1px solid var(--ui-border);background:' + (on ? 'var(--rc-sand)' : hov ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';color:' + (on ? '#111111' : 'var(--ui-text-muted)'),
S('display:flex;justify-content:center', Cb(on, 18)),
D('display:flex;align-items:center;gap:12px;min-width:0;padding-right:12px', Th(im, 'width:40px;height:40px;flex-shrink:0;border:1px solid var(--ui-border)', 16), S('font-weight:500;color:' + (on ? '#111111' : 'var(--ui-text-highlighted)') + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', im.n), im.dup && Bd('Duplicate', 'dup')),
S('', im.dim), S('', im.sz), S('', im.ty), S('', usedLink(im)), S('overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:8px', im.by), S('', im.d))
}))
}
const Cards = () => D('background:var(--ui-bg);border:1px solid var(--ui-border);margin:0 -16px;border-left:0;border-right:0', ...imgs.map(im => {
const on = sel.includes(im.id)
return D('display:flex;align-items:center;gap:0;min-height:80px;padding:8px 12px 8px 0;border-bottom:1px solid var(--ui-border);background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? '#444444' : 'var(--ui-text-muted)'),
S('width:44px;height:44px;flex-shrink:0;display:flex;align-items:center;justify-content:center', Cb(on, 22)),
Th(im, 'width:56px;height:56px;flex-shrink:0;border:1px solid var(--ui-border)', 20),
D('flex:1;min-width:0;display:flex;flex-direction:column;gap:3px;padding-left:12px', S('font-weight:500;font-size:15px;color:' + (on ? '#111111' : 'var(--ui-text-highlighted)') + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', im.n), S('font-size:12px', im.sz + ' · ' + im.dim + ' · ' + im.ty), D('display:flex;flex-wrap:wrap;gap:4px;align-items:center;font-size:12px', im.dup && Bd('Duplicate', 'dup'), usedBadge(im), S('', im.by + ' · ' + im.d))),
I('chevron-right', 16, 'margin-left:8px'))
}))
const dg = s.dupEmpty ? 0 : s.dupGroups ?? 2
const tabs = [['all', 'All', String(imgs.length)], ['unused', 'Unused', String(imgs.filter(x => !x.used).length)], ['dups', 'Duplicates', String(dg)]]
const tab = s.tab || 'all'
const TabRow = () => D('display:flex;gap:20px;flex:1;' + (m ? 'border-bottom:1px solid var(--ui-border)' : ''), ...tabs.map(([k, t, n]) => S('height:' + (m ? 44 : 36) + 'px;display:flex;align-items:center;gap:6px;border-bottom:2px solid ' + (tab === k ? 'var(--ui-primary)' : 'transparent') + ';margin-bottom:-1px;font-weight:' + (tab === k ? 500 : 400) + ';color:' + (tab === k ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)') + ';font-size:' + (m ? 15 : 13) + 'px', t, k === 'dups' ? (dg > 0 && S('height:18px;min-width:18px;padding:0 5px;display:inline-flex;align-items:center;justify-content:center;background:var(--rc-info-soft);color:var(--ui-info);font:500 11px \'IBM Plex Mono\',monospace', n)) : S('font:500 11px \'IBM Plex Mono\',monospace;color:var(--ui-text-muted)', n))))
const Search = () => D('height:' + (m ? 44 : 32) + 'px;' + (m ? '' : 'width:240px;margin-bottom:0;') + 'padding:0 4px 0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:' + (s.q ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + (m ? ';font-size:16px' : ''), I('search', 14), S('flex:1', s.q || 'Search by filename'), s.q && S('width:' + (m ? 36 : 24) + 'px;height:' + (m ? 36 : 24) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 14)))
const Sort = () => D('height:' + (m ? 44 : 32) + 'px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);' + (m ? 'flex:1;' : 'width:128px;'), S('flex:1', s.sort || 'Newest'), I('chevron-down', 14, 'color:var(--ui-text-muted)'))
const view = s.view || 'grid'
const Toggle = () => D('display:flex;border:1px solid var(--ui-border-accented);flex-shrink:0', ...[['grid', 'layout-grid'], ['list', 'list']].map(([k, ic]) => S('width:' + (m ? 44 : 32) + 'px;height:' + (m ? 42 : 30) + 'px;display:flex;align-items:center;justify-content:center;background:' + (view === k ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (view === k ? '#111111' : 'var(--ui-text-toned)'), I(ic))))
const SelectAll = () => D('display:flex;align-items:center;gap:10px;color:var(--ui-text-muted)', m ? S('width:44px;height:44px;margin-left:-11px;display:flex;align-items:center;justify-content:center', Cb(false, 22, sel.length > 0)) : Cb(false, 18, sel.length > 0), S('color:var(--ui-text-highlighted);font-weight:500', 'Select all'), S('', imgs.length + ' images'))
const UP = s.upload
const upRow = r => {
const sc = { err: 'var(--ui-error)', dup: 'var(--ui-info)' }[r.st] || 'var(--ui-text-muted)'
const body = {
prog: [S('', 'Uploading ' + r.p), D('width:100%;height:4px;background:var(--ui-bg-accented)', D('width:' + r.pct + ';height:4px;background:var(--ui-primary)'))],
wait: [S('color:var(--ui-text-muted)', 'Waiting')],
ok: [S('display:flex;align-items:center;gap:6px;color:var(--ui-success)', I('circle-check', 14), 'Added to your library')],
dup: [S('font-weight:600;color:var(--ui-text-highlighted)', 'Already in your library'), S('color:var(--ui-text-toned);font-size:12px', r.msg), D('display:flex;gap:8px;flex-wrap:wrap', B('Use existing', { h: m ? 44 : 28, fs: 12, px: 10, full: m }), B('Upload anyway', { h: m ? 44 : 28, fs: 12, px: 10, full: m }))],
rej: [S('display:flex;gap:6px;align-items:center;font-weight:600;color:var(--ui-error)', I('file-x', 14), r.title), S('color:var(--ui-text-toned);font-size:12px', r.msg)],
fail: [S('display:flex;gap:6px;align-items:center;font-weight:600;color:var(--ui-error)', I('circle-x', 14), 'Upload failed'), S('color:var(--ui-text-toned);font-size:12px', 'The server didn\'t accept the file. Nothing was added to your library.'), D('display:flex', B('Try again', { icon: 'rotate-cw', z: 14, h: m ? 44 : 28, fs: 12, px: 10, full: m }))]
}[r.st]
return D('display:flex;gap:12px;align-items:flex-start;padding:12px 14px;border-top:1px solid var(--ui-border-muted)', Th(byId(r.im || 8), 'width:40px;height:40px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:6px', D('display:flex;justify-content:space-between;gap:8px', S('font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', r.n), S('color:var(--ui-text-muted);white-space:nowrap', r.sz)), ...body), r.st === 'prog' && S('width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 14)))
}
const UpPanel = () => D('background:var(--ui-bg);border:1px solid var(--ui-border);display:flex;flex-direction:column', D('display:flex;align-items:center;gap:12px;padding:12px 14px', D('flex:1;display:flex;flex-direction:column;gap:2px', S('font-weight:600;color:var(--ui-text-highlighted)', UP.title), S('color:var(--ui-text-muted)', UP.sub)), UP.done ? B('Dismiss', { h: m ? 44 : 28, fs: 12 }) : B('Cancel all', { h: m ? 44 : 28, fs: 12 })), ...UP.rows.map(upRow), UP.summary && D('padding:12px 14px;border-top:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-weight:500', UP.summary))
const Groups = () => {
const G = [
{ n: 'floorboards.jpg', sub: '3 identical images · 2.4 MB each · 3600 × 2400', c: [12, 5, 6], keep: 12 },
{ n: 'IMG_4471.jpg', sub: '2 identical images · 3.1 MB each · 3600 × 2400', c: [7, 9], keep: 7 }
]
return D('display:flex;flex-direction:column;gap:16px', ...G.slice(2 - dg).map((g, gi) => D('background:var(--ui-bg);border:1px solid var(--ui-border)', D('padding:12px 14px;border-bottom:1px solid var(--ui-border);display:flex;flex-direction:column;gap:2px', S('font-weight:600;color:var(--ui-text-highlighted)', 'Group ' + (gi + 1)), S('color:var(--ui-text-muted)', g.sub)),
D('display:grid;grid-template-columns:' + (m ? 'minmax(0,1fr)' : 'repeat(' + g.c.length + ',minmax(0,1fr))') + ';gap:' + (m ? 0 : 16) + 'px;padding:' + (m ? 0 : 16) + 'px', ...g.c.map(id => {
const im = byId(id), k = id === g.keep
const others = g.c.filter(x => x !== id).length
return D('display:flex;' + (m ? 'flex-direction:column;gap:12px;padding:14px;border-bottom:1px solid var(--ui-border-muted)' : 'flex-direction:column;gap:12px;padding:12px;border:1px solid ' + (k ? 'var(--ui-primary)' : 'var(--ui-border)')) + ';background:' + (k ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (k ? '#111111' : 'var(--ui-text)'),
D('display:flex;gap:12px;' + (m ? 'align-items:flex-start' : 'flex-direction:column'), Th(im, (m ? 'width:96px;height:72px;flex-shrink:0' : 'aspect-ratio:4/3') + ';border:1px solid var(--ui-border)'),
D('display:flex;flex-direction:column;gap:4px;min-width:0', D('display:flex;gap:6px;align-items:center;flex-wrap:wrap', S('font-weight:500;color:' + (k ? '#111111' : 'var(--ui-text-highlighted)') + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', im.n), k && S('height:20px;padding:0 6px;display:inline-flex;align-items:center;background:var(--ui-primary);color:var(--ui-bg);font-size:12px;font-weight:500', 'Suggested')),
S('font-size:12px;color:' + (k ? '#444444' : 'var(--ui-text-muted)'), 'Uploaded ' + im.d + ' by ' + im.by),
S('font-weight:500;text-decoration:underline;text-underline-offset:3px', 'Used in ' + pl(im.used)))),
B('Merge into this one', { k: k ? 'primary' : 'outline', full: false, icon: 'merge', h: bh }))
})),
D('padding:10px 14px;border-top:1px solid var(--ui-border);color:var(--ui-text-muted);font-size:12px', 'Suggested: the most used copy, then the oldest. Choosing another copy switches articles to it.'))))
}
const Header = () => D('display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap', D('flex:1;min-width:200px;display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Media'), S('color:var(--ui-text-muted)', 'Every image you\'ve uploaded, ready to use in any article.')), !m && (s.limited ? B('Try again in 0:42', { k: 'primary', icon: 'timer', dis: true }) : B('Upload', { k: 'primary', icon: 'upload', dis: s.offline })))
const pageBody = () => {
const parts = []
if (s.empty === 'first') { parts.push(Empty('images', 'No images yet', 'Upload your first image. You can use it in any article.', 'Upload an image', true), S('align-self:center;color:var(--ui-text-muted);font-size:12px', 'PNG, JPEG, WEBP, SVG · max 10 MB each')); return parts }
if (s.limited) parts.push(Alert('info', 'timer', 'Too many uploads', 'For your security, wait a moment before uploading again.'))
if (s.error) parts.push(Alert('error', 'circle-x', 'Couldn\'t load your images', 'Check your connection and try again.', 'Try again'))
const tb = m
? D('display:flex;flex-direction:column;gap:12px', Search(), TabRow(), D('display:flex;gap:8px', Sort(), Toggle()))
: D('display:flex;align-items:flex-end;gap:12px;border-bottom:1px solid var(--ui-border)', TabRow(), S('display:flex;padding-bottom:6px;gap:8px', Search(), Sort(), Toggle()))
parts.push(tb)
if (UP) parts.push(UpPanel())
if (s.loading) parts.push(Skel())
else if (s.error) { }
else if (s.empty === 'search') parts.push(Empty('search', 'No images match "wall"', 'Nothing matches in Unused. Check the spelling, or clear the filters.', 'Clear filters', false))
else if (tab === 'dups') parts.push(s.dupEmpty ? Empty('copy', 'No duplicates', 'Every image in your library is different.', 'Back to all images', false) : Groups())
else {
if (view === 'grid') parts.push(SelectAll(), Grid()); else parts.push(m ? Cards() : Table())
}
return parts
}
const BulkBar = () => D('position:absolute;z-index:30;bottom:' + (m ? 16 : 24) + 'px;' + (m ? 'left:16px;right:16px' : 'left:calc(224px + (100% - 224px) / 2);transform:translateX(-50%);width:400px') + ';display:flex;align-items:center;gap:8px;padding:8px 8px 8px 16px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);box-shadow:var(--rc-shadow-pop)', S('flex:1;font-weight:500', sel.length + ' selected'), B('Delete', { k: 'danger', icon: 'trash-2' }), B('Clear', { k: 'inv' }))
// ---------- detail panel
const Panel = () => {
const im = byId(s.panel.id), list = usedList(im.used)
const meta = [['Type', im.ty], ['Size', im.sz], ['Dimensions', im.dim], ['Uploaded', im.d], ['Uploaded by', im.by]]
const w = m ? '100%' : '460px'
return D('position:absolute;inset:0;z-index:40;display:flex;justify-content:flex-end;background:' + (m ? 'var(--ui-bg)' : 'var(--rc-overlay)'),
D('width:' + w + ';height:100%;background:var(--ui-bg);display:flex;flex-direction:column;' + (m ? '' : 'box-shadow:var(--rc-shadow-modal)'),
D('height:' + (m ? 56 : 56) + 'px;flex-shrink:0;display:flex;align-items:center;gap:' + (m ? 4 : 12) + 'px;padding:0 ' + (m ? 6 : 20) + 'px;border-bottom:1px solid var(--ui-border)', m && S('width:44px;height:44px;display:flex;align-items:center;justify-content:center', I('arrow-left', 20)), S('flex:1;min-width:0;font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', im.n), !m && S('width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? 16 : 20) + 'px;display:flex;flex-direction:column;gap:20px',
Th(im, 'aspect-ratio:' + (m ? '4/3' : '16/10') + ';border:1px solid var(--ui-border);flex-shrink:0', 40),
D('display:flex;gap:8px;' + (m ? 'flex-wrap:wrap' : ''), B('Copy URL', { icon: 'link', full: m }), B('Rename', { icon: 'pencil', full: m }), B('Delete', { k: 'del', icon: 'trash-2', full: m })),
im.dup && Alert('info', 'copy', 'Identical to 2 other images', 'floorboards-copy.jpg and IMG_2208.jpg have the same content.', 'Review duplicates', { icon: 'arrow-right' }),
D('display:grid;grid-template-columns:110px minmax(0,1fr);gap:8px 12px;color:var(--ui-text-toned)', ...meta.flatMap(([k, v]) => [S('color:var(--ui-text-muted)', k), S('color:var(--ui-text-highlighted)', v)]), S('color:var(--ui-text-muted)', 'URL'), S('font:400 12px/20px \'IBM Plex Mono\',monospace;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', 'cms.renew.example/media/' + im.n)),
D('display:flex;flex-direction:column;gap:8px', S('font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', im.used ? 'Used in ' + pl(im.used) : 'Not used in any article'),
im.used ? D('border:1px solid var(--ui-border)', ...list.map(([t, on], i) => D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 48 : 40) + 'px;padding:0 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;text-decoration:underline;text-underline-offset:3px;color:var(--ui-text-highlighted)', t), on ? Bd('Online', 'on', 22) : Bd('Draft', 'draft', 22), I('arrow-up-right', 14, 'color:var(--ui-text-muted)'))))
: S('color:var(--ui-text-muted)', 'This image is safe to delete. No article shows it.'),
im.used ? S('color:var(--ui-text-muted);font-size:12px', 'Counted from the text of your articles each time you open this page.') : null))))
}
// ---------- modal
const Field = (lab, val, o = {}) => D('display:flex;flex-direction:column;gap:6px', D('display:flex;justify-content:space-between', S('font-weight:500;color:var(--ui-text-highlighted)', lab), o.count && S('font:400 12px \'IBM Plex Mono\',monospace;color:var(--ui-text-muted)', o.count)), D('display:flex;align-items:center;min-height:' + (o.area ? 72 : m ? 44 : 40) + 'px;border:1px solid ' + (o.err ? 'var(--ui-error)' : o.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';' + (o.focus ? 'box-shadow:0 0 0 1px var(--ui-primary);' : '') + 'background:var(--ui-bg);font-size:' + (m ? 16 : 13) + 'px;' + (o.area ? 'align-items:flex-start;' : ''), S('flex:1;min-width:0;padding:' + (o.area ? '10px' : '0') + ' 12px;color:' + (o.val ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + (o.dis ? ';opacity:.6' : ''), val), o.suffix && S('padding:0 12px;color:var(--ui-text-muted);font:400 13px \'IBM Plex Mono\',monospace', o.suffix)), o.err && S('display:flex;gap:6px;align-items:center;color:var(--ui-error);font-size:12px', I('circle-alert', 14), o.err), o.help && S('color:var(--ui-text-muted);font-size:12px', o.help))
const ArtBox = (list, more) => D('border:1px solid var(--ui-border)', ...list.map(([t, on], i) => D('display:flex;align-items:center;gap:8px;min-height:36px;padding:0 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ui-text-highlighted)', t), on ? Bd('Online', 'on') : Bd('Draft', 'draft'))), more && D('padding:8px 12px;border-top:1px solid var(--ui-border-muted);color:var(--ui-text-muted)', more))
const modalCfg = () => {
const md = s.modal, k = md.k
if (k === 'rename') return { title: 'Rename image', desc: 'Only the filename changes. The URL stays the same, so articles that use this image keep working.', body: [Field('Filename', md.err ? '' : md.val || 'north-wall', { val: !md.err, suffix: '.jpg', focus: !md.loading, err: md.err, dis: md.loading, help: md.err ? '' : 'Letters, numbers, dashes and underscores. The .jpg ending stays.' })], cancel: 'Cancel', confirm: md.loading ? 'Renaming…' : 'Rename' }
if (k === 'del') return { title: 'Delete this image?', desc: '"window-frame.jpg" will be deleted from your library. No article uses it. This can\'t be undone.', cancel: 'Cancel', confirm: md.loading ? 'Deleting…' : 'Delete image', tone: 'danger' }
if (k === 'delUsed') return { title: 'Delete an image that\'s in use?', desc: '"north-wall.jpg" is used in 2 articles. If you delete it, they will show a broken image. This can\'t be undone.', body: [ArtBox(usedList(2))], cancel: 'Cancel', confirm: md.loading ? 'Deleting…' : 'Delete image', tone: 'danger' }
if (k === 'bulk') return { title: 'Delete 3 images?', desc: '2 of them are used in 5 articles, which will show a broken image. The other one isn\'t used anywhere. This can\'t be undone.', body: [D('display:flex;flex-direction:column;border:1px solid var(--ui-border)', ...[['north-wall.jpg', 'Used in 2'], ['kitchen-after.webp', 'Used in 3'], ['window-frame.jpg', 'Unused']].map(([n, u], i) => D('display:flex;align-items:center;gap:8px;min-height:36px;padding:0 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('flex:1;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', n), Bd(u === 'Unused' ? 'Unused' : u, u === 'Unused' ? 'unused' : 'used'))))], cancel: 'Cancel', confirm: md.loading ? 'Deleting…' : 'Delete 3 images', tone: 'danger' }
if (k === 'merge') return { title: 'Merge into floorboards.jpg?', desc: 'Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can\'t be undone.', body: [D('display:flex;gap:12px;align-items:center;border:1px solid var(--ui-border);padding:10px 12px', Th(byId(12), 'width:56px;height:42px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('display:flex;flex-direction:column;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', 'floorboards.jpg'), S('font-size:12px;color:var(--ui-text-muted)', 'Kept · used in 4 articles now')))], cancel: 'Cancel', confirm: md.loading ? 'Merging…' : 'Merge 2 copies', tone: 'danger' }
if (k === 'remove') return { title: 'Remove this image from the article?', desc: 'It\'s removed from this article only. The file stays in your library and in any other article that uses it.', body: [D('display:flex;gap:12px;align-items:center;border:1px solid var(--ui-border);padding:10px 12px', Th(byId(1), 'width:56px;height:42px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('display:flex;flex-direction:column;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', 'north-wall.jpg'), S('font-size:12px;color:var(--ui-text-muted)', 'Stays in the library · 3.9 MB')))], cancel: 'Cancel', confirm: 'Remove image' }
}
const Shell = (o, body) => D('position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'flex-start') + ';justify-content:center;padding:' + (m ? 16 : o.top || 140) + 'px 16px 16px',
D('width:' + (m ? '100%' : (o.w || 460) + 'px') + ';max-height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column',
D('padding:20px 24px 0;display:flex;align-items:flex-start;gap:12px', D('flex:1;display:flex;flex-direction:column;gap:4px', S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', o.title), o.desc && S('color:var(--ui-text-muted);text-wrap:pretty', o.desc)), o.close && S('width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
body,
D('padding:20px 24px;display:flex;flex-direction:' + (m ? 'column-reverse' : 'row') + ';justify-content:flex-end;gap:8px;flex-shrink:0', o.cancel && B(o.cancel, { dis: o.loading }), o.confirm && S('height:' + bh + 'px;padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + (o.tone === 'danger' ? 'var(--ui-error)' : 'var(--ui-primary)') + ';color:var(--ui-bg);font-weight:500;opacity:' + (o.cdis ? 0.4 : 1), o.loading && I('loader-circle'), o.confirm))))
const Modal = () => {
const md = s.modal, c = modalCfg()
return Shell(c, D('padding:20px 24px 0;display:flex;flex-direction:column;gap:16px;min-height:0;overflow:hidden', md.notice && D('display:flex;gap:8px;padding:10px 12px;background:var(--rc-error-soft);color:var(--ui-error)', S('display:flex;padding-top:2px', I('circle-x')), S('color:var(--ui-text-toned)', md.notice)), ...(c.body || [])))
}
const Toasts = () => D('position:absolute;z-index:60;display:flex;flex-direction:column;gap:8px;' + (m ? 'left:16px;right:16px;bottom:' + (s.sel && s.sel.length ? 80 : 16) + 'px' : 'right:24px;bottom:24px;width:356px'), ...s.toasts.map(t => D('display:flex;gap:10px;padding:12px 14px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop)', S('margin-top:2px;display:flex;color:var(--ui-' + t[0] + ')', I(t[1])), D('flex:1;display:flex;flex-direction:column;gap:6px', D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;color:var(--ui-text-highlighted)', t[2]), S('color:var(--ui-text-muted)', t[3])), t[4] && B(t[4], { icon: 'rotate-cw', z: 14, h: m ? 44 : 28, fs: 12, px: 10 })), I('x'))))
// ---------- picker (editor + dialog)
const Editor = () => D('flex:1;min-height:0;overflow:hidden;background:var(--ui-bg-muted);display:flex;flex-direction:column',
D('background:var(--ui-bg);border-bottom:1px solid var(--ui-border);padding:12px ' + (m ? 16 : 24) + 'px;display:flex;align-items:center;gap:12px', S('flex:1;font:600 ' + (m ? 16 : 20) + 'px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'How we price a bathroom renovation'), !m && B('Save', { dis: true }), !m && B('Publish', { k: 'primary' })),
D('flex:1;overflow:hidden;padding:' + (m ? '20px 16px' : '32px 0') + ';display:flex;justify-content:center', D('width:100%;max-width:680px;display:flex;flex-direction:column;gap:16px;font-family:\'Source Serif 4\',serif;font-size:' + (m ? 16 : 18) + 'px;line-height:1.66;color:var(--ui-text-toned)',
S('display:block', 'A bathroom is priced by what has to happen behind the walls before anything gets tiled.'),
s.inArticle && D('position:relative;aspect-ratio:16/9;border:1px solid var(--ui-border);overflow:hidden', Th(byId(1), 'position:absolute;inset:0', 40), S('position:absolute;top:8px;right:8px;height:28px;padding:0 10px;display:flex;align-items:center;gap:6px;background:var(--ui-bg);border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font:500 12px \'IBM Plex Sans\',sans-serif', I('trash-2', 14), 'Remove image')),
S('display:block', 'We don\'t add a margin on materials you choose yourself.'))))
const PT = s.pk || {}
const pImgs = (PT.ids || [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]).map(byId)
const inArt = [1, 4]
const cols = m ? 3 : 4
const PTile = im => {
const on = PT.sel === im.id
return D('display:flex;flex-direction:column;gap:6px;min-width:0', D('position:relative;' + (on ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : ''), Th(im, 'aspect-ratio:1/1;border:1px solid var(--ui-border)', 20),
inArt.includes(im.id) && S('position:absolute;left:4px;bottom:4px;height:18px;padding:0 5px;display:flex;align-items:center;background:var(--rc-sand);color:#111111;font-size:11px;font-weight:500', 'In this article'),
on && S('position:absolute;top:4px;right:4px;width:20px;height:20px;display:flex;align-items:center;justify-content:center;background:var(--ui-primary);color:var(--ui-bg)', I('check', 14))),
S('font-size:12px;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', im.n))
}
const PGrid = () => D('display:grid;grid-template-columns:repeat(' + cols + ',minmax(0,1fr));gap:12px ' + (m ? 8 : 12) + 'px', ...pImgs.map(PTile), ...(PT.more ? Array.from({ length: cols }, () => D('display:flex;flex-direction:column;gap:6px', S('display:block;aspect-ratio:1/1;background:var(--ui-bg-accented)'), S('display:block;width:70%;height:10px;background:var(--ui-bg-accented)'))) : []))
const PSkel = () => D('display:grid;grid-template-columns:repeat(' + cols + ',minmax(0,1fr));gap:12px ' + (m ? 8 : 12) + 'px', ...Array.from({ length: m ? 9 : 8 }, () => D('display:flex;flex-direction:column;gap:6px', S('display:block;aspect-ratio:1/1;background:var(--ui-bg-accented)'), S('display:block;width:70%;height:10px;background:var(--ui-bg-accented)'))))
const PTabs = () => D('display:flex;gap:20px;border-bottom:1px solid var(--ui-border);padding:0 24px;margin-top:16px', ...['Upload', 'Library'].map(t => S('height:' + (m ? 44 : 36) + 'px;display:flex;align-items:center;gap:6px;border-bottom:2px solid ' + (PT.tab === t ? 'var(--ui-primary)' : 'transparent') + ';margin-bottom:-1px;font-weight:' + (PT.tab === t ? 500 : 400) + ';color:' + (PT.tab === t ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)'), I(t === 'Upload' ? 'upload' : 'images', 14), t)))
const drop = (icon, title, text, bd, ic) => D('height:180px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:16px;text-align:center;border:' + bd + ';background:var(--ui-bg)', I(icon, 24, 'color:' + (ic || 'var(--ui-text-toned)')), S('font-weight:500;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-toned);font-size:12px;max-width:300px', text))
const PBody = () => {
if (PT.tab === 'Upload') {
if (PT.dup) return D('display:flex;flex-direction:column;gap:12px;padding:16px;border:1px solid var(--ui-info);background:var(--ui-bg)', D('display:flex;gap:12px;align-items:center', Th(byId(1), 'width:72px;height:54px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('display:flex;flex-direction:column;gap:2px;min-width:0', S('font-weight:600;color:var(--ui-text-highlighted)', 'Already in your library'), S('color:var(--ui-text-toned);font-size:12px', 'north-wall.jpg matches an image uploaded on 12 Sep 2026 by Marta Alves.'))), D('display:flex;gap:8px;' + (m ? 'flex-direction:column' : ''), B('Use existing', { k: 'primary', full: m }), B('Upload anyway', { full: m })))
return drop('image-plus', 'Drop an image here, or choose a file', 'PNG, JPEG, WEBP, SVG · max 10 MB', '1px dashed var(--ui-border-accented)')
}
const kids = []
kids.push(D('height:' + (m ? 44 : 32) + 'px;padding:0 4px 0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:' + (PT.q ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + (m ? ';font-size:16px' : ''), I('search', 14), S('flex:1', PT.q || 'Search by filename'), PT.q && S('width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 14))))
if (PT.error) kids.push(Alert('error', 'circle-x', 'Couldn\'t load your library', 'Check your connection and try again.', 'Try again'))
else if (PT.empty === 'first') kids.push(Empty('images', 'Your library is empty', 'Images you upload appear here, ready to use in any article.', 'Upload an image', true, 32))
else if (PT.empty === 'search') kids.push(Empty('search', 'No images match "wall"', 'Check the spelling, or search for a shorter part of the filename.', 'Clear search', false, 32))
else if (PT.loading) kids.push(PSkel())
else {
kids.push(S('color:var(--ui-text-muted);font-size:12px', 'Newest first · 48 images'), D('min-height:0;overflow:hidden;' + (PT.sel ? 'max-height:' + (m ? 120 : 190) + 'px' : 'max-height:' + (m ? 300 : 300) + 'px'), PGrid()))
if (!PT.sel) kids.push(D('display:flex;justify-content:center', B(PT.more ? 'Loading…' : 'Load more', { icon: PT.more ? 'loader-circle' : null, dis: !!PT.more })))
}
if (PT.sel) { const im = byId(PT.sel); kids.push(D('display:flex;flex-direction:column;gap:12px;padding-top:12px;border-top:1px solid var(--ui-border)', D('display:flex;gap:12px;align-items:center', Th(im, 'width:56px;height:56px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('display:flex;flex-direction:column;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', im.n), S('font-size:12px;color:var(--ui-text-muted)', im.dim + ' · ' + im.sz))), Field('Alt text (optional)', PT.alt || 'e.g. North wall of the kitchen before plastering', { val: !!PT.alt, focus: !!PT.alt, help: 'Describes the image for people who use a screen reader, and shows if it doesn\'t load. Leave it empty if the image is only decoration.' }))) }
return D('display:flex;flex-direction:column;gap:12px', ...kids)
}
const Picker = () => {
const ins = PT.tab === 'Library'
return Shell({ title: 'Add an image', close: true, w: 640, top: 40, cancel: 'Cancel', confirm: ins ? 'Insert image' : null, cdis: ins && !PT.sel }, D('display:flex;flex-direction:column;min-height:0;flex:1', PTabs(), D('padding:16px 24px 0;min-height:0;overflow:hidden;display:flex;flex-direction:column', PBody())))
}
// ---------- shell
const nav = [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['users', 'users', 'Users'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
const isPicker = s.kind === 'picker'
const navKey = isPicker ? 'articles' : 'media'
const crumbs = isPicker ? ['Articles', 'How we price a bathroom renovation'] : ['Media']
const side = D('width:224px;flex-shrink:0;background:var(--ui-bg);border-right:1px solid var(--ui-border);display:flex;flex-direction:column;padding:12px;gap:2px',
D('display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px', h('svg', { width: 26, height: 26, viewBox: '0 0 16 16', shapeRendering: 'crispEdges' }, h('rect', { width: 16, height: 16, fill: '#111' }), h('path', { fill: '#fff', fillRule: 'evenodd', d: 'M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z' })), S('font-weight:600;font-size:14px;color:var(--ui-text-highlighted)', 'RenewCMS')),
...nav.map(([k, ic, t]) => D('display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:' + (k === navKey ? 'var(--rc-sand)' : 'transparent') + ';color:' + (k === navKey ? '#111111' : 'var(--ui-text-toned)') + ';font-weight:' + (k === navKey ? 500 : 400), I(ic), t)),
D('flex:1'), D('display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;color:var(--ui-text-highlighted)', S('width:26px;height:26px;flex-shrink:0;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 11px \'IBM Plex Sans\',sans-serif', 'MA'), S('flex:1;font-weight:500', 'Marta Alves'), I('chevrons-up-down', 14)))
const topbar = m
? D('height:56px;flex-shrink:0;border-bottom:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;padding:0 6px;gap:4px;color:var(--ui-text-highlighted)', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center', I(isPicker ? 'arrow-left' : 'menu', 20)), S('flex:1;font-weight:600;font-size:16px', isPicker ? 'Edit article' : 'Media'), isPicker && S('width:44px;height:44px;display:flex;align-items:center;justify-content:center', I('ellipsis', 20)))
: D('height:48px;flex-shrink:0;border-bottom:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;padding:0 24px;gap:8px;color:var(--ui-text-muted)', I('panel-left-close'), S('width:1px;height:16px;background:var(--ui-border);margin:0 6px'), ...crumbs.flatMap((c, i) => [i > 0 && I('chevron-right', 14), S('color:' + (i === crumbs.length - 1 ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + ';font-weight:' + (i === crumbs.length - 1 ? 500 : 400), c)]))
const offline = s.offline && D('flex-shrink:0;display:flex;align-items:center;gap:10px;padding:10px 16px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted)', I('wifi-off'), S('flex:1', 'You\'re offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection.'))
const pad = m ? 16 : 24
const content = isPicker ? Editor() : D('flex:1;min-height:0;overflow:hidden;padding:' + pad + 'px;display:flex;flex-direction:column;gap:16px;position:relative', Header(), ...pageBody())
const dragOv = s.drag && D('position:absolute;z-index:35;inset:' + (m ? 8 : 12) + 'px;top:' + (m ? 64 : 60) + 'px;border:2px dashed var(--ui-primary);background:var(--rc-sand);color:#111111;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;text-align:center', I('upload', 28), S('font-weight:600;font-size:16px', 'Drop images to upload'), S('color:#444444', 'PNG, JPEG, WEBP, SVG · max 10 MB each'))
const fab = m && !isPicker && !(sel.length) && !s.panel && !s.modal && !s.error && s.empty !== 'first' && !s.drag && D('position:absolute;left:16px;right:16px;bottom:16px;height:48px;display:flex;align-items:center;justify-content:center;gap:8px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;font-size:15px;box-shadow:var(--rc-shadow-pop);opacity:' + (s.limited || s.offline ? 0.4 : 1), I(s.limited ? 'timer' : 'upload', 18), s.limited ? 'Try again in 0:42' : 'Upload')
const ann = s.ann && D('position:absolute;z-index:20;' + (m ? 'left:16px;bottom:24px' : 'left:248px;bottom:20px'), Note(s.ann))
const root = D('width:' + (m ? 390 : 1440) + 'px;height:' + (m ? 844 : 900) + 'px;position:relative;display:flex;flex-direction:' + (m ? 'column' : 'row') + ';background:var(--ui-bg-muted);color:var(--ui-text);font-family:\'IBM Plex Sans\',sans-serif;font-size:13px;line-height:20px;overflow:hidden',
!m && side, D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column', topbar, offline, content),
dragOv, fab, ann, sel.length > 0 && !s.panel && !s.modal && BulkBar(), s.panel && Panel(), isPicker && s.picker && Picker(), s.modal && Modal(), s.toasts && Toasts())
return { view: root }
}
}
</script>
</body>
</html>
@@ -39,12 +39,13 @@
<div style="display:flex;flex-direction:column;gap:12px"> <div style="display:flex;flex-direction:column;gap:12px">
<div style="display:flex;flex-direction:column;gap:2px;max-width:1040px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}</span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div> <div style="display:flex;flex-direction:column;gap:2px;max-width:1040px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}</span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
<div style="display:flex;gap:24px;align-items:flex-start"> <div style="display:flex;gap:24px;align-items:flex-start">
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><dc-import name="AppFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></div></div> <div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if></div></div>
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><dc-import name="AppFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></div></div> <div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if></div></div>
</div> </div>
</div> </div>
</sc-for> </sc-for>
</div> </div>
<sc-if value="{{ g.table }}"><div style="display:flex;flex-direction:column;gap:8px;max-width:1240px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.tableTitle }}</span><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><div style="display:grid;grid-template-columns:90px 240px minmax(0,1fr);padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Where</span><span>Copy</span></div><sc-for list="{{ g.table }}" as="r" hint-placeholder-count="3"><div style="display:grid;grid-template-columns:90px 240px minmax(0,1fr);gap:0;padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-toned);padding-right:16px">{{ r.b }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.c }}</span></div></sc-for></div></div></sc-if>
</section> </section>
</sc-for> </sc-for>
@@ -107,7 +108,17 @@ class Component extends DCLogic {
const invF = (value, extra = {}) => [{ label: 'Email', value, ...extra }] const invF = (value, extra = {}) => [{ label: 'Email', value, ...extra }]
const P = (pf = {}, o = {}) => withM({ kind: 'profile', nav: 'profile', crumbs: ['Profile'], mTitle: 'Profile', ...o, pf }) const P = (pf = {}, o = {}) => withM({ kind: 'profile', nav: 'profile', crumbs: ['Profile'], mTitle: 'Profile', ...o, pf })
const dname = 'Marta Alves' const dname = 'Marta Alves'
return [ const MS = (o) => ({ frame: 'MediaFrame', isMedia: true, ...o, m: { frame: 'MediaFrame', isMedia: true, ...o } })
const Mp = (o) => MS({ kind: 'page', ...o })
const TT = (tone, icon, title, text, action) => [tone, icon, title, text, action]
const upT = (rows, o = {}) => ({ title: 'Uploading 6 images', sub: '2 of 6 done', rows, ...o })
const rowOk = (n, sz, im) => ({ n, sz, st: 'ok', im })
const rowDup = { n: 'floorboards-2.jpg', sz: '2.4 MB', st: 'dup', im: 12, msg: 'Same as "floorboards.jpg", uploaded 14 Jun 2026 by Inès Duarte.' }
const rowRej = { n: 'scan.tif', sz: '14.2 MB', st: 'rej', title: 'scan.tif is 14.2 MB', msg: 'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.' }
const rowFail = { n: 'roof.png', sz: '1.4 MB', st: 'fail', im: 10 }
const gone = [1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12]
const pk = (o = {}, x = {}) => MS({ kind: 'picker', picker: true, pk: { tab: 'Library', ...o }, ...x })
return this.finish([
{ label: 'Boot', title: 'App boot', route: 'any route, first load', api: 'GET /v1/auth/me (pending)', note: 'While the session is checked, the shell renders with skeletons, never a spinner. The possible outcomes are: 200 with a verified user, which renders the route; 200 with an unverified user, which goes to /register/pending; and 401, which goes to /login?redirect=<path>. Public routes skip this check.', states: [ { label: 'Boot', title: 'App boot', route: 'any route, first load', api: 'GET /v1/auth/me (pending)', note: 'While the session is checked, the shell renders with skeletons, never a spinner. The possible outcomes are: 200 with a verified user, which renders the route; 200 with an unverified user, which goes to /register/pending; and 401, which goes to /login?redirect=<path>. Public routes skip this check.', states: [
withM({ ...home, name: 'Checking session', trigger: 'The first paint on any back-office URL, and usually visible for well under a second.', boot: true }) withM({ ...home, name: 'Checking session', trigger: 'The first paint on any back-office URL, and usually visible for well under a second.', boot: true })
] }, ] },
@@ -164,6 +175,82 @@ class Component extends DCLogic {
E({ save: 'offline' }, { name: 'Offline', trigger: 'The connection dropped. Writing continues, and Save and Publish wait.', offline: true }), E({ save: 'offline' }, { name: 'Offline', trigger: 'The connection dropped. Writing continues, and Save and Publish wait.', offline: true }),
E({ notfound: true }, { name: 'Not found (404)', trigger: 'Unknown id, or the article was deleted elsewhere.' }) E({ notfound: true }, { name: 'Not found (404)', trigger: 'Unknown id, or the article was deleted elsewhere.' })
] }, ] },
{ label: 'Media', title: 'Media', route: '/media', api: 'GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage', note: 'New route for verified users, in the sidebar between Articles and Users with the images icon. Images belong to the library, not to an article: one image can be used in many articles, and deleting an article never deletes images. Uploads from this page are not cropped or resized, and the file is stored as chosen. "Used in" is read from article bodies each time the list loads. Duplicates are images with identical content, whatever their filenames. Alt text is not stored on the image; it is set when inserting into an article. Search, filter, sort and view are stored in the query string. Keyboard: grid items are buttons in one tab stop each; Enter opens the detail panel, Space toggles selection, and Escape closes the panel or clears the selection. Selection changes are announced ("north-wall.jpg selected, 1 selected").', states: [
Mp({ name: 'Grid, many images', trigger: 'Default view. 12 images, newest first. Each item is a button labelled like "north-wall.jpg, 3.9 MB, used in 2 articles". Badges: "Unused" (warm), "Duplicate" (info), "Used in N" (outline). The Duplicates tab carries a count of groups only while duplicates exist.' }),
Mp({ ids: [1, 4, 8], name: 'Grid, few images', trigger: 'Three images. Cards keep their size instead of stretching. Checkerboard shows behind the SVG.' }),
Mp({ view: 'list', name: 'List view', trigger: 'The list icon in the toggle. A table from md up (Filename, Dimensions, Size, Type, Used in, Uploaded by, Uploaded) and a card list below md. The "Used in" count opens the article list in the detail panel.' }),
Mp({ loading: true, name: 'Loading', trigger: 'The first load, a retry, or a change of search or filter. Skeleton cards appear after 150 ms.' }),
Mp({ empty: 'first', name: 'Empty library, first run', trigger: 'Zero images. Toolbar and tabs are hidden; one call to action. Copy: "No images yet" / "Upload your first image. You can use it in any article." / "Upload an image".' }),
Mp({ tab: 'unused', q: 'wall', empty: 'search', name: 'No results', trigger: 'Search or filter matched nothing. "Clear filters" resets the search and goes back to All.' }),
Mp({ hover: 2, focus: 3, ann: 'aria-label: kitchen-before.webp, 1.1 MB, used in 1 article', name: 'Hover, focus and keyboard', trigger: 'Hover (second item) darkens the border, adds the pop shadow and reveals the checkbox. Keyboard focus (third item) shows the 2 px primary ring with a 2 px offset and reveals the checkbox. Tab moves between items, Enter opens the detail panel, Space toggles selection, and arrow keys move focus across the grid.' }),
Mp({ sel: [1, 2, 8], name: 'Selection and bulk bar', trigger: 'Checkbox on any item, or Space. Selected items take the sand fill with a primary outline, and Select all shows a partial state. The bar is sticky at the bottom (above the bottom edge on mobile, replacing the Upload button). Announced: "3 selected". Escape or Clear empties it.' }),
Mp({ sel: [1, 2, 8], modal: { k: 'bulk' }, name: 'Bulk delete: confirm', trigger: 'Delete in the bulk bar. The dialog says how many of the selection are in use and lists them. The confirm button carries the count.' }),
Mp({ sel: [1, 2, 8], modal: { k: 'bulk', loading: true }, name: 'Bulk delete: in progress', trigger: 'Requests in flight. The dialog can\'t be dismissed.' }),
Mp({ ids: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12], sel: [8], toasts: [TT('error', 'circle-x', 'Couldn\'t delete 1 image', '2 images were deleted. window-frame.jpg is still in your library. Try again.', 'Retry')], name: 'Bulk delete: partly failed', trigger: 'Some requests failed. Deleted images leave the grid, failed ones stay selected, and the toast stays until retried or dismissed.' }),
Mp({ ids: [3, 4, 5, 6, 7, 9, 10, 11, 12], toasts: [TT('success', 'circle-check', '3 images deleted', 'They\'re gone from your library.')], name: 'Bulk delete: done', trigger: '204 for all. Focus moves to the next item.' }),
Mp({ panel: { id: 1 }, name: 'Detail panel: in use', trigger: 'Click or Enter on an item. Right-hand slideover on desktop, full screen on mobile with a back arrow. Actions: Copy URL, Rename, Delete. "Used in 2 articles" lists title, Online or Draft, and links to the editor. Focus returns to the item on close.' }),
Mp({ panel: { id: 8 }, name: 'Detail panel: unused', trigger: 'Used in nothing. The section reads "Not used in any article" and says the image is safe to delete.' }),
Mp({ panel: { id: 12 }, name: 'Detail panel: duplicate', trigger: 'The image has identical copies. An info alert links to the duplicates view.' }),
Mp({ panel: { id: 1 }, toasts: [TT('success', 'circle-check', 'URL copied', 'Paste it wherever you need the image address.')], name: 'Copy URL', trigger: 'Copy URL. Clipboard write succeeded. Success toasts last 5 s.' }),
Mp({ panel: { id: 1 }, modal: { k: 'rename', val: 'north-wall' }, name: 'Rename: dialog', trigger: 'Rename in the panel. The field holds the name without its extension, and the .jpg ending is fixed. The description says the URL stays the same.' }),
Mp({ panel: { id: 1 }, modal: { k: 'rename', val: 'north-wall-01', loading: true }, name: 'Rename: saving', trigger: 'PATCH in flight. Field and buttons disabled, dialog can\'t be dismissed.' }),
Mp({ panel: { id: 1 }, modal: { k: 'rename', err: 'Enter a filename.' }, name: 'Rename: validation', trigger: 'Empty or only symbols, on submit or blur. Nothing is sent.' }),
Mp({ panel: { id: 1 }, modal: { k: 'rename', val: 'north-wall-01', notice: 'Couldn\'t rename the image. Your text is still here. Try again in a moment.' }, name: 'Rename: failed', trigger: '500 or network failure. The error shows inside the dialog.' }),
Mp({ panel: { id: 1 }, toasts: [TT('success', 'circle-check', 'Image renamed', 'It\'s now "north-wall-01.jpg". The URL is the same.')], name: 'Rename: done', trigger: '200: the panel title and list update in place.' }),
Mp({ panel: { id: 8 }, modal: { k: 'del' }, name: 'Delete: unused image', trigger: 'Delete in the panel. Plain confirm. Says what happens and that it can\'t be undone.' }),
Mp({ panel: { id: 1 }, modal: { k: 'delUsed' }, name: 'Delete: image in use', trigger: 'Stronger confirm. Lists the affected articles and states that they will show a broken image.' }),
Mp({ panel: { id: 1 }, modal: { k: 'delUsed', loading: true }, name: 'Delete: in progress', trigger: 'DELETE in flight. The dialog can\'t be dismissed.' }),
Mp({ panel: { id: 8 }, modal: { k: 'del', notice: 'Couldn\'t delete the image, so it\'s still there. Try again in a moment.' }, name: 'Delete: failed', trigger: '500 or network failure. The error shows inside the dialog, as in the article delete.' }),
Mp({ ids: gone, toasts: [TT('success', 'circle-check', 'Image deleted', '"window-frame.jpg" is gone from your library.')], name: 'Delete: done', trigger: '204: the panel closes, the item leaves the grid and focus moves to the next item.' }),
Mp({ ids: gone, toasts: [TT('info', 'info', 'That image no longer exists', 'It was deleted somewhere else, so we removed it from the library.')], name: 'Image already gone (404)', trigger: 'Any action on an image that was deleted elsewhere. The panel closes and the item leaves the list.' }),
Mp({ drag: true, name: 'Upload: drag over the page', trigger: 'Files dragged onto the page. A dashed drop area covers the content. Dropping starts the upload straight away; there is no crop step on this page.' }),
Mp({ upload: upT([rowOk('kitchen-roof.jpg', '2.2 MB', 2), { n: 'porch-steps.jpg', sz: '2.6 MB', st: 'prog', p: '1.1 of 2.6 MB', pct: '42%', im: 11 }, rowDup, rowRej, rowFail, { n: 'attic.jpg', sz: '1.9 MB', st: 'wait', im: 3 }]), name: 'Upload: several files, mixed', trigger: 'Upload button or drop, several files. Each file has its own row and progress. Files upload two at a time. A row can end as added, "Already in your library", rejected in the browser, or failed. Rejected and failed rows stay until dismissed.' }),
Mp({ upload: upT([rowOk('kitchen-roof.jpg', '2.2 MB', 2), rowOk('porch-steps.jpg', '2.6 MB', 11), rowDup, rowRej, rowFail], { title: 'Upload finished', sub: '5 images', done: true, summary: '2 uploaded · 1 already in your library · 2 need attention' }), name: 'Upload: summary', trigger: 'All rows settled. The summary counts each outcome. "Use existing" selects the existing image and drops the row; "Upload anyway" stores a second copy, which then appears under Duplicates. "Try again" retries one file. Announced when it finishes.' }),
Mp({ upload: upT([rowRej, { n: 'notes.pdf', sz: '210 KB', st: 'rej', title: 'notes.pdf isn\'t an image', msg: 'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.' }, { n: 'broken.png', sz: '3.0 MB', st: 'rej', title: 'broken.png couldn\'t be read', msg: 'The server couldn\'t read this image. Save it again as a PNG or JPEG, then try again.' }], { title: 'Nothing was uploaded', sub: '3 files were rejected', done: true }), name: 'Upload: rejected files (413, 400)', trigger: 'Type and size are checked in the browser first, so nothing is sent. A server 413 or 400 for a file that got through shows the same row with its own message.' }),
Mp({ limited: true, name: 'Upload: too many requests (429)', trigger: 'Rate limited. Retry-After is counted down in the button. It re-enables at zero. Already-added files are kept.' }),
Mp({ tab: 'dups', name: 'Duplicates: groups', trigger: 'The Duplicates tab. Groups of identical images side by side, each with its uploader, upload date and "Used in N articles". The suggested copy to keep is the most used, then the oldest.' }),
Mp({ tab: 'dups', dupEmpty: true, name: 'Duplicates: empty', trigger: 'No groups. The Duplicates tab loses its count badge.' }),
Mp({ tab: 'dups', modal: { k: 'merge' }, name: 'Merge: confirm', trigger: '"Merge into this one" on floorboards.jpg. The dialog says articles using the other copies will switch to this image and the other copies will be deleted, with the article count.' }),
Mp({ tab: 'dups', modal: { k: 'merge', loading: true }, name: 'Merge: in progress', trigger: 'Request in flight. The dialog can\'t be dismissed.' }),
Mp({ tab: 'dups', modal: { k: 'merge', notice: 'Couldn\'t merge the copies. Nothing was changed. Try again in a moment.' }, name: 'Merge: failed', trigger: '500 or network failure. Nothing is switched or deleted.' }),
Mp({ tab: 'dups', modal: { k: 'merge', notice: 'One of these images changed, so nothing was merged. The list is up to date now. Check it and try again.' }, name: 'Merge: conflict (409)', trigger: 'A copy was deleted, or a new copy appeared, since the page loaded.' }),
Mp({ tab: 'dups', dupGroups: 1, ids: [1, 2, 3, 4, 7, 8, 9, 10, 11, 12], toasts: [TT('success', 'circle-check', 'Copies merged', '2 copies merged. 5 articles updated.')], name: 'Merge: done', trigger: '200: the group leaves the list. When it was the last one, "No duplicates" shows.' }),
Mp({ error: true, name: 'List failed to load', trigger: 'A 500 or network failure on the list request. The alert replaces the images; Try again repeats the request.' }),
Mp({ offline: true, name: 'Offline', trigger: 'navigator.onLine is false. Loaded images stay browsable. Upload, Rename, Delete and Merge are disabled, and the banner says why.' })
], tableTitle: 'Status to copy', table: [
{ a: '200 / 201', b: 'Upload, rename, merge', c: 'Upload: row reads "Added to your library". Rename: "Image renamed" / "It\'s now "north-wall-01.jpg". The URL is the same." Merge: "Copies merged" / "2 copies merged. 5 articles updated."' },
{ a: '204', b: 'Delete, bulk delete', c: '"Image deleted" / ""window-frame.jpg" is gone from your library." Bulk: "3 images deleted" / "They\'re gone from your library."' },
{ a: '400', b: 'Upload row', c: '"broken.png couldn\'t be read" / "The server couldn\'t read this image. Save it again as a PNG or JPEG, then try again." In a dialog: "Something in the form isn\'t valid. Check the highlighted fields."' },
{ a: '401', b: 'Any call', c: 'Handled globally: /login?redirect=/media&reason=expired. No local copy.' },
{ a: '403', b: 'Any call, unverified', c: 'Handled globally: /register/pending.' },
{ a: '404', b: 'Rename, delete, merge, panel', c: 'Toast (info): "That image no longer exists" / "It was deleted somewhere else, so we removed it from the library." The panel or dialog closes.' },
{ a: '409', b: 'Merge', c: 'Dialog alert: "One of these images changed, so nothing was merged. The list is up to date now. Check it and try again."' },
{ a: '413', b: 'Upload row', c: '"scan.tif is 14.2 MB" / "Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image."' },
{ a: '415', b: 'Upload row', c: '"notes.pdf isn\'t an image" / "Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image."' },
{ a: '429', b: 'Upload', c: 'Alert (info): "Too many uploads" / "For your security, wait a moment before uploading again." The button reads "Try again in 0:42" and counts down.' },
{ a: '500 / network', b: 'List', c: 'Alert: "Couldn\'t load your images" / "Check your connection and try again." with "Try again".' },
{ a: '500 / network', b: 'Upload row', c: '"Upload failed" / "The server didn\'t accept the file. Nothing was added to your library." with "Try again".' },
{ a: '500 / network', b: 'Rename dialog', c: '"Couldn\'t rename the image. Your text is still here. Try again in a moment."' },
{ a: '500 / network', b: 'Delete dialog', c: '"Couldn\'t delete the image, so it\'s still there. Try again in a moment."' },
{ a: '500 / network', b: 'Merge dialog', c: '"Couldn\'t merge the copies. Nothing was changed. Try again in a moment."' },
{ a: 'Partial', b: 'Bulk delete', c: 'Toast (error, stays): "Couldn\'t delete 1 image" / "2 images were deleted. window-frame.jpg is still in your library. Try again." with "Retry".' },
{ a: 'Offline', b: 'Whole page', c: 'Banner: "You\'re offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection."' }
] },
{ label: 'Media picker', title: 'Picker in the editor', route: '/article/:id/edit', api: 'GET /v1/media/?q=&limit=&cursor= · POST /v1/media/ (same as the Media page)', note: 'The existing "Add an image" dialog gains two tabs, Upload and Library. The Upload tab is the current flow unchanged, so it still offers crop and resize, and a new image lands in the library and is inserted as before. The Library tab opens on the Library tab when the library has images and on Upload when it is empty. Images load 24 at a time with "Load more". Newest first, and images already in this article carry "In this article". Alt text is set here and lives only in the article. The dialog is 640 px wide on desktop and a bottom sheet on mobile.', states: [
pk({ tab: 'Upload' }, { name: 'Upload tab', trigger: 'The image button in the toolbar. Identical to the current "Add an image" content; only the tabs are new. Rejected, uploading and failed states are unchanged.', inArticle: false }),
pk({ tab: 'Upload', dup: true }, { name: 'Upload tab: already in your library', trigger: 'A chosen file has the same content as an existing image. Nothing is sent. "Use existing" inserts the existing image (the alt text step follows); "Upload anyway" continues to crop and upload as usual.' }),
pk({ loading: true }, { name: 'Library: loading', trigger: 'The Library tab was opened, or a search changed. Skeleton tiles after 150 ms.' }),
pk({}, { name: 'Library: nothing selected', trigger: 'Newest first. Tiles are buttons labelled with filename, size and use, and Enter or Space selects. Images already in this article carry "In this article". "Insert image" is disabled.' }),
pk({ sel: 2, alt: '' }, { name: 'Library: selected', trigger: 'One image selected (single selection). Below the grid: thumbnail, filename, dimensions and size, and an optional alt text field. "Insert image" turns on.' }),
pk({ sel: 2, alt: 'A kitchen with new oak worktops and white tiles' }, { name: 'Library: alt text entered', trigger: 'Alt text is optional. Empty alt text is inserted as a decorative image. It is not saved on the image.' }),
pk({ more: true }, { name: 'Library: loading more', trigger: '"Load more" pressed. The button shows progress and new skeleton tiles appear at the end. Focus stays on the button until the new tiles arrive, then moves to the first new tile.' }),
pk({ empty: 'first' }, { name: 'Library: empty', trigger: 'Zero images. "Upload an image" switches to the Upload tab.' }),
pk({ q: 'wall', empty: 'search' }, { name: 'Library: no results', trigger: 'Search matched nothing. "Clear search" resets it.' }),
pk({ error: true }, { name: 'Library: failed to load', trigger: 'A 500 or network failure. The error shows in the dialog and Try again repeats the request. A 429 uses the same countdown alert as the Media page, and a 401 is handled globally.' }),
MS({ kind: 'picker', inArticle: true, name: 'Inserted', trigger: 'Insert image from either tab. The image goes in at the cursor with the chosen alt text, and the article now has unsaved changes.', toasts: [TT('success', 'circle-check', 'Image added', 'Save to keep it in the article.')] }),
MS({ kind: 'picker', inArticle: true, picker: false, modal: { k: 'remove' }, name: 'Remove image: confirm', trigger: 'Remove image on an image in the article. It now only removes it from this article, and the copy says the file stays in the library. The confirm button is neutral, not red, because nothing is deleted. Title: "Remove this image from the article?"' }),
MS({ kind: 'picker', name: 'Removed', trigger: 'Confirmed. The image leaves the article body, which now has unsaved changes.', toasts: [TT('success', 'circle-check', 'Image removed', 'It\'s still in your library.')] })
] },
{ label: 'Integration', title: 'Connect your website', route: '/integration', api: 'none (static)', note: 'Written for a developer. The base URL is window.location.origin.', states: [ { label: 'Integration', title: 'Connect your website', route: '/integration', api: 'none (static)', note: 'Written for a developer. The base URL is window.location.origin.', states: [
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: 'Default', trigger: 'Opened from the sidebar or the Home card.' }), withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: 'Default', trigger: 'Opened from the sidebar or the Home card.' }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', copied: true, name: 'Copied', trigger: 'The base URL was copied. The button reads "Copied" for 2 s and this is announced.' }) withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', copied: true, name: 'Copied', trigger: 'The base URL was copied. The button reads "Copied" for 2 s and this is announced.' })
@@ -211,8 +298,9 @@ class Component extends DCLogic {
withM({ ...L({ offline: true }), name: 'Offline banner', trigger: 'navigator.onLine is false. The banner is shown on every back-office page.' }), withM({ ...L({ offline: true }), name: 'Offline banner', trigger: 'navigator.onLine is false. The banner is shown on every back-office page.' }),
withM({ ...L({ toasts: [T('success', 'circle-check', 'Article published', '"Winter checklist for timber windows" is now online.'), T('info', 'info', 'That article no longer exists', 'It was deleted somewhere else, so we removed it from the list.'), T('error', 'circle-x', 'Couldn\'t unpublish', 'The server didn\'t respond. Nothing changed.', 'Retry')] }), name: 'Toast stack', trigger: 'Three at most, newest at the bottom. A fourth pushes out the oldest non-error toast.' }) withM({ ...L({ toasts: [T('success', 'circle-check', 'Article published', '"Winter checklist for timber windows" is now online.'), T('info', 'info', 'That article no longer exists', 'It was deleted somewhere else, so we removed it from the list.'), T('error', 'circle-x', 'Couldn\'t unpublish', 'The server didn\'t respond. Nothing changed.', 'Retry')] }), name: 'Toast stack', trigger: 'Three at most, newest at the bottom. A fourth pushes out the oldest non-error toast.' })
] } ] }
] ])
} }
finish(gs) { return gs.map(g => ({ ...g, states: g.states.map(st => ({ notMedia: !st.isMedia, ...st, m: { ...st.m } })) })) }
renderVals() { renderVals() {
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => { const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => {
const on = this.state.mode === k const on = this.state.mode === k
+65 -1
View File
@@ -80,6 +80,60 @@ Every state is drawn at desktop and mobile on the screen boards, with final copy
| Editor | `/article/:id/edit` | Offline | navigator.onLine is false | [editor](screens/editor.md) | | Editor | `/article/:id/edit` | Offline | navigator.onLine is false | [editor](screens/editor.md) |
| Editor | `/article/:id/edit` | Deleted | 204: /article with toast | [editor](screens/editor.md) | | Editor | `/article/:id/edit` | Deleted | 204: /article with toast | [editor](screens/editor.md) |
| Editor | `/article/:id/edit` | Session expired | 401: draft kept in sessionStorage, then /login | [editor](screens/editor.md) | | Editor | `/article/:id/edit` | Session expired | 401: draft kept in sessionStorage, then /login | [editor](screens/editor.md) |
| Media | `/media` | Grid, many images | Default view | [media](screens/media.md) |
| Media | `/media` | Grid, few images | Three images | [media](screens/media.md) |
| Media | `/media` | List view | The list icon in the toggle | [media](screens/media.md) |
| Media | `/media` | Loading | The first load, a retry, or a change of search or filter | [media](screens/media.md) |
| Media | `/media` | Empty library, first run | Zero images | [media](screens/media.md) |
| Media | `/media` | No results | Search or filter matched nothing | [media](screens/media.md) |
| Media | `/media` | Hover, focus and keyboard | Hover (second item) darkens the border, adds the pop shadow and reveals the checkbox | [media](screens/media.md) |
| Media | `/media` | Selection and bulk bar | Checkbox on any item, or Space | [media](screens/media.md) |
| Media | `/media` | Bulk delete: confirm | Delete in the bulk bar | [media](screens/media.md) |
| Media | `/media` | Bulk delete: in progress | Requests in flight | [media](screens/media.md) |
| Media | `/media` | Bulk delete: partly failed | Some requests failed | [media](screens/media.md) |
| Media | `/media` | Bulk delete: done | 204 for all | [media](screens/media.md) |
| Media | `/media` | Detail panel: in use | Click or Enter on an item | [media](screens/media.md) |
| Media | `/media` | Detail panel: unused | Used in nothing | [media](screens/media.md) |
| Media | `/media` | Detail panel: duplicate | The image has identical copies | [media](screens/media.md) |
| Media | `/media` | Copy URL | Copy URL | [media](screens/media.md) |
| Media | `/media` | Rename: dialog | Rename in the panel | [media](screens/media.md) |
| Media | `/media` | Rename: saving | PATCH in flight | [media](screens/media.md) |
| Media | `/media` | Rename: validation | Empty or only symbols, on submit or blur | [media](screens/media.md) |
| Media | `/media` | Rename: failed | 500 or network failure | [media](screens/media.md) |
| Media | `/media` | Rename: done | 200: the panel title and list update in place | [media](screens/media.md) |
| Media | `/media` | Delete: unused image | Delete in the panel | [media](screens/media.md) |
| Media | `/media` | Delete: image in use | Stronger confirm | [media](screens/media.md) |
| Media | `/media` | Delete: in progress | DELETE in flight | [media](screens/media.md) |
| Media | `/media` | Delete: failed | 500 or network failure | [media](screens/media.md) |
| Media | `/media` | Delete: done | 204: the panel closes, the item leaves the grid and focus moves to the next item | [media](screens/media.md) |
| Media | `/media` | Image already gone (404) | Any action on an image that was deleted elsewhere | [media](screens/media.md) |
| Media | `/media` | Upload: drag over the page | Files dragged onto the page | [media](screens/media.md) |
| Media | `/media` | Upload: several files, mixed | Upload button or drop, several files | [media](screens/media.md) |
| Media | `/media` | Upload: summary | All rows settled | [media](screens/media.md) |
| Media | `/media` | Upload: rejected files (413, 400) | Type and size are checked in the browser first, so nothing is sent | [media](screens/media.md) |
| Media | `/media` | Upload: too many requests (429) | Rate limited | [media](screens/media.md) |
| Media | `/media` | Duplicates: groups | The Duplicates tab | [media](screens/media.md) |
| Media | `/media` | Duplicates: empty | No groups | [media](screens/media.md) |
| Media | `/media` | Merge: confirm | "Merge into this one" on floorboards.jpg | [media](screens/media.md) |
| Media | `/media` | Merge: in progress | Request in flight | [media](screens/media.md) |
| Media | `/media` | Merge: failed | 500 or network failure | [media](screens/media.md) |
| Media | `/media` | Merge: conflict (409) | A copy was deleted, or a new copy appeared, since the page loaded | [media](screens/media.md) |
| Media | `/media` | Merge: done | 200: the group leaves the list | [media](screens/media.md) |
| Media | `/media` | List failed to load | A 500 or network failure on the list request | [media](screens/media.md) |
| Media | `/media` | Offline | navigator.onLine is false | [media](screens/media.md) |
| Media picker | `/article/:id/edit` | Upload tab | The image button in the toolbar | [media-picker](screens/media-picker.md) |
| Media picker | `/article/:id/edit` | Upload tab: already in your library | A chosen file has the same content as an existing image | [media-picker](screens/media-picker.md) |
| Media picker | `/article/:id/edit` | Library: loading | The Library tab was opened, or a search changed | [media-picker](screens/media-picker.md) |
| Media picker | `/article/:id/edit` | Library: nothing selected | Newest first | [media-picker](screens/media-picker.md) |
| Media picker | `/article/:id/edit` | Library: selected | One image selected (single selection) | [media-picker](screens/media-picker.md) |
| Media picker | `/article/:id/edit` | Library: alt text entered | Alt text is optional | [media-picker](screens/media-picker.md) |
| Media picker | `/article/:id/edit` | Library: loading more | "Load more" pressed | [media-picker](screens/media-picker.md) |
| Media picker | `/article/:id/edit` | Library: empty | Zero images | [media-picker](screens/media-picker.md) |
| Media picker | `/article/:id/edit` | Library: no results | Search matched nothing | [media-picker](screens/media-picker.md) |
| Media picker | `/article/:id/edit` | Library: failed to load | A 500 or network failure | [media-picker](screens/media-picker.md) |
| Media picker | `/article/:id/edit` | Inserted | Insert image from either tab | [media-picker](screens/media-picker.md) |
| Media picker | `/article/:id/edit` | Remove image: confirm | Remove image on an image in the article | [media-picker](screens/media-picker.md) |
| Media picker | `/article/:id/edit` | Removed | Confirmed | [media-picker](screens/media-picker.md) |
| Integration | `/integration` | Default | Page load | [integration](screens/integration.md) | | Integration | `/integration` | Default | Page load | [integration](screens/integration.md) |
| Integration | `/integration` | Copied | Copy clicked (2 s) | [integration](screens/integration.md) | | Integration | `/integration` | Copied | Copy clicked (2 s) | [integration](screens/integration.md) |
| Users | `/users` | Default | List loaded, newest first | [users](screens/users.md) | | Users | `/users` | Default | List loaded, newest first | [users](screens/users.md) |
@@ -164,4 +218,14 @@ Added by the Users and Profile screens, none of which exist yet:
Still not supported, so shown disabled or omitted: Still not supported, so shown disabled or omitted:
Renaming a title (shown read-only, with an explanation in Details), resending the verification email (the pending page explains how to start over), changing the email or username, listing or deleting individual images, removing users, promoting or demoting anyone, and any role other than owner and admin. Changing the password while logged in works only through the emailed reset link on the Profile page. Renaming a title (shown read-only, with an explanation in Details), resending the verification email (the pending page explains how to start over), changing the email or username, removing users, promoting or demoting anyone, and any role other than owner and admin. Changing the password while logged in works only through the emailed reset link on the Profile page.
Added by the Media screens, none of which exist yet:
- `GET /v1/media/?q=&filter=&sort=&limit=&cursor=&hash=`: the library, newest first by default. `hash` (SHA-256 hex) returns only the images with that content, which is how the browser checks for a duplicate before uploading. Each item carries `id`, `filename`, `url`, `size`, `content_type`, `width`, `height` (null for SVG), `uploaded_by`, `created_at`, `used_in` (count) and `duplicate` (bool). The response also carries the counts for the Unused and Duplicates tabs.
- `POST /v1/media/`: multipart `file`, no article id. 201 with the item. It always stores the file: the duplicate check happens in the browser (hash with `crypto.subtle`, then `GET /v1/media/?hash=`), and when `crypto.subtle` is unavailable the check is skipped and any duplicate shows up under Duplicates. The server keeps each image's SHA-256. 413 for over 10 MB, 415 for another type, 400 for an unreadable image, 429 with `Retry-After`.
- `PATCH /v1/media/{id}`: `{ filename }`, the extension is kept and the URL does not change. 200, 404.
- `DELETE /v1/media/{id}`: 204, 404. Deleting an image that is in use is allowed.
- `POST /v1/media/merge`: `{ keep_id, merge_ids }`. Rewrites article bodies from the merged copies' URLs to the kept image's URL, then deletes the copies. 200 with `{ articles_updated, merged }`, 404, 409 when a listed copy is gone or the group changed.
- `GET /v1/media/{id}/usage`: `[{ id, title, published }]`, read from article bodies.
- Images no longer belong to an article, and deleting an article no longer deletes images. `POST /v1/article/{id}/image` and `DELETE /v1/article/{id}/image/{imageId}` are replaced by the endpoints above.
+1
View File
@@ -26,3 +26,4 @@ Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create -
| `v2.2.0-beta` | v2.2.0-beta - Image crop, image removal & reset screens | | `v2.2.0-beta` | v2.2.0-beta - Image crop, image removal & reset screens |
| `v2.3.0-beta` | v2.3.0-beta - Multi-user invitations, display names & profile page | | `v2.3.0-beta` | v2.3.0-beta - Multi-user invitations, display names & profile page |
| `v2.4.0-beta` | v2.4.0-beta - Owner role, invite management & Settings page | | `v2.4.0-beta` | v2.4.0-beta - Owner role, invite management & Settings page |
| `v2.5.0-beta` | v2.5.0-beta - Media library & image picker |
+55
View File
@@ -0,0 +1,55 @@
RenewCMS now has a media library. Every uploaded image lives in one place where you can browse, rename, delete and clean up duplicates, and the article editor can reuse an image you already uploaded.
## New
**Media page**
- A new Media page at `/media` sits between Articles and Users in the sidebar and the mobile drawer.
- Browse the library as a grid or a list, with search by filename, sort by newest, oldest, name or largest, and Unused and Duplicates tabs with counts.
- Open an image to see its type, size, dimensions, uploader, upload date and URL, copy its URL, and see which articles use it.
- Rename an image. Only the filename changes, the URL stays the same, so articles keep working.
- Delete one image or many at once. Deleting an image that is still used asks for a stronger confirmation and lists the articles that will show a broken image.
- Upload several images at once, with a progress row for each one and a summary. Drop files anywhere on the page.
- When you upload a file that is already in the library, you are told so and can use the existing image instead.
- A Duplicates view groups identical images and merges them into one. Articles that used the other copies switch to the one you keep.
**Image picker in the editor**
- The editor's "Add an image" dialog now has Upload and Library tabs. Pick an image you already uploaded, set its alt text and insert it.
- Images already in the article are tagged "In this article".
- Removing an image from an article now only removes it from that article. The file stays in the library.
## Improvements and fixes
- Deleting an article no longer deletes its images, and its confirmation says so.
- An image that no longer exists shows a "This image is no longer available" placeholder in the editor, with a way to remove it, instead of a collapsed pixel.
- Deleted images stop showing as soon as you open an article, not only after a refresh. Uploaded images are now revalidated by the browser instead of being cached for a year.
- The editor's Images row in the article details counts the images in the article body.
## API
- `GET /v1/media/` lists the library with `q`, `filter` (`all`, `unused`, `duplicates`), `sort`, `limit`, `cursor` and `hash` parameters. Each item carries its size, type, dimensions, uploader, `used_in` count and a `duplicate` flag, and the response carries the counts for each tab.
- `POST /v1/media/` uploads an image (multipart `file`). It returns 201, 400 for an unreadable image, 413 for a file over 10 MB and 415 for another type.
- `PATCH /v1/media/{id}` renames an image and keeps its extension and URL. It returns 200, or 404.
- `DELETE /v1/media/{id}` deletes an image, even when articles use it. It returns 204, or 404.
- `POST /v1/media/merge` takes `keep_id` and `merge_ids`, switches the articles that use the other copies to the kept image, deletes the copies and returns how many articles changed. It returns 409 when a copy is gone or the images are no longer identical.
- `GET /v1/media/{id}/usage` lists the articles that use an image, with their title and whether they are online.
- Changed: images no longer belong to an article. `GET /v1/article/{id}` no longer returns `images`, and deleting an article keeps its images.
- Changed: `DELETE /v1/article/{id}/image/{imageId}` and `POST /v1/article/{id}/image` are removed, replaced by the `/v1/media/` endpoints.
- Changed: `/static/uploadedImages/*` now sends `Cache-Control: public, no-cache` instead of a one-year `immutable` lifetime.
## Under the hood
- The image domain, repository, use cases and handlers were rebuilt around a library model, keeping the domain, use-case and infrastructure layering.
- WEBP dimensions are read with `golang.org/x/image`. `golang.org/x/sys` and `golang.org/x/text` were updated with it.
- New feature specs and tests cover the media API, the Media page, uploads, duplicates and the editor picker.
## Upgrading
- Replace the binary and restart. Your `.env`, SQLite file and uploads directory stay as they are, and there are no new environment variables.
- The database migrates itself on startup. It adds columns to the images table, and a one-time backfill reads each existing image file to fill in its size, type, dimensions and content hash. An image whose file is missing is left alone. Back up your SQLite file and uploads directory first as a precaution.
- Existing images become part of the library and are no longer tied to an article. The old article link is no longer used.
- Clients that call the removed article image endpoints, or read `images` from an article, must switch to `/v1/media/`.
- Browsers keep serving images they cached before the upgrade until they are reloaded once.
## Known limitations
- The Media page shows only the first 100 images.
- There is no rate limit on `/v1/media/` yet.
- The library list loads every article and image in memory, so very large libraries will be slow.
- A file whose extension doesn't match its content type, such as a JPEG labelled as PNG, is rejected.
- Searching filenames with non-ASCII letters is case-sensitive.
- Uploaded SVGs are served as they are. They are sandboxed with a restrictive content security policy, but not sanitised.
+6 -6
View File
@@ -59,9 +59,9 @@ Feature: Articles list screen
remaining articles, and raises an info toast (icon info, id "article-not-found-{id}"): title remaining articles, and raises an info toast (icon info, id "article-not-found-{id}"): title
"That article no longer exists" text "It was deleted somewhere else, so we removed it from "That article no longer exists" text "It was deleted somewhere else, so we removed it from
the list." the list."
And "Delete…" opens ConfirmDialog: title "Delete this article?" text "\"{title}\" and its And "Delete…" opens ConfirmDialog: title "Delete this article?" text "\"{title}\" will be
{n} images will be deleted. This can't be undone." (a components.md convention: the row's deleted. Its images stay in your library. This can't be undone." (the second sentence is
title plus its image count, "and its image" for exactly 1 and no image clause at all for 0), present only when the article body holds at least one image),
"Cancel" and "Delete article" (danger); while the DELETE request is in flight the dialog "Cancel" and "Delete article" (danger); while the DELETE request is in flight the dialog
cannot be dismissed by Cancel, Esc or a backdrop click, both buttons are disabled, the cannot be dismissed by Cancel, Esc or a backdrop click, both buttons are disabled, the
confirm button reads "Deleting…" with a loading indicator, and aria-busy is set confirm button reads "Deleting…" with a loading indicator, and aria-busy is set
@@ -72,7 +72,7 @@ Feature: Articles list screen
And a successful delete (204) closes the dialog, removes the row, moves focus to the next row And a successful delete (204) closes the dialog, removes the row, moves focus to the next row
(or the page heading if the deleted row was last), recomputes the tab counts, and raises a (or the page heading if the deleted row was last), recomputes the tab counts, and raises a
success toast (icon circle-check, id "article-deleted-{id}"): title "Article deleted" text success toast (icon circle-check, id "article-deleted-{id}"): title "Article deleted" text
"\"{title}\" and its images are gone."; this is also where a delete initiated from the editor "\"{title}\" is gone."; this is also where a delete initiated from the editor
ends up ends up
And the list, sorted by last updated with the most recently updated article first, holds And the list, sorted by last updated with the most recently updated article first, holds
these 6 articles by default: id 1 "How we price a bathroom renovation" (online, updated "2 these 6 articles by default: id 1 "How we price a bathroom renovation" (online, updated "2
@@ -231,7 +231,7 @@ Feature: Articles list screen
Given the Default state has loaded, and id 4 "Winter checklist for timber windows" has 3 images Given the Default state has loaded, and id 4 "Winter checklist for timber windows" has 3 images
When the user chooses "Delete…" from id 4's row menu When the user chooses "Delete…" from id 4's row menu
Then ConfirmDialog opens: title "Delete this article?" text "\"Winter checklist for timber Then ConfirmDialog opens: title "Delete this article?" text "\"Winter checklist for timber
windows\" and its 3 images will be deleted. This can't be undone.", "Cancel" and "Delete windows\" will be deleted. Its images stay in your library. This can't be undone.", "Cancel" and "Delete
article" (danger) article" (danger)
Scenario: Delete in progress — the dialog cannot be dismissed while the request is in flight Scenario: Delete in progress — the dialog cannot be dismissed while the request is in flight
@@ -256,4 +256,4 @@ Feature: Articles list screen
Then the dialog closes, id 4's row is removed from the list, and focus moves to the next row Then the dialog closes, id 4's row is removed from the list, and focus moves to the next row
And the tabs read "All 5" "Online 4" "Drafts 1" And the tabs read "All 5" "Online 4" "Drafts 1"
And a success toast appears: icon circle-check, id "article-deleted-4", title "Article And a success toast appears: icon circle-check, id "article-deleted-4", title "Article
deleted", text "\"Winter checklist for timber windows\" and its images are gone." deleted", text "\"Winter checklist for timber windows\" is gone."
+1 -1
View File
@@ -114,7 +114,7 @@ as listed), unless a scenario's Examples row substitutes a smaller fixture.
18. **Deleted** — continuing from 16 (a fresh attempt), resolve the DELETE 204; assert the dialog 18. **Deleted** — continuing from 16 (a fresh attempt), resolve the DELETE 204; assert the dialog
closes, id 4's row is removed, focus moves to the next row; assert tabs read "All 5" "Online closes, id 4's row is removed, focus moves to the next row; assert tabs read "All 5" "Online
4" "Drafts 1"; assert a toast with id "article-deleted-4", icon circle-check, title "Article 4" "Drafts 1"; assert a toast with id "article-deleted-4", icon circle-check, title "Article
deleted", text "\"Winter checklist for timber windows\" and its images are gone." deleted", text "\"Winter checklist for timber windows\" is gone."
## Visual (manual verification — desktop and mobile, light and dark) ## Visual (manual verification — desktop and mobile, light and dark)
@@ -1,84 +0,0 @@
Feature: Deleting an article also deletes its images
As an authenticated RenewCMS user managing the CMS
I want deleting an article to also delete every image attached to it
So that removing an article doesn't leave orphaned image rows and files behind,
and so that an id that doesn't exist still answers 404 the same way every other
article write does
Background:
Given internal/domain/article.Article carries Images []*image.Image, populated by
ArticleRepository.Get via Preload("Images")
And internal/domain/image.Repository already exposes Delete(id uint32) error, which
on the real ImageRepository also removes the uploaded file from UPLOAD_DIR
And before this slice, DeleteArticleUseCase.DeleteArticle only called
articleRepository.Delete(id) and never touched image.Repository, so an article's
images were never deleted along with it
And this slice changes NewDeleteArticleUseCase to also take an image.Repository, and
changes DeleteArticleUseCase.DeleteArticle(id) to look up the article, delete every
image in its Images slice through image.Repository.Delete, and only then delete the
article itself through article.Repository.Delete
And this slice rewires api/dependecyInjection.go so
useCases.NewDeleteArticleUseCase(repos.ArticleRepo, repos.ImageRepo) supplies both
repositories
And DELETE /v1/article/{id} keeps sitting behind the jwtauth Verifier and
Authenticator group in api/router.go, unchanged from
features/write_not_found_errors.feature
And DELETE /v1/article/{id} keeps answering 204 with an empty body on success and 404
with body apperr.ArticleNotFoundErrorMessage for an id absent from the article
repository, backed by errors.Is(err, article.ErrArticleNotFound), unchanged from
features/write_not_found_errors.feature
And no image HTTP endpoint is mounted in api/router.go as of this slice, so cascading
image deletion is only observable through the image repository's state, not through
a separate HTTP call
And the use case deletes images in the order they appear in the article's Images
slice, and aborts on the first image.Repository.Delete error, leaving the article
and any not-yet-deleted images untouched, and answering the same generic 500 the
DeleteArticle handler already falls back to on any non-not-found error
Scenario: Deleting an article with images removes the article and every one of its images
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 whose Images slice holds
image id 10 and image id 11
And the image repository contains images with id 10 and id 11
When the client sends DELETE /v1/article/1 with that cookie
Then the response status is 204
And the article repository no longer contains an article with id 1
And the image repository no longer contains an image with id 10 or id 11
Scenario: Deleting an article with no images still succeeds
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 2 whose Images slice is empty
When the client sends DELETE /v1/article/2 with that cookie
Then the response status is 204
And the article repository no longer contains an article with id 2
And no call reaches the image repository's Delete method
Scenario: Deleting an unknown article still answers 404 and deletes no images
Given a valid jwt cookie for a user with id 1
And the article repository has no article with id 9999
When the client sends DELETE /v1/article/9999 with that cookie
Then the response status is 404
And the response body is the text "Article not found"
And no call reaches the image repository's Delete method
Scenario: Deleting an article still requires authentication
Given no jwt cookie is sent
And the article repository contains an article with id 1 whose Images slice holds
image id 10
When the client sends DELETE /v1/article/1 without a cookie
Then the response status is 401
And the article repository still contains the article with id 1
And the image repository still contains the image with id 10
Scenario: A failure deleting one of an article's images aborts the whole delete
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 3 whose Images slice holds
image id 20 followed by image id 21
And the image repository contains images with id 20 and id 21
And the image repository's Delete fails for image id 20
When the client sends DELETE /v1/article/3 with that cookie
Then the response status is 500
And the response body is the text "Failed to delete article"
And the article repository still contains the article with id 3
And the image repository still contains the image with id 21
@@ -1,67 +0,0 @@
# QA procedure: delete_article_cascades_images
Package: `test` (black-box, extending `article_test.go`).
## Setup shared by every case
1. Call `validator.InitValidator()` once.
2. Reuse `mockArticleRepo` (`test/article_test.go`) and `mockImageRepo`
(`test/image_test.go`) as-is. Extend `mockArticleRepo` seeding so a seeded article's
`Images []*image.Image` can hold pointers into the same `mockImageRepo.images` map used
by the test, so deleting through the article side and asserting through the image side
observe the same state.
3. Add a call counter (or reuse the existing `failSwitch`/call-tracking pattern already in
`test/`) on `mockImageRepo.Delete` so a scenario can assert it was never called, and reuse
`mockImageRepo.fail` (the existing `failSwitch`) to make `Delete` fail for a specific
image id.
4. Wire the real use cases over the fakes: `useCases.NewCreateArticleUseCase`,
`useCases.NewGetArticleUseCase`, `useCases.NewListArticlesUseCase`,
`useCases.NewUpdateArticleUseCase`, and the new two-argument
`useCases.NewDeleteArticleUseCase(articleRepo, imageRepo)`. Build `article.NewHandler(...)`
with the same constructor shape it already has (the use case's internal dependency count
changes, not the handler's).
5. Build a test `jwtauth.New("HS256", []byte("test-secret"), nil)`.
6. Mount the routes on a fresh `chi.NewRouter()` mirroring `api/router.go`'s current shape:
`GET /` under `jwtauth.Verifier` only; `GET /{id}`, `POST /`, `DELETE /{id}`,
`PATCH /{id}`, `PATCH /{id}/publish`, `PATCH /{id}/unpublish` under `jwtauth.Verifier` +
`jwtauth.Authenticator`.
7. Mint a valid jwt cookie for user id 1: `token.Encode(map[string]any{"user_id": uint32(1)})`,
set as the `jwt` cookie on the relevant `httptest.NewRequest`.
## HTTP checks
8. Seed `imageRepo.images[10]` and `imageRepo.images[11]`, then seed
`articleRepo.articles[1] = article.Article{ID: 1, Title: "Backyard Renovation Update",
Images: []*image.Image{imageRepo.images[10], imageRepo.images[11]}}` (pointers into the
same map). Send `DELETE /v1/article/1` with the valid cookie -> status `204`; assert
`articleRepo.articles` no longer has key `1`; assert `imageRepo.images` no longer has
keys `10` or `11`.
9. Seed `articleRepo.articles[2] = article.Article{ID: 2, Title: "Draft: Kitchen Remodel"}`
with a nil/empty `Images` slice. Send `DELETE /v1/article/2` with the valid cookie ->
status `204`; assert `articleRepo.articles` no longer has key `2`; assert the image-delete
call counter is `0`.
10. Empty `articleRepo`, empty `imageRepo`. Send `DELETE /v1/article/9999` with the valid
cookie -> status `404`, body `"Article not found\n"`; assert the image-delete call
counter is `0`.
11. Seed `imageRepo.images[10]` and `articleRepo.articles[1]` with `Images` holding image id
10, per step 8's pattern. Send `DELETE /v1/article/1` with **no** cookie -> status `401`;
assert `articleRepo.articles[1]` is still present and `imageRepo.images[10]` is still
present.
12. Seed `imageRepo.images[20]` and `imageRepo.images[21]`, then seed
`articleRepo.articles[3] = article.Article{ID: 3, Title: "Kitchen Progress Photos",
Images: []*image.Image{imageRepo.images[20], imageRepo.images[21]}}` (image 20 before
image 21 in the slice). Arm `imageRepo.fail` so `Delete` returns an error only when
called with id `20` (leave id `21` unarmed). Send `DELETE /v1/article/3` with the valid
cookie -> status `500`, body `"Failed to delete article\n"`; assert
`articleRepo.articles[3]` is still present; assert `imageRepo.images[21]` is still
present (never reached because the delete aborted on image `20` first).
## Manual verification (not exercised by the Go test)
- Real cascading delete against a real SQLite file and real `UPLOAD_DIR`: confirm that after
`DELETE /v1/article/{id}` on a real running instance, the uploaded image files under
`UPLOAD_DIR` for that article are gone from disk and the `images` table no longer holds
those rows, and that no orphaned rows are left behind in the `article_images` join table.
Not exercised by `task test` (fakes only); requires `task dev` or a built `task build`
binary with a scratch DB/upload dir.
- SPA-visible behaviour: confirm the back-office article deletion flow (if/when one exists)
reflects that any images shown for that article are gone too. No such UI exists yet as of
this slice.
- No SMTP involvement.
@@ -0,0 +1,39 @@
Feature: Deleting an article never deletes images
As an authenticated RenewCMS user
I want deleting an article to leave the media library alone
So that an image used by other articles, or waiting in the library, is never lost
Background:
Given images belong to the library and internal/domain/article.Article carries no Images
And DeleteArticleUseCase depends only on article.Repository and calls its Delete
And api/dependecyInjection.go builds useCases.NewDeleteArticleUseCase(repos.ArticleRepo)
And DELETE /v1/article/{id} stays behind the same jwtauth group, answering 204 with an
empty body, 404 with apperr.ArticleNotFoundErrorMessage for errors.Is(err,
article.ErrArticleNotFound), and the generic 500 "Failed to delete article" otherwise
Scenario: Deleting an article leaves every image it used
Given a valid jwt cookie for user 1
And article 1 whose body contains the urls of images 10 and 11
And the image repository contains images 10 and 11
When the client sends DELETE /v1/article/1
Then the response status is 204
And the article repository no longer contains article 1
And the image repository still contains images 10 and 11 and Delete was never called
Scenario: Deleting an unknown article is 404 and touches no image
Given a valid jwt cookie for user 1 and no article 9999
When the client sends DELETE /v1/article/9999
Then the response status is 404 and the body is "Article not found"
And the image repository is untouched
Scenario: Deleting still requires authentication
Given no jwt cookie
When the client sends DELETE /v1/article/1
Then the response status is 401 and article 1 and image 10 still exist
Scenario: An article repository failure is 500 and everything stays
Given the article repository's Delete fails
When the client sends DELETE /v1/article/1 with a valid cookie
Then the response status is 500 and the body is "Failed to delete article"
And the image repository is untouched
@@ -0,0 +1,12 @@
# QA procedure: delete_article_keeps_images
Package: `test` (black-box). Wire `useCases.NewDeleteArticleUseCase(articleRepo)` (single argument) with the other article use cases and mount as in the retired `delete_article_cascades_images.qa.md`: `Verifier` plus `Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`, jwt cookie for user id 1. Seed images 10 and 11 in `mockImageRepo` and the article body with their urls. Call `validator.InitValidator()` first.
## HTTP checks
1. `DELETE /v1/article/1` with a valid cookie -> 204, `articleRepo` no longer holds article 1, `imageRepo.images` still holds 10 and 11 and `deleteCalls` is empty.
2. `DELETE /v1/article/9999` -> 404, body `"Article not found\n"`, image repo untouched.
3. `DELETE /v1/article/1` without cookie -> 401, article and images still present.
4. Article fake's `Delete` armed to fail -> 500, body `"Failed to delete article\n"`, image repo untouched.
## Manual verification
With a real SQLite file and `UPLOAD_DIR`, delete an article and confirm the image files and rows remain and still load. No SMTP.
+10 -8
View File
@@ -9,8 +9,10 @@ Feature: Crop and resize step in the editor's image uploader
Given ImageUploader inserts a crop-and-resize step between the idle file picker and the Given ImageUploader inserts a crop-and-resize step between the idle file picker and the
upload, for any JPEG, PNG or WEBP that already passed the browser validation described in upload, for any JPEG, PNG or WEBP that already passed the browser validation described in
features/editor_image_upload.feature; that feature's idle, dragging-over, rejected, features/editor_image_upload.feature; that feature's idle, dragging-over, rejected,
uploading, inserted and failed states and its POST /v1/article/{articleId}/image contract uploading, inserted and failed states and its POST /v1/media/ contract are unchanged and
are unchanged and out of scope here out of scope here
And the hash lookup from features/editor_image_upload.feature runs before this step opens,
so the step only opens for a file with no match, or after "Upload anyway"
And an SVG file skips this step entirely and uploads immediately, as before And an SVG file skips this step entirely and uploads immediately, as before
And opening the step retitles the modal from "Add an image" to "Crop and resize", with the And opening the step retitles the modal from "Add an image" to "Crop and resize", with the
description "{filename} · {size}" (e.g. "north-wall.jpg · 3.9 MB") description "{filename} · {size}" (e.g. "north-wall.jpg · 3.9 MB")
@@ -32,8 +34,8 @@ Feature: Crop and resize step in the editor's image uploader
crop is narrower than the chosen width, an info row reads "The crop is {crop width} px crop is narrower than the chosen width, an info row reads "The crop is {crop width} px
wide, so it's uploaded at that size. Enlarging it would make it blurry." wide, so it's uploaded at that size. Enlarging it would make it blurry."
And the footer offers "Choose another file" (discards this step and returns to the idle file And the footer offers "Choose another file" (discards this step and returns to the idle file
picker with no adjustments carried over) and "Upload image" (uploads the cropped, resized picker, with the tabs, with no adjustments carried over) and "Upload image" (uploads the cropped, resized
file through the unchanged POST /v1/article/{articleId}/image flow) file through the unchanged POST /v1/media/ flow)
And "Rotate" turns the image 90° clockwise each activation, cycling 0°→90°→180°→270°→0°, and And "Rotate" turns the image 90° clockwise each activation, cycling 0°→90°→180°→270°→0°, and
width presets apply to the post-rotation image width presets apply to the post-rotation image
And "Reset" returns the ratio to Original, the width to 1600 px, the crop frame to the whole And "Reset" returns the ratio to Original, the width to 1600 px, the crop frame to the whole
@@ -43,7 +45,7 @@ Feature: Crop and resize step in the editor's image uploader
Given ImageUploader is open in the idle state for article 12 Given ImageUploader is open in the idle state for article 12
When the user chooses a file named "north-wall.jpg", content type "image/jpeg", size 3.9 MB, When the user chooses a file named "north-wall.jpg", content type "image/jpeg", size 3.9 MB,
natural dimensions 4032 × 3024 px natural dimensions 4032 × 3024 px
Then no request is sent to POST /v1/article/12/image Then no request is sent to POST /v1/media/
And the modal retitles to "Crop and resize" with the description "north-wall.jpg · 3.9 MB" And the modal retitles to "Crop and resize" with the description "north-wall.jpg · 3.9 MB"
And the crop frame starts as the whole image, ratio "Original" is selected, and width And the crop frame starts as the whole image, ratio "Original" is selected, and width
"1600 px" is selected "1600 px" is selected
@@ -53,7 +55,7 @@ Feature: Crop and resize step in the editor's image uploader
Scenario: An SVG skips the crop step and uploads immediately Scenario: An SVG skips the crop step and uploads immediately
Given ImageUploader is open in the idle state for article 12 Given ImageUploader is open in the idle state for article 12
When the user chooses a file named "diagram.svg", content type "image/svg+xml", size 40 KB When the user chooses a file named "diagram.svg", content type "image/svg+xml", size 40 KB
Then POST /v1/article/12/image is sent immediately with that file Then POST /v1/media/ is sent immediately with that file
And the crop-and-resize step is never shown And the crop-and-resize step is never shown
Scenario Outline: Choosing an aspect ratio locks the frame to it Scenario Outline: Choosing an aspect ratio locks the frame to it
@@ -108,7 +110,7 @@ Feature: Crop and resize step in the editor's image uploader
Scenario: "Choose another file" returns to the file picker without uploading Scenario: "Choose another file" returns to the file picker without uploading
Given the crop-and-resize step is open for "north-wall.jpg" Given the crop-and-resize step is open for "north-wall.jpg"
When the user activates "Choose another file" When the user activates "Choose another file"
Then no request is sent to POST /v1/article/12/image Then no request is sent to POST /v1/media/
And the idle state is shown again, with the file picker available And the idle state is shown again, with the file picker available
Scenario: A newly chosen file opens a fresh crop step, not the previous file's adjustments Scenario: A newly chosen file opens a fresh crop step, not the previous file's adjustments
@@ -123,7 +125,7 @@ Feature: Crop and resize step in the editor's image uploader
Given the crop-and-resize step is open for "north-wall.jpg" with ratio "Original", width Given the crop-and-resize step is open for "north-wall.jpg" with ratio "Original", width
"1600 px" and the mono line "Uploads as 1600 × 1200 px · about 420 KB" "1600 px" and the mono line "Uploads as 1600 × 1200 px · about 420 KB"
When the user activates "Upload image" When the user activates "Upload image"
Then POST /v1/article/12/image is sent with a single file under the field name "file" whose Then POST /v1/media/ is sent with a single file under the field name "file" whose
pixel dimensions are 1600 × 1200 and whose size is about 420 KB, not the original pixel dimensions are 1600 × 1200 and whose size is about 420 KB, not the original
4032 × 3024, 3.9 MB file 4032 × 3024, 3.9 MB file
And the uploading state shows "Uploading 0.0 of 420 KB" And the uploading state shows "Uploading 0.0 of 420 KB"
+1 -1
View File
@@ -18,7 +18,7 @@ it renders internally (canvas/OffscreenCanvas is a coder implementation detail).
"1600 px" selected, mono line "Uploads as 1600 × 1200 px · about 420 KB", footer "Choose "1600 px" selected, mono line "Uploads as 1600 × 1200 px · about 420 KB", footer "Choose
another file" / "Upload image". another file" / "Upload image".
2. **An SVG bypasses the step** — choose `diagram.svg` (image/svg+xml, 40 KB); assert an XHR to 2. **An SVG bypasses the step** — choose `diagram.svg` (image/svg+xml, 40 KB); assert an XHR to
`/v1/article/12/image` is created immediately and the crop step never renders. `/v1/media/` is created immediately and the crop step never renders.
3. **Ratio presets lock the frame, Examples-driven (16:9 / 4:3 / 1:1)** — from the opened step, 3. **Ratio presets lock the frame, Examples-driven (16:9 / 4:3 / 1:1)** — from the opened step,
select each ratio pill; assert the frame's reported rect and the mono line's output size match select each ratio pill; assert the frame's reported rect and the mono line's output size match
the feature's Examples table. the feature's Examples table.
+68 -126
View File
@@ -1,148 +1,90 @@
Feature: Removing an inline image from the editor Feature: Removing an inline image from the article
As a verified RenewCMS owner writing an article As a verified RenewCMS owner writing an article
I want to remove an image I've already inserted into the article body, I want to take an image out of the article body, from a button or with Backspace or Delete
either through an explicit delete button or by pressing Backspace/Delete So that an unwanted image leaves the article without deleting the file from my library
on the selected image, directly from the editor
So that a mistaken or unwanted image doesn't have to stay in the article
forever, and removing it behaves like every other destructive action in
RenewCMS: it asks for confirmation first, however the user starts it
Background: Background:
Given docs/design/ has no board or state for removing an inline image; Given this slice is SPA only for this file; the Go alias deletion is in
this feature was added at the user's explicit request ahead of design features/remove_legacy_article_image_alias.feature
coverage, reusing the editor's existing visual language rather than And docs/design/screens/media-picker.md now covers this flow ("Remove image: confirm" and
inventing new components: the trash icon and error color already used "Removed"); it supersedes the earlier version of this feature, which had no design
by the article "Delete" button, and the ConfirmDialog component coverage and used a red confirm that deleted through DELETE /v1/article/{id}/image/{imageId}
already used by "Delete this article?" (docs/design/screens/editor.md's And removing an image is a body edit only: no API call of any kind is sent, before, during
"Delete: confirm" state) or after the confirm, and the library and every other article are untouched
And this slice changes ArticleEditor's exposed insertImage(src, alt) to And ArticleEditor keeps its exposed insertImage(src, alt, imageId) and removeImage(imageId),
insertImage(src, alt, imageId), stamping the inserted <img> node with its "request-remove-image" event with the image id, and its Backspace and Delete handling,
the image's numeric id as a data-image-id attribute, persisted in the which still intercepts the keys before Tiptap deletes the node
saved article body HTML; EditorView passes the id it now receives from And an image already in a body from before data-image-id existed has no id, shows no
a successful upload (features/remove_article_image.feature's change to "Remove image" button and is not intercepted by Backspace or Delete (unchanged)
POST .../image's 201 body) through to insertImage And selecting an image node that carries data-image-id shows a button "Remove image" at the
And an image already present in an article's body from before this slice image's top-right corner (8 px from the top and right): trash-2 icon at 14 px, neutral,
has no data-image-id attribute and so shows no delete affordance and 28 px high, 12 px medium text, a 1 px accented border and the page background; deselecting
is not intercepted by Backspace/Delete, until it is removed and
re-added
And selecting an image node that does carry a data-image-id attribute
shows a small delete button anchored to the image's top-right corner,
icon i-lucide-trash-2, color error, matching the article Delete
button's icon and color; deselecting the image (clicking elsewhere)
hides it hides it
And there are two equivalent triggers for the same removal flow: And EditorView reacts to "request-remove-image" by opening ConfirmDialog with tone "neutral",
activating that delete button, and pressing Backspace or Delete while title "Remove this image from the article?", description "It's removed from this article
the image node is selected (a Tiptap NodeSelection on the image). only. The file stays in your library and in any other article that uses it.", confirmLabel
Tiptap's native node deletion on Backspace/Delete is intercepted "Remove image" and cancelLabel "Cancel"; the confirm button is primary, not red
before it reaches the document (e.g. via a keyboard-shortcut/keydown And the body of the confirm is a bordered row with a 56 × 42 px thumbnail of the image, its
handler registered on the Image node or at the editor level, checked filename, and "Stays in the library · {size}", for example "north-wall.jpg" and "Stays in
first while an image node is selected) so the key press never removes the library · 3.9 MB"
the node directly; instead it triggers exactly the same flow as the And the filename and size come from the images the SPA has seen this session, kept by image
button id: those inserted from either tab and those loaded in the Library list
And either trigger emits ArticleEditor's new "request-remove-image" And confirming runs removeImage(imageId), moves useArticleDraft to "dirty", closes the
event with the node's numeric image id; ArticleEditor does not call dialog and shows the toast; the action cannot fail, so there is no inline error
the API or show any dialog itself, mirroring how "request-image" And a thumbnail uses the image's src from the node
already only opens ImageUploader from EditorView
And EditorView reacts to "request-remove-image" by opening the existing
ConfirmDialog component with title "Remove this image?", description
"This image will be removed from the article. This can't be undone.",
confirmLabel "Remove image", cancelLabel "Cancel", pendingLabel
"Removing…", tone "danger" — the same component and tone already used
for "Delete this article?"
And ConfirmDialog's action calls DELETE /v1/article/{articleId}/image/
{imageId} through useApi (not the raw XMLHttpRequest used for upload,
since there is no progress to observe)
And on success (204) the action removes the image node from
ArticleEditor's content via a new exposed removeImage(imageId) method,
which moves useArticleDraft's state to "dirty" the same way any other
body edit does; ConfirmDialog then closes and a toast appears: color
success, icon i-lucide-circle-check, title "Image removed",
description "Save to keep this change."
And on any failure (404 for either the article or the image, 500, or a
network failure) the action throws, so ConfirmDialog shows its own
inline error "Couldn't remove the image, so it's still there. Try
again in a moment." and stays open; the image node is not removed and
the draft's dirty state is unchanged
And a 401 is handled entirely by useApi's global handling
(features/api_client_global_error_handling.feature) and is out of
scope for new scenarios here
And once the confirm dialog is open, its cancel/confirm/failure
behaviour (covered below) is identical regardless of which trigger
opened it, so those scenarios are written once, against the button
trigger, and are not duplicated per trigger
Scenario: Selecting an inserted image reveals its delete button Scenario: Selecting an inserted image reveals "Remove image"
Given the user is on /article/12/edit with an image in the body carrying Given the user is on /article/12/edit with an image in the body carrying data-image-id "1"
data-image-id "10"
When the user clicks that image When the user clicks that image
Then a delete button (trash icon, error color) appears at the image's Then a button "Remove image" with a trash-2 icon shows at the image's top-right corner
top-right corner
When the user clicks elsewhere in the editor When the user clicks elsewhere in the editor
Then the delete button is no longer shown Then the button is no longer shown
Scenario Outline: Either trigger opens the confirm dialog and sends no request yet, without letting Tiptap delete the node itself Scenario: An image without data-image-id has no button
Given the image with data-image-id "10" is selected on /article/12/edit Given the body has an image with no data-image-id
When the user clicks it
Then no "Remove image" button shows and Backspace does nothing special to it
Scenario Outline: Either trigger opens the neutral confirm and sends nothing
Given the image with data-image-id "1" ("north-wall.jpg", 3.9 MB, inserted this session) is selected on /article/12/edit
When the user <triggers-removal> When the user <triggers-removal>
Then a dialog titled "Remove this image?" opens, with description "This Then a dialog titled "Remove this image from the article?" opens, with the description "It's removed from this article only. The file stays in your library and in any other article that uses it."
image will be removed from the article. This can't be undone.", And its body shows the thumbnail, "north-wall.jpg" and "Stays in the library · 3.9 MB"
cancel "Cancel" and confirm "Remove image" And its buttons read "Cancel" and a primary "Remove image"
And no request has been sent to DELETE /v1/article/12/image/10 And no request has been sent
And the image node is still present in the editor's content (Tiptap's And the image node is still in the editor's content (Tiptap's native deletion did not run)
native node deletion did not run)
Examples: Examples:
| triggers-removal | | triggers-removal |
| activates the image's delete button | | activates the "Remove image" button |
| presses Backspace with the image node selected | | presses Backspace with the image node selected |
| presses Delete with the image node selected | | presses Delete with the image node selected |
Scenario: Cancelling the confirm dialog changes nothing Scenario: The confirm for an image the session has not seen
Given the confirm dialog is open for image id 10 on /article/12/edit Given the image with data-image-id "7" was in the saved body and is not in the session's record
When the user activates "Remove image"
Then the body row shows the thumbnail and "Stays in the library" with no filename and no size
Scenario: Cancelling changes nothing
Given the confirm is open for image id 1
When the user activates "Cancel" When the user activates "Cancel"
Then the dialog closes Then the dialog closes, no request has been sent, the image is still in the content, and the draft's dirty state is unchanged
And no request has been sent to DELETE /v1/article/12/image/10
And the image is still present in the editor's content
And the draft's dirty state is unchanged
Scenario: Confirming removes the image, marks the draft dirty, and shows a success toast Scenario: Confirming removes the image from the body only
Given the confirm dialog is open for image id 10 on /article/12/edit, Given the confirm is open for image id 1 on /article/12/edit, the draft in the Saved state
the draft in the Saved state
When the user activates "Remove image" When the user activates "Remove image"
Then DELETE /v1/article/12/image/10 is sent Then the dialog closes and the image leaves the editor's content
When that request resolves 204 And SaveStatus reads "Unsaved changes" and Save is enabled
Then the dialog closes And a toast appears: color success, icon i-lucide-circle-check, title "Image removed", description "It's still in your library."
And the image is removed from the editor's content And no request of any kind has been sent
And SaveStatus reads "Unsaved changes" and the Save button becomes
enabled
And a toast appears: color success, icon i-lucide-circle-check, title
"Image removed", description "Save to keep this change."
Scenario Outline: A failed removal keeps the image and shows the dialog's inline error Scenario: A removal started by Backspace behaves like one started by the button
Given the confirm dialog is open for image id 10 on /article/12/edit Given the confirm is open for image id 1, opened by Backspace, the draft in the Saved state
When the user activates "Remove image" When the user activates "Remove image"
And DELETE /v1/article/12/image/10 <failure> Then the outcome is the same as above
Then the dialog stays open, showing the inline error "Couldn't remove
the image, so it's still there. Try again in a moment."
And the image is still present in the editor's content
And the draft's dirty state is unchanged
Examples: Scenario: The removed image can be picked again
| failure | Given image id 1 was removed from the body this session
| resolves with a 404 response | When the user opens the Library tab
| resolves with a 500 response | Then "north-wall.jpg" no longer carries "In this article"
| never reaches the server |
Scenario: Confirming a removal started by Backspace/Delete behaves exactly like confirming one started by the button
Given the confirm dialog is open for image id 10 on /article/12/edit,
opened by pressing Backspace with the image node selected, the draft
in the Saved state
When the user activates "Remove image"
Then DELETE /v1/article/12/image/10 is sent
When that request resolves 204
Then the dialog closes
And the image is removed from the editor's content
And SaveStatus reads "Unsaved changes" and the Save button becomes
enabled
And a toast appears: color success, icon i-lucide-circle-check, title
"Image removed", description "Save to keep this change."
+15 -78
View File
@@ -1,88 +1,25 @@
# QA: Removing an inline image from the editor # QA: Removing an inline image from the article
## Automated (Vitest + Vue Test Utils, `task test:web`) ## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount EditorPage inside DashboardShell at `/article/12/edit` with the Mount EditorPage at `/article/12/edit` with the `editor_core.qa.md` fixture article and a body of `<img src="/static/uploadedImages/1.jpg" data-image-id="1" alt="">`. Seed the session's image record with id 1 ("north-wall.jpg", 3.9 MB). Spy on `request` and on `XMLHttpRequest` and assert they are never called by any step below.
editor_core.qa.md fixture article (id 12, "How we price a bathroom
renovation", is_online false), GET already resolved 200, and a body
containing `<img src="/static/uploadedImages/before.png" data-image-id="10"
alt="">`. Mock `useApi`'s `request` for `DELETE /v1/article/12/image/10`
(fetch-based, not XHR — reuse whatever mock
`api_client_global_error_handling.qa.md` already sets up for `request`).
1. **Selecting the image reveals the delete button** — click the image 1. **Selecting reveals the button** - click the image; assert a button named "Remove image" with a trash-2 icon renders at the corner; click elsewhere; assert it is gone.
node; assert a delete button (trash icon, error color) renders anchored 2. **No id, no button** - an image without `data-image-id`: no button, and a Backspace keydown does not open the dialog.
to it; click elsewhere in the editor; assert the button is gone. 3. **Triggers, Examples-driven** - button click, Backspace keydown and Delete keydown each open the dialog with the exact title, description, "Cancel" and a primary (not error) "Remove image"; assert the body shows the thumbnail, "north-wall.jpg" and "Stays in the library · 3.9 MB"; assert no request; assert the node is still in the document.
2. **Activating the delete button opens the confirm dialog** — select the 4. **Unseen image** - id 7 not in the record; assert the row shows the thumbnail and "Stays in the library" only.
image, activate its delete button; assert a dialog titled "Remove this 5. **Cancel changes nothing.**
image?", description "This image will be removed from the article. This 6. **Confirm** - click "Remove image"; assert the dialog closes, the node is gone, SaveStatus "Unsaved changes", Save enabled, the toast (color success, icon `i-lucide-circle-check`, "Image removed" / "It's still in your library."), and zero requests.
can't be undone.", cancel "Cancel", confirm "Remove image"; assert no 7. **Backspace-opened confirm** gives the same outcome as 6.
request was sent yet; assert the image node is still present (Tiptap's 8. **Removed image loses its tag** - open the Library after step 6; assert "north-wall.jpg" has no "In this article" tag.
native deletion did not run).
3. **Backspace on the selected image opens the same confirm dialog instead
of deleting the node** — select the image node (NodeSelection), fire a
`keydown` for `Backspace`; assert the same dialog as step 2 opens;
assert no request was sent yet; assert the image node is still present
in the editor's document (i.e. the keydown was intercepted before
Tiptap's default node-deletion ran, not just undone afterwards).
4. **Delete on the selected image opens the same confirm dialog instead of
deleting the node** — same as step 3 but firing `keydown` for `Delete`.
5. **Cancelling changes nothing** — from the open dialog (opened via the
button), click "Cancel"; assert the dialog closes, no request was sent,
the image node is still in the editor's content, and SaveStatus/dirty
state is unchanged.
6. **Confirming removes the image, marks dirty, and toasts (button
trigger)** — from the open dialog (Saved state), click "Remove image";
assert a DELETE request to `/v1/article/12/image/10` was made; resolve
it 204; assert the dialog closes, the image node is gone from the
editor's content, SaveStatus reads "Unsaved changes" and Save is
enabled, and a toast appears with color success, icon
`i-lucide-circle-check`, title "Image removed", description "Save to
keep this change."
7. **Confirming removes the image the same way when opened via
Backspace/Delete** — repeat step 6's assertions, but open the dialog via
the Backspace keydown from step 3 instead of the button; assert the
outcome (request, node removal, dirty state, toast) is identical.
8. **A failed removal keeps the image and shows the inline error,
Examples-driven** — for a 404 response, a 500 response, and a rejected/
network-failure request: from the open dialog (button trigger), click
"Remove image"; resolve/reject accordingly; assert the dialog stays open
showing the inline error "Couldn't remove the image, so it's still
there. Try again in a moment."; assert the image node is still present
and the draft's dirty state is unchanged.
## Visual ## Visual: `task visual:check -- media-picker`
No `docs/design/` board or state exists for this trigger (see the feature's The states "Remove image: confirm" and "Removed" are in the `media-picker` group; the setups are in `editor_media_picker.qa.md`. The sample data is north-wall.jpg, image id 1, 3.9 MB. The "Remove image" button is visible in "Remove image: confirm" and "Inserted", so the setup must select the image node. The confirm's confirm button must not be error-coloured.
Background). Per the user's explicit direction this slice ships the SPA
behaviour anyway, but `task visual:check -- editor` has nothing to compare
it against, so no visual capture is added for it — this is an intentional
gap, `skip: no-design-coverage` (distinct from `skip: clock-sensitive`), not
a QA oversight. If a board for this state is added to `docs/design/` later,
it needs its own follow-up slice to add the visual check.
- [ ] Keyboard/focus: once a delete button is shown, it is reachable by Tab - [ ] Keyboard and focus: the button is reachable by Tab and activates with Enter or Space. Backspace or Delete with focus outside the editor does nothing. ConfirmDialog's focus trap and default focus on "Cancel" are unchanged.
and activates on Enter/Space; pressing Backspace/Delete with the
image node selected but focus elsewhere in the page does not trigger
removal (only an actual Tiptap NodeSelection on the image should);
ConfirmDialog's existing focus trap and cancel-button-default-focus
behaviour (already covered by the article-delete flow) applies
unchanged regardless of which trigger opened it.
## Manual verification ## Manual verification
- **Real DELETE round trip against a running server** — Vitest only mocks - **Real keyboard behaviour** in a real browser, including an IME: Backspace or Delete on a selected image opens the confirm instead of deleting the node (jsdom doesn't exercise ProseMirror's keymap precedence).
`useApi`'s request; confirm a real removal (204/404/500) against - **Save after removal** against `task dev`: remove an image, save, reload; the image is gone from the article and still listed in `/media`.
`task dev` with a live SQLite file and the real
`DELETE /v1/article/{id}/image/{imageId}` handler, since
`features/remove_article_image.feature`'s httptest suite covers the
handler in isolation but never through this SPA flow.
- **Real keyboard behaviour** — confirm in a real browser that selecting an
image (click) and pressing Backspace/Delete opens the confirm dialog
rather than deleting the node, including with an IME or on a platform
where Backspace/Delete semantics differ, since jsdom's `keydown`
simulation doesn't exercise Tiptap's real ProseMirror keymap precedence.
- **Design review of the delete-button/ConfirmDialog approach** — since
there is no board for this state, once a board is added, re-verify this
implementation still matches it (or update the implementation to match);
until then this is a best-effort, design-team-unreviewed UI.
+153 -98
View File
@@ -1,58 +1,75 @@
Feature: Wiring the editor's image upload flow Feature: The editor's Upload tab uploads to the library
As a verified RenewCMS owner writing an article As a verified RenewCMS owner writing an article
I want the editor's image button (and dropping a file onto the editor) to open an upload I want the Upload tab of the "Add an image" dialog to check my file, spot duplicates,
modal that validates the file, shows progress, and inserts the result at my cursor upload to my library and insert the result at my cursor
So that I can add images to an article's body without leaving the editor, with clear So that I can add images to an article's body without leaving the editor, with clear
feedback when a file is rejected or the upload fails feedback when a file is rejected or the upload fails
Background: Background:
Given EditorView.vue listens for ArticleEditor's "request-image" event and, on it, opens Given this slice is SPA only for this file: the API from the media library slice is used
ImageUploader (a UModal titled "Add an image") with the current article's id unchanged
And dropping a file directly onto the editor surface also opens ImageUploader, with that And EditorView listens for ArticleEditor's "request-image" event and opens ImageUploader
file already provided to it instead of an empty idle state (a UModal titled "Add an image") with the picker tabs, whose Library tab is specified in
And ImageUploader validates a chosen or dropped file in the browser, checking both its MIME features/editor_media_picker.feature
type and its file extension against png/jpeg/webp/svg, and that its size is at most And dropping a file onto the editor surface opens ImageUploader on the Upload tab with that
10 × 1024 × 1024 bytes, before sending anything file already provided, and no list request is sent until the Library tab is opened
And a file that passes validation is uploaded via a new useImageUpload composable, using And this file supersedes these statements of the earlier version: the upload goes to
XMLHttpRequest (not useApi) so upload progress is observable, as multipart/form-data with POST /v1/media/ and no longer to POST /v1/article/{articleId}/image; the 201 body is the
the file under the field name "file", to POST /v1/article/{articleId}/image, per library item { id, filename, url, size, content_type, width, height, sha256, uploaded_by,
features/upload_article_image.feature's already-implemented and already-mounted contract created_at, used_in, duplicate }; there is no article id in any upload request and no 404
And a 201 response's {"location": "..."} is inserted into ArticleEditor's content at the for a deleted article on upload
current cursor position via an exposed insertImage(src, alt) method, with alt ""; this And ImageUploader validates a chosen or dropped file in the browser with validateImageFile
moves useArticleDraft's state to "dirty" the same way any other body edit does from web/src/lib/imageUpload.ts, size first (10485760 bytes allowed, one byte more is too
And closing the modal while an upload is in flight aborts the XMLHttpRequest large), then MIME type and extension must both be allowed (png, jpeg, webp, svg), before
And a 401 response is handled entirely by useApi's global handling hashing or sending anything
(features/api_client_global_error_handling.feature) and is out of scope for new scenarios And a file that passes validation is hashed with hashFile from web/src/lib/fileHash.ts (the
here; the same is true of a 404 (the article was deleted elsewhere), which the editor's lib shared with MediaPage), and GET /v1/media/?hash=<sha256 hex> is sent through useApi;
existing not-found handling already covers (features/editor_core.feature) when hashFile resolves null, or the lookup fails with 500 or a network error, the check
is skipped and the flow continues as if nothing matched
And a file with no match continues exactly as in features/editor_image_crop_resize.feature:
a JPEG, PNG or WEBP opens the crop and resize step, an SVG is uploaded immediately
And the upload is sent with XMLHttpRequest (not useApi) so progress is observable, as
multipart/form-data with the file under the field name "file" to POST /v1/media/, with the
jwt cookie
And a 201 inserts the item's url at the cursor through insertImage(item.url, "", item.id),
so the node carries data-image-id, and useArticleDraft moves to "dirty"
And closing the dialog while an upload is in flight aborts the XMLHttpRequest
And the tabs show only on the choose step (idle, the duplicate card and the Library tab);
the crop, uploading, rejected and failed steps keep the earlier look without tabs
And the footer on the Upload tab holds only the step's own actions ("Cancel" in idle);
there is no "Insert image" button on it
Scenario: Opening the uploader from the toolbar's image button shows the idle state Rule: Opening
Scenario: The toolbar's image button opens on Upload when the library is empty
Given the user is on /article/12/edit with the editor focused Given the user is on /article/12/edit with the editor focused
And GET /v1/media/?limit=24 resolves 200 with no item
When the user activates the toolbar's "Image" button When the user activates the toolbar's "Image" button
Then ImageUploader opens titled "Add an image" Then ImageUploader opens titled "Add an image" with the tabs "Upload" (icon upload) and "Library" (icon images)
And it shows "Drop an image here", a "Choose file" action, and the help text And the Upload tab is selected and shows the image-plus icon, "Drop an image here, or choose a file" and "PNG, JPEG, WEBP, SVG · max 10 MB"
"PNG, JPEG, WEBP, SVG · max 10 MB" And its footer reads "Cancel" only
And its cancel action reads "Cancel" And no POST has been sent
And no request has been sent
Scenario: Dragging a file over the editor surface shows the dragging-over state Scenario: Dropping a file opens Upload with the file
Given ImageUploader is open in the idle state Given the user is on /article/12/edit and ImageUploader is not open
When the user drops a file named "before.png", content type "image/png", size 3.9 MB, onto the editor surface
Then ImageUploader opens on the Upload tab
And GET /v1/media/?hash=<sha256 of the file> is sent, and no request to GET /v1/media/?limit=24 is sent
And when the lookup resolves 200 with no item the crop and resize step shows for "before.png"
And no POST /v1/media/ has been sent
Scenario: Dragging a file over the drop zone shows the dragging-over state
Given ImageUploader is open on the Upload tab in the idle state
When the user drags a file over the drop zone When the user drags a file over the drop zone
Then the drop zone reads "Drop to upload" Then the drop zone reads "Drop to upload"
Scenario: Dropping a valid file onto the editor opens the uploader already uploading it Rule: Browser checks, nothing is sent
Given the user is on /article/12/edit with the editor focused and ImageUploader is not open
When the user drops a file named "before.png", content type "image/png", size 3.9 MB, onto
the editor surface
Then ImageUploader opens titled "Add an image"
And POST /v1/article/12/image is sent with that file under the field name "file"
And the uploading state is shown, not the idle state
Scenario Outline: Choosing a file the browser can reject never sends a request Scenario Outline: Choosing a file the browser can reject sends nothing
Given ImageUploader is open in the idle state for article 12 Given ImageUploader is open in the idle state
When the user chooses a file named "<filename>", content type "<content_type>", size <size> When the user chooses a file named "<filename>", content type "<content_type>", size <size>
Then no request is sent to POST /v1/article/12/image Then no hash lookup and no POST /v1/media/ is sent
And the rejected state shows the title "\"<filename>\" can't be used" And the rejected state shows the title "\"<filename>\" can't be used"
And its text reads "<description>" And its text reads "<description>"
And its confirm action reads "Choose another file" and its cancel action reads "Cancel" And its confirm action reads "Choose another file" and its cancel action reads "Cancel"
@@ -64,73 +81,111 @@ Feature: Wiring the editor's image upload flow
| north-wall.tif | image/png | 14.2 MB | north-wall.tif is 14.2 MB. Images can be up to 10 MB. | | north-wall.tif | image/png | 14.2 MB | north-wall.tif is 14.2 MB. Images can be up to 10 MB. |
Scenario: "Choose another file" from the rejected state returns to idle Scenario: "Choose another file" from the rejected state returns to idle
Given ImageUploader is in the rejected state for a file named "plan.pdf" Given ImageUploader is in the rejected state for "plan.pdf"
When the user activates "Choose another file" When the user activates "Choose another file"
Then the idle state is shown again, with the file picker available Then the idle state is shown again, with the tabs
Scenario: A valid upload shows progress and its cancel action reads "Cancel upload" Rule: Already in your library
Given ImageUploader is open in the idle state for article 12
When the user chooses a file named "north-wall.jpg", content type "image/jpeg", size 3.9 MB
Then POST /v1/article/12/image is sent with that file under the field name "file"
And the uploading state shows "north-wall.jpg 0%" and "Uploading 0.0 of 3.9 MB"
And its cancel action reads "Cancel upload"
When the upload reports 2.4 MB sent of 3.9 MB
Then the uploading state shows "north-wall.jpg 62%" and "Uploading 2.4 of 3.9 MB"
Scenario: Closing the dialog during an upload cancels it Scenario: A file whose content exists shows the duplicate card and sends nothing
Given ImageUploader is uploading a file named "north-wall.jpg" to article 12 Given ImageUploader is open in the idle state
When the user activates "Cancel upload" (or otherwise closes the modal) And GET /v1/media/?hash=<sha256 of north-wall.jpg> resolves 200 with the image "north-wall.jpg" (id 1, 4032 × 3024, uploaded 12 Sep 2026 by Marta Alves)
Then the in-flight XMLHttpRequest is aborted When the user chooses a valid file named "north-wall.jpg" of 3.9 MB
And the modal closes Then the Upload tab shows a card with an info border: a 72 × 54 thumbnail of the existing image, the title "Already in your library" and the text "north-wall.jpg matches an image uploaded on 12 Sep 2026 by Marta Alves."
And the editor's body is unchanged and remains in its previous dirty/clean state And the card has the buttons "Use existing" (primary) and "Upload anyway", stacked and full width below md
And no POST /v1/media/ has been sent and the crop step has not opened
Scenario: A successful upload inserts the image at the cursor and marks the draft dirty Scenario: Several matches name the oldest one
Given the user is on /article/12/edit in the Saved state and ImageUploader is uploading a Given the hash lookup resolves 200 with several images
file named "north-wall.jpg" to article 12 Then the card names the oldest match
When POST /v1/article/12/image resolves 201 with {"location": "/static/uploadedImages/north-wall.jpg"}
Then the uploading state briefly shows "Image added"
And the image at "/static/uploadedImages/north-wall.jpg" is inserted into the editor's
content at the cursor via insertImage
And SaveStatus reads "Unsaved changes" and the Save button becomes enabled
And a toast appears: color success, icon i-lucide-circle-check, title "Image added",
description "Save to keep it in the article."
And the modal closes 600 ms after the response resolves
Scenario Outline: A server-side upload failure keeps the article unchanged and offers a retry Scenario: A match without a known uploader drops the "by" part
Given ImageUploader is uploading a file named "north-wall.jpg" to article 12 Given the matched image's uploaded_by is empty
When POST /v1/article/12/image <failure> Then the text reads "north-wall.jpg matches an image uploaded on 12 Sep 2026."
Then the failed state shows the title "Upload failed"
And its text reads "The server didn't accept the file. Your article hasn't changed." Scenario: "Use existing" goes to the alt text step for the existing image
And its confirm action reads "Try again" Given the duplicate card is showing for image id 1
And the editor's body is unchanged, and no image is inserted When the user activates "Use existing"
Then the Library tab is selected with "north-wall.jpg" selected, its panel visible with the empty "Alt text (optional)" field, and "Insert image" enabled
And no POST /v1/media/ has been sent
Scenario: "Upload anyway" continues to crop and upload
Given the duplicate card is showing for "north-wall.jpg" (4032 × 3024)
When the user activates "Upload anyway"
Then the crop and resize step opens for that file
And the hash lookup is not repeated
And uploading from that step sends POST /v1/media/ and follows the normal states
Scenario: "Upload anyway" for an SVG uploads immediately
Given the duplicate card is showing for "diagram.svg"
When the user activates "Upload anyway"
Then POST /v1/media/ is sent with that file
Scenario Outline: The duplicate check is skipped when it cannot run
Given ImageUploader is open in the idle state
When the user chooses a valid file and <condition>
Then the flow continues to the crop step (or the upload for an SVG) with no card
Examples: Examples:
| failure | | condition |
| resolves with a 500 response | | crypto.subtle is undefined (an insecure context) |
| never reaches the server | | GET /v1/media/?hash= resolves with a 500 response |
| GET /v1/media/?hash= never reaches the server |
Rule: Uploading
Scenario: A valid upload shows progress and its cancel action reads "Cancel upload"
Given the crop step was confirmed (or an SVG chosen) for "north-wall.jpg", 3.9 MB
Then POST /v1/media/ is sent with the file under the field name "file"
And the uploading state shows "north-wall.jpg 0%" and "Uploading 0.0 of 3.9 MB", and its cancel action reads "Cancel upload"
When the upload reports 2.4 MB sent of 3.9 MB
Then it shows "north-wall.jpg 62%" and "Uploading 2.4 of 3.9 MB"
Scenario: Closing the dialog during an upload cancels it
Given ImageUploader is uploading "north-wall.jpg"
When the user activates "Cancel upload" (or otherwise closes the dialog)
Then the in-flight XMLHttpRequest is aborted and the dialog closes
And the editor's body and its dirty or clean state are unchanged
Scenario: A successful upload inserts the image at the cursor and marks the draft dirty
Given the user is on /article/12/edit in the Saved state and "north-wall.jpg" is uploading
When POST /v1/media/ resolves 201 with the item { id: 13, url: "/static/uploadedImages/13.jpg", filename: "north-wall.jpg" }
Then the uploading state briefly shows "Image added"
And the image "/static/uploadedImages/13.jpg" is inserted at the cursor with alt "" and data-image-id 13
And SaveStatus reads "Unsaved changes" and Save is enabled
And a toast appears: color success, icon i-lucide-circle-check, title "Image added", description "Save to keep it in the article."
And the dialog closes 600 ms after the response
Scenario Outline: A server answer for a file that passed the browser checks
Given ImageUploader is uploading "<file>" of <size>
When POST /v1/media/ resolves <status>
Then the <state> state shows the title "<title>"
And its text reads "<text>"
And its confirm action reads "<confirm>"
And no image is inserted and the editor's body is unchanged
Examples:
| file | size | status | state | title | text | confirm |
| notes.pdf | 210 KB | 415 | rejected | "notes.pdf" can't be used | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. | Choose another file |
| north-wall.tif | 14.2 MB | 413 | rejected | "north-wall.tif" can't be used | north-wall.tif is 14.2 MB. Images can be up to 10 MB. | Choose another file |
| broken.png | 3.0 MB | 400 | rejected | "broken.png" can't be used | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. | Choose another file |
| north-wall.jpg | 3.9 MB | 500 | failed | Upload failed | The server didn't accept the file. Your article hasn't changed. | Try again |
| north-wall.jpg | 3.9 MB | 429 | failed | Upload failed | The server didn't accept the file. Your article hasn't changed. | Try again |
| north-wall.jpg | 3.9 MB | none (network) | failed | Upload failed | The server didn't accept the file. Your article hasn't changed. | Try again |
Scenario: "Try again" re-attempts the upload with the same file Scenario: "Try again" re-attempts the upload with the same file
Given ImageUploader is in the failed state for a file named "north-wall.jpg", article 12, Given ImageUploader is in the failed state for "north-wall.jpg" after a 500
after a 500 response
When the user activates "Try again" When the user activates "Try again"
Then POST /v1/article/12/image is sent again with that same file under the field name "file" Then POST /v1/media/ is sent again with that same file and no new hash lookup, and the uploading state shows
And the uploading state is shown
Scenario: A server 400 for a type the client should have caught shows the same rejected copy Rule: Sessions
Given ImageUploader is uploading a file named "notes.pdf" to article 12 (a type that
defensively passed the browser's own check, e.g. a MIME type the browser misreported)
When POST /v1/article/12/image resolves 400
Then the rejected state shows the title "\"notes.pdf\" can't be used"
And its text reads "Use a PNG, JPEG, WEBP or SVG image, up to 10 MB."
And its confirm action reads "Choose another file"
And the editor's body is unchanged, and no image is inserted
Scenario: A server 400 for a size the client should have caught shows the same size-named copy Scenario Outline: Session errors are global
Given ImageUploader is uploading a file named "north-wall.tif" to article 12 (a file that When <call> resolves <status>
defensively passed the browser's own size check somehow, e.g. its reported size changed Then the user is sent where useApi sends a <status> from /article/12/edit, and no local copy shows
between selection and send)
When POST /v1/article/12/image resolves 400 Examples:
Then the rejected state shows the title "\"north-wall.tif\" can't be used" | call | status |
And its text reads "north-wall.tif is 14.2 MB. Images can be up to 10 MB." | POST /v1/media/ (XHR) | 401 |
And its confirm action reads "Choose another file" | POST /v1/media/ (XHR) | 403 |
And the editor's body is unchanged, and no image is inserted | GET /v1/media/?hash= | 401 |
+24 -81
View File
@@ -1,90 +1,33 @@
# QA: Wiring the editor's image upload flow # QA: The editor's Upload tab uploads to the library
## Automated (Vitest + Vue Test Utils, `task test:web`) ## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `XMLHttpRequest` (a fake implementing `open`/`setRequestHeader`/`send`/`abort` and firing Mock `XMLHttpRequest` (a fake with `open`, `setRequestHeader`, `send`, `abort`, firing `upload.progress`, `load`, `error`) for `POST /v1/media/`. Mock useApi's `request` for `GET /v1/media/` (list and `?hash=`), reusing the mock from `api_client_global_error_handling.qa.md`. Stub `hashFile` where a hash is needed, and add one test with `crypto.subtle` deleted. Mount EditorPage inside DashboardShell at `/article/12/edit` with the `editor_core.qa.md` fixture article, GET already resolved 200. Assert URLs by parsing the query, not by string equality.
`upload.progress`, `load`, `error`) for `POST /v1/article/{id}/image`; useApi/fetch is not
involved in this slice. Mount EditorPage inside DashboardShell at `/article/12/edit` with the
editor_core.qa.md fixture article (id 12, title "How we price a bathroom renovation", is_online
false), GET already resolved 200.
1. **Toolbar button opens the idle state** — click the toolbar's "Image" button; assert 1. **Toolbar button, empty library** - `GET /v1/media/?limit=24` resolves `{items: [], next_cursor: null, counts: {all: 0, unused: 0, duplicates: 0}}`; click "Image"; assert the dialog "Add an image", both tabs, the Upload tab selected, the drop zone copy, footer "Cancel" only, no XHR.
ImageUploader opens titled "Add an image", showing "Drop an image here", "Choose file", 2. **Drop opens Upload with the file** - dispatch `drop` with `before.png` (image/png, 3.9 MB); assert the Upload tab, a `?hash=` request, no `?limit=24` list request, then the crop step after an empty lookup, and no XHR.
"PNG, JPEG, WEBP, SVG · max 10 MB", cancel "Cancel"; assert no XHR was created. 3. **Dragging over shows "Drop to upload"**.
2. **Dragging over shows "Drop to upload"** — with the uploader open idle, fire a `dragover` on 4. **Rejected, Examples-driven** - `plan.pdf`, `disguised.png`, `north-wall.tif`; assert no `?hash=` request and no XHR, plus the exact title, text, confirm and cancel.
the drop zone; assert the text changes to "Drop to upload". 5. **"Choose another file" returns to idle**, with the tabs back.
3. **Dropping a file on the editor opens the uploader already uploading** — with the uploader 6. **Duplicate card** - the hash lookup resolves one item (id 1, "north-wall.jpg", `created_at` 12 Sep 2026, `uploaded_by` "Marta Alves"); assert the card copy and buttons, no XHR, and no crop step.
closed, dispatch a `drop` event with a file `before.png` (image/png, 3.9 MB) on the editor 7. **Several matches name the oldest**, and **no uploader drops the "by" part**.
surface; assert ImageUploader opens and an XHR to `/v1/article/12/image` is created with that 8. **"Use existing"** - assert the Library tab is selected, image 1 selected, the alt field empty, "Insert image" enabled, and no XHR.
file, and the uploading state (not idle) is shown. 9. **"Upload anyway"** - assert the crop step opens (raster) with no second `?hash=` request; for an SVG assert the XHR is created at once.
4. **Rejected — Examples-driven on type, extension mismatch, and size** — for each of `plan.pdf` 10. **Check skipped** - Examples: `hashFile` resolves null, lookup 500, lookup rejects; assert the crop step opens with no card.
(application/pdf, 1.2 MB), `disguised.png` (application/pdf, 1.2 MB), and `north-wall.tif` 11. **Uploading and progress** - confirm the crop step; assert the XHR is `POST /v1/media/` with field `file`, cancel "Cancel upload", and `loaded: 2.4 * 1024 * 1024, total: 3.9 * 1024 * 1024` gives "north-wall.jpg 62%" and "Uploading 2.4 of 3.9 MB".
(image/png, 14.2 MB), choose the file via the `UFileUpload`; assert no XHR is created and the 12. **Closing during an upload aborts** - `abort` called, dialog closed, body and dirty state unchanged.
rejected state shows the exact title/text/confirm/cancel copy from the feature's Examples. 13. **201 inserts** - resolve the XHR with the item (id 13, url `/static/uploadedImages/13.jpg`); assert `insertImage` was called with (url, "", 13), SaveStatus "Unsaved changes", Save enabled, the toast (color success, icon `i-lucide-circle-check`, "Image added" / "Save to keep it in the article."), and after 600 ms of fake timers the dialog closes.
5. **"Choose another file" returns to idle** — from the rejected state, click "Choose another 14. **Server answers, Examples-driven** - 415, 413, 400 give the rejected copy; 500, 429 and a network `error` give "Upload failed"; assert no `insertImage` call and the body unchanged.
file"; assert the idle state re-renders. 15. **"Try again" resends the same file** with no new `?hash=` request.
6. **Uploading shows live progress and "Cancel upload"** — choose `north-wall.jpg` (image/jpeg, 16. **Sessions** - Examples from the feature; assert the same redirect `useApi` performs, with no local copy.
3.9 MB); assert the XHR is created with that file under field `file`; assert cancel reads
"Cancel upload"; fire an `upload.progress` event with `loaded: 2.4 * 1024 * 1024, total: 3.9 *
1024 * 1024`; assert the text reads "north-wall.jpg 62%" and "Uploading 2.4 of 3.9 MB".
7. **Closing during an upload aborts the XHR** — from the uploading state, click "Cancel
upload"; assert the fake XHR's `abort` was called, the modal closes, and the editor's body
and dirty state are unchanged from before the upload started.
8. **A 201 inserts at the cursor and marks the draft dirty** — from the uploading state, resolve
the fake XHR with status 201 and body `{"location": "/static/uploadedImages/north-wall.jpg"}`;
assert `insertImage` (or the editor's exposed method) was called with that location; assert
SaveStatus now reads "Unsaved changes" and Save is enabled; assert a toast appears with color
success, icon `i-lucide-circle-check`, title "Image added", description "Save to keep it in
the article."; advance fake timers 600 ms and assert the modal has closed.
9. **A 500 or network failure shows "Upload failed" and keeps the article unchanged, Examples-
driven** — for (a) the fake XHR firing `load` with status 500 and (b) firing `error`; assert
the failed state shows title "Upload failed", text "The server didn't accept the file. Your
article hasn't changed.", confirm "Try again"; assert no `insertImage` call and the editor's
body/dirty state is unchanged.
10. **"Try again" resends the same file** — from the failed state for `north-wall.jpg`, click
"Try again"; assert a new XHR is created for `/v1/article/12/image` with the same file, and
the uploading state is shown again.
11. **A server 400 for a type the client should have caught shows the rejected copy** — from an
uploading state for `notes.pdf`, resolve the fake XHR with status 400; assert the rejected
state shows title "\"notes.pdf\" can't be used", text "Use a PNG, JPEG, WEBP or SVG image,
up to 10 MB.", confirm "Choose another file"; assert no `insertImage` call.
12. **A server 400 for a size the client should have caught shows the size-named rejected copy**
— from an uploading state for `north-wall.tif`, resolve the fake XHR with status 400; assert
the rejected state shows title "\"north-wall.tif\" can't be used", text "north-wall.tif is
14.2 MB. Images can be up to 10 MB.", confirm "Choose another file"; assert no `insertImage`
call.
## Visual (manual verification — desktop and mobile, light and dark) ## Visual
Reference: `docs/design/screens/editor.md` states "Upload: choose", "Upload: in progress", The Upload tab states are drawn in the `media-picker` group; see `editor_media_picker.qa.md`. The editor-group state 'Upload: choose' awaits the user's editor board update: the user refreshes the editor board themselves, and until then `task visual:check -- editor` is expected to fail on that state. The states "Upload: in progress" and "Upload: rejected" and the crop and resize states are unchanged. `web/visual/states.ts` line 2309 mocks `POST /v1/article/12/image` and must mock `POST /v1/media/` with the item shape, and every editor upload state needs a `GET /v1/media/?limit=24` mock.
"Upload: rejected", "Upload: inserted", and `docs/design/source/RenewCMS Screens Back-
office.dc.html`. Run `task visual:check -- editor` for each state below; add each state's mock
setup to `web/visual/states.ts` first, reusing editor_core.qa.md's fixture article (id 12, title
"How we price a bathroom renovation", draft, "Today, 11:02", 3 images) plus the sample file data
from the board: filename "north-wall.jpg", size 3.9 MB, progress 62% / 2.4 of 3.9 MB uploaded.
- [ ] Upload: choose — modal "Add an image", idle state, cancel "Cancel".
- [ ] Upload: in progress — modal "Add an image", uploading "north-wall.jpg 62%" / "Uploading
2.4 of 3.9 MB", cancel "Cancel upload".
- [ ] Upload: rejected — modal "Add an image", rejected state for a sample rejected file per the
board's script, cancel "Cancel", confirm "Choose another file".
- [ ] Upload: inserted — toast "Image added" / "Save to keep it in the article.", editor status
"draft", save "dirty".
- [ ] Mobile (all of the above at 390×844): same modal layout constraints as other editor
modals (leading arrow-left button, trailing ellipsis in the header behind it).
- [ ] Keyboard/focus: the drop zone is reachable by Tab and Enter opens the file picker; focus
moves into the modal on open and is trapped there; Esc during "Upload: in progress"
behaves like "Cancel upload" (aborts, closes); the progress bar's `role="progressbar"` /
`aria-valuenow` updates are announced without spamming on every progress tick.
- [ ] Motion: modal open/close follows the standard 240 ms timing; the success state's 600 ms
auto-close is not visually checked as a capture (it's a timing behavior, not a static
state) — cover only via the Vitest fake-timer assertion above.
## Manual verification ## Manual verification
- **Real POST round trip against a running server** — Vitest only mocks `XMLHttpRequest`; - **Real round trip against `task dev`** with a live SQLite file: a real upload through the SPA (201, 413, 415, 400, 401), then the image shows in `/media`.
confirm a real upload (200/400/401/404/500) against `task dev` with a live SQLite file and the - **Real OS drag and drop** of an image onto the editor in a real browser.
real `POST /v1/article/{id}/image` handler, since `features/upload_article_image.feature`'s - **Real `crypto.subtle`** in a secure context (localhost or HTTPS), including the duplicate card for a file already in the library, and the skipped check on plain HTTP.
httptest suite covers the handler in isolation but never through this SPA flow. - **Keyboard and focus**: the drop zone is reachable by Tab and opens the file chooser with Enter or Space. Focus moves into the dialog and is trapped. Esc during "Upload: in progress" aborts and closes. Tabs are reachable and arrow keys move between them.
- **Real drag-and-drop from the OS file picker** — jsdom's `drop` event is synthetic; confirm a
real OS-level drag of an image file onto the editor in a real browser.
+221
View File
@@ -0,0 +1,221 @@
Feature: The editor's Library tab, picking an image from the media library
As a verified RenewCMS owner writing an article
I want to browse my library from the "Add an image" dialog and insert an existing image
with alt text
So that I can reuse an image without uploading it again
Background:
Given this slice is SPA only for this feature: the API from the media library slice is used
unchanged, and the Go change to remove the legacy alias is specified in
features/remove_legacy_article_image_alias.feature
And the screen is docs/design/screens/media-picker.md, drawn by docs/design/source/MediaFrame.dc.html,
inside ImageUploader (UModal "Add an image") on /article/:id/edit
And the dialog has a title "Add an image", an x to close, and two tabs under the title,
"Upload" (icon upload) and "Library" (icon images), 36 px high on desktop and 44 px on
mobile with a 2 px primary underline on the selected one; it is 640 px wide on desktop and
a bottom sheet on mobile
And the footer reads "Cancel", and on the Library tab also a primary "Insert image" that is
disabled until an image is selected
And the Library tab calls GET /v1/media/ through useApi with limit=24, with q only while a
search is set and cursor only when loading more (no filter and no sort parameter), and the
Upload tab is specified in features/editor_image_upload.feature
And the response is { items, next_cursor, counts }, newest first; sizes read "8 KB" and
"3.9 MB", dimensions read "2400 × 1600" (an SVG reads "Vector"), dates are absolute
And the 12-image fixture, ids 1 to 12, is the one in features/media_browse.feature, and the
picker mocks use next_cursor "c24" and counts { all: 48, unused: 2, duplicates: 2 }
And an image is "in this article" when an image node with its id as data-image-id is in
the editor's current content, unsaved insertions included, when the dialog opens;
in the examples below images 1 (north-wall.jpg) and 4 (logo-renew.svg) are in the article
And the search field reads "Search by filename" (32 px high on desktop, 44 px with 16 px
text on mobile), with a search icon and, once it has text, an x that clears it
And the grid has 4 columns on desktop and 3 on mobile with a 12 px gap (8 px between
columns on mobile), square tiles with a 1 px border, and the filename under each tile in
12 px toned text with an ellipsis; it scrolls inside the dialog, 300 px high with nothing
selected, 190 px on desktop and 120 px on mobile with one selected
And every tile is one button whose accessible name reads like the Media page's, for example
"north-wall.jpg, 3.9 MB, used in 2 articles" or "window-frame.jpg, 1.8 MB, unused"
And skeleton tiles show only after 150 ms: 8 on desktop and 9 on mobile for a first load
or a search, one row (4 or 3) at the end for "Load more"
And a 401 or 403 on any of these calls is handled globally by useApi, with no local copy
Rule: Which tab opens
Scenario: The library has images, so Library opens
Given GET /v1/media/?limit=24 resolves 200 with the 12-image fixture
When the user activates the toolbar's "Image" button
Then the Library tab is selected and the footer reads "Cancel" and a disabled "Insert image"
And nothing is drawn in the grid for the first 150 ms, then 8 skeleton tiles show until the response arrives
Scenario: The library is empty, so Upload opens
Given GET /v1/media/?limit=24 resolves 200 with no item and no search set
When the dialog opens
Then the Upload tab is selected, in its idle state
When the user selects the Library tab
Then the empty panel shows
Scenario: A dropped file opens Upload and sends no list request
When the user drops "before.png" onto the editor
Then the Upload tab is selected and no GET /v1/media/?limit=24 is sent
When the user selects the Library tab
Then GET /v1/media/?limit=24 is sent
Rule: Browsing
Scenario: Library, nothing selected
Given the first page resolved with the 12-image fixture
Then the line "Newest first · 48 images" shows under the search field
And 12 tiles show in fixture order, 4 per row on desktop and 3 on mobile, each with its filename
And the tiles of "north-wall.jpg" and "logo-renew.svg" carry a sand "In this article" tag (bottom-left, 18 px high, 11 px text)
And no other tile carries it
And "Load more" shows below the grid, and "Insert image" is disabled
Scenario: A search is active, so the count is not shown
Given the user typed "wall" and the response resolved with 2 items
Then the line reads "Newest first"
Scenario: Load more is hidden on the last page
Given the response has next_cursor null
Then "Load more" is not shown
Scenario Outline: A tile's accessible name
Given the first page resolved with the 12-image fixture
Then the tile for "<filename>" is named "<name>"
Examples:
| filename | name |
| north-wall.jpg | north-wall.jpg, 3.9 MB, used in 2 articles |
| kitchen-before.webp | kitchen-before.webp, 1.1 MB, used in 1 article |
| window-frame.jpg | window-frame.jpg, 1.8 MB, unused |
Rule: Selection
Scenario Outline: A tile is selected by pointer or keyboard
Given the first page resolved with the 12-image fixture
When the user <action> on the tile of "kitchen-after.webp"
Then that tile has a 2 px primary outline (2 px offset) and a 20 px primary check at its top-right
And "Insert image" is enabled
Examples:
| action |
| clicks |
| presses Enter |
| presses Space |
Scenario: The selection panel
Given "kitchen-after.webp" is selected
Then the grid shrinks and a panel shows below it, under a 1 px top border
And the panel has a 56 px thumbnail, the filename "kitchen-after.webp", "2400 × 1600 · 1.2 MB", and a field "Alt text (optional)"
And the field's placeholder reads "e.g. North wall of the kitchen before plastering"
And its help reads "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."
And "Load more" is hidden while an image is selected
Scenario: Selection is single
Given "kitchen-after.webp" is selected and the alt text reads "A kitchen"
When the user selects "window-frame.jpg"
Then only "window-frame.jpg" is selected and the alt text field is empty
Scenario: Activating the selected tile again keeps it selected
Given "kitchen-after.webp" is selected
When the user activates its tile
Then it stays selected
Scenario: An SVG's panel reads "Vector"
When the user selects "logo-renew.svg"
Then the panel reads "Vector · 8 KB"
Scenario: An image already in the article can be selected and inserted again
When the user selects "north-wall.jpg", which carries "In this article"
Then "Insert image" is enabled
Rule: Insert
Scenario: Insert with alt text
Given the user is on /article/12/edit in the Saved state with "kitchen-after.webp" (id 2, url "/static/uploadedImages/2.webp") selected
And the alt text reads "A kitchen with new oak worktops and white tiles"
When the user activates "Insert image"
Then insertImage is called with "/static/uploadedImages/2.webp", "A kitchen with new oak worktops and white tiles" and image id 2
And the image is in the editor's content at the cursor, with data-image-id 2
And SaveStatus reads "Unsaved changes" and Save is enabled
And a toast appears: color success, icon i-lucide-circle-check, title "Image added", description "Save to keep it in the article."
And the dialog closes at once
And no POST, PATCH or DELETE has been sent
Scenario: Empty alt text inserts a decorative image
Given "kitchen-after.webp" is selected and the alt text is empty
When the user activates "Insert image"
Then insertImage is called with alt ""
Scenario: Alt text lives only in the article
Given "kitchen-after.webp" is selected with alt text "A kitchen"
When the user activates "Insert image"
Then no request carries the alt text and the library item is unchanged
Scenario Outline: Leaving without inserting changes nothing
Given "kitchen-after.webp" is selected with alt text "A kitchen"
When the user <action>
Then the dialog closes, the editor's content and dirty state are unchanged, and no toast shows
Examples:
| action |
| activates "Cancel" |
| activates the x |
| presses Escape |
Rule: Search
Scenario: Typing searches after a debounce
Given the Library tab shows the 12-image fixture
When the user types "wall"
Then after a 120 ms debounce GET /v1/media/ is sent with q=wall and limit=24 and no cursor
And skeleton tiles show after 150 ms, and any selection is cleared
Scenario: The x in the field clears the search
Given the search field holds "wall" with results shown
When the user activates the x in the field
Then the field is empty and GET /v1/media/ is sent with limit=24 and no q
Scenario: No results
Given GET /v1/media/?q=wall&limit=24 resolves 200 with no item
Then the search icon shows with the title `No images match "wall"`, the text "Check the spelling, or search for a shorter part of the filename." and a "Clear search" button
And the search field still shows "wall" above it, and "Insert image" is disabled
When the user activates "Clear search"
Then the field is emptied and the list is requested again with no q
Rule: Load more
Scenario: Loading more shows progress and appends
Given the first page resolved with 12 images, next_cursor "c24"
When the user activates "Load more"
Then GET /v1/media/ is sent with limit=24 and cursor=c24
And the button reads "Loading…" with a loader-circle and is disabled, and one row of skeleton tiles shows at the end
And focus stays on the button
When the request resolves 200 with 12 more images
Then the new tiles are appended after the first 12, and focus moves to the first new tile
Scenario: The cursor and the search combine
Given the search "wall" is active and the page has next_cursor "c24"
When the user activates "Load more"
Then GET /v1/media/ is sent with q=wall, limit=24 and cursor=c24
Rule: Empty and failed
Scenario: Empty library
Given the Library tab is selected and GET /v1/media/?limit=24 resolved with no item
Then the images icon shows with "Your library is empty", "Images you upload appear here, ready to use in any article." and a primary "Upload an image"
And "Insert image" is disabled
When the user activates "Upload an image"
Then the Upload tab is selected
Scenario Outline: The library fails to load
Given GET /v1/media/ <failure>
When the Library tab loads
Then an error alert shows in the dialog: icon circle-x, title "Couldn't load your library", text "Check your connection and try again.", and a "Try again" button
And "Insert image" is disabled
When the user activates "Try again"
Then the same request is sent again with a skeleton, and on 200 the tiles replace the alert
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
+54
View File
@@ -0,0 +1,54 @@
# QA: The editor's Library tab
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `request` for `GET /v1/media/` by parsed query. Use the 12-image fixture from `media_browse.feature` as `items`, with `next_cursor: "c24"` and `counts: {all: 48, unused: 2, duplicates: 2}`. Mount EditorPage at `/article/12/edit` with a body containing `<img data-image-id="1">` and `<img data-image-id="4">`. Use fake timers for the 150 ms and 120 ms steps.
1. **Opens on Library with images** - first response has items; assert Library selected and skeleton tiles only after 150 ms.
2. **Opens on Upload when empty** - empty first response; assert Upload selected; then click Library and assert the empty panel.
3. **Drop sends no list request** - drop a file; assert no `?limit=24` request until Library is clicked.
4. **Nothing selected** - assert the line, 12 tiles in order, the "In this article" tag on exactly ids 1 and 4, "Load more", "Insert image" disabled.
5. **Active search hides the count** - the line reads "Newest first".
6. **Last page** - `next_cursor: null` hides "Load more".
7. **Accessible names** - the three Examples.
8. **Selection** - click, Enter and Space each select; check, outline, "Insert image" enabled.
9. **Panel copy** - kitchen-after.webp panel, placeholder and help text exact; "Load more" hidden; SVG reads "Vector · 8 KB".
10. **Single selection resets alt**; reactivating keeps it selected.
11. **Insert** - alt "A kitchen with new oak worktops and white tiles" gives `insertImage("/static/uploadedImages/2.webp", alt, 2)`, SaveStatus "Unsaved changes", the toast, the dialog closed, no non-GET request. Empty alt gives `""`.
12. **Cancel, x and Escape** change nothing.
13. **Search** - type "wall"; at 119 ms no request, at 120 ms the request has `q=wall&limit=24` and no cursor; the selection is cleared. The field's x and "Clear search" send a request with no `q`.
14. **No results** and **Empty** copy; "Upload an image" switches to the Upload tab.
15. **Load more** - request has `cursor=c24`; button "Loading…" and disabled; skeleton row of 4 (3 below md); focus on the button, then on the first new tile after resolve; combined `q` and `cursor`.
16. **Failed to load, Examples 500 and network** - alert copy; "Try again" repeats the request; success replaces the alert.
## Visual: `task visual:check -- media-picker`
Add a setup in `web/visual/states.ts` for each of the 13 states in `docs/design/screens/media-picker.md`, from the board's script (`RenewCMS Screens Back-office.dc.html` lines 239 to 253). Every capture must be within 10 % at 1440×900 and 390×844, light and dark. The sample data to reuse:
- Library mock: the 12-image fixture, `counts.all` 48, `next_cursor` non-null; images 1 and 4 in the article. The dialog is opened on the editor "How we price a bathroom renovation".
- "Upload tab": Upload selected, idle drop zone.
- "Upload tab: already in your library": a chosen file named `north-wall.jpg`; the hash lookup returns id 1 (12 Sep 2026, Marta Alves); the thumbnail is 72 × 54.
- "Library: loading": skeleton tiles.
- "Library: nothing selected": the 12 tiles, tags on ids 1 and 4, "Load more".
- "Library: selected": id 2 (kitchen-after.webp, 2400 × 1600 · 1.2 MB), empty alt.
- "Library: alt text entered": id 2 with alt "A kitchen with new oak worktops and white tiles".
- "Library: loading more": 12 tiles plus one skeleton row, button "Loading…".
- "Library: empty": a mock with zero items. "Library: no results": search "wall" and zero items.
- "Library: failed to load": a 500.
- "Inserted": the editor with an image (id 1 as the board draws it) and the toast "Image added" / "Save to keep it in the article.".
- "Remove image: confirm": the confirm over the editor, image id 1 (north-wall.jpg, 3.9 MB). See `editor_image_removal.qa.md`.
- "Removed": the editor without the image and the toast "Image removed" / "It's still in your library.".
Clock-sensitive: none. Dates are absolute. Nothing is skipped.
Layout facts the mocks must hit (from the frame): the dialog is 640 px at 40 px from the top; the tabs row has a 20 px gap and 24 px side padding; the body has 16 px top padding and 24 px side padding; the search field is 32 px (44 px on mobile); the grid gap is 12 px (8 px between columns on mobile).
Manual checks the screenshots can't cover:
- [ ] Focus order is tabs, search, tiles, "Load more", footer. Enter and Space select a tile. Focus returns to the toolbar's image button when the dialog closes.
- [ ] Hover on a tile and on "Load more", and the focus ring.
- [ ] Motion: the dialog follows the standard 240 ms timing.
- [ ] A real library of more than 24 images through `task dev`: "Load more" and the cursor.
- [ ] A screen reader reads the tile names and the alt text help.
## Manual verification
Real `GET /v1/media/` with `q` and `cursor` against `task dev` with a live SQLite file.
+2 -2
View File
@@ -17,8 +17,8 @@ Feature: Allowed image types and the size limit are a domain rule
the matching sentinel and calling the repository not at all when either check fails the matching sentinel and calling the repository not at all when either check fails
And ImageRepository.Create no longer rejects an unrecognized content type itself; it And ImageRepository.Create no longer rejects an unrecognized content type itself; it
only maps an already-allowed content type to a file extension only maps an already-allowed content type to a file extension
And no image HTTP endpoint is mounted in api/router.go as of this slice, so this And the HTTP mapping of these sentinels is 413, 415 and 400, specified in
behavior is observable only through CreateImageUseCase features/media_library.feature
Scenario Outline: CreateImage accepts every allowed content type within the size limit Scenario Outline: CreateImage accepts every allowed content type within the size limit
Given an empty image repository fake Given an empty image repository fake
+370
View File
@@ -0,0 +1,370 @@
Feature: Media page, part 1: browse and manage
As a verified RenewCMS owner
I want /media to show every image in my library, let me search, filter, sort and switch view, and
let me inspect, copy, rename and delete one image at a time
So that I can manage the library from one place, with the page always reflecting what the
server actually holds
Background:
Given /media is a verified route (meta.auth "verified", meta.title "Media") rendered as
MediaPage inside DashboardShell, per docs/design/routes.md
And the sidebar gains the item "Media" (icon images) between "Articles" and "Users", in the
desktop sidebar and in the mobile drawer, it is the active item on /media, and the
breadcrumb reads "Media" (mobile title "Media")
And this slice is SPA only: the API from slice A already exists and is used unchanged, and
no Go code, use case or repository changes
And upload, multi-select, the bulk bar, the Duplicates tab content, duplicate merge,
drag-and-drop and the rate-limited state are specified in
features/media_upload_selection_duplicates.feature
And useMedia is a composable over useApi. It exposes the list state (items, counts, loading,
error), the request functions for list, usage, rename and delete, and the copy helper, and
it maps ApiError and NetworkError to the states below
And MediaPage calls GET /v1/media/ with the query built from the URL, always with limit=100,
and every request carries the jwt cookie through useApi
And search, tab, sort and view live in the URL query string so the view can be linked and
survives a reload: ?q= (search text), ?filter= ("unused" or "duplicates"; absent means
All), ?sort= ("oldest", "name" or "largest"; absent means Newest), ?view= ("list"; absent
means grid); a value outside these sets is dropped and the default applies; the names
mirror the API's own q, filter and sort parameters
And the header shows the title "Media", the subtitle "Every image you've uploaded, ready to
use in any article." and a primary "Upload" button (icon upload) on the right; on mobile it is
the full-width 48 px button pinned to the bottom
And the toolbar holds the tabs "All", "Unused" and "Duplicates" with a count each, then the
search field (placeholder "Search by filename"), the sort menu (options "Newest", "Oldest",
"Name", "Largest", sent as sort=newest, oldest, name, largest) and the grid/list toggle
(icons layout-grid and list); below md the toolbar stacks: search, tabs, then sort and toggle
And the tab counts come from the response's counts object (all, unused, duplicates). The
Duplicates count is a number of groups, drawn as an info-tinted badge only while it is
above 0. The All and Unused counts are drawn as plain muted mono numbers, as in the board
And the Duplicates tab selects the groups view and ?filter=duplicates is a valid value
And the grid is 5 columns on desktop and 2 on mobile, with a 16 px gap (10 px on mobile),
and each card is one button holding MediaThumb (4:3, a checkerboard behind PNG and SVG),
the filename (medium weight, ellipsis), "size · dimensions" and the MediaBadges; an SVG's
dimensions read "Vector"
And sizes read "8 KB" and "860 KB" (whole KB) below 1 MB and "3.9 MB" (one decimal, 1024
base) from 1 MB up; dimensions read "4032 × 3024"; dates read "12 Sep 2026" (absolute,
never relative); the Type column reads JPEG, PNG, WEBP or SVG from content_type
And the MediaBadges are: "Unused" (warning-soft fill, icon unlink) when used_in is 0,
"Duplicate" (info-soft fill, icon copy) when duplicate is true, and "Used in N"
(outline, toned text) when used_in is above 0; in the detail panel the article rows use
"Online" (icon globe, inverted fill) and "Draft" (icon pencil-line, outline)
And each card's accessible name reads "north-wall.jpg, 3.9 MB, used in 2 articles",
"kitchen-before.webp, 1.1 MB, used in 1 article" (singular for 1) or "window-frame.jpg,
1.8 MB, unused"
And list view is a table from md up (columns Filename with a 40 px thumbnail and the
Duplicate badge, Dimensions, Size, Type, Used in, Uploaded by, and Uploaded with an
arrow-down as the sorted column; 36 px header, 56 px rows) and a card list below md (56 px
thumbnail, filename, "size · dimensions · type", badges plus "uploader · date", and a
trailing chevron-right); in the table the "Used in" cell is an underlined count that
opens the detail panel, and reads the Unused badge when used_in is 0
And the checkbox column of the table (36 px), the 44 px checkbox column of the card list and
the "Select all" row above the grid are specified in
features/media_upload_selection_duplicates.feature
And the detail panel is a USlideover: 460 px on the right on desktop (a close button x),
full screen on mobile with an arrow-left back button; its title is the filename; the body
holds the 16:10 preview (4:3 on mobile), the actions "Copy URL" (icon link), "Rename"
(icon pencil) and "Delete" (icon trash-2, error-coloured text), the duplicate alert when
the image is a duplicate, the metadata list (Type, Size, Dimensions, Uploaded, Uploaded by,
and URL in mono) and the "Used in" section
And opening the panel for an image with used_in above 0 calls GET /v1/media/{id}/usage; for an
image with used_in 0 no usage call is sent
And the dialogs (Rename, Delete) are UModal with sm:max-w-md, a bottom sheet on mobile and
Cancel first; while a request runs the confirm button shows a loader-circle, both buttons
are disabled, and the dialog cannot be dismissed by Cancel, Escape or a backdrop click; a
failure shows an error alert at the top of the dialog body and the dialog stays open
And every success toast lasts 5 s; toast ids follow the action-plus-id convention:
"media-url-copied-{id}", "media-renamed-{id}", "media-deleted-{id}", "media-not-found-{id}"
And the library is this 12-image fixture, newest first (id, filename, size, dimensions, type,
used in, uploader, uploaded, flags), from the board's own script:
| id | filename | size | dimensions | type | used | uploaded by | uploaded | flags |
| 1 | north-wall.jpg | 3.9 MB | 4032 × 3024 | JPEG | 2 | Marta Alves | 12 Sep 2026 | |
| 2 | kitchen-after.webp | 1.2 MB | 2400 × 1600 | WEBP | 3 | Inès Duarte | 8 Sep 2026 | |
| 3 | kitchen-before.webp | 1.1 MB | 2400 × 1600 | WEBP | 1 | Inès Duarte | 8 Sep 2026 | |
| 4 | logo-renew.svg | 8 KB | Vector | SVG | 5 | jonas | 5 Sep 2026 | alpha |
| 5 | floorboards-copy.jpg | 2.4 MB | 3600 × 2400 | JPEG | 3 | Marta Alves | 2 Sep 2026 | duplicate |
| 6 | IMG_2208.jpg | 2.4 MB | 3600 × 2400 | JPEG | 2 | jonas | 30 Aug 2026 | duplicate |
| 7 | IMG_4471.jpg | 3.1 MB | 3600 × 2400 | JPEG | 2 | Marta Alves | 29 Aug 2026 | duplicate |
| 8 | window-frame.jpg | 1.8 MB | 3000 × 2000 | JPEG | 0 | Inès Duarte | 28 Aug 2026 | |
| 9 | stone-wall-detail.jpg | 3.1 MB | 3600 × 2400 | JPEG | 1 | jonas | 21 Aug 2026 | duplicate |
| 10 | bathroom-tiles.png | 860 KB | 1800 × 1200 | PNG | 0 | Marta Alves | 4 Aug 2026 | alpha |
| 11 | lime-plaster.jpg | 2.1 MB | 3200 × 2133 | JPEG | 2 | Inès Duarte | 19 Jul 2026 | |
| 12 | floorboards.jpg | 2.4 MB | 3600 × 2400 | JPEG | 4 | Inès Duarte | 14 Jun 2026 | duplicate |
with counts { all: 12, unused: 2, duplicates: 2 }, and next_cursor null
Scenario: Default, many images
Given GET /v1/media/?filter=all&sort=newest&limit=100 resolves 200 with the 12-image fixture
When the user navigates to /media
Then the breadcrumb reads "Media" (mobile title "Media")
And the tabs read "All 12", "Unused 2", "Duplicates 2", with "All" selected, and the
Duplicates count drawn as an info-tinted badge
And the grid shows the 12 images in exactly the fixture's order, 5 per row on desktop and 2
on mobile, each card with its thumbnail, filename, "size · dimensions" and badges
And the "Select all" row above the grid reads "Select all" and "12 images"
And the toolbar shows "Search by filename", the sort "Newest" and the grid toggle selected
Scenario Outline: Each card's badges and accessible name follow the image
Given the Default state has loaded
Then the card for "<filename>" has the accessible name "<name>" and the badges "<badges>"
Examples:
| filename | name | badges |
| north-wall.jpg | north-wall.jpg, 3.9 MB, used in 2 articles | Used in 2 |
| kitchen-before.webp | kitchen-before.webp, 1.1 MB, used in 1 article | Used in 1 |
| window-frame.jpg | window-frame.jpg, 1.8 MB, unused | Unused |
| floorboards.jpg | floorboards.jpg, 2.4 MB, used in 4 articles | Duplicate, Used in 4 |
| logo-renew.svg | logo-renew.svg, 8 KB, used in 5 articles | Used in 5 |
Scenario: Grid, few images, keeps card size
Given GET /v1/media/ resolves 200 with ids 1, 4 and 8 and counts { all: 3, unused: 1, duplicates: 0 }
When the user navigates to /media
Then three cards render at the same size as in the 12-image grid, not stretched
And a checkerboard shows behind the SVG thumbnail of "logo-renew.svg"
And the tabs read "All 3", "Unused 1" and "Duplicates" with no count badge
Scenario: List view, desktop, is a table
Given the Default state has loaded at md width or more
When the user activates the list icon in the toggle
Then the URL gains ?view=list
And a table shows the columns Filename, Dimensions, Size, Type, Used in, Uploaded by and
Uploaded (with an arrow-down), 12 rows of 56 px under a 36 px header, in fixture order
And each Filename cell shows a 40 px thumbnail, the filename and, for duplicates, the
Duplicate badge; the Used in cell shows an underlined count, or the Unused badge
for id 8 and id 10
And the Uploaded by cells read the uploader and the Uploaded cells read "12 Sep 2026"
Scenario: List view, mobile, is a card list
Given the Default state has loaded below md width with ?view=list
Then a card list replaces the grid; each row shows a 56 px thumbnail, the filename,
"3.9 MB · 4032 × 3024 · JPEG", the badges followed by "Marta Alves · 12 Sep 2026", and a
trailing chevron-right
When the user taps a row
Then the detail panel opens for that image
Scenario Outline: The query string is the source of truth for search, tab, sort and view
Given GET /v1/media/ is mocked
When the user opens /media<url>
Then GET /v1/media/ is sent with "<request>" and limit=100
And the control shows "<control>"
Examples:
| url | request | control |
| ?filter=unused | filter=unused&sort=newest | tab "Unused" selected |
| ?sort=largest | filter=all&sort=largest | sort "Largest" |
| ?sort=name | filter=all&sort=name | sort "Name" |
| ?sort=oldest | filter=all&sort=oldest | sort "Oldest" |
| ?q=wall | q=wall&filter=all | search field "wall" |
| ?view=list | filter=all&sort=newest | list icon selected |
| ?filter=bogus&sort=zzz | filter=all&sort=newest | tab "All" and sort "Newest", URL kept clean |
| ?filter=duplicates | filter=all&sort=newest | tab "All" selected |
Scenario: Changing a control writes the query string and reloads the list
Given the Default state has loaded
When the user types "wall" in the search field
Then after a 120 ms debounce the URL gains ?q=wall and GET /v1/media/?q=wall&filter=all&sort=newest&limit=100 is sent
When the user selects the "Unused" tab
Then the URL reads ?q=wall&filter=unused and the list is requested again
When the user picks "Largest" in the sort menu
Then the URL gains sort=largest and the list is requested again
And selecting "All" removes filter from the URL, "Newest" removes sort, and the grid icon removes view
Scenario: Loading, skeleton cards after 150 ms
Given GET /v1/media/ has not yet resolved
When the user navigates to /media
Then nothing is drawn for the first 150 ms
And after 150 ms 10 skeleton cards (6 on mobile) replace the images, the list container is
aria-busy="true", and the toolbar stays visible
When the request resolves 200 with the 12-image fixture
Then the skeleton cards are replaced by the Default state's cards
And the same skeleton appears on a retry and on a change of search, tab or sort, in grid and
list view alike
Scenario: Empty library, first run
Given GET /v1/media/ resolves 200 with zero items and counts { all: 0, unused: 0, duplicates: 0 } and no q
When the user navigates to /media
Then the toolbar and the tabs are hidden
And one panel shows the images icon, title "No images yet", text "Upload your first image. You can use it in any article.", a primary "Upload an image" button, and under it "PNG, JPEG, WEBP, SVG · max 10 MB each"
Scenario: No results, search and filter
Given the Default state has loaded
When the user selects "Unused" and types "wall" and GET /v1/media/?q=wall&filter=unused&sort=newest&limit=100 resolves 200 with zero items
Then a panel replaces the images with the search icon, title `No images match "wall"`, text "Nothing matches in Unused. Check the spelling, or clear the filters." and an outline "Clear filters" button
When the user activates "Clear filters"
Then the search field is emptied, the tab returns to "All", ?q and ?filter leave the URL, and the Default cards reappear
Scenario: Hover, focus and keyboard
Given the Default state has loaded on desktop
Then every card is one button and one tab stop
When the pointer rests on the second card ("kitchen-after.webp")
Then its border darkens (border-accented) and it gains the pop shadow
When the third card ("kitchen-before.webp") takes keyboard focus
Then it shows a 2 px primary ring with a 2 px offset and its accessible name reads "kitchen-before.webp, 1.1 MB, used in 1 article"
When the user presses an arrow key on a card
Then focus moves to the neighbouring card across the grid (left and right by one, up and down by one row, stopping at the edges)
When the user presses Enter on a card
Then the detail panel opens for that image
When the user presses Space on a card
Then the card's selection toggles and no panel opens
Scenario: Detail panel, in use
Given the Default state has loaded and GET /v1/media/1/usage resolves 200 with [{ id: 10, title: "How we price a bathroom renovation", published: true }, { id: 11, title: "Winter checklist for timber windows", published: false }]
When the user activates the "north-wall.jpg" card
Then the slideover opens with the title "north-wall.jpg", a 16:10 preview and the actions "Copy URL", "Rename" and "Delete" (Delete in error-coloured text)
And the metadata list reads Type "JPEG", Size "3.9 MB", Dimensions "4032 × 3024", Uploaded "12 Sep 2026", Uploaded by "Marta Alves" and URL in mono
And the "Used in" section reads "Used in 2 articles" over one bordered row per article: the title as an underlined link to /article/{id}/edit, a badge "Online" (icon globe, inverted) for "How we price a bathroom renovation" or "Draft" (icon pencil-line, outline) for "Winter checklist for timber windows", and an arrow-up-right
And the footnote reads "Counted from the text of your articles each time you open this page."
When the user closes the panel (x, the back arrow on mobile, or Escape)
Then the panel closes and focus returns to the "north-wall.jpg" card
Scenario: Detail panel, unused
Given the Default state has loaded
When the user activates the "window-frame.jpg" card
Then no usage request is sent
And the "Used in" section reads "Not used in any article" over the text "This image is safe to delete. No article shows it."
Scenario: Detail panel, duplicate
Given the Default state has loaded, GET /v1/media/?hash=<sha256 of id 12> resolves 200 with ids 12, 5 and 6, and GET /v1/media/12/usage resolves 200
When the user activates the "floorboards.jpg" card
Then an info alert reads "Identical to 2 other images" over "floorboards-copy.jpg and IMG_2208.jpg have the same content." with an action "Review duplicates" (icon arrow-right)
And the action is enabled and selects the Duplicates tab
Scenario: The Used in count in list view opens the panel
Given the Default state has loaded with ?view=list at md width or more
When the user activates the underlined "2" in the "north-wall.jpg" row
Then the detail panel opens for "north-wall.jpg" with its "Used in 2 articles" list
Scenario: Copy URL
Given the detail panel is open for "north-wall.jpg"
When the user activates "Copy URL"
Then "{origin}{url}" (the absolute address of the image) is written to the clipboard
And a success toast appears: icon circle-check, id "media-url-copied-1", title "URL copied", text "Paste it wherever you need the image address."
Scenario: Rename, dialog
Given the detail panel is open for "north-wall.jpg"
When the user activates "Rename"
Then a dialog opens: title "Rename image", description "Only the filename changes. The URL stays the same, so articles that use this image keep working."
And the field "Filename" holds "north-wall" with the mono suffix ".jpg" fixed after it and the help "Letters, numbers, dashes and underscores. The .jpg ending stays."
And the buttons read "Cancel" then "Rename"
Scenario Outline: Rename, validation, nothing is sent
Given the Rename dialog is open for "north-wall.jpg"
When the user enters "<value>" and submits or leaves the field
Then the field shows the error "Enter a filename." with a circle-alert icon and the help text is replaced
And no PATCH is sent
Examples:
| value |
| |
| --- |
| !!! |
Scenario: Rename, saving
Given the Rename dialog is open for "north-wall.jpg" with "north-wall-01" entered
When the user activates "Rename"
Then PATCH /v1/media/1 is sent with { "filename": "north-wall-01" }
And the field and both buttons are disabled, the confirm button reads "Renaming…" with a loader-circle, and Cancel, Escape and a backdrop click do not close the dialog
Scenario Outline: Rename, failed, the dialog stays and keeps the text
Given a rename is in flight for "north-wall.jpg" with "north-wall-01" entered
When PATCH /v1/media/1 <failure>
Then the dialog stays open with an error alert "Couldn't rename the image. Your text is still here. Try again in a moment." at the top of its body
And "north-wall-01" is still in the field and Cancel and Rename are enabled again
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario: Rename, done
Given a rename is in flight for "north-wall.jpg" with "north-wall-01" entered
When PATCH /v1/media/1 resolves 200 with the item, filename "north-wall-01.jpg"
Then the dialog closes, the panel title reads "north-wall-01.jpg", the card or row for id 1 reads "north-wall-01.jpg" in place (the list is not reordered), and the URL row is unchanged
And a success toast appears: icon circle-check, id "media-renamed-1", title "Image renamed", text `It's now "north-wall-01.jpg". The URL is the same.`
Scenario: Delete, unused image
Given the detail panel is open for "window-frame.jpg" (used in 0)
When the user activates "Delete"
Then a dialog opens: title "Delete this image?", description `"window-frame.jpg" will be deleted from your library. No article uses it. This can't be undone.`, no body, "Cancel" and a danger "Delete image"
Scenario: Delete, image in use
Given the detail panel is open for "north-wall.jpg" with its 2 articles loaded from GET /v1/media/1/usage
When the user activates "Delete"
Then a dialog opens: title "Delete an image that's in use?", description `"north-wall.jpg" is used in 2 articles. If you delete it, they will show a broken image. This can't be undone.`
And the body lists the two affected articles, each with its title and an "Online" or "Draft" badge, "Cancel" and a danger "Delete image"
Scenario: Delete, in progress
Given the Delete dialog is open for "window-frame.jpg"
When the user activates "Delete image"
Then DELETE /v1/media/8 is sent
And both buttons are disabled, the confirm button reads "Deleting…" with a loader-circle, aria-busy is set, and Cancel, Escape and a backdrop click do not close it
Scenario Outline: Delete, failed, the image stays
Given a delete is in flight for "window-frame.jpg"
When DELETE /v1/media/8 <failure>
Then the dialog stays open with an error alert "Couldn't delete the image, so it's still there. Try again in a moment." and both buttons enabled again
And the "window-frame.jpg" card is untouched behind the dialog
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario: Delete, done
Given a delete is in flight for "window-frame.jpg" (id 8, the 8th card)
When DELETE /v1/media/8 resolves 204
Then the dialog and the panel close and the card leaves the grid
And focus moves to the next card ("stone-wall-detail.jpg"); when the deleted card was the last, focus moves to the page heading
And a success toast appears: icon circle-check, id "media-deleted-8", title "Image deleted", text `"window-frame.jpg" is gone from your library.`
And the list is then requested again in the background, with no skeleton, to refresh the counts and the Duplicate flags
Scenario Outline: Image already gone, a 404 on any action removes it
Given the detail panel or a dialog is open for "window-frame.jpg"
When <trigger> resolves 404
Then the panel and any dialog close and the image leaves the list
And an info toast appears: icon info, id "media-not-found-8", title "That image no longer exists", text "It was deleted somewhere else, so we removed it from the library."
And the list is then requested again in the background to refresh the counts
Examples:
| trigger |
| DELETE /v1/media/8 |
| PATCH /v1/media/8 |
| GET /v1/media/8/usage (on open) |
Scenario Outline: List failed to load
Given GET /v1/media/ <failure>
When the user navigates to /media
Then an error alert replaces the images: icon circle-x, title "Couldn't load your images", text "Check your connection and try again.", and a "Try again" button (icon rotate-cw)
And the toolbar stays visible
When the user activates "Try again"
Then the skeleton cards show and GET /v1/media/ is sent again, and on success the Default state replaces the alert
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario: Offline
Given the Default state has loaded
When navigator.onLine becomes false
Then a banner with the wifi-off icon reads "You're offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection."
And the loaded cards stay browsable and the detail panel still opens and "Copy URL" still works
And "Upload", "Rename" and "Delete" are disabled
When navigator.onLine becomes true
Then the banner disappears and "Rename" and "Delete" are enabled again
Scenario Outline: Session errors are global
When any /v1/media call resolves <status>
Then the user is sent to <destination> by useApi, and no local media copy is shown
Examples:
| status | destination |
| 401 | /login?redirect=/media&reason=expired |
| 403 | /register/pending (unverified user) |
Scenario: The sidebar item and the mobile drawer
Given the user is signed in and verified
When the sidebar renders
Then it lists Home, Articles, Media (icon images), Users, Integration, Settings in that order
And on /media, "Media" is the active item, on desktop and in the mobile drawer
When the user activates "Media"
Then the router goes to /media
+151
View File
@@ -0,0 +1,151 @@
# QA: Media page, part 1 (browse and manage)
## Scope note (informational)
SPA only. The media API already exists (slice A), so there is no HTTP-level Go test in this slice
and no `test/` file. The Go suite is run only to confirm nothing regressed (`task test`).
Slice B2 (upload, multi-select, bulk bar, Duplicates content, merge, drag and drop, rate limit) is
specified in features/media_upload_selection_duplicates.feature and its .qa.md.
Toast ids: "media-url-copied-{id}", "media-renamed-{id}", "media-deleted-{id}", "media-not-found-{id}".
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `request` (or the global fetch it wraps) per case for `GET /v1/media/`,
`GET /v1/media/{id}/usage`, `GET /v1/media/?hash=`, `PATCH /v1/media/{id}` and
`DELETE /v1/media/{id}`, plus `navigator.clipboard.writeText` and `navigator.onLine`. No real
backend. Mount MediaPage inside DashboardShell with a verified session and a test router at
/media. Sample data is the 12-image fixture in the .feature Background (same ids, filenames,
sizes, dimensions, used_in, uploaders and dates as the board's script), with counts { all: 12,
unused: 2, duplicates: 2 }. Use fake timers for the 150 ms skeleton delay and the 120 ms search
debounce.
Logic and copy:
1. **Formatting** (pure functions) - size: 8192 bytes to "8 KB", 880640 to "860 KB", 4089446 to
"3.9 MB"; dimensions "4032 × 3024"; null width and height to "Vector"; date "12 Sep 2026"
from `created_at`; type from content_type (image/jpeg "JPEG", image/png "PNG", image/webp
"WEBP", image/svg+xml "SVG"); accessible name for used_in 2, 1 and 0 ("used in 2 articles",
"used in 1 article", "unused").
2. **Query string mapping** - Examples-driven from the .feature: each URL produces the exact
request query and control state; unknown values fall back to defaults;
changing a control rewrites the URL and drops default values.
3. **Default** - 12 cards in fixture order, tabs "All 12" "Unused 2" "Duplicates 2", the
Duplicates badge only when duplicates > 0, "Select all" and "12 images" in the Select all row,
breadcrumb "Media".
4. **Badges and accessible names** - the 5 Examples rows (badges and aria-label / accessible name).
5. **Grid, few images** - 3 items; tabs "All 3" "Unused 1" "Duplicates" without a badge.
6. **List view** - desktop: table with the 7 column headers and 12 rows, the Used in cell
underlined count or Unused badge; mobile: card list with "3.9 MB · 4032 × 3024 · JPEG" and
"Marta Alves · 12 Sep 2026"; clicking the Used in count opens the panel.
7. **Duplicates tab** - covered by the B2 spec.
8. **Loading** - unresolved GET: nothing before 150 ms; after 150 ms the skeleton (10 cards,
6 below md) and aria-busy; resolve; cards replace it; same on retry and on search, tab and
sort change, in list view too.
9. **Empty library** - zero items and counts 0 with no q: toolbar and tabs absent, copy exact,
"Upload an image" enabled, the footnote "PNG, JPEG, WEBP, SVG · max 10 MB each".
10. **No results** - unused + "wall": title `No images match "wall"`, text "Nothing matches in
Unused. Check the spelling, or clear the filters."; "Clear filters" resets q and filter and
restores the cards.
11. **Keyboard** - each card is a button; Enter opens the panel; Space toggles selection and opens nothing; arrow keys
move focus (left, right, up, down by one row, stopping at the edges); Escape closes the panel
and focus returns to the card.
12. **Detail panel, in use** - usage called once for id 1, title, actions, metadata rows, article
rows with links to /article/{id}/edit and Online / Draft badges, footnote text.
13. **Detail panel, unused** - no usage request; "Not used in any article" and the safe-to-delete
text.
14. **Detail panel, duplicate** - hash query used; alert title "Identical to 2 other images" and
"floorboards-copy.jpg and IMG_2208.jpg have the same content." (the image itself is excluded);
the "Review duplicates" action is enabled and selects the Duplicates tab.
15. **Copy URL** - clipboard called with `{origin}{url}`; toast id "media-url-copied-1", icon
circle-check, title "URL copied", text "Paste it wherever you need the image address.".
16. **Rename dialog** - title, description, field "north-wall" with ".jpg" suffix, help text,
Cancel then Rename.
17. **Rename validation** - "", "---", "!!!": error "Enter a filename." on submit and on blur,
no PATCH.
18. **Rename saving** - PATCH /v1/media/1 body { filename: "north-wall-01" } (no extension); the
field and buttons disabled, "Renaming…", dialog not dismissible by Escape or backdrop.
19. **Rename failed** - 500 and network rejection: alert copy exact, text kept, buttons enabled.
20. **Rename done** - 200: panel title, card and row updated in place, order unchanged; toast id
"media-renamed-1", title "Image renamed", text `It's now "north-wall-01.jpg". The URL is the same.`
21. **Delete, unused** and **Delete, in use** - dialog titles, descriptions with the exact quotes
and counts, the article list with badges only in the in-use variant, "Delete image" danger.
22. **Delete in progress** - DELETE /v1/media/8; buttons disabled; "Deleting…"; not dismissible.
23. **Delete failed** - 500 and network rejection: alert copy exact, item untouched.
24. **Delete done** - 204: dialog and panel closed, card removed, focus on the next card (heading
when last), toast id "media-deleted-8", title "Image deleted", text `"window-frame.jpg" is gone from your library.`; a background list refresh
without skeleton follows and updates the counts.
25. **404 Examples-driven** - DELETE, PATCH, usage: everything closes, the item is removed,
toast id "media-not-found-8", icon info, title "That image no longer exists", text "It was
deleted somewhere else, so we removed it from the library.".
26. **List failed** - 500 and network rejection: alert copy exact, toolbar visible; "Try again"
re-requests, shows skeleton, then the list.
27. **Offline** - onLine false: banner copy exact, Upload, Rename and Delete disabled, Copy URL
and panel still work; back online restores them.
28. **401 / 403** - handled by useApi (redirect target strings asserted); no local media copy.
29. **Sidebar and drawer** - Media between Articles and Users with the images icon, active on
/media, in the desktop sidebar and the mobile drawer; the route has meta.auth "verified" and
meta.title "Media" (routerGuard spec extended).
30. **Header** - title, subtitle, and the Upload button (also the mobile pinned 48 px variant).
## Visual (`task visual:check -- media`, desktop and mobile, light and dark)
Reference: docs/design/screens/media.md and `source/MediaFrame.dc.html` (group "Media" in the
back-office board). Add each setup to `web/visual/states.ts` first, mocking `/v1/media/`,
`/v1/media/{id}/usage` and `/v1/media/?hash=` from the board's own script: the 12 images (ids,
filenames, sizes, dimensions, used in, uploaders, absolute dates), and the article rows for
the panels ("How we price a bathroom renovation" Online, "Winter checklist for timber windows"
Draft for north-wall.jpg). None of the B1 states depends on the current date (all dates are
absolute), so none is `skip: 'clock-sensitive'`.
The board draws its thumbnails as placeholder tiles (a striped tone fill with an image
icon, the RenewCMS mark on the SVG, a checkerboard behind PNG and SVG), not photographs. The
mocked image URLs must render the same way, or the 10 % gate fails for a reason unrelated to
the code.
Harness note: the B2 states now have setups and no longer carry `skip: 'pending'`.
Setups for this slice (name in screens/media.md, then what must be drawn):
- [ ] Grid, many images: 12 cards, tabs 12 / 2 / 2, Select all row showing "12 images" only.
- [ ] Grid, few images: ids 1, 4, 8; checkerboard behind the SVG.
- [ ] List view: table at desktop, card list at mobile.
- [ ] Loading: skeleton cards (10 desktop, 6 mobile), toolbar visible.
- [ ] Empty library, first run: no toolbar, "No images yet" panel and the footnote.
- [ ] No results: unused + "wall".
- [ ] Hover, focus and keyboard: hover on id 2, focus ring on id 3 (the checkbox reveal is covered by B2).
- [ ] Detail panel: in use (id 1), unused (id 8), duplicate (id 12).
- [ ] Copy URL: id 1 panel plus the toast.
- [ ] Rename: dialog, saving, validation, failed, done (id 1, "north-wall-01").
- [ ] Delete: unused (id 8), in use (id 1), in progress (id 1), failed (id 8), done (grid without
id 8 plus toast).
- [ ] Image already gone (404): grid without id 8 plus the info toast.
- [ ] List failed to load: alert with "Try again".
- [ ] Offline: banner and disabled Upload.
The B2 states are listed in features/media_upload_selection_duplicates.qa.md.
Manual checks screenshots cannot cover:
- [ ] Keyboard and focus order: Tab visits the search, tabs, sort, toggle, then one stop per card;
arrow keys move across the grid; Enter opens the panel; focus is trapped in the panel and in
dialogs, starts on Cancel in dialogs, and returns to the card on close; after a delete focus
lands on the next card.
- [ ] Hover, motion and reduced motion: card hover shadow, skeleton pulse, panel and dialog
transitions stop under reduced motion.
- [ ] Screen reader: the card's accessible name reads correctly.
## Manual verification
- **Real round trips against a running server** (`task dev`): list with real counts, sorting,
search, usage, rename (extension kept, URL unchanged), delete, a concurrent 404. Vitest only
mocks `useApi`.
- **Real image rendering**: the thumbnails and previews load from the real `url` and the
checkerboard shows behind real transparent PNG and SVG files.
- **Real clipboard write** in a real browser.
- **Toast placement and stacking** on a live app.
- **Counts after a delete** against a live server: the background refresh gives the true Unused
and Duplicates counts.
- **A library above 100 images**: B1 requests `limit=100` and ignores `next_cursor`, so only the
first 100 show.
+391
View File
@@ -0,0 +1,391 @@
Feature: The media library holds every uploaded image as a library-level asset
As a verified RenewCMS user
I want to upload, browse, rename, delete, merge and inspect images that belong to the
library and not to any article
So that one image can be used in many articles, deleting an article never deletes an image,
and duplicates can be merged without leaving a broken image behind
Background:
Given internal/domain/image.Image carries ID, Path, Filename, Size, ContentType, Width,
Height (0 for SVG), SHA256, UploadedByID, CreatedAt and UpdatedAt, and no article id
And image.ImageInput gains Width, Height, SHA256 and UploadedByID
And internal/domain/image defines the sentinels ErrImageUnreadable, ErrInvalidFilename,
ErrMergeCopyMissing and ErrMergeHashMismatch, plus the existing ErrImageNotFound,
ErrImageTypeNotAllowed and ErrImageTooLarge, all asserted with errors.Is
And image.Repository exposes Create, Get, Delete, List(query), FindByHash, Rename,
ListByHash groups and Counts, and article.Repository exposes
FindByBodyContaining(filename) and the existing UpdateBody, and no longer AddImage
And CreateImageUseCase reads the bytes fully, rejects a type outside image/png, image/jpeg,
image/webp and image/svg+xml with ErrImageTypeNotAllowed, rejects more than 10485760
bytes actually read with ErrImageTooLarge, computes SHA-256 hex and, for raster images,
width and height from the bytes, and rejects bytes that cannot be decoded (or an SVG
without an svg root) with ErrImageUnreadable, all before image.Repository.Create is called
And an image's "used in" is the count of articles, online or draft, whose body contains
the image's url, read from article bodies each time
And /v1/media is mounted in api/router.go behind jwtauth.Verifier, jwtauth.Authenticator,
middleware.RequireCurrentToken and middleware.RequireVerifiedUser
And every media item is JSON with snake_case fields:
{"id","filename","url","size","content_type","width","height","sha256","uploaded_by",
"created_at","used_in","duplicate"}, where url is the stored path
"/static/uploadedImages/<uuid><ext>", width and height are null for SVG, uploaded_by is
the uploader's display name (username when empty, "" when unknown) and duplicate is true
when another image has the same sha256
And request bodies are validated with validate tags and validator.Validate.Struct, and
errors are plain text from http.Error
Scenario Outline: Every media route refuses callers that are not logged in and verified
When the client sends <method> <path> <credentials>
Then the response status is <status>
And no image or article is read or changed
Examples:
| method | path | credentials | status |
| GET | /v1/media/ | no cookie | 401 |
| POST | /v1/media/ | no cookie | 401 |
| PATCH | /v1/media/1 | no cookie | 401 |
| DELETE | /v1/media/1 | no cookie | 401 |
| POST | /v1/media/merge | no cookie | 401 |
| GET | /v1/media/1/usage | no cookie | 401 |
| GET | /v1/media/ | a stale token_version cookie | 401 |
| GET | /v1/media/ | cookie of an unverified user | 403 |
| DELETE | /v1/media/1 | cookie of an unverified user | 403 |
Rule: Listing
Background:
Given user 1 has display name "Inès Duarte" and user 2 has no display name and username "marta"
And the library holds, uploaded in this order (oldest first):
| id | filename | size | content_type | width | height | sha256 | uploaded by |
| 1 | north-wall.jpg | 4089446 | image/jpeg | 3600 | 2400 | A | 1 |
| 2 | floorboards.jpg | 2516582 | image/jpeg | 3600 | 2400 | B | 1 |
| 3 | floorboards-copy.jpg | 2516582 | image/jpeg | 3600 | 2400 | B | 2 |
| 4 | window-frame.jpg | 812345 | image/jpeg | 1200 | 800 | C | 2 |
| 5 | logo.svg | 1204 | image/svg+xml | 0 | 0 | D | 1 |
And article 1 "Kitchen Renovation" is online and its body contains the urls of images 1 and 2
And article 2 "Backyard Notes" is a draft and its body contains the url of image 1
And article 3 "Empty" has a body with no image url
Scenario: The default list is newest first with used_in, duplicate and tab counts
When the client sends GET /v1/media/ with a valid cookie
Then the response status is 200
And the "items" ids are 5, 4, 3, 2, 1 in that order
And item 1 has used_in 2, duplicate false and uploaded_by "Inès Duarte"
And items 2 and 3 have duplicate true, and item 3 has uploaded_by "marta"
And item 5 has width null, height null and used_in 0
And "next_cursor" is null
And "counts" is {"all": 5, "unused": 3, "duplicates": 1}
Scenario: An empty library answers an empty list, not null
Given the library holds no image
When the client sends GET /v1/media/ with a valid cookie
Then the response status is 200
And "items" is an empty array and "counts" is {"all": 0, "unused": 0, "duplicates": 0}
Scenario Outline: sort orders the list
When the client sends GET /v1/media/?sort=<sort> with a valid cookie
Then the "items" ids are <ids> in that order
Examples:
| sort | ids |
| newest | 5, 4, 3, 2, 1 |
| oldest | 1, 2, 3, 4, 5 |
| name | 2, 3, 5, 1, 4 |
| largest | 1, 2, 3, 4, 5 |
Scenario Outline: filter narrows the list and the counts stay library-wide
When the client sends GET /v1/media/?filter=<filter> with a valid cookie
Then the "items" ids, as a set, are <ids>
And "counts" is still {"all": 5, "unused": 3, "duplicates": 1}
Examples:
| filter | ids |
| all | 1,2,3,4,5 |
| unused | 3, 4, 5 |
| duplicates | 2, 3 |
Scenario Outline: q matches the filename case-insensitively
When the client sends GET /v1/media/?q=<q> with a valid cookie
Then the "items" ids are <ids> in that order
And "counts" is still {"all": 5, "unused": 3, "duplicates": 1}
Examples:
| q | ids |
| FLOORBOARD | 3, 2 |
| wall | 1 |
| zzz | |
Scenario: q, filter and sort combine
When the client sends GET /v1/media/?q=floorboards&filter=unused&sort=oldest with a valid cookie
Then the "items" ids are 3
Scenario: hash returns only the images with that content
When the client sends GET /v1/media/?hash=<sha256 of image 2's bytes> with a valid cookie
Then the "items" ids, as a set, are 2, 3
Scenario: hash of unknown content returns no item
When the client sends GET /v1/media/?hash=<64 hex zeros> with a valid cookie
Then the response status is 200 and "items" is empty
Scenario: limit and cursor page through the list
When the client sends GET /v1/media/?limit=2 with a valid cookie
Then the "items" ids are 5, 4 and "next_cursor" is a non-empty string
When the client sends GET /v1/media/?limit=2&cursor=<that next_cursor>
Then the "items" ids are 3, 2 and "next_cursor" is a non-empty string
When the client sends GET /v1/media/?limit=2&cursor=<that next_cursor>
Then the "items" ids are 1 and "next_cursor" is null
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 |
| filter=bogus |
| sort=bogus |
| limit=0 |
| limit=101 |
| limit=abc |
| cursor=%%% |
| hash=notahash |
Rule: Uploading
Scenario Outline: An allowed image is always stored, with its metadata and hash
Given a valid cookie for user 1 whose display name is "Inès Duarte"
When the client sends POST /v1/media/ with multipart field "file" named <name>, content type <content_type> and real <content_type> bytes
Then the response status is 201
And the body is a media item whose filename is <name>, content_type is <content_type>, size is the byte count, width and height are <width> and <height>, sha256 is the SHA-256 hex of the bytes, uploaded_by is "Inès Duarte", used_in is 0 and duplicate is false
And the item's url matches "/static/uploadedImages/<uuid><ext>"
And the image repository holds one new image with UploadedByID 1
Examples:
| name | content_type | width | height |
| north-wall.png | image/png | 3 | 2 |
| IMG_2208.jpg | image/jpeg | 3 | 2 |
| hero.webp | image/webp | 1 | 1 |
| logo.svg | image/svg+xml | null | null |
Scenario: Uploading the same bytes twice stores both and marks both as duplicates
Given the library holds image 1 with the same bytes
When the client sends POST /v1/media/ with those bytes named "north-wall-again.png"
Then the response status is 201 and the item has duplicate true
And the library now holds two images with the same sha256 and different urls
And "counts".duplicates is 1 when listed
Scenario: A filename without extension gets the canonical one, a path is stripped
When the client uploads real PNG bytes named "C:\\photos\\north wall" with content type "image/png"
Then the item filename is "north wall.png"
Scenario: A file exactly at 10485760 bytes is accepted, one byte more is 413
When the client uploads a valid PNG padded to 10485760 bytes
Then the response status is 201
When the client uploads a valid PNG padded to 10485761 bytes
Then the response status is 413
And the response body is the text "the file exceeds the maximum upload size"
And the image repository's Create was called once in total
Scenario: A disallowed content type is 415
When the client uploads "notes.pdf" with content type "application/pdf"
Then the response status is 415
And the response body is the text "the file must be a PNG, JPEG, WEBP, or SVG image"
And the image repository's Create is never called
Scenario Outline: Bytes that cannot be read as the declared type are 400
When the client uploads <name> with content type <content_type> and the bytes "not an image"
Then the response status is 400
And the response body is the text "the image could not be read"
And the image repository's Create is never called
Examples:
| name | content_type |
| broken.png | image/png |
| broken.jpg | image/jpeg |
| broken.webp | image/webp |
| broken.svg | image/svg+xml |
Scenario: A request without the "file" part is 400
When the client sends POST /v1/media/ with a multipart body that has no "file" field
Then the response status is 400
And the image repository's Create is never called
Scenario: A repository failure is a generic 500
Given the image repository's next Create fails
When the client uploads a valid PNG
Then the response status is 500 and the body is "Internal server error"
Rule: Renaming
Background:
Given image 1 is "north-wall.jpg" with url "/static/uploadedImages/aaa.jpeg" and article 1 body contains that url
Scenario: Rename changes the display name, keeps the extension and the url
When the client sends PATCH /v1/media/1 with {"filename": "north-wall-01"}
Then the response status is 200
And the body is the media item with filename "north-wall-01.jpg" and url "/static/uploadedImages/aaa.jpeg"
And article 1's body is unchanged
And a following GET /v1/media/?q=north-wall-01 returns image 1
Scenario: Surrounding whitespace is trimmed and unicode letters are allowed
When the client sends PATCH /v1/media/1 with {"filename": " Été_2026-a "}
Then the response status is 200 and the filename is "Été_2026-a.jpg"
Scenario Outline: An invalid new name is 400 and nothing changes
When the client sends PATCH /v1/media/1 with <body>
Then the response status is 400
And the response body is the text "Enter a filename." for every row except "not json"
And the image's filename is still "north-wall.jpg"
Examples:
| body |
| {"filename": ""} |
| {"filename": " "} |
| {"filename": "***"} |
| {"filename": "a.b"} |
| {"filename": "a b"} |
| {"filename": "<101 letters>"} |
| {} |
| not json |
Scenario: Renaming an unknown image is 404
When the client sends PATCH /v1/media/9999 with {"filename": "x"}
Then the response status is 404 and the body is "Image not found"
Scenario: A malformed id is 400
When the client sends PATCH /v1/media/abc with {"filename": "x"}
Then the response status is 400 and the body is apperr.IdUint32ErrorMessage
Rule: Deleting
Scenario: An image in use can be deleted and article bodies are left alone
Given image 1 is used in article 1's body
When the client sends DELETE /v1/media/1 with a valid cookie
Then the response status is 204 and the body is empty
And image 1 is gone from the repository and its file from UPLOAD_DIR
And article 1's body is byte-for-byte unchanged
Scenario: A file already missing from disk still deletes the row
Given image 4's file is not in UPLOAD_DIR
When the client sends DELETE /v1/media/4
Then the response status is 204 and image 4 is gone from the repository
Scenario: Deleting an unknown image is 404
When the client sends DELETE /v1/media/9999
Then the response status is 404 and the body is "Image not found"
Scenario: A malformed id is 400
When the client sends DELETE /v1/media/abc
Then the response status is 400 and the body is apperr.IdUint32ErrorMessage
Scenario: A repository failure is a generic 500 and the image stays
Given the image repository's Delete fails
When the client sends DELETE /v1/media/1
Then the response status is 500 and image 1 is still in the repository
Rule: Usage
Scenario: Usage lists the articles whose body contains the url
Given article 1 "Kitchen Renovation" is online and article 2 "Backyard Notes" is a draft, both containing image 1's url, and article 3 does not
When the client sends GET /v1/media/1/usage with a valid cookie
Then the response status is 200
And the body is [{"id": 1, "title": "Kitchen Renovation", "published": true}, {"id": 2, "title": "Backyard Notes", "published": false}]
Scenario: An unused image has an empty array, not null
When the client sends GET /v1/media/4/usage
Then the response status is 200 and the body is []
Scenario: An absolute url still counts as used
Given article 3's body contains "https://cms.example.com/static/uploadedImages/aaa.jpeg" for image 1
Then GET /v1/media/1/usage includes article 3
Scenario Outline: Unknown or malformed ids
When the client sends GET /v1/media/<id>/usage
Then the response status is <status> and the body is <body>
Examples:
| id | status | body |
| 9999 | 404 | Image not found |
| abc | 400 | apperr.IdUint32ErrorMessage |
Rule: Merging
Background:
Given images 2, 3 and 6 share sha256 B, image 1 has sha256 A
And article 1 body contains the urls of images 2 and 3, article 2 body contains image 3's url twice, article 3 body contains image 2's url only, and article 4 has none of them
Scenario: Merge switches bodies to the kept url and deletes the copies
When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3, 6]}
Then the response status is 200
And the body is {"articles_updated": 2, "merged": 2}
And article 1's body has image 3's url replaced by image 2's url
And article 2's body has both occurrences of image 3's url replaced by image 2's url
And article 3 and article 4 are unchanged
And images 3 and 6 are gone from the repository and from UPLOAD_DIR, and image 2 remains
Scenario: Merging copies that no article uses updates zero articles
When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [6]}
Then the response status is 200 and the body is {"articles_updated": 0, "merged": 1}
Scenario: A copy that is gone answers 409 and changes nothing
When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3, 9999]}
Then the response status is 409 and the body is apperr.MediaMergeConflictErrorMessage
And no article body changed and images 2 and 3 still exist
Scenario: A copy that does not share the hash answers 409 and changes nothing
When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3, 1]}
Then the response status is 409
And no article body changed and images 1, 2 and 3 still exist
Scenario: A kept image that is gone answers 404
When the client sends POST /v1/media/merge with {"keep_id": 9999, "merge_ids": [3]}
Then the response status is 404 and the body is "Image not found"
And nothing changed
Scenario Outline: An invalid merge request is 400 and nothing changes
When the client sends POST /v1/media/merge with <body>
Then the response status is 400 and the body is apperr.MediaMergeRequestErrorMessage
Examples:
| body |
| {"keep_id": 2, "merge_ids": []} |
| {"keep_id": 2} |
| {"merge_ids": [3]} |
| {"keep_id": 2, "merge_ids": [2, 3]} |
| {"keep_id": 2, "merge_ids": [3, 3]} |
| {"keep_id": 0, "merge_ids": [3]} |
| not json |
Scenario: A failure rewriting a body restores the bodies already rewritten
Given the article repository's UpdateBody fails for article 2
When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3]}
Then the response status is 500
And article 1's body is back to its original text, article 2 is unchanged
And images 2 and 3 still exist
Scenario: A failure deleting a copy after the rewrite is 500 and the bodies stay switched
Given the image repository's Delete fails for image 3
When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3]}
Then the response status is 500
And article 1's and article 2's bodies use image 2's url and image 3 still exists
@manual
Rule: Startup backfill and migration (persistence, verified by hand, see the qa.md)
Scenario: Existing rows get metadata and hash from the files on disk
Given the images table has rows created before this slice, with a path and no filename, size, content_type, width, height or sha256, and their files are in UPLOAD_DIR
When the server boots
Then each row gets filename equal to the stored file name, size, content_type, width and height (0 for SVG) and sha256 read from its file, and UploadedByID stays 0
And the old article_id column and the article_images table are kept and not written
Scenario: A row whose file is missing does not stop the boot
Given a row whose file is not in UPLOAD_DIR
When the server boots
Then the boot completes, the row is left without metadata, and the server serves requests
Scenario: The backfill is idempotent
When the server boots a second time
Then no row is changed again
Scenario: A row without metadata still lists
Then GET /v1/media/ returns it with size 0, width null, height null, sha256 "" and duplicate false
+39
View File
@@ -0,0 +1,39 @@
# QA procedure: media_library
Package: `test` (black-box). New file `test/media_library_http_test.go`. Fake type names must be unique in the package.
## Setup shared by every case
1. Call `validator.InitValidator()` once.
2. Rewrite `mockImageRepo` (`test/image_test.go`) to the new `image.Repository`: Create (stores the metadata from the ImageInput and derives a `/static/uploadedImages/<n>.<ext>` path), Get, Delete (records calls, honours `failDeleteFor`), List(query), FindByHash, Rename, ListByHash groups, Counts. Add fields for seeding `Filename`, `Size`, `ContentType`, `Width`, `Height`, `SHA256`, `UploadedByID`, `CreatedAt`.
3. Extend `mockArticleRepo` (`test/article_test.go`) with `FindByBodyContaining(filename)` (substring over `Body`) and a per-id failure switch for `UpdateBody`. Remove `AddImage` and the article `Images` field from the fake and from every test that uses them.
4. Use the existing user fake so `uploaded_by` resolves: user 1 display name "Inès Duarte", user 2 username "marta" with empty display name.
5. Wire the real use cases over the fakes: create, list, rename, delete, merge and usage media use cases, `NewCreateImageUseCase(imageRepo)`, `NewGetUserUseCase(userRepo)`. Build `media.NewHandler(...)` (`api/handlers/media`).
6. Build `jwtauth.New("HS256", []byte("test-secret"), nil)`. Mount on a fresh `chi.NewRouter()` mirroring `api/router.go`: `jwtauth.Verifier` at the top, then in the media group `Authenticator`, `RequireCurrentToken(getUser)`, `RequireVerifiedUser(getUser)`, with the six routes under `/v1/media`.
7. Mint cookies via `token.Encode(map[string]any{"user_id": uint32(n), ...})` as in the other HTTP tests. Build a stale-token cookie with a `token_version` lower than the user's, and use an unverified user for the 403 case.
8. Build multipart bodies with `mime/multipart.Writer`, using `CreatePart` with an explicit part `Content-Type` header. Generate real bytes in the test:
- PNG (3x2) with `image/png`;
- JPEG (3x2) with `image/jpeg`;
- a tiny valid WEBP fixture (1x1) as a byte constant;
- SVG `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>`.
Compute expected sha256 in the test with `crypto/sha256`. For size limits, pad valid PNG bytes to 10485760 and 10485761 bytes.
9. Seed the Listing background exactly as in the feature (5 images, 3 articles, 2 users), with `created_at` strictly increasing by id.
## HTTP checks
10. Auth table: each method+path from the outline with no cookie -> 401, stale cookie -> 401 with `apperr.UnauthorizedErrorMessage`, unverified cookie -> 403 with `apperr.VerificationRequiredErrorMessage`. Assert no fake method beyond the middleware's user lookup was called.
11. List: decode `{items, next_cursor, counts}`. Assert each row of the sort, filter, `q` and `hash` tables, the combined query, the paging walk (limit 2 across three pages ending in `next_cursor == null`), the empty library (`items` is `[]` in the raw JSON, not `null`), SVG `width`/`height` are JSON `null` (assert on the raw body), and `counts` is unchanged by `q`/`filter`. Assert every 400 query row, body equals `apperr.MediaQueryErrorMessage + "\n"`.
12. Upload: for each allowed type assert 201, the item fields, `sha256` equals the test's own hash, `uploaded_by`, the url regex, and one repo entry with `UploadedByID == 1`. Upload identical bytes twice: both 201, second has `duplicate: true`, list `counts.duplicates == 1`. Assert filename rules (no extension gets `.png`, path stripped). Assert 413/415/400 statuses and bodies, that `Create` is never called on rejection, boundary 10485760 vs 10485761, a missing `file` part -> 400, and an armed `Create` failure -> 500 `"Internal server error\n"`.
13. Rename: assert 200 with the new filename, the same `url`, the article body unchanged, and every invalid row -> 400 with `"Enter a filename.\n"` for the filename rows; 404 for 9999; 400 with `apperr.IdUint32ErrorMessage` for `abc`. Assert the repository filename is unchanged after each 400.
14. Delete: in-use image -> 204 with an empty body, article bodies byte-identical, `deleteCalls == [1]`; 404, 400, 500 rows. The "file already gone" case is a persistence behaviour and is a manual item.
15. Usage: assert the exact JSON arrays (raw body compared as JSON), the empty `[]`, the absolute-url article, 404 and 400.
16. Merge: assert 200 `{articles_updated, merged}`, resulting bodies (both occurrences replaced), unchanged bodies for the other articles, and only image 2 left. Assert the 409 rows and 404 row, with no body change and no `Delete` calls (`deleteCalls` empty). Assert every 400 row with `apperr.MediaMergeRequestErrorMessage`. Assert the two failure rows: `UpdateBody` failing for article 2 leaves article 1 restored and no `Delete` call; `Delete` failing for image 3 leaves bodies switched and returns 500.
17. Unit-level asserts (same file, no HTTP) with `errors.Is` on the sentinels `ErrImageUnreadable`, `ErrMergeCopyMissing`, `ErrMergeHashMismatch`, `ErrInvalidFilename`.
18. Layering guard: `go list -deps` or a grep-style test that `api/handlers/media` imports no `persistence`, `mailer`, `models` or `gorm` package.
## Manual verification (not exercised by `task test`)
- Real upload, rename, delete, merge against a scratch SQLite file and `UPLOAD_DIR` via `task dev`: the files land on disk, `url` loads through `/static/uploadedImages/`, deleting an in-use image leaves the article body text untouched, and a merge removes the copy files.
- Delete when the file was already removed from `UPLOAD_DIR` still answers 204.
- Startup backfill: copy an old database (rows with only `path`), boot, and check that filename, size, content_type, width, height and sha256 are filled; check idempotency on a second boot; check that a row whose file is missing does not stop the boot; check that the `article_images` table and `article_id` column are still present and not written.
- `UploadedByID` 0 rows display an empty `uploaded_by`.
- WEBP dimensions with a real WEBP file (the fixture is minimal).
- No SMTP involvement. No real rate limit (out of scope for this slice).
@@ -0,0 +1,452 @@
Feature: Media page, part 2: upload, selection and duplicates
As a verified RenewCMS owner
I want to upload images to the library from /media, select several images to delete them
together, and merge identical copies
So that I can keep the library complete and free of duplicates without leaving the page
Background:
Given /media is the verified route from slice B1, rendered as MediaPage inside
DashboardShell, per docs/design/routes.md and docs/design/screens/media.md
And this slice is SPA only: the API from slice A is used unchanged, and no Go code, use case
or repository changes
And this slice supersedes these B1 statements: the Upload button, the mobile pinned button and
"Upload an image" are enabled; the Duplicates tab is enabled and ?filter=duplicates is a
valid value; "Review duplicates" is enabled; Space on a card toggles selection; the checkbox,
the "Select all" text and the partial state exist; the B2 states no longer carry
skip 'pending' in web/visual/states.ts
And the calls used are POST /v1/media/ (multipart field "file", sent with XMLHttpRequest so
that upload progress is reported, with the jwt cookie), GET /v1/media/?hash=<sha256 hex>,
DELETE /v1/media/{id}, POST /v1/media/merge { keep_id, merge_ids } and the list call from
B1 with filter=duplicates; every other /v1 call goes through useApi
And 401 and 403 on any of these calls, including the XHR upload, send the user to
/login?redirect=/media&reason=expired and /register/pending as useApi does, with no local
copy
And the browser-side checks reuse validateImageFile from web/src/lib/imageUpload.ts: size is
checked first (10 MB = 10485760 bytes is allowed, one byte more is too large), then the
file's MIME type and its extension must both be allowed (PNG, JPEG, WEBP, SVG; .png, .jpg,
.jpeg, .webp, .svg); a rejected file is never hashed and never sent
And hashing lives in a shared module under web/src/lib that depends on nothing in the Media
page, so that the editor's media picker slice reuses it: it reads the file, computes the
SHA-256 with crypto.subtle and resolves the lowercase 64-character hex, or resolves null when
crypto.subtle is unavailable
And at most 2 uploads are in flight at once; files are processed in the order they were
chosen or dropped, and hashing runs inside the file's concurrency slot while its row reads
"Waiting"
And uploads from this page are not cropped or resized, and each file is sent as chosen
And toast ids are "media-bulk-deleted", "media-bulk-delete-failed", "media-merged-{keep_id}"
and "media-not-found-{id}", and success toasts last 5 s; the bulk-delete failure toast has
no duration and stays until retried or dismissed
And the 12-image fixture, its counts { all: 12, unused: 2, duplicates: 2 } and its sample
data are the ones in features/media_browse.feature; the duplicate groups are {12, 5, 6}
(floorboards.jpg, floorboards-copy.jpg, IMG_2208.jpg, 3600 × 2400, 2.4 MB each) and
{7, 9} (IMG_4471.jpg, stone-wall-detail.jpg, 3600 × 2400, 3.1 MB each)
And where a board frame contradicts the design prose or the fixture, this spec follows the
prose and the fixture, and the design is corrected by the user, with no visual exception
Rule: Upload entry points
Scenario Outline: The Upload controls open the file chooser
Given the Default state has loaded <context>
When the user activates "<control>"
Then the file chooser opens, accepts several files and offers image/png, image/jpeg, image/webp and image/svg+xml
Examples:
| context | control |
| at md width or more | Upload |
| below md width | Upload |
| and the library has no image | Upload an image |
Scenario: Dropping files anywhere on the page
Given the Default state has loaded at md width or more
When the user drags files over the page
Then a dashed primary-bordered area covers the content, with the upload icon, the title "Drop images to upload" and the text "PNG, JPEG, WEBP, SVG · max 10 MB each"
And the mobile pinned Upload button is hidden while the area shows
When the user drags the files away from the page
Then the area disappears and nothing is uploaded
When the user drops the files on the page
Then the area disappears and the upload of each file starts straight away, with no crop step
Scenario: Dragging something that is not a file does nothing
When the user drags selected text over the page
Then no drop area shows
Rule: Browser checks, nothing is sent
Scenario Outline: A file that fails the checks becomes a rejected row
Given the user chooses the file "<file>" of <size> with MIME type "<mime>"
Then one row is added with the file-x icon, the title "<title>" and the text "Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image."
And no hash lookup and no POST /v1/media/ is sent for it
Examples:
| file | size | mime | title |
| scan.tif | 14.2 MB | image/tiff | scan.tif is 14.2 MB |
| notes.pdf | 210 KB | application/pdf | notes.pdf isn't an image |
| photo.png | 10.1 MB | image/png | photo.png is 10.1 MB |
| fake.jpg | 40 KB | text/plain | fake.jpg isn't an image |
| logo.gif | 40 KB | image/gif | logo.gif isn't an image |
| pic.jpg | 40 KB | image/png | pic.jpg isn't an image |
Scenario: Every rejected file leaves the panel with "Nothing was uploaded"
Given the user chooses scan.tif (14.2 MB), notes.pdf (210 KB) and broken.png (3.0 MB, rejected by the server with 400)
Then the panel header reads "Nothing was uploaded" with "3 files were rejected" and a "Dismiss" button
And the rows read "scan.tif is 14.2 MB", "notes.pdf isn't an image" and "broken.png couldn't be read" with "The server couldn't read this image. Save it again as a PNG or JPEG, then try again."
And no summary line shows
Scenario: Rejected and failed rows without an added image
Given the user chooses one rejected file and one file whose upload fails with 500
Then the panel header reads "Nothing was uploaded" with "2 files need attention"
And no summary line shows
Rule: The upload panel and its rows
Scenario: Files upload two at a time with progress
Given the user chooses 6 valid images and the duplicate lookup finds nothing
Then the panel shows above the images, below the toolbar, with the title "Uploading 6 images", "0 of 6 done" and a "Cancel all" button
And rows 1 and 2 read "Uploading", and rows 3 to 6 read "Waiting" (each row: 40 px thumbnail, filename, size)
When the first upload reports 1.1 MB sent of 2.6 MB
Then its row reads "Uploading 1.1 of 2.6 MB" over a 4 px progress bar filled to 42 % and shows a cancel x
When the first upload resolves 201
Then its row reads "Added to your library" with a circle-check icon, the header reads "1 of 6 done", and row 3 starts uploading
And at no time are more than 2 requests to POST /v1/media/ in flight
Scenario: The panel header counts settled rows
Given a panel with rows Added, Duplicate, Rejected, Failed, Uploading and Waiting
Then the header reads "4 of 6 done"
Scenario: Singular forms
Given the user chooses 1 valid image
Then the panel title reads "Uploading 1 image"
Scenario: New files added while the panel is open
Given the panel is showing rows
When the user chooses or drops more files
Then their rows are appended and the header counts cover all rows
Scenario: A new upload refreshes the list in the background
Given an upload resolves 201
Then the list is requested again with no skeleton, so the new image shows and the counts update
Scenario: A row whose content already exists
Given the user chooses "floorboards-2.jpg" (2.4 MB, valid)
When the SHA-256 of the file is computed
Then GET /v1/media/?hash=<that hex> is sent before any POST
And when it resolves 200 with the image "floorboards.jpg" (id 12, uploaded 14 Jun 2026 by Inès Duarte)
Then the row shows "Already in your library", the text `Same as "floorboards.jpg", uploaded 14 Jun 2026 by Inès Duarte.`, and the buttons "Use existing" and "Upload anyway"
And nothing is sent with POST /v1/media/ for it, and its concurrency slot is released
Scenario: Several existing matches name the oldest one
Given the lookup matches several images
Then the row names the oldest match
Scenario: Use existing selects the existing image
Given the row for "floorboards-2.jpg" is showing "Already in your library" for id 12
When the user activates "Use existing"
Then the row is dropped from the panel
And the image "floorboards.jpg" is selected, the bulk bar reads "1 selected", and the page announces "floorboards.jpg selected, 1 selected"
Scenario: Use existing when the image is not in the loaded list
Given the existing image is not in the loaded list because of the search, the tab or the 100 limit
When the user activates "Use existing"
Then the search and the tab reset to All and the image is selected
And if it is still not loaded the row is dropped without selecting
Scenario: Upload anyway stores a second copy
Given the row for "floorboards-2.jpg" is showing "Already in your library"
When the user activates "Upload anyway"
Then the file is sent with POST /v1/media/ and the row follows the normal upload states
And when it resolves 201 the list is reloaded and the Duplicates tab count reflects the new copy
Scenario Outline: The duplicate check is skipped when it cannot run
Given the user chooses a valid image and <condition>
Then the file is sent with POST /v1/media/ without waiting for the answer to a lookup
And any duplicate then shows up under Duplicates
Examples:
| condition |
| crypto.subtle is undefined (an insecure context) |
| GET /v1/media/?hash= fails with 500 or never reaches the server |
Scenario Outline: Server answers for an upload that passed the checks
Given a valid image "<file>" of <size> is uploading
When POST /v1/media/ resolves <status>
Then the row reads <row>
Examples:
| file | size | status | row |
| roof.png | 1.4 MB | 201 | circle-check "Added to your library" |
| scan.png | 9.9 MB | 413 | file-x "scan.png is 9.9 MB" over "Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image." |
| notes.png | 210 KB | 415 | file-x "notes.png isn't an image" over the same text |
| broken.png | 3.0 MB | 400 | file-x "broken.png couldn't be read" over "The server couldn't read this image. Save it again as a PNG or JPEG, then try again." |
| roof.png | 1.4 MB | 500 | circle-x "Upload failed" over "The server didn't accept the file. Nothing was added to your library." and a "Try again" button (icon rotate-cw) |
| roof.png | 1.4 MB | no answer (network) | the same Failed row |
Scenario: Try again retries one file
Given a row is "Upload failed"
When the user activates "Try again"
Then only that file is sent again with POST /v1/media/ (no new duplicate lookup), and the row follows the normal upload states
Scenario: Cancelling one upload or all of them
Given several files are uploading or waiting
When the user activates the cancel x on an uploading row
Then that request is aborted and the row is removed, and the next waiting file starts
When the user activates "Cancel all"
Then every request in flight is aborted and every waiting row is removed, Added, rejected and failed rows stay, and when no row is left the panel closes
Scenario: The panel settles with a summary
Given 5 files settled as 2 Added, 1 Already in your library (still undecided), 1 rejected and 1 failed
Then the header reads "Upload finished" with "5 images" and a "Dismiss" button in place of "Cancel all"
And a summary line reads "2 uploaded · 1 already in your library · 2 need attention"
And the page announces the summary when the last row settles
When the user activates "Dismiss"
Then the panel and its rows disappear, including rejected and failed rows, which stay until then
Scenario Outline: The summary line counts each outcome and omits empty parts
Given the panel has settled with <uploaded> added, <dup> already in the library, <attention> rejected or failed
Then the summary line reads "<line>"
Examples:
| uploaded | dup | attention | line |
| 2 | 1 | 2 | 2 uploaded · 1 already in your library · 2 need attention |
| 3 | 0 | 0 | 3 uploaded |
| 0 | 1 | 0 | 1 already in your library |
| 1 | 0 | 1 | 1 uploaded · 1 need attention |
Rule: Too many uploads (429)
Scenario: The server rate limits an upload
Given a file is uploading and files are waiting
When POST /v1/media/ resolves 429 with the header Retry-After: 42
Then an info alert with the timer icon reads "Too many uploads" over "For your security, wait a moment before uploading again."
And the Upload button reads "Try again in 0:42" with the timer icon and is disabled, and so is the mobile pinned button
And Added rows stay in the panel
And the rate-limited file goes back to Waiting and the queue pauses
And the countdown is driven by useRetryAfter and ticks once per second
When the countdown reaches 0:00
Then the alert disappears, the Upload button reads "Upload" and is enabled again
And the paused file and the waiting files resume by themselves
Scenario: A missing Retry-After counts as 60 seconds
When POST /v1/media/ resolves 429 without a readable Retry-After header
Then the countdown starts at 1:00
Scenario: Dropping or choosing files while rate limited
Given the countdown is running
When the user drops files on the page
Then nothing is uploaded and the drop area does not show
Rule: Offline
Scenario: Upload, bulk delete and merge need a connection
Given the Default state has loaded and navigator.onLine becomes false
Then the offline banner from B1 shows
And "Upload" (header, mobile pinned button and "Upload an image"), "Delete" in the bulk bar and every "Merge into this one" are disabled, and dropping files does nothing
And browsing, selecting and "Clear" still work
When navigator.onLine becomes true
Then they are enabled again
Rule: Selection
Scenario: Selecting images
Given the Default state has loaded at md width or more
Then no checkbox is shown on the cards
When the pointer rests on a card, or a card takes keyboard focus
Then its checkbox shows at the top left (36 px)
When the user ticks the checkbox of "north-wall.jpg" (or presses Space on the card)
Then the card takes the sand fill (--rc-sand) with a primary outline, and every card now shows its checkbox
And the page announces "north-wall.jpg selected, 1 selected"
And the keyboard path is Space on the card: the checkbox itself is not an extra tab stop
And Enter still opens the detail panel, and ticking a checkbox does not
Scenario: Unselecting announces the new count
Given 3 images are selected
When the user unselects "north-wall.jpg"
Then the page announces "north-wall.jpg unselected, 2 selected"
And when the last one is unselected it announces "Selection cleared"
Scenario: Selection on mobile
Given the Default state has loaded below md width
Then every card shows a 44 px checkbox, and so does every row of the card list in list view
And tapping the checkbox toggles the selection, and tapping the card opens the detail panel
Scenario: Select all and the partial state
Given the Default state has loaded
Then the row above the grid reads "Select all" and "12 images", with an unchecked checkbox
When 3 images are selected
Then that checkbox shows the partial state
When the user activates it while it is partial
Then all 12 loaded images are selected and it shows checked
When the user activates it while it is checked
Then the selection is emptied
And in list view at md width or more the table header checkbox does the same; the mobile card list has no "Select all" row and is selected through its 44 px row checkboxes
Scenario: Escape and Clear empty the selection
Given 3 images are selected and the detail panel is closed
When the user presses Escape, or activates "Clear" in the bulk bar
Then the selection is emptied and the bulk bar disappears
Given the detail panel is open over a selection
When the user presses Escape
Then only the panel closes and the selection stays
Scenario: The selection follows the query
Given 3 images are selected
When the user changes the search text, the tab or the sort
Then the selection is cleared
When the user switches between grid and list
Then the selection is kept
Scenario: The bulk bar
Given the images "north-wall.jpg", "kitchen-after.webp" and "window-frame.jpg" are selected
Then a sticky bar sits at the bottom: 400 px wide and 24 px above the bottom edge, centred over the content area on desktop; full width with 16 px side margins and 16 px above the bottom edge on mobile
And it has the inverted fill, the text "3 selected", a danger "Delete" button (icon trash-2) and an inverted "Clear" button
And on mobile it replaces the pinned Upload button
And toasts sit above it on mobile
Rule: Bulk delete
Scenario: Bulk delete, confirm
Given "north-wall.jpg" (used in 2), "kitchen-after.webp" (used in 3) and "window-frame.jpg" (unused) are selected
When the user activates "Delete"
Then a dialog opens titled "Delete 3 images?" with the description "2 of them are used in 5 articles, which will show a broken image. The other one isn't used anywhere. This can't be undone."
And the body has one bordered row per selected image: the filename and the badge "Used in 2", "Used in 3" or "Unused"
And the buttons read "Cancel" first, then a danger "Delete 3 images"
And no usage request is sent
Scenario: Bulk delete, singular
Given one image is selected
When the user activates "Delete"
Then the dialog is titled "Delete 1 image?" and the confirm button reads "Delete 1 image"
Scenario: Bulk delete, in progress
Given the bulk delete dialog is open for 3 images
When the user activates "Delete 3 images"
Then one DELETE /v1/media/{id} is sent for each of the 3 images
And both buttons are disabled, the confirm button reads "Deleting…" with a loader-circle, and Cancel, Escape and a backdrop click do not close the dialog
Scenario: Bulk delete, done
Given a bulk delete of ids 1, 2 and 8 is in flight
When every DELETE resolves 204
Then the dialog closes, the 3 cards leave the grid and the selection is empty
And focus moves to the card now at the position of the first deleted card, or to the page heading when no card remains
And a success toast appears: icon circle-check, id "media-bulk-deleted", title "3 images deleted", text "They're gone from your library."
And the list is then requested again in the background, with no skeleton
Scenario: Bulk delete, partly failed
Given a bulk delete of ids 1, 2 and 8 is in flight
When DELETE /v1/media/1 and DELETE /v1/media/2 resolve 204 and DELETE /v1/media/8 resolves 500
Then the dialog closes, the cards for ids 1 and 2 leave the grid, and "window-frame.jpg" stays in the grid and stays selected, so the bulk bar reads "1 selected"
And an error toast appears and stays until retried or dismissed: icon circle-x, id "media-bulk-delete-failed", title "Couldn't delete 1 image", text "2 images were deleted. window-frame.jpg is still in your library. Try again." and a "Retry" button (icon rotate-cw)
When the user activates "Retry"
Then DELETE /v1/media/8 is sent again with no dialog, and on 204 the card leaves and the toast is replaced by a success toast
Scenario: Bulk delete, several failures
Given a bulk delete of 4 images in which two fail
Then the toast title reads "Couldn't delete 2 images" and the text names both files, "a.jpg and b.jpg are still in your library. Try again."
Scenario: Bulk delete, an image that no longer exists
Given a bulk delete of 3 images is in flight
When one DELETE resolves 404 and the others 204
Then the 404 image leaves the list and counts as deleted, and the success toast reads "3 images deleted"
Scenario: Bulk delete, every request fails
Given a bulk delete of 3 images is in flight
When every DELETE resolves 500 or never arrives
Then the dialog stays open with an error alert "Couldn't delete the images, so they're still there. Try again in a moment." and both buttons enabled
Rule: Duplicates
Scenario: The Duplicates tab is enabled
Given the Default state has loaded
When the user selects the "Duplicates 2" tab
Then the URL gains ?filter=duplicates and the list is requested with filter=duplicates and limit=100
And the tab is selected, and "Duplicates" carries the info-tinted count of groups
And opening /media?filter=duplicates lands on the same view, and "Review duplicates" in the detail panel of "floorboards.jpg" is enabled and selects this tab
And search applies, the sort menu and the view toggle show as on the other tabs, sort is sent with the request, and the view toggle has no effect on the groups view while the grid or list preference stays in the URL
Scenario: Duplicates, groups
Given GET /v1/media/?filter=duplicates&sort=newest&limit=100 resolves 200 with ids 5, 6, 7, 9 and 12
When the user opens the Duplicates tab
Then the images are grouped by sha256 into 2 bordered cards stacked with a 16 px gap, the group with the oldest suggested copy first
And group 1 has the header "Group 1" over "3 identical images · 2.4 MB each · 3600 × 2400", and group 2 has "Group 2" over "2 identical images · 3.1 MB each · 3600 × 2400"
And group 1 has one column per copy, in the order "floorboards.jpg", "floorboards-copy.jpg", "IMG_2208.jpg" (most used, then oldest, then lowest id), and group 2 has "IMG_4471.jpg" then "stone-wall-detail.jpg"; below md the columns stack
And each copy shows its thumbnail, filename, "Uploaded 14 Jun 2026 by Inès Duarte", an underlined "Used in 4 articles" that opens its detail panel, and a "Merge into this one" button (icon merge)
And "floorboards.jpg" and "IMG_4471.jpg" carry the sand fill, a primary outline, the "Suggested" tag and a primary button, and the other copies are outlined with outline buttons
And each group's footer reads "Suggested: the most used copy, then the oldest. Choosing another copy switches articles to it."
Scenario: Group header details
Given a group of SVG images whose uploader is unknown
Then the header reads "2 identical images · 8 KB each · Vector" and each copy reads "Uploaded 14 Jun 2026" without "by"
Scenario Outline: The suggested copy
Given a group of copies <copies>
Then the suggested copy is <suggested>
Examples:
| copies | suggested |
| used in 4 (14 Jun), 3 (2 Sep) and 2 (30 Aug) | the copy used in 4 |
| used in 2 (29 Aug) and 2 (21 Aug) | the copy of 21 Aug (same use, oldest) |
| used in 0 (3 Sep) and 0 (1 Sep) | the copy of 1 Sep |
Scenario: Duplicates, empty
Given GET /v1/media/?filter=duplicates resolves 200 with no item and counts.duplicates 0
Then the Duplicates tab has no count badge
And a panel shows the copy icon, "No duplicates", "Every image in your library is different." and a "Back to all images" button
When the user activates "Back to all images"
Then the tab returns to All and ?filter leaves the URL
Rule: Merge
Scenario: Merge, confirm
Given the Duplicates tab shows group 1
When the user activates "Merge into this one" on "floorboards.jpg"
Then a dialog opens titled "Merge into floorboards.jpg?" with the description "Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can't be undone."
And the body shows the kept image: its thumbnail, "floorboards.jpg" and "Kept · used in 4 articles now"
And the buttons read "Cancel" first, then a danger "Merge 2 copies"
Scenario: Choosing another copy keeps that one
When the user activates "Merge into this one" on "IMG_2208.jpg" in group 1
Then the dialog is titled "Merge into IMG_2208.jpg?" and its article count is the sum of the used_in of "floorboards.jpg" and "floorboards-copy.jpg"
Scenario: Merge, singular
Given a group of 2 copies
Then the description reads "Articles that use the other copy will be switched to …" and the confirm button reads "Merge 1 copy"
Scenario: Merge, in progress
Given the Merge dialog is open for group 1 with "floorboards.jpg" kept
When the user activates "Merge 2 copies"
Then POST /v1/media/merge is sent with { "keep_id": 12, "merge_ids": [5, 6] }
And both buttons are disabled, the confirm button reads "Merging…" with a loader-circle, and Cancel, Escape and a backdrop click do not close the dialog
Scenario: Merge, done
Given a merge for group 1 is in flight
When POST /v1/media/merge resolves 200 with { "articles_updated": 5, "merged": 2 }
Then the dialog closes and group 1 leaves the list
And a success toast appears: icon circle-check, id "media-merged-12", title "Copies merged", text "2 copies merged. 5 articles updated."
And the page announces the same text, and the list is requested again in the background so the counts and the Duplicate flags update
And when it was the last group, the "No duplicates" panel shows and the tab loses its count badge
Scenario Outline: Merge, errors keep the dialog open or remove the image
Given a merge for group 1 is in flight
When POST /v1/media/merge <failure>
Then <result>
Examples:
| failure | result |
| resolves with a 500 response | the dialog stays open with the error alert "Couldn't merge the copies. Nothing was changed. Try again in a moment." and both buttons enabled again |
| never reaches the server | the same alert |
| resolves with a 409 response | the dialog stays open with "One of these images changed, so nothing was merged. The list is up to date now. Check it and try again.", and the list is reloaded |
| resolves with a 404 response | the dialog closes, the info toast "That image no longer exists" (id "media-not-found-12") shows, and the list is reloaded |
Rule: Sessions
Scenario Outline: Session errors are global for the new calls
When <call> resolves <status>
Then the user is sent to <destination> and no local copy shows
Examples:
| call | status | destination |
| POST /v1/media/ (XHR) | 401 | /login?redirect=/media&reason=expired |
| POST /v1/media/ (XHR) | 403 | /register/pending |
| POST /v1/media/merge | 401 | /login?redirect=/media&reason=expired |
| DELETE /v1/media/{id} | 403 | /register/pending |
| GET /v1/media/?hash= | 401 | /login?redirect=/media&reason=expired |
@@ -0,0 +1,58 @@
# QA: Media page, part 2 (upload, selection, duplicates)
## Scope note
SPA only. No Go test and no `test/` file; run the Go suite once (`task test`) to confirm nothing regressed. The server never returns 429 for POST /v1/media/ today (no rate limiter is mounted on the media router), so every 429 case is proven with a mock only.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount MediaPage in DashboardShell with a verified session and a test router at /media, as in the B1 spec. Mock useApi's `request` for GET/DELETE/merge and the list; replace `XMLHttpRequest` with a controllable stub (per instance: `open`, `send`, `abort`, `upload.onprogress` with `loaded`/`total`, `status`, `responseText`, `getResponseHeader('Retry-After')`, `load`, `error`, `abort` events). Stub `crypto.subtle.digest` to a known hex per file with `vi.stubGlobal`, and to `undefined` for the unavailable case. Use fake timers for the 150 ms skeleton, the 120 ms search debounce and the 1 s Retry-After tick. Sample data is the 12-image fixture of media_browse.feature. Files are built with `new File([...], name, { type })` and a `size` override for over-limit sizes.
1. **Hashing lib** (pure): digest of a known byte array gives the expected lowercase 64-char hex; `crypto.subtle` undefined gives null; nothing in the lib imports from the Media page.
2. **Browser checks**: the six rows of the .feature, plus 10485760 bytes accepted and 10485761 rejected; size beats type; a rejected file triggers no hash call, no GET and no XHR; titles and text exact.
3. **Entry points**: Upload (desktop header), the mobile pinned button and "Upload an image" each click the hidden `input[type=file]` (multiple, exact accept list).
4. **Drop overlay**: dragenter with Files shows the copy exactly; dragleave hides; drop starts uploads; a drag without Files shows nothing; mobile pinned button hidden while shown.
5. **Queue**: 6 valid files give at most 2 XHRs open at once; order kept; header "Uploading 6 images" and "N of 6 done" counts settled rows; "Uploading 1 image" singular; progress 1.1 of 2.6 MB reads "Uploading 1.1 of 2.6 MB" with a 42 % bar; 201 gives "Added to your library"; a silent list reload follows; files added while open are appended.
6. **Duplicate check**: GET `?hash=` precedes POST; a match gives the Duplicate row with exact text (oldest match named); slot released; "Use existing" drops the row and selects the id (bulk bar "1 selected", live region text), including the reset-to-All path; "Upload anyway" sends POST; crypto undefined and lookup failure skip straight to POST.
7. **Server statuses**: 201, 413, 415, 400, 500, network error: row copy and icon exact; "Try again" re-sends only that file with no new GET `?hash=`.
8. **Cancel**: row x aborts that XHR and removes the row, the next file starts; "Cancel all" aborts all and removes waiting rows; panel closes when empty.
9. **Summary**: Examples-driven summary lines; "Upload finished" / "{n} images" and "Dismiss"; "Nothing was uploaded" with "3 files were rejected" and with "2 files need attention", no summary; live-region text; Dismiss removes every row.
10. **429**: `Retry-After: 42` gives the alert copy and "Try again in 0:42" (disabled, timer icon; mobile pinned button too); the 429'd file returns to Waiting; advancing 42 s re-enables, removes the alert and resumes the queue; Added rows kept; missing header gives 1:00; drops ignored while counting.
11. **Offline**: Upload, "Upload an image", bulk "Delete" and "Merge into this one" disabled; drops ignored; Clear and selection still work; back online re-enables.
12. **Selection**: checkbox visibility (hover, focus, any selection, always on mobile); tick and Space toggle; Enter opens the panel and ticking does not; live-region texts for select, unselect and "Selection cleared"; the checkbox is not a tab stop; select-all states (unchecked, partial, checked) in the grid row and the table header (no row in the mobile card list); Escape closes the panel first and clears the selection next; Clear; selection cleared on q/tab/sort change, kept on view change.
13. **Bulk bar**: copy, buttons, desktop and mobile placement classes, replaces the mobile pinned button.
14. **Bulk delete**: dialog copy exact (title, description with the summed count, rows "Used in 2", "Used in 3", "Unused"), singular form; no usage request; in progress (one DELETE per id, buttons disabled, not dismissible); done (cards leave, selection empty, focus target, toast id and text, silent reload); partly failed (two removed, failed one stays selected, bar "1 selected", toast with Retry and no auto-dismiss, Retry re-sends only the failed DELETE); several failures title; 404 counted as deleted; all failed gives the inline alert.
15. **Duplicates tab**: enabled, URL and request (`filter=duplicates`, limit=100), deep link, "Review duplicates" enabled and switching tab; sort and view toggle shown (sort sent with the request, toggle keeps the preference in the URL); grouping by sha256, group order, headers (including the SVG/unknown uploader case), column order, "Suggested" tag and footer, "Uploaded 14 Jun 2026 by Inès Duarte", "Used in 4 articles" opens the panel; suggested-copy Examples; empty state and "Back to all images"; badge absent at 0.
16. **Merge**: dialog copy exact for floorboards.jpg and another kept copy, singular form; POST body `{ keep_id: 12, merge_ids: [5, 6] }`; in progress not dismissible; 200 gives group removed, toast id "media-merged-12" and text, live-region text, silent reload, "No duplicates" when last; 500 and network give the alert; 409 gives the alert and a reload; 404 gives the info toast and closes.
17. **Global errors**: 401 and 403 from XHR upload, merge, delete and hash lookup call the same redirect handler as useApi; no local copy.
18. **B1 regression**: media_browse specs updated for the superseded statements and the rest still passes.
## Visual (`task visual:check -- media`, desktop and mobile, light and dark)
Reference: docs/design/screens/media.md and `source/MediaFrame.dc.html`. The 20 B2 states lose `skip: 'pending'` and get a setup in `web/visual/states.ts`. None depends on the current date (dates are absolute), so none is `skip: 'clock-sensitive'`. Where a board frame contradicts the prose or fixture (bulk delete done / partly failed, "4 articles", tab badge at zero, "2 of 6 done", merge done with one group left), the user corrects the board; no exception is added.
Harness requirements:
- Upload states use an init-script XHR stub that holds a request at 1.1 of 2.6 MB (42 %); files come from `setInputFiles`; the duplicate state mocks `GET /v1/media/?hash=` by the hash of the file's bytes.
- The drag state dispatches a synthetic `dragenter` with `dataTransfer.types` = `['Files']`.
- The 429 state freezes timers with Playwright `page.clock` so the button reads "0:42".
Setups:
- [ ] Selection and bulk bar (ids 1, 2, 8; partial select-all)
- [ ] Bulk delete: confirm, in progress, partly failed, done
- [ ] Upload: drag over the page, several files mixed, summary, rejected files (413, 400), too many requests (429)
- [ ] Duplicates: groups, empty
- [ ] Merge: confirm, in progress, failed, conflict (409), done
Manual checks screenshots cannot cover:
- [ ] Keyboard and focus order: Space toggles selection, Enter opens the panel, Escape order (panel first, then selection); focus after a bulk delete; dialog focus starts on Cancel.
- [ ] Drag and drop with real files from the OS, including a folder and a non-image drop.
- [ ] Progress bar smoothness, hover and focus rings, reduced motion.
- [ ] Screen reader announcements for selection, upload completion and merge.
## Manual verification
- Real upload round trip against a running server (`task dev`): progress on a large file, 413 at 10 MB + 1 byte, 415, 400 for a corrupt PNG, uploading the same bytes twice (duplicate row, "Upload anyway", Duplicates tab).
- Real SHA-256 over a 10 MB file, and the skip path on an insecure origin (plain http on a LAN address).
- Real merge: article bodies rewritten, counts after the refresh.
- Real 429 cannot be checked: the media router has no limiter.
- Real bulk delete of 20+ images with one deleted from another tab first (404 handling).
+1 -1
View File
@@ -23,7 +23,7 @@ Feature: Single article read follows the same online/offline visibility rule as
group that already holds GET /v1/article/ (features/public_article_listing.feature), so a group that already holds GET /v1/article/ (features/public_article_listing.feature), so a
missing or invalid jwt cookie no longer blocks the request; POST /v1/article/, missing or invalid jwt cookie no longer blocks the request; POST /v1/article/,
DELETE /v1/article/{id}, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, DELETE /v1/article/{id}, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish,
PATCH /v1/article/{id}/unpublish and POST /v1/article/{id}/image keep requiring and PATCH /v1/article/{id}/unpublish keep requiring
Verifier + Authenticator + RequireVerifiedUser, unchanged Verifier + Authenticator + RequireVerifiedUser, unchanged
And Handler.GetArticle reuses the existing Handler.callerIsVerified(r) helper already used by And Handler.GetArticle reuses the existing Handler.callerIsVerified(r) helper already used by
Handler.ListArticles: when the request resolves to a verified account it calls Handler.ListArticles: when the request resolves to a verified account it calls
-99
View File
@@ -1,99 +0,0 @@
Feature: Removing an image from an article
As an authenticated RenewCMS user managing the CMS
I want to delete an already-uploaded image by its id
So that a removed image's file and row are actually gone, and a delete on
an unknown article or an unknown image answers 404 the same way every
other article write does
Background:
Given internal/infrastructure/useCases.DeleteImageUseCase.DeleteImage(imageId)
already exists and calls image.Repository.Delete(imageId), which returns
image.ErrImageNotFound for an unknown id and, on the real ImageRepository,
also removes the uploaded file from UPLOAD_DIR, per
features/delete_article_cascades_images.feature
And this slice changes POST /v1/article/{id}/image's success response
(api/handlers/image.Handler.PostImage, features/upload_article_image.feature)
to also return the created image's numeric id, so the 201 body becomes
{"id": ..., "location": ...}; this lets a client address the image later
through this slice's DELETE route
And this slice mounts DELETE /v1/article/{articleId}/image/{imageId} in
api/router.go, in the same jwtauth Verifier + Authenticator group as
POST .../image
And this slice adds a DeleteImage method to api/handlers/image.Handler,
grows NewHandler's signature to also take DeleteImageUseCase, and wires
it in api/router.go
And DeleteImage parses articleId and imageId from the URL, answering 400
with apperr.IdUint32ErrorMessage if either isn't a valid uint32
And DeleteImage first calls GetArticleUseCase.GetArticle(articleId);
errors.Is(err, article.ErrArticleNotFound) answers 404 with
apperr.ArticleNotFoundErrorMessage; this only confirms the article id
in the URL names a real article, and does not verify that the image id
belongs to that article's Images slice (a shallow existence check on
both ids, not an ownership check)
And DeleteImage then calls DeleteImageUseCase.DeleteImage(imageId);
errors.Is(err, image.ErrImageNotFound) answers 404 with the new
apperr.ImageNotFoundErrorMessage = "Image not found"; any other error
falls back to the existing generic 500
And on success DeleteImage answers 204 with an empty body, the same shape
as DELETE /v1/article/{id}
Scenario: Removing an image from an article it belongs to succeeds
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 titled
"Backyard Renovation Update" whose Images slice holds image id 10
And the image repository contains an image with id 10
When the client sends DELETE /v1/article/1/image/10 with that cookie
Then the response status is 204
And the response body is empty
And the image repository no longer contains an image with id 10
Scenario: Removing an image from an unknown article answers 404 and never touches the image repository
Given a valid jwt cookie for a user with id 1
And the article repository has no article with id 9999
And the image repository contains an image with id 10
When the client sends DELETE /v1/article/9999/image/10 with that cookie
Then the response status is 404
And the response body is the text "Article not found"
And the image repository's Delete is never called
And the image repository still contains the image with id 10
Scenario: Removing an unknown image from an existing article answers 404
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 titled
"Backyard Renovation Update"
And the image repository has no image with id 9999
When the client sends DELETE /v1/article/1/image/9999 with that cookie
Then the response status is 404
And the response body is the text "Image not found"
Scenario: Removing an image id that belongs to a different article still succeeds, because the check is shallow
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 titled
"Backyard Renovation Update" whose Images slice is empty
And the article repository contains a second article with id 2 whose
Images slice holds image id 10
And the image repository contains an image with id 10
When the client sends DELETE /v1/article/1/image/10 with that cookie
Then the response status is 204
And the image repository no longer contains an image with id 10
Scenario: Removing an image still requires authentication
Given no jwt cookie is sent
And the article repository contains an article with id 1 titled
"Backyard Renovation Update" whose Images slice holds image id 10
And the image repository contains an image with id 10
When the client sends DELETE /v1/article/1/image/10 without a cookie
Then the response status is 401
And the image repository still contains the image with id 10
Scenario Outline: A malformed id in the URL answers 400 before either repository is touched
Given a valid jwt cookie for a user with id 1
When the client sends DELETE /v1/article/<article_id>/image/<image_id> with that cookie
Then the response status is 400
And the response body is the text "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)."
Examples:
| article_id | image_id |
| abc | 10 |
| 1 | abc |
-56
View File
@@ -1,56 +0,0 @@
# QA procedure: remove_article_image
Package: `test` (black-box, extending `article_test.go` / `image_test.go`).
## Setup shared by every case
1. Call `validator.InitValidator()` once.
2. Reuse `mockArticleRepo` and `mockImageRepo` as-is.
3. Wire `useCases.NewDeleteImageUseCase(imageRepo)` (already used by
`delete_article_cascades_images.qa.md`'s suite) alongside the existing
use cases.
4. Build `image.NewHandler(...)` with its grown constructor including the
delete use case, matching whatever parameter order the coder settles on.
5. Build a test `jwtauth.New("HS256", []byte("test-secret"), nil)`.
6. Mount `DELETE /{id}/image/{imageId}` alongside the existing article
routes under the authenticated group, mirroring `api/router.go` after
this slice.
7. Mint a valid jwt cookie for user id 1 as in `upload_article_image.qa.md`.
## HTTP checks
8. Seed `articleRepo.articles[1]` with `Images: []*image.Image{{ID: 10}}`
and `imageRepo.images[10] = image.Image{ID: 10}`. Send
`DELETE /v1/article/1/image/10` with the valid cookie. Assert status
204, empty body; assert `imageRepo.images` no longer has key 10.
9. Empty `articleRepo`; seed `imageRepo.images[10]`. Send
`DELETE /v1/article/9999/image/10`. Assert 404, body
`"Article not found\n"`; assert `imageRepo.images` still has key 10
(Delete never called — track via a call-count field on the fake, the
same way the existing fakes already track `Create`).
10. Seed `articleRepo.articles[1]`; empty `imageRepo`. Send
`DELETE /v1/article/1/image/9999`. Assert 404, body
`"Image not found\n"`.
11. Seed `articleRepo.articles[1]` (empty Images) and `articleRepo.articles[2]`
with `Images: []*image.Image{{ID: 10}}`; seed `imageRepo.images[10]`.
Send `DELETE /v1/article/1/image/10`. Assert 204; assert
`imageRepo.images` no longer has key 10 — asserts the shallow-check
contract from the feature's fourth scenario.
12. Seed as in step 8. Send `DELETE /v1/article/1/image/10` with **no**
cookie. Assert 401; assert `imageRepo.images` still has key 10.
13. Examples-driven: send `DELETE /v1/article/abc/image/10` and
`DELETE /v1/article/1/image/abc` with the valid cookie. Assert 400,
body `"The server expects the ID to be in the format of an unsigned
32-bit integer (uint32).\n"` for each.
## Also touched by this slice: upload_article_image's 201 body
14. Extend `upload_article_image.qa.md`'s existing 201-body assertion to
also decode and check the `id` field equals the newly created image's
`ID` in `imageRepo.images`.
## Manual verification (not exercised by the Go test)
- Real DELETE against a real SQLite file and real UPLOAD_DIR: confirm the
file is actually removed from disk and the `article_images` join row is
gone, the same way `upload_article_image.qa.md`'s manual item covers the
POST side.
- SPA-visible behaviour: covered separately by
`features/editor_image_removal.qa.md`.
- No SMTP involvement.
@@ -0,0 +1,42 @@
Feature: The legacy POST /v1/article/{id}/image alias is removed
As the maintainer of RenewCMS
I want the editor to upload through /v1/media only and the article alias deleted
So that there is one way to add an image and no dead route to keep
Background:
Given this is a Go API change in a slice that is otherwise SPA only, made at the user's request
And features/upload_article_image.feature and its .qa.md are deleted, with their tests
And api/router.go no longer mounts r.Post("/{id}/image", ...) in NewArticleRouter, and the
imageHandler variable and the api/handlers/image import are removed from it
And the package api/handlers/image (PostLegacyArticleImage, its NewHandler and its helpers) is deleted
And CreateImageUseCase and GetArticleUseCase stay: both are used by the media handler and
the article handler, and api/pkg/upload stays because the media handler uses it
And the rest of the protected article group is unchanged, still behind jwtauth.Authenticator,
RequireCurrentToken and RequireVerifiedUser
And docs/architecture.md no longer lists api/handlers/image
And POST /v1/media/ is the only upload route, and DELETE /v1/article/{id}/image/{imageId}
stays unmounted, as before
Scenario Outline: The removed routes are not found
Given a valid jwt cookie for verified user 1 and an article with id 1
When the client sends <method> <path> with a valid PNG as multipart field "file"
Then the response status is 404
And the image repository's Create is never called
And no image exists afterwards
Examples:
| method | path |
| POST | /v1/article/1/image |
| DELETE | /v1/article/1/image/10 |
Scenario: Uploading still works through the media route
Given a valid jwt cookie for verified user 1
When the client sends POST /v1/media/ with a real PNG named "before.png"
Then the response status is 201 and the body is the library item with a "url" and an "id"
And GET /v1/media/ lists it
Scenario: The rest of the article router is unchanged
Given a valid jwt cookie for verified user 1 and an article with id 1
When the client sends PATCH /v1/article/1/publish
Then the response is as before this slice
@@ -0,0 +1,21 @@
# QA procedure: remove_legacy_article_image_alias
Package: `test` (black-box, httptest, in-memory fakes, no live server).
## Setup
1. Call `validator.InitValidator()`.
2. Build the article router exactly as `NewArticleRouter` does after the change (no image handler), wrapped in `Verifier`, `Authenticator`, `RequireCurrentToken` and `RequireVerifiedUser`, over fake article, image and user repositories. Reuse `newMultipartFileRequest` and `pngBytes` from `test/media_fixtures_test.go`.
## HTTP checks
3. `POST /v1/article/1/image` with a valid PNG and a valid cookie -> 404, `Create` never called.
4. `DELETE /v1/article/1/image/10` with a valid cookie -> 404, image 10 still exists.
5. `POST /v1/media/` with a valid PNG -> 201 with `id` and `url` (this stays covered by `media_library_http_test.go`; confirm the case still passes after the deletions).
6. `PATCH /v1/article/1/publish` still answers as in `verified_account_required_http_test.go`.
## Cleanup (Go)
7. Delete `test/upload_article_image_http_test.go`.
8. In `test/verified_account_required_http_test.go`: remove the `api/handlers/image` import, the `imageHandler` construction (line 34), the route `r.Post("/{id}/image", ...)` (line 53) and the "unverified user is refused when attaching an image" case (around line 149).
9. Run `task test` and `task lint`; nothing else may reference `PostLegacyArticleImage` or `handlers/image`.
## Manual verification
With `task dev`, `curl -i -X POST /v1/article/1/image` with a valid cookie answers 404.
+8 -6
View File
@@ -23,9 +23,11 @@ Feature: Serving uploaded images as static files
"application/octet-stream" "application/octet-stream"
And every response from this handler, success or 404, sets And every response from this handler, success or 404, sets
"X-Content-Type-Options: nosniff" "X-Content-Type-Options: nosniff"
And every successful response sets a long-lived, public Cache-Control And every successful response sets the Cache-Control header
header, e.g. "public, max-age=31536000, immutable", because a stored "public, no-cache", so a browser may store the file but must revalidate
filename is a UUID and its bytes never change it before reusing it; a stored filename is a UUID and its bytes never
change, but the file can be deleted from the media library, and a
long-lived cache would keep showing it
And an "image/svg+xml" response additionally sets And an "image/svg+xml" response additionally sets
"Content-Security-Policy: default-src 'none'; style-src "Content-Security-Policy: default-src 'none'; style-src
'unsafe-inline'; sandbox", so a script embedded in an uploaded SVG 'unsafe-inline'; sandbox", so a script embedded in an uploaded SVG
@@ -42,7 +44,7 @@ Feature: Serving uploaded images as static files
cookie cookie
Then the response status is 200 Then the response status is 200
And the response Content-Type is <content_type> And the response Content-Type is <content_type>
And the response Cache-Control is "public, max-age=31536000, immutable" And the response Cache-Control is "public, no-cache"
And the response X-Content-Type-Options is "nosniff" And the response X-Content-Type-Options is "nosniff"
And the response has no Content-Security-Policy header And the response has no Content-Security-Policy header
And the response body is that file's exact content And the response body is that file's exact content
@@ -61,7 +63,7 @@ Feature: Serving uploaded images as static files
with no jwt cookie with no jwt cookie
Then the response status is 200 Then the response status is 200
And the response Content-Type is "image/svg+xml" And the response Content-Type is "image/svg+xml"
And the response Cache-Control is "public, max-age=31536000, immutable" And the response Cache-Control is "public, no-cache"
And the response X-Content-Type-Options is "nosniff" And the response X-Content-Type-Options is "nosniff"
And the response Content-Security-Policy is And the response Content-Security-Policy is
"default-src 'none'; style-src 'unsafe-inline'; sandbox" "default-src 'none'; style-src 'unsafe-inline'; sandbox"
@@ -128,7 +130,7 @@ Feature: Serving uploaded images as static files
with no jwt cookie with no jwt cookie
Then the response status is 200 Then the response status is 200
And the response Content-Type is "image/webp" And the response Content-Type is "image/webp"
And the response Cache-Control is "public, max-age=31536000, immutable" And the response Cache-Control is "public, no-cache"
And the response X-Content-Type-Options is "nosniff" And the response X-Content-Type-Options is "nosniff"
And the response body is empty And the response body is empty
+1 -1
View File
@@ -29,7 +29,7 @@ Package: `test` (black-box, new file `test/serve_uploaded_images_test.go`).
non-empty byte content. For each, `httptest.NewRequest("GET", non-empty byte content. For each, `httptest.NewRequest("GET",
"/static/uploadedImages/"+name, nil)`, no cookie. Assert status `200`; "/static/uploadedImages/"+name, nil)`, no cookie. Assert status `200`;
`Content-Type` equals the expected value; `Cache-Control` equals `Content-Type` equals the expected value; `Cache-Control` equals
`"public, max-age=31536000, immutable"`; `X-Content-Type-Options` equals `"public, no-cache"`; `X-Content-Type-Options` equals
`"nosniff"`; `Content-Security-Policy` header is absent; body bytes equal `"nosniff"`; `Content-Security-Policy` header is absent; body bytes equal
what was written. what was written.
7. Write a `.svg` file whose content contains an inline `onload` handler 7. Write a `.svg` file whose content contains an inline `onload` handler
-99
View File
@@ -1,99 +0,0 @@
Feature: Uploading an image to an article
As an authenticated RenewCMS user managing the CMS
I want to attach an image file to an existing article and get back the
image's public location
So that an unknown article answers 404 the same way every other article
write does, without ever creating an orphaned image, and a disallowed
content type is rejected with 400 before it becomes part of the
article, without a maintainer needing to trust the client to send a
sane file
Background:
Given internal/infrastructure/useCases.CreateImageUseCase.CreateImage
already checks the uploaded content type against the allowed list
(image/png, image/jpeg, image/webp, image/svg+xml) and the size
against the 10485760-byte maximum before calling image.Repository.Create,
returning image.ErrImageTypeNotAllowed or image.ErrImageTooLarge and
calling the repository not at all when either check fails
And internal/infrastructure/persistence's ArticleRepository.AddImage
already returns article.ErrArticleNotFound when the article id doesn't
match any row, and image.ErrImageNotFound when the article exists but
the image id doesn't, both asserted with errors.Is
And before this slice, api/handlers/image.Handler.PostImage existed and
was wired to CreateImageUseCase and UpdateArticleUseCase.AddImage, but
no route called it, and every error it got back from either use case
fell through to a blanket 500
And this slice mounts POST /v1/article/{id}/image in api/router.go, in
the same jwtauth Verifier + Authenticator group as the other article
write routes, and changes the upload flow so that the article's
existence is confirmed before the image is created, so that an
unknown article id never leaves an orphaned image behind
And this slice changes PostImage's error handling so that errors.Is(err,
image.ErrImageTypeNotAllowed) or errors.Is(err, image.ErrImageTooLarge)
answers 400 with the sentinel's own message, errors.Is(err,
article.ErrArticleNotFound) answers 404 with body
apperr.ArticleNotFoundErrorMessage, and any other error still falls
back to a generic 500
And PostImage reads the uploaded file from the multipart form field
"file"
And on success PostImage answers 201 with a JSON body
{"id": ..., "location": ...} holding the created image's numeric id
and public path, e.g. "/static/uploadedImages/<stored-filename>"
And this slice (features/remove_article_image.feature) adds the "id"
field to that body; the id lets a client later address the image
through DELETE /v1/article/{id}/image/{imageId}
Scenario: Uploading an allowed image type to an existing article succeeds
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 titled
"Backyard Renovation Update"
When the client sends POST /v1/article/1/image with that cookie and a
multipart form field "file" holding a file named "before.png" with
content type "image/png"
Then the response status is 201
And the response body is JSON with a "location" field whose value is
"/static/uploadedImages/before.png"
And the response body's "id" field equals the new image's id in the
image repository
And the image repository contains a new image whose Path is
"/static/uploadedImages/before.png"
And the article repository's article with id 1 has that new image's id
in its Images slice
Scenario: Uploading to an unknown article answers 404 and creates no image
Given a valid jwt cookie for a user with id 1
And the article repository has no article with id 9999
When the client sends POST /v1/article/9999/image with that cookie and a
multipart form field "file" holding a file named "before.png" with
content type "image/png"
Then the response status is 404
And the response body is the text "Article not found"
And the image repository's Create is never called
And no image exists in the image repository
Scenario: Uploading a disallowed content type answers 400 before the article is touched
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 titled
"Backyard Renovation Update"
When the client sends POST /v1/article/1/image with that cookie and a
multipart form field "file" holding a file named "notes.pdf" with
content type "application/pdf"
Then the response status is 400
And the response body is the text
"the file must be a PNG, JPEG, WEBP, or SVG image"
And the image repository's Create is never called
And the article repository's article with id 1 still has an empty
Images slice
Scenario: Uploading an image still requires authentication
Given no jwt cookie is sent
And the article repository contains an article with id 1 titled
"Backyard Renovation Update"
When the client sends POST /v1/article/1/image without a cookie and a
multipart form field "file" holding a file named "before.png" with
content type "image/png"
Then the response status is 401
And the image repository's Create is never called
And the article repository's article with id 1 still has an empty
Images slice
-73
View File
@@ -1,73 +0,0 @@
# QA procedure: upload_article_image
Package: `test` (black-box, extending `article_test.go` / `image_test.go`).
## Setup shared by every case
1. Call `validator.InitValidator()` once.
2. Reuse `mockArticleRepo` (`test/article_test.go`) and `mockImageRepo`
(`test/image_test.go`) as-is; both already implement `AddImage` /
`Create` returning `article.ErrArticleNotFound` / `image.ErrImageNotFound`
for an unknown id, per `features/write_not_found_errors.feature`.
3. Wire the real use cases over the fakes: `useCases.NewCreateArticleUseCase`,
`useCases.NewGetArticleUseCase`, `useCases.NewListArticlesUseCase`,
`useCases.NewUpdateArticleUseCase(articleRepo)`,
`useCases.NewDeleteArticleUseCase(articleRepo, imageRepo)`,
`useCases.NewCreateImageUseCase(imageRepo)`.
4. Build `article.NewHandler(...)` with its existing constructor shape, and
`image.NewHandler(createImageUseCase, updateArticleUseCase)`
(`api/handlers/image`).
5. Build a test `jwtauth.New("HS256", []byte("test-secret"), nil)`.
6. Mount the routes on a fresh `chi.NewRouter()` mirroring `api/router.go`'s
shape after this slice: `GET /` under `jwtauth.Verifier` only;
`GET /{id}`, `POST /`, `DELETE /{id}`, `PATCH /{id}`,
`PATCH /{id}/publish`, `PATCH /{id}/unpublish` and the new
`POST /{id}/image` (routed to `imageHandler.PostImage`) under
`jwtauth.Verifier` + `jwtauth.Authenticator`.
7. Mint a valid jwt cookie for user id 1:
`token.Encode(map[string]any{"user_id": uint32(1)})`, set as the `jwt`
cookie on the relevant `httptest.NewRequest`.
8. Build multipart request bodies with `mime/multipart.Writer`, field name
`"file"`, setting the part's `Content-Type` header explicitly (Go's
multipart writer defaults to sniffing otherwise, so set it via
`CreatePart` with an explicit MIME header rather than
`CreateFormFile`), and set the request's `Content-Type` header to the
writer's `FormDataContentType()`.
## HTTP checks
9. Seed `articleRepo.articles[1] = article.Article{ID: 1, Title: "Backyard
Renovation Update"}`. Send `POST /v1/article/1/image` with the valid
cookie and a multipart `"file"` part named `"before.png"`,
content type `"image/png"`, some non-empty byte content. Assert status
`201`; decode the JSON body and assert `location` equals
`"/static/uploadedImages/before.png"`; assert `imageRepo.images` gained
exactly one entry whose `Path` matches that location; assert
`articleRepo.articles[1].Images` now contains that image's id.
10. Empty `articleRepo`, empty `imageRepo`. Send `POST /v1/article/9999/image`
with the valid cookie and the same `"before.png"` / `"image/png"` part.
Assert status `404`, body `"Article not found\n"`; assert
`imageRepo.images` is still empty (the article's existence is confirmed
before any image is created, so nothing is left orphaned).
11. Seed `articleRepo.articles[1] = article.Article{ID: 1, Title: "Backyard
Renovation Update"}` with an empty `Images` slice. Send
`POST /v1/article/1/image` with the valid cookie and a multipart
`"file"` part named `"notes.pdf"`, content type `"application/pdf"`.
Assert status `400`, body
`"the file must be a PNG, JPEG, WEBP, or SVG image\n"`; assert
`imageRepo.images` is still empty; assert
`articleRepo.articles[1].Images` is still empty.
12. Seed the same article id 1 as step 11. Send `POST /v1/article/1/image`
with **no** cookie and the `"before.png"` / `"image/png"` part. Assert
status `401`; assert `imageRepo.images` is still empty; assert
`articleRepo.articles[1].Images` is still empty.
## Manual verification (not exercised by the Go test)
- Real upload against a real SQLite file and real `UPLOAD_DIR`: confirm that
after `POST /v1/article/{id}/image` on a real running instance, the file
actually lands on disk under `UPLOAD_DIR`, the `images` row's `path`
resolves to a URL the SPA can load, and the `article_images` join table
gets the new row. Not exercised by `task test` (fakes only); requires
`task dev` or a built `task build` binary with a scratch DB/upload dir.
- SPA-visible behaviour: confirm the back-office article editor (if/when an
image-upload UI exists) shows the uploaded image at that location. No such
UI exists yet as of this slice.
- No SMTP involvement.
+2 -12
View File
@@ -12,12 +12,12 @@ Feature: Protected article routes and the offline-inclusive listing require a ve
Given before this slice, the protected group inside NewArticleRouter (api/router.go) applies Given before this slice, the protected group inside NewArticleRouter (api/router.go) applies
only jwtauth.Verifier then jwtauth.Authenticator to GET /v1/article/{id}, POST /v1/article/, only jwtauth.Verifier then jwtauth.Authenticator to GET /v1/article/{id}, POST /v1/article/,
DELETE /v1/article/{id}, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, DELETE /v1/article/{id}, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish,
PATCH /v1/article/{id}/unpublish and POST /v1/article/{id}/image, so any caller with a PATCH /v1/article/{id}/unpublish, so any caller with a
validly signed, unexpired jwt cookie reaches the handler regardless of that account's validly signed, unexpired jwt cookie reaches the handler regardless of that account's
is_verified status is_verified status
And this slice adds a third middleware, api/middleware.RequireVerifiedUser(getUser And this slice adds a third middleware, api/middleware.RequireVerifiedUser(getUser
*useCases.GetUserUseCase), mounted after jwtauth.Authenticator in that same group, so the *useCases.GetUserUseCase), mounted after jwtauth.Authenticator in that same group, so the
order becomes Verifier, Authenticator, RequireVerifiedUser for all seven routes above order becomes Verifier, Authenticator, RequireVerifiedUser for all six routes above
And RequireVerifiedUser reads user_id from the request's jwt claims using a new exported And RequireVerifiedUser reads user_id from the request's jwt claims using a new exported
helper, api/pkg/claims.UserId(r *http.Request) (uint32, bool), which replaces the helper, api/pkg/claims.UserId(r *http.Request) (uint32, bool), which replaces the
identical private userIdFromClaims method api/handlers/auth.Handler.Verify and identical private userIdFromClaims method api/handlers/auth.Handler.Verify and
@@ -81,16 +81,6 @@ Feature: Protected article routes and the offline-inclusive listing require a ve
| PATCH | /v1/article/1/publish | | PATCH | /v1/article/1/publish |
| PATCH | /v1/article/1/unpublish | | PATCH | /v1/article/1/unpublish |
Scenario: An unverified user is also refused when attaching an image
Given a user repository containing user id 1, is_verified false
And a valid jwt cookie for user id 1
And the article repository contains an article with id 1 titled "Backyard Renovation Update"
When the client sends POST /v1/article/1/image with that cookie and a multipart form field
"file" holding a file named "before.png" with content type "image/png"
Then the response status is 403
And the response body is the apperr.VerificationRequiredErrorMessage text
And the image repository's Create is never called
Scenario: A verified user still exercises the protected routes exactly as before Scenario: A verified user still exercises the protected routes exactly as before
Given a user repository containing user id 1, is_verified true Given a user repository containing user id 1, is_verified true
And a valid jwt cookie for user id 1 And a valid jwt cookie for user id 1
+2 -7
View File
@@ -29,8 +29,7 @@ routers, so the HTTP-level checks live in a new `test/verified_account_required_
- a nested `r.Group` adding `jwtauth.Authenticator(token)` then - a nested `r.Group` adding `jwtauth.Authenticator(token)` then
`middleware.RequireVerifiedUser(getUser)`, containing `r.Get("/{id}", ...)`, `middleware.RequireVerifiedUser(getUser)`, containing `r.Get("/{id}", ...)`,
`r.Post("/", ...)`, `r.Delete("/{id}", ...)`, `r.Patch("/{id}", ...)`, `r.Post("/", ...)`, `r.Delete("/{id}", ...)`, `r.Patch("/{id}", ...)`,
`r.Patch("/{id}/publish", ...)`, `r.Patch("/{id}/unpublish", ...)`, `r.Patch("/{id}/publish", ...)`, `r.Patch("/{id}/unpublish", ...)`
`r.Post("/{id}/image", ...)`
No live server. No live server.
8. Mint jwt cookies with `token.Encode(map[string]any{"user_id": uint32(1)})` for the valid-token 8. Mint jwt cookies with `token.Encode(map[string]any{"user_id": uint32(1)})` for the valid-token
cases, following the convention in `features/article_publishing.qa.md`. cases, following the convention in `features/article_publishing.qa.md`.
@@ -45,10 +44,6 @@ routers, so the HTTP-level checks live in a new `test/verified_account_required_
`PATCH /v1/article/1/unpublish`, each sent with the valid cookie from step 8 → assert status `PATCH /v1/article/1/unpublish`, each sent with the valid cookie from step 8 → assert status
`403` and body exactly `apperr.VerificationRequiredErrorMessage`. Assert the article repository `403` and body exactly `apperr.VerificationRequiredErrorMessage`. Assert the article repository
was not mutated by any of the write calls. was not mutated by any of the write calls.
11. Same seed, `POST /v1/article/1/image` with the valid cookie and a multipart form field `"file"`
holding `before.png` / `image/png` → assert status `403`, body
`apperr.VerificationRequiredErrorMessage`, and the image repository's `Create` is never called
(mirroring the assertion style in `test/upload_article_image_test.go`).
## HTTP checks: a verified caller is unaffected ## HTTP checks: a verified caller is unaffected
@@ -127,7 +122,7 @@ routers, so the HTTP-level checks live in a new `test/verified_account_required_
## Manual verification (not exercised by the Go test) ## Manual verification (not exercised by the Go test)
- The SPA-visible behaviour of an unverified, logged-in user landing on the back-office article - The SPA-visible behaviour of an unverified, logged-in user landing on the back-office article
screens (create/edit/publish/image-upload) and seeing a 403 instead of the normal editing UI, screens (create/edit/publish) and seeing a 403 instead of the normal editing UI,
and the front-office/back-office listing showing only published articles to that same unverified and the front-office/back-office listing showing only published articles to that same unverified
session, is out of scope for this slice's automated test and should be checked by hand against a session, is out of scope for this slice's automated test and should be checked by hand against a
running dev build with an unverified test account. running dev build with an unverified test account.
+3 -2
View File
@@ -12,6 +12,7 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.44.0 golang.org/x/crypto v0.44.0
golang.org/x/image v0.46.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )
@@ -41,8 +42,8 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/asm v1.2.1 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.31.0 // indirect golang.org/x/text v0.42.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
modernc.org/libc v1.67.0 // indirect modernc.org/libc v1.67.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
+12 -10
View File
@@ -82,17 +82,19 @@ golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-2
View File
@@ -1,7 +1,6 @@
package article package article
import ( import (
domain "RenewCMS/internal/domain/image"
"errors" "errors"
"time" "time"
"unicode/utf8" "unicode/utf8"
@@ -30,7 +29,6 @@ type Article struct {
ID uint32 `json:"id"` ID uint32 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Body string `json:"body"` Body string `json:"body"`
Images []*domain.Image `json:"images"`
IsOnline bool `json:"is_online"` IsOnline bool `json:"is_online"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@@ -13,5 +13,5 @@ type Repository interface {
UpdateBody(id uint32, body string) (Article, error) UpdateBody(id uint32, body string) (Article, error)
UpdateIsOnline(id uint32, isOnline bool) (Article, error) UpdateIsOnline(id uint32, isOnline bool) (Article, error)
Delete(id uint32) error Delete(id uint32) error
AddImage(postId uint32, imageId uint32) error FindByBodyContaining(filename string) ([]Article, error)
} }
+158 -1
View File
@@ -3,16 +3,33 @@ package image
import ( import (
"errors" "errors"
"io" "io"
"path"
"regexp"
"sort" "sort"
"strings"
"time" "time"
"unicode/utf8"
) )
var ErrImageNotFound = errors.New("image not found") var ErrImageNotFound = errors.New("image not found")
var ErrImageTypeNotAllowed = errors.New("the file must be a PNG, JPEG, WEBP, or SVG image") var ErrImageTypeNotAllowed = errors.New("the file must be a PNG, JPEG, WEBP, or SVG image")
var ErrImageTooLarge = errors.New("the file exceeds the maximum upload size") var ErrImageTooLarge = errors.New("the file exceeds the maximum upload size")
var ErrImageUnreadable = errors.New("the image could not be read")
var ErrInvalidFilename = errors.New("invalid filename")
var ErrInvalidQuery = errors.New("invalid media query")
var ErrInvalidMergeRequest = errors.New("invalid merge request")
var ErrMergeCopyMissing = errors.New("a merged copy no longer exists")
var ErrMergeHashMismatch = errors.New("a merged copy does not share the kept image's content")
const MaxImageSize int64 = 10485760 const MaxImageSize int64 = 10485760
const (
MinFilenameRunes = 1
MaxFilenameRunes = 100
DefaultListLimit = 24
MaxListLimit = 100
)
var allowedContentTypes = map[string]bool{ var allowedContentTypes = map[string]bool{
"image/png": true, "image/png": true,
"image/jpeg": true, "image/jpeg": true,
@@ -20,6 +37,16 @@ var allowedContentTypes = map[string]bool{
"image/svg+xml": true, "image/svg+xml": true,
} }
var canonicalExtensions = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"image/svg+xml": ".svg",
}
var filenameStemPattern = regexp.MustCompile(`^[\p{L}\p{N}_-]+$`)
var sha256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
func IsAllowedContentType(contentType string) bool { func IsAllowedContentType(contentType string) bool {
return allowedContentTypes[contentType] return allowedContentTypes[contentType]
} }
@@ -33,17 +60,147 @@ func AllowedContentTypes() []string {
return types return types
} }
func NormalizeUploadFilename(original string, contentType string) string {
name := original[strings.LastIndexAny(original, `/\`)+1:]
name = strings.TrimSpace(name)
if name == "" || name == "." {
name = "image"
}
if path.Ext(name) == "" {
name += canonicalExtensions[contentType]
}
return name
}
func ExtensionOf(filename string) string {
return path.Ext(filename)
}
func StemOf(filename string) string {
return strings.TrimSuffix(filename, path.Ext(filename))
}
func ValidateFilenameStem(stem string) (string, error) {
trimmed := strings.TrimSpace(stem)
runeCount := utf8.RuneCountInString(trimmed)
if runeCount < MinFilenameRunes || runeCount > MaxFilenameRunes {
return "", ErrInvalidFilename
}
if !filenameStemPattern.MatchString(trimmed) {
return "", ErrInvalidFilename
}
return trimmed, nil
}
func StoredName(imagePath string) string {
return path.Base(imagePath)
}
type Sort string
const (
SortNewest Sort = "newest"
SortOldest Sort = "oldest"
SortName Sort = "name"
SortLargest Sort = "largest"
)
type Filter string
const (
FilterAll Filter = "all"
FilterUnused Filter = "unused"
FilterDuplicates Filter = "duplicates"
)
type ListQuery struct {
Search string
Hash string
Filter Filter
Sort Sort
Limit int
Cursor string
}
func NewListQuery() ListQuery {
return ListQuery{Filter: FilterAll, Sort: SortNewest, Limit: DefaultListLimit}
}
func (q ListQuery) Validate() error {
switch q.Filter {
case FilterAll, FilterUnused, FilterDuplicates:
default:
return ErrInvalidQuery
}
switch q.Sort {
case SortNewest, SortOldest, SortName, SortLargest:
default:
return ErrInvalidQuery
}
if q.Limit < 1 || q.Limit > MaxListLimit {
return ErrInvalidQuery
}
if q.Hash != "" && !sha256Pattern.MatchString(q.Hash) {
return ErrInvalidQuery
}
return nil
}
func SortImages(images []Image, order Sort) {
sort.SliceStable(images, func(i, j int) bool {
a, b := images[i], images[j]
switch order {
case SortOldest:
if !a.CreatedAt.Equal(b.CreatedAt) {
return a.CreatedAt.Before(b.CreatedAt)
}
return a.ID < b.ID
case SortName:
nameA, nameB := strings.ToLower(StemOf(a.Filename)), strings.ToLower(StemOf(b.Filename))
if nameA != nameB {
return nameA < nameB
}
return a.ID < b.ID
case SortLargest:
if a.Size != b.Size {
return a.Size > b.Size
}
return a.ID < b.ID
default:
if !a.CreatedAt.Equal(b.CreatedAt) {
return a.CreatedAt.After(b.CreatedAt)
}
return a.ID > b.ID
}
})
}
type Counts struct {
All int
DuplicateGroups int
}
type ImageInput struct { type ImageInput struct {
Content io.Reader Content io.Reader
Filename string Filename string
Size int64 Size int64
ContentType string ContentType string
Width int
Height int
SHA256 string
UploadedByID uint32
} }
type Image struct { type Image struct {
ID uint32 `json:"id"` ID uint32 `json:"id"`
Path string `json:"path"` Path string `json:"path"`
ArticleID uint32 `json:"article_id"` Filename string `json:"filename"`
Size int64 `json:"size"`
ContentType string `json:"content_type"`
Width int `json:"width"`
Height int `json:"height"`
SHA256 string `json:"sha256"`
UploadedByID uint32 `json:"uploaded_by_id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@@ -1,6 +1,17 @@
package image package image
type SearchQuery struct {
Search string
Hash string
}
type Repository interface { type Repository interface {
Create(input ImageInput) (Image, error) Create(input ImageInput) (Image, error)
Get(id uint32) (Image, error)
Delete(id uint32) error Delete(id uint32) error
List(query SearchQuery) ([]Image, error)
FindByHash(hash string) ([]Image, error)
Rename(id uint32, filename string) (Image, error)
ListDuplicates() ([]Image, error)
Counts() (Counts, error)
} }
@@ -2,22 +2,14 @@ package mappers
import ( import (
"RenewCMS/internal/domain/article" "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/models" "RenewCMS/internal/infrastructure/persistence/models"
) )
func ArticleToDomain(m models.Article) article.Article { func ArticleToDomain(m models.Article) article.Article {
domainImages := make([]*image.Image, len(m.Images))
for i, img := range m.Images {
domainImg := ImageToDomain(*img)
domainImages[i] = &domainImg
}
return article.Article{ return article.Article{
ID: m.ID, ID: m.ID,
Title: m.Title, Title: m.Title,
Body: m.Body, Body: m.Body,
Images: domainImages,
IsOnline: m.IsOnline, IsOnline: m.IsOnline,
CreatedAt: m.CreatedAt, CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt, UpdatedAt: m.UpdatedAt,
@@ -25,16 +17,9 @@ func ArticleToDomain(m models.Article) article.Article {
} }
func ArticleToModel(a article.Article) models.Article { func ArticleToModel(a article.Article) models.Article {
modelImages := make([]*models.Image, len(a.Images))
for i, img := range a.Images {
modelImage := ImageToModel(*img)
modelImages[i] = &modelImage
}
return models.Article{ return models.Article{
Title: a.Title, Title: a.Title,
Body: a.Body, Body: a.Body,
Images: modelImages,
IsOnline: a.IsOnline, IsOnline: a.IsOnline,
CreatedAt: a.CreatedAt, CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt, UpdatedAt: a.UpdatedAt,
@@ -9,7 +9,13 @@ func ImageToDomain(imageModel models.Image) image.Image {
return image.Image{ return image.Image{
ID: imageModel.ID, ID: imageModel.ID,
Path: imageModel.Path, Path: imageModel.Path,
ArticleID: imageModel.ArticleID, Filename: imageModel.Filename,
Size: imageModel.Size,
ContentType: imageModel.ContentType,
Width: imageModel.Width,
Height: imageModel.Height,
SHA256: imageModel.SHA256,
UploadedByID: imageModel.UploadedByID,
CreatedAt: imageModel.CreatedAt, CreatedAt: imageModel.CreatedAt,
UpdatedAt: imageModel.UpdatedAt, UpdatedAt: imageModel.UpdatedAt,
} }
@@ -17,8 +23,15 @@ func ImageToDomain(imageModel models.Image) image.Image {
func ImageToModel(imageDomain image.Image) models.Image { func ImageToModel(imageDomain image.Image) models.Image {
return models.Image{ return models.Image{
ID: imageDomain.ID,
Path: imageDomain.Path, Path: imageDomain.Path,
ArticleID: imageDomain.ArticleID, Filename: imageDomain.Filename,
Size: imageDomain.Size,
ContentType: imageDomain.ContentType,
Width: imageDomain.Width,
Height: imageDomain.Height,
SHA256: imageDomain.SHA256,
UploadedByID: imageDomain.UploadedByID,
CreatedAt: imageDomain.CreatedAt, CreatedAt: imageDomain.CreatedAt,
UpdatedAt: imageDomain.UpdatedAt, UpdatedAt: imageDomain.UpdatedAt,
} }
@@ -11,7 +11,6 @@ type Article struct {
ID uint32 `gorm:"primary_key;auto_increment;not_null"` ID uint32 `gorm:"primary_key;auto_increment;not_null"`
Title string Title string
Body string Body string
Images []*Image `gorm:"many2many:article_images;"`
IsOnline bool `gorm:"not_null;default:false"` IsOnline bool `gorm:"not_null;default:false"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
@@ -10,7 +10,13 @@ type Image struct {
gorm.Model gorm.Model
ID uint32 `gorm:"primary_key;auto_increment;not_null"` ID uint32 `gorm:"primary_key;auto_increment;not_null"`
Path string Path string
ArticleID uint32 Filename string
Size int64
ContentType string
Width int
Height int
SHA256 string `gorm:"column:sha256;index"`
UploadedByID uint32
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
} }
@@ -2,7 +2,6 @@ package persistence
import ( import (
domain "RenewCMS/internal/domain/article" domain "RenewCMS/internal/domain/article"
imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/mappers" "RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models" entity "RenewCMS/internal/infrastructure/persistence/models"
@@ -18,7 +17,7 @@ func NewArticleRepository(db *gorm.DB) *ArticleRepository {
} }
func (a *ArticleRepository) FindByFilters(filters domain.Filters) ([]domain.Article, error) { func (a *ArticleRepository) FindByFilters(filters domain.Filters) ([]domain.Article, error) {
query := a.db.Model(&entity.Article{}).Preload("Images") query := a.db.Model(&entity.Article{})
if filters.IsOnline != nil { if filters.IsOnline != nil {
query = query.Where("is_online = ?", *filters.IsOnline) query = query.Where("is_online = ?", *filters.IsOnline)
@@ -37,7 +36,7 @@ func (a *ArticleRepository) FindByFilters(filters domain.Filters) ([]domain.Arti
func (a *ArticleRepository) Get(id uint32) (domain.Article, error) { func (a *ArticleRepository) Get(id uint32) (domain.Article, error) {
var article entity.Article var article entity.Article
err := a.db.Model(&entity.Article{}).Preload("Images").First(&article, id).Error err := a.db.Model(&entity.Article{}).First(&article, id).Error
if err != nil { if err != nil {
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound) return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
} }
@@ -125,25 +124,18 @@ func (a *ArticleRepository) Delete(id uint32) error {
return deleteOrNotFound(result, domain.ErrArticleNotFound) return deleteOrNotFound(result, domain.ErrArticleNotFound)
} }
func (a *ArticleRepository) AddImage(articleId uint32, imageId uint32) error { func (a *ArticleRepository) FindByBodyContaining(filename string) ([]domain.Article, error) {
var localArticle entity.Article var entityArticles []entity.Article
err := a.db.Model(&entity.Article{}).First(&localArticle, articleId).Error err := a.db.Model(&entity.Article{}).Where("instr(body, ?) > 0", filename).Order("id asc").Find(&entityArticles).Error
if err != nil { if err != nil {
return mapNotFoundErr(err, domain.ErrArticleNotFound) return nil, err
} }
var localImage entity.Image domainArticles := make([]domain.Article, 0, len(entityArticles))
err = a.db.Model(&entity.Image{}).First(&localImage, imageId).Error for _, entityArticle := range entityArticles {
if err != nil { domainArticles = append(domainArticles, mappers.ArticleToDomain(entityArticle))
return mapNotFoundErr(err, imageDomain.ErrImageNotFound)
} }
return domainArticles, nil
err = a.db.Model(&localArticle).Association("Images").Append(&localImage)
if err != nil {
return err
}
return nil
} }
var _ domain.Repository = &ArticleRepository{} var _ domain.Repository = &ArticleRepository{}
@@ -0,0 +1,77 @@
package persistence
import (
entity "RenewCMS/internal/infrastructure/persistence/models"
"bytes"
"crypto/sha256"
"encoding/hex"
stdimage "image"
"os"
"path/filepath"
_ "golang.org/x/image/webp"
_ "image/jpeg"
_ "image/png"
"gorm.io/gorm"
)
var extensionContentTypes = map[string]string{
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".svg": "image/svg+xml",
}
func BackfillImageMetadata(db *gorm.DB, uploadDir string) error {
var pending []entity.Image
err := db.Where("filename = '' OR filename IS NULL").Order("id asc").Find(&pending).Error
if err != nil {
return err
}
for _, row := range pending {
metadata, ok := readStoredImage(uploadDir, row.Path)
if !ok {
continue
}
err := db.Model(&entity.Image{}).Where("id = ?", row.ID).UpdateColumns(metadata).Error
if err != nil {
return err
}
}
return nil
}
func readStoredImage(uploadDir string, storedPath string) (map[string]any, bool) {
fileName := filepath.Base(storedPath)
contentType, known := extensionContentTypes[filepath.Ext(fileName)]
if !known {
return nil, false
}
data, err := os.ReadFile(filepath.Join(uploadDir, fileName))
if err != nil {
return nil, false
}
width, height := 0, 0
if contentType != "image/svg+xml" {
config, _, err := stdimage.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, false
}
width, height = config.Width, config.Height
}
checksum := sha256.Sum256(data)
return map[string]any{
"filename": fileName,
"size": int64(len(data)),
"content_type": contentType,
"width": width,
"height": height,
"sha256": hex.EncodeToString(checksum[:]),
}, true
}
@@ -4,14 +4,18 @@ import (
domain "RenewCMS/internal/domain/image" domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/mappers" "RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models" entity "RenewCMS/internal/infrastructure/persistence/models"
"errors"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
) )
const uploadedImagesURLPrefix = "/static/uploadedImages/"
type ImageRepository struct { type ImageRepository struct {
db *gorm.DB db *gorm.DB
uploadDir string uploadDir string
@@ -29,49 +33,142 @@ var contentTypeExtensions = map[string]string{
} }
func (i ImageRepository) Create(input domain.ImageInput) (domain.Image, error) { func (i ImageRepository) Create(input domain.ImageInput) (domain.Image, error) {
uploadDir := i.uploadDir
fileBytes, err := io.ReadAll(input.Content) fileBytes, err := io.ReadAll(input.Content)
if err != nil { if err != nil {
return domain.Image{}, err return domain.Image{}, err
} }
err = os.MkdirAll(uploadDir, 0755) err = os.MkdirAll(i.uploadDir, 0755)
if err != nil { if err != nil {
return domain.Image{}, err return domain.Image{}, err
} }
extension := contentTypeExtensions[input.ContentType] newName := uuid.NewString() + contentTypeExtensions[input.ContentType]
finalPath := filepath.Join(i.uploadDir, newName)
newName := uuid.NewString() + extension
finalPath := filepath.Join(uploadDir, newName)
err = os.WriteFile(finalPath, fileBytes, 0666) err = os.WriteFile(finalPath, fileBytes, 0666)
if err != nil { if err != nil {
return domain.Image{}, err return domain.Image{}, err
} }
newImage := i.db.Create(&domain.Image{Path: "/static/uploadedImages/" + newName}) model := mappers.ImageToModel(domain.Image{
var createdImage entity.Image Path: uploadedImagesURLPrefix + newName,
newImage.Scan(&createdImage) Filename: input.Filename,
Size: input.Size,
ContentType: input.ContentType,
Width: input.Width,
Height: input.Height,
SHA256: input.SHA256,
UploadedByID: input.UploadedByID,
})
if err := i.db.Create(&model).Error; err != nil {
_ = os.Remove(finalPath)
return domain.Image{}, err
}
return mappers.ImageToDomain(createdImage), nil return mappers.ImageToDomain(model), nil
}
func (i ImageRepository) Get(id uint32) (domain.Image, error) {
var model entity.Image
err := i.db.First(&model, id).Error
if err != nil {
return domain.Image{}, mapNotFoundErr(err, domain.ErrImageNotFound)
}
return mappers.ImageToDomain(model), nil
} }
func (i ImageRepository) Delete(id uint32) error { func (i ImageRepository) Delete(id uint32) error {
uploadDir := i.uploadDir var model entity.Image
var entityImage entity.Image err := i.db.First(&model, id).Error
err := i.db.Model(&entity.Image{}).First(&entityImage, id).Error
if err != nil { if err != nil {
return mapNotFoundErr(err, domain.ErrImageNotFound) return mapNotFoundErr(err, domain.ErrImageNotFound)
} }
fileName := filepath.Base(entityImage.Path) err = os.Remove(filepath.Join(i.uploadDir, filepath.Base(model.Path)))
err = os.Remove(filepath.Join(uploadDir, fileName)) if err != nil && !errors.Is(err, os.ErrNotExist) {
if err != nil {
return err return err
} }
return i.db.Delete(&entity.Image{}, id).Error return i.db.Delete(&entity.Image{}, id).Error
} }
func (i ImageRepository) List(query domain.SearchQuery) ([]domain.Image, error) {
statement := i.db.Model(&entity.Image{})
if query.Search != "" {
statement = statement.Where("instr(lower(filename), ?) > 0", strings.ToLower(query.Search))
}
if query.Hash != "" {
statement = statement.Where("sha256 = ?", query.Hash)
}
var models []entity.Image
if err := statement.Order("id asc").Find(&models).Error; err != nil {
return nil, err
}
return imagesToDomain(models), nil
}
func (i ImageRepository) FindByHash(hash string) ([]domain.Image, error) {
if hash == "" {
return []domain.Image{}, nil
}
var models []entity.Image
if err := i.db.Where("sha256 = ?", hash).Order("id asc").Find(&models).Error; err != nil {
return nil, err
}
return imagesToDomain(models), nil
}
func (i ImageRepository) Rename(id uint32, filename string) (domain.Image, error) {
var model entity.Image
err := i.db.First(&model, id).Error
if err != nil {
return domain.Image{}, mapNotFoundErr(err, domain.ErrImageNotFound)
}
model.Filename = filename
if err := i.db.Save(&model).Error; err != nil {
return domain.Image{}, err
}
return mappers.ImageToDomain(model), nil
}
func (i ImageRepository) ListDuplicates() ([]domain.Image, error) {
var models []entity.Image
err := i.db.Where("sha256 IN (?)", i.duplicatedHashes()).Order("sha256 asc, id asc").Find(&models).Error
if err != nil {
return nil, err
}
return imagesToDomain(models), nil
}
func (i ImageRepository) Counts() (domain.Counts, error) {
var all int64
if err := i.db.Model(&entity.Image{}).Count(&all).Error; err != nil {
return domain.Counts{}, err
}
var groups int64
if err := i.db.Table("(?) as duplicated", i.duplicatedHashes()).Count(&groups).Error; err != nil {
return domain.Counts{}, err
}
return domain.Counts{All: int(all), DuplicateGroups: int(groups)}, nil
}
func (i ImageRepository) duplicatedHashes() *gorm.DB {
return i.db.Model(&entity.Image{}).
Select("sha256").
Where("sha256 <> ''").
Group("sha256").
Having("COUNT(*) > 1")
}
func imagesToDomain(models []entity.Image) []domain.Image {
images := make([]domain.Image, 0, len(models))
for _, model := range models {
images = append(images, mappers.ImageToDomain(model))
}
return images
}
var _ domain.Repository = &ImageRepository{} var _ domain.Repository = &ImageRepository{}
@@ -2,8 +2,26 @@ package useCases
import ( import (
"RenewCMS/internal/domain/image" "RenewCMS/internal/domain/image"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/xml"
stdimage "image"
"io"
_ "golang.org/x/image/webp"
_ "image/jpeg"
_ "image/png"
) )
const svgRootElement = "svg"
var rasterFormats = map[string]string{
"image/png": "png",
"image/jpeg": "jpeg",
"image/webp": "webp",
}
type CreateImageUseCase struct { type CreateImageUseCase struct {
imageRepository image.Repository imageRepository image.Repository
} }
@@ -16,8 +34,68 @@ func (g *CreateImageUseCase) CreateImage(input image.ImageInput) (image.Image, e
if !image.IsAllowedContentType(input.ContentType) { if !image.IsAllowedContentType(input.ContentType) {
return image.Image{}, image.ErrImageTypeNotAllowed return image.Image{}, image.ErrImageTypeNotAllowed
} }
if input.Size > image.MaxImageSize {
return image.Image{}, image.ErrImageTooLarge data, err := readWithinLimit(input.Content)
if err != nil {
return image.Image{}, err
}
width, height, err := measure(data, input.ContentType)
if err != nil {
return image.Image{}, err
}
checksum := sha256.Sum256(data)
return g.imageRepository.Create(image.ImageInput{
Content: bytes.NewReader(data),
Filename: image.NormalizeUploadFilename(input.Filename, input.ContentType),
Size: int64(len(data)),
ContentType: input.ContentType,
Width: width,
Height: height,
SHA256: hex.EncodeToString(checksum[:]),
UploadedByID: input.UploadedByID,
})
}
func readWithinLimit(content io.Reader) ([]byte, error) {
if content == nil {
return nil, image.ErrImageUnreadable
}
data, err := io.ReadAll(io.LimitReader(content, image.MaxImageSize+1))
if err != nil {
return nil, err
}
if int64(len(data)) > image.MaxImageSize {
return nil, image.ErrImageTooLarge
}
return data, nil
}
func measure(data []byte, contentType string) (int, int, error) {
if contentType == "image/svg+xml" {
if !hasSvgRoot(data) {
return 0, 0, image.ErrImageUnreadable
}
return 0, 0, nil
}
config, format, err := stdimage.DecodeConfig(bytes.NewReader(data))
if err != nil || format != rasterFormats[contentType] {
return 0, 0, image.ErrImageUnreadable
}
return config.Width, config.Height, nil
}
func hasSvgRoot(data []byte) bool {
decoder := xml.NewDecoder(bytes.NewReader(data))
for {
token, err := decoder.Token()
if err != nil {
return false
}
if start, ok := token.(xml.StartElement); ok {
return start.Name.Local == svgRootElement
}
} }
return g.imageRepository.Create(input)
} }
@@ -1,30 +1,15 @@
package useCases package useCases
import ( import "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
)
type DeleteArticleUseCase struct { type DeleteArticleUseCase struct {
articleRepository article.Repository articleRepository article.Repository
imageRepository image.Repository
} }
func NewDeleteArticleUseCase(articleRepository article.Repository, imageRepository image.Repository) *DeleteArticleUseCase { func NewDeleteArticleUseCase(articleRepository article.Repository) *DeleteArticleUseCase {
return &DeleteArticleUseCase{articleRepository, imageRepository} return &DeleteArticleUseCase{articleRepository}
} }
func (g *DeleteArticleUseCase) DeleteArticle(id uint32) error { func (g *DeleteArticleUseCase) DeleteArticle(id uint32) error {
a, err := g.articleRepository.Get(id)
if err != nil {
return err
}
for _, img := range a.Images {
if err := g.imageRepository.Delete(img.ID); err != nil {
return err
}
}
return g.articleRepository.Delete(id) return g.articleRepository.Delete(id)
} }
@@ -1,15 +0,0 @@
package useCases
import "RenewCMS/internal/domain/image"
type DeleteImageUseCase struct {
imageRepository image.Repository
}
func NewDeleteImageUseCase(imageRepository image.Repository) *DeleteImageUseCase {
return &DeleteImageUseCase{imageRepository}
}
func (g *DeleteImageUseCase) DeleteImage(imageId uint32) error {
return g.imageRepository.Delete(imageId)
}
@@ -0,0 +1,15 @@
package useCases
import "RenewCMS/internal/domain/image"
type DeleteMediaUseCase struct {
imageRepository image.Repository
}
func NewDeleteMediaUseCase(imageRepository image.Repository) *DeleteMediaUseCase {
return &DeleteMediaUseCase{imageRepository}
}
func (u *DeleteMediaUseCase) Delete(id uint32) error {
return u.imageRepository.Delete(id)
}
@@ -0,0 +1,23 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
)
type GetMediaUsageUseCase struct {
imageRepository image.Repository
articleRepository article.Repository
}
func NewGetMediaUsageUseCase(imageRepository image.Repository, articleRepository article.Repository) *GetMediaUsageUseCase {
return &GetMediaUsageUseCase{imageRepository, articleRepository}
}
func (u *GetMediaUsageUseCase) Usage(id uint32) ([]article.Article, error) {
img, err := u.imageRepository.Get(id)
if err != nil {
return nil, err
}
return u.articleRepository.FindByBodyContaining(image.StoredName(img.Path))
}
@@ -0,0 +1,199 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"encoding/base64"
"strconv"
"strings"
)
const cursorPrefix = "offset:"
type MediaItem struct {
Image image.Image
UploadedBy string
UsedIn int
Duplicate bool
}
type MediaCounts struct {
All int
Unused int
Duplicates int
}
type MediaPage struct {
Items []MediaItem
NextCursor string
Counts MediaCounts
}
type ListMediaUseCase struct {
imageRepository image.Repository
articleRepository article.Repository
userRepository user.Repository
}
func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository) *ListMediaUseCase {
return &ListMediaUseCase{imageRepository, articleRepository, userRepository}
}
func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
if err := query.Validate(); err != nil {
return MediaPage{}, err
}
offset, err := decodeCursor(query.Cursor)
if err != nil {
return MediaPage{}, err
}
usage, err := l.usageCounter()
if err != nil {
return MediaPage{}, err
}
duplicateHashes, err := l.duplicateHashes()
if err != nil {
return MediaPage{}, err
}
counts, err := l.counts(usage)
if err != nil {
return MediaPage{}, err
}
images, err := l.imageRepository.List(image.SearchQuery{Search: strings.TrimSpace(query.Search), Hash: query.Hash})
if err != nil {
return MediaPage{}, err
}
image.SortImages(images, query.Sort)
matching := make([]image.Image, 0, len(images))
for _, img := range images {
if matchesFilter(query.Filter, usage(img), duplicateHashes[img.SHA256] && img.SHA256 != "") {
matching = append(matching, img)
}
}
pageEnd := min(offset+query.Limit, len(matching))
pageStart := min(offset, pageEnd)
names := newUploaderNames(l.userRepository)
items := []MediaItem{}
for _, img := range matching[pageStart:pageEnd] {
uploadedBy, err := names.nameOf(img.UploadedByID)
if err != nil {
return MediaPage{}, err
}
items = append(items, MediaItem{
Image: img,
UploadedBy: uploadedBy,
UsedIn: usage(img),
Duplicate: img.SHA256 != "" && duplicateHashes[img.SHA256],
})
}
page := MediaPage{Items: items, Counts: counts}
if pageEnd < len(matching) {
page.NextCursor = encodeCursor(pageEnd)
}
return page, nil
}
func (l *ListMediaUseCase) Describe(img image.Image) (MediaItem, error) {
articles, err := l.articleRepository.FindByBodyContaining(image.StoredName(img.Path))
if err != nil {
return MediaItem{}, err
}
uploadedBy, err := newUploaderNames(l.userRepository).nameOf(img.UploadedByID)
if err != nil {
return MediaItem{}, err
}
duplicate := false
if img.SHA256 != "" {
sameContent, err := l.imageRepository.FindByHash(img.SHA256)
if err != nil {
return MediaItem{}, err
}
duplicate = len(sameContent) > 1
}
return MediaItem{Image: img, UploadedBy: uploadedBy, UsedIn: len(articles), Duplicate: duplicate}, nil
}
func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
articles, err := l.articleRepository.FindByFilters(article.Filters{})
if err != nil {
return nil, err
}
return func(img image.Image) int {
storedName := image.StoredName(img.Path)
used := 0
for _, a := range articles {
if strings.Contains(a.Body, storedName) {
used++
}
}
return used
}, nil
}
func (l *ListMediaUseCase) duplicateHashes() (map[string]bool, error) {
duplicates, err := l.imageRepository.ListDuplicates()
if err != nil {
return nil, err
}
hashes := make(map[string]bool, len(duplicates))
for _, img := range duplicates {
hashes[img.SHA256] = true
}
return hashes, nil
}
func (l *ListMediaUseCase) counts(usage func(image.Image) int) (MediaCounts, error) {
libraryCounts, err := l.imageRepository.Counts()
if err != nil {
return MediaCounts{}, err
}
everything, err := l.imageRepository.List(image.SearchQuery{})
if err != nil {
return MediaCounts{}, err
}
unused := 0
for _, img := range everything {
if usage(img) == 0 {
unused++
}
}
return MediaCounts{All: libraryCounts.All, Unused: unused, Duplicates: libraryCounts.DuplicateGroups}, nil
}
func matchesFilter(filter image.Filter, usedIn int, duplicate bool) bool {
switch filter {
case image.FilterUnused:
return usedIn == 0
case image.FilterDuplicates:
return duplicate
default:
return true
}
}
func encodeCursor(offset int) string {
return base64.RawURLEncoding.EncodeToString([]byte(cursorPrefix + strconv.Itoa(offset)))
}
func decodeCursor(cursor string) (int, error) {
if cursor == "" {
return 0, nil
}
raw, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil || !strings.HasPrefix(string(raw), cursorPrefix) {
return 0, image.ErrInvalidQuery
}
offset, err := strconv.Atoi(strings.TrimPrefix(string(raw), cursorPrefix))
if err != nil || offset < 0 {
return 0, image.ErrInvalidQuery
}
return offset, nil
}
@@ -0,0 +1,137 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
"cmp"
"errors"
"slices"
"strings"
)
type MergeMediaResult struct {
ArticlesUpdated int
Merged int
}
type MergeMediaUseCase struct {
imageRepository image.Repository
articleRepository article.Repository
}
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository) *MergeMediaUseCase {
return &MergeMediaUseCase{imageRepository, articleRepository}
}
type bodyRewrite struct {
articleId uint32
original string
rewritten string
}
func (u *MergeMediaUseCase) Merge(keepId uint32, mergeIds []uint32) (MergeMediaResult, error) {
if err := validateMergeRequest(keepId, mergeIds); err != nil {
return MergeMediaResult{}, err
}
kept, err := u.imageRepository.Get(keepId)
if err != nil {
return MergeMediaResult{}, err
}
copies, err := u.loadCopies(kept, mergeIds)
if err != nil {
return MergeMediaResult{}, err
}
rewrites, err := u.planRewrites(kept, copies)
if err != nil {
return MergeMediaResult{}, err
}
if err := u.applyRewrites(rewrites); err != nil {
return MergeMediaResult{}, err
}
for _, duplicate := range copies {
if err := u.imageRepository.Delete(duplicate.ID); err != nil {
return MergeMediaResult{}, err
}
}
return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies)}, nil
}
func validateMergeRequest(keepId uint32, mergeIds []uint32) error {
if keepId == 0 || len(mergeIds) == 0 {
return image.ErrInvalidMergeRequest
}
seen := map[uint32]bool{keepId: true}
for _, id := range mergeIds {
if id == 0 || seen[id] {
return image.ErrInvalidMergeRequest
}
seen[id] = true
}
return nil
}
func (u *MergeMediaUseCase) loadCopies(kept image.Image, mergeIds []uint32) ([]image.Image, error) {
copies := make([]image.Image, 0, len(mergeIds))
for _, id := range mergeIds {
duplicate, err := u.imageRepository.Get(id)
if errors.Is(err, image.ErrImageNotFound) {
return nil, image.ErrMergeCopyMissing
}
if err != nil {
return nil, err
}
if duplicate.SHA256 == "" || duplicate.SHA256 != kept.SHA256 {
return nil, image.ErrMergeHashMismatch
}
copies = append(copies, duplicate)
}
return copies, nil
}
func (u *MergeMediaUseCase) planRewrites(kept image.Image, copies []image.Image) ([]bodyRewrite, error) {
keptName := image.StoredName(kept.Path)
rewritten := map[uint32]*bodyRewrite{}
for _, duplicate := range copies {
copyName := image.StoredName(duplicate.Path)
articles, err := u.articleRepository.FindByBodyContaining(copyName)
if err != nil {
return nil, err
}
for _, a := range articles {
plan, ok := rewritten[a.ID]
if !ok {
plan = &bodyRewrite{articleId: a.ID, original: a.Body, rewritten: a.Body}
rewritten[a.ID] = plan
}
plan.rewritten = strings.ReplaceAll(plan.rewritten, copyName, keptName)
}
}
plans := make([]bodyRewrite, 0, len(rewritten))
for _, plan := range rewritten {
plans = append(plans, *plan)
}
slices.SortFunc(plans, func(a, b bodyRewrite) int { return cmp.Compare(a.articleId, b.articleId) })
return plans, nil
}
func (u *MergeMediaUseCase) applyRewrites(rewrites []bodyRewrite) error {
for applied, plan := range rewrites {
if _, err := u.articleRepository.UpdateBody(plan.articleId, plan.rewritten); err != nil {
u.restoreBodies(rewrites[:applied])
return err
}
}
return nil
}
func (u *MergeMediaUseCase) restoreBodies(applied []bodyRewrite) {
for _, plan := range applied {
_, _ = u.articleRepository.UpdateBody(plan.articleId, plan.original)
}
}
@@ -0,0 +1,25 @@
package useCases
import "RenewCMS/internal/domain/image"
type RenameMediaUseCase struct {
imageRepository image.Repository
}
func NewRenameMediaUseCase(imageRepository image.Repository) *RenameMediaUseCase {
return &RenameMediaUseCase{imageRepository}
}
func (u *RenameMediaUseCase) Rename(id uint32, stem string) (image.Image, error) {
validStem, err := image.ValidateFilenameStem(stem)
if err != nil {
return image.Image{}, err
}
current, err := u.imageRepository.Get(id)
if err != nil {
return image.Image{}, err
}
return u.imageRepository.Rename(id, validStem+image.ExtensionOf(current.Filename))
}
@@ -14,10 +14,6 @@ func (g *UpdateArticleUseCase) UpdateBody(id uint32, body string) (article.Artic
return g.articleRepository.UpdateBody(id, body) return g.articleRepository.UpdateBody(id, body)
} }
func (g *UpdateArticleUseCase) AddImage(articleId uint32, imageId uint32) error {
return g.articleRepository.AddImage(articleId, imageId)
}
func (g *UpdateArticleUseCase) UpdateIsOnline(id uint32, isOnline bool) (article.Article, error) { func (g *UpdateArticleUseCase) UpdateIsOnline(id uint32, isOnline bool) (article.Article, error) {
return g.articleRepository.UpdateIsOnline(id, isOnline) return g.articleRepository.UpdateIsOnline(id, isOnline)
} }
@@ -0,0 +1,40 @@
package useCases
import (
"RenewCMS/internal/domain/user"
"errors"
)
type uploaderNames struct {
userRepository user.Repository
names map[uint32]string
}
func newUploaderNames(userRepository user.Repository) *uploaderNames {
return &uploaderNames{userRepository: userRepository, names: map[uint32]string{}}
}
func (u *uploaderNames) nameOf(userId uint32) (string, error) {
if userId == 0 {
return "", nil
}
if name, ok := u.names[userId]; ok {
return name, nil
}
uploader, err := u.userRepository.Get(userId)
if errors.Is(err, user.ErrUserNotFound) {
u.names[userId] = ""
return "", nil
}
if err != nil {
return "", err
}
name := uploader.DisplayName
if name == "" {
name = uploader.Username
}
u.names[userId] = name
return name, nil
}
+1 -1
View File
@@ -26,7 +26,7 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWT
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase, getUserUseCase,
) )
+14 -15
View File
@@ -2,16 +2,16 @@ package test
import ( import (
"RenewCMS/internal/domain/article" "RenewCMS/internal/domain/article"
imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"errors" "errors"
"sort" "sort"
"strings"
"testing" "testing"
) )
type mockArticleRepo struct { type mockArticleRepo struct {
articles map[uint32]article.Article articles map[uint32]article.Article
images map[uint32]*imageDomain.Image failUpdateBodyFor map[uint32]error
nextID uint32 nextID uint32
fail failSwitch fail failSwitch
createCalled bool createCalled bool
@@ -20,7 +20,6 @@ type mockArticleRepo struct {
func newMockArticleRepo() *mockArticleRepo { func newMockArticleRepo() *mockArticleRepo {
return &mockArticleRepo{ return &mockArticleRepo{
articles: make(map[uint32]article.Article), articles: make(map[uint32]article.Article),
images: make(map[uint32]*imageDomain.Image),
} }
} }
@@ -95,6 +94,9 @@ func (m *mockArticleRepo) UpdateBody(id uint32, body string) (article.Article, e
if err := m.fail.trigger("UpdateBody"); err != nil { if err := m.fail.trigger("UpdateBody"); err != nil {
return article.Article{}, err return article.Article{}, err
} }
if err, ok := m.failUpdateBodyFor[id]; ok {
return article.Article{}, err
}
a, ok := m.articles[id] a, ok := m.articles[id]
if !ok { if !ok {
return article.Article{}, article.ErrArticleNotFound return article.Article{}, article.ErrArticleNotFound
@@ -128,21 +130,18 @@ func (m *mockArticleRepo) Delete(id uint32) error {
return nil return nil
} }
func (m *mockArticleRepo) AddImage(articleId uint32, imageId uint32) error { func (m *mockArticleRepo) FindByBodyContaining(filename string) ([]article.Article, error) {
if err := m.fail.trigger("AddImage"); err != nil { if err := m.fail.trigger("FindByBodyContaining"); err != nil {
return err return nil, err
} }
a, ok := m.articles[articleId] result := make([]article.Article, 0)
if !ok { for _, id := range m.sortedIds() {
return article.ErrArticleNotFound a := m.articles[id]
if strings.Contains(a.Body, filename) {
result = append(result, a)
} }
img, ok := m.images[imageId]
if !ok {
return imageDomain.ErrImageNotFound
} }
a.Images = append(a.Images, img) return result, nil
m.articles[articleId] = a
return nil
} }
var _ article.Repository = &mockArticleRepo{} var _ article.Repository = &mockArticleRepo{}
+1 -1
View File
@@ -29,7 +29,7 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *
useCases.NewCreateArticleUseCase(repo), useCases.NewCreateArticleUseCase(repo),
useCases.NewGetArticleUseCase(repo), useCases.NewGetArticleUseCase(repo),
useCases.NewListArticlesUseCase(repo), useCases.NewListArticlesUseCase(repo),
useCases.NewDeleteArticleUseCase(repo, newMockImageRepo()), useCases.NewDeleteArticleUseCase(repo),
useCases.NewUpdateArticleUseCase(repo), useCases.NewUpdateArticleUseCase(repo),
getUserUseCase, getUserUseCase,
) )
+8 -30
View File
@@ -3,7 +3,6 @@ package test
import ( import (
"RenewCMS/api/handlers/article" "RenewCMS/api/handlers/article"
domainArticle "RenewCMS/internal/domain/article" domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
"net/http" "net/http"
@@ -24,7 +23,7 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth)
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase, getUserUseCase,
) )
@@ -43,16 +42,9 @@ func TestArticlesListHttp(t *testing.T) {
validator.InitValidator() validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := jwtauth.New("HS256", []byte("test-secret"), nil)
t.Run("GET /v1/article/ returns each article's images array populated", func(t *testing.T) { t.Run("GET /v1/article/ items carry no images field now that images belong to the library", func(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
repo.articles[4] = domainArticle.Article{ repo.articles[4] = domainArticle.Article{ID: 4, Title: "Winter checklist for timber windows"}
ID: 4,
Title: "Winter checklist for timber windows",
Images: []*domainImage.Image{
{ID: 10, ArticleID: 4},
{ID: 11, ArticleID: 4},
},
}
router := newArticlesListRouter(repo, token) router := newArticlesListRouter(repo, token)
req := httptest.NewRequest(http.MethodGet, "/v1/article/", nil) req := httptest.NewRequest(http.MethodGet, "/v1/article/", nil)
@@ -64,29 +56,15 @@ func TestArticlesListHttp(t *testing.T) {
t.Fatalf("expected status 200, got %d", rec.Code) t.Fatalf("expected status 200, got %d", rec.Code)
} }
var got []domainArticle.Article var got []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("failed to decode response: %v", err) t.Fatalf("failed to decode response: %v", err)
} }
if len(got) != 1 {
var found *domainArticle.Article t.Fatalf("expected 1 article, got %d", len(got))
for i := range got {
if got[i].ID == 4 {
found = &got[i]
} }
} if _, present := got[0]["images"]; present {
if found == nil { t.Errorf("expected no images field, got %v", got[0]["images"])
t.Fatalf("expected article id 4 in response, got %v", got)
}
if len(found.Images) != 2 {
t.Fatalf("expected 2 images, got %d", len(found.Images))
}
ids := map[uint32]bool{}
for _, img := range found.Images {
ids[img.ID] = true
}
if !ids[10] || !ids[11] {
t.Errorf("expected images 10 and 11, got %v", found.Images)
} }
}) })
} }
-230
View File
@@ -1,230 +0,0 @@
package test
import (
"RenewCMS/api/handlers/article"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func seedArticleImages(imageRepo *mockImageRepo, ids ...uint32) []*domainImage.Image {
images := make([]*domainImage.Image, len(ids))
for i, id := range ids {
imageRepo.images[id] = domainImage.Image{ID: id}
seeded := imageRepo.images[id]
images[i] = &seeded
}
return images
}
func TestDeleteArticleUseCaseCascadesImageDeletion(t *testing.T) {
t.Run("deletes every image before deleting the article, in Images slice order", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Images: seedArticleImages(imageRepo, 10, 11)}
uc := useCases.NewDeleteArticleUseCase(articleRepo, imageRepo)
if err := uc.DeleteArticle(1); err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got := imageRepo.deleteCalls; len(got) != 2 || got[0] != 10 || got[1] != 11 {
t.Errorf("expected image Delete to be called with [10 11] in order, got %v", got)
}
if _, exists := articleRepo.articles[1]; exists {
t.Errorf("expected article 1 to have been deleted")
}
})
t.Run("stops at the first image Delete failure and leaves the article untouched", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
articleRepo.articles[3] = domainArticle.Article{ID: 3, Images: seedArticleImages(imageRepo, 20, 21)}
imageRepo.failDeleteFor = map[uint32]error{20: errRepositoryFailure}
uc := useCases.NewDeleteArticleUseCase(articleRepo, imageRepo)
err := uc.DeleteArticle(3)
if !errors.Is(err, errRepositoryFailure) {
t.Fatalf("expected errRepositoryFailure, got %v", err)
}
if got := imageRepo.deleteCalls; len(got) != 1 || got[0] != 20 {
t.Errorf("expected image Delete to be called only once with 20, got %v", got)
}
if _, exists := articleRepo.articles[3]; !exists {
t.Errorf("expected article 3 to still be present")
}
if _, exists := imageRepo.images[21]; !exists {
t.Errorf("expected image 21 to still be present")
}
})
t.Run("propagates ErrArticleNotFound without touching the image repository", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
uc := useCases.NewDeleteArticleUseCase(articleRepo, imageRepo)
err := uc.DeleteArticle(9999)
if !errors.Is(err, domainArticle.ErrArticleNotFound) {
t.Fatalf("expected article.ErrArticleNotFound, got %v", err)
}
if len(imageRepo.deleteCalls) != 0 {
t.Errorf("expected no call to the image repository's Delete method, got %v", imageRepo.deleteCalls)
}
})
}
func newDeleteArticleCascadesImagesRouter(articleRepo *mockArticleRepo, imageRepo *mockImageRepo, token *jwtauth.JWTAuth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, imageRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase,
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Route("/article", func(r chi.Router) {
r.Delete("/{id}", articleHandler.DeleteArticle)
})
})
})
return r
}
func TestDeleteArticleCascadesImages(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
t.Run("Deleting an article with images removes the article and every one of its images", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Images: seedArticleImages(imageRepo, 10, 11)}
router := newDeleteArticleCascadesImagesRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/1", nil)
setJwtCookie(t, req, token, 1)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected status 204, got %d", rec.Code)
}
if _, exists := articleRepo.articles[1]; exists {
t.Errorf("expected article 1 to have been deleted")
}
if _, exists := imageRepo.images[10]; exists {
t.Errorf("expected image 10 to have been deleted")
}
if _, exists := imageRepo.images[11]; exists {
t.Errorf("expected image 11 to have been deleted")
}
})
t.Run("Deleting an article with no images still succeeds", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
articleRepo.articles[2] = domainArticle.Article{ID: 2}
router := newDeleteArticleCascadesImagesRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/2", nil)
setJwtCookie(t, req, token, 1)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected status 204, got %d", rec.Code)
}
if _, exists := articleRepo.articles[2]; exists {
t.Errorf("expected article 2 to have been deleted")
}
if len(imageRepo.deleteCalls) != 0 {
t.Errorf("expected no call to the image repository's Delete method, got %v", imageRepo.deleteCalls)
}
})
t.Run("Deleting an unknown article still answers 404 and deletes no images", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
router := newDeleteArticleCascadesImagesRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/9999", nil)
setJwtCookie(t, req, token, 1)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", rec.Code)
}
if rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if len(imageRepo.deleteCalls) != 0 {
t.Errorf("expected no call to the image repository's Delete method, got %v", imageRepo.deleteCalls)
}
})
t.Run("Deleting an article still requires authentication", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Images: seedArticleImages(imageRepo, 10)}
router := newDeleteArticleCascadesImagesRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/1", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d", rec.Code)
}
if _, exists := articleRepo.articles[1]; !exists {
t.Errorf("expected article 1 to still be present")
}
if _, exists := imageRepo.images[10]; !exists {
t.Errorf("expected image 10 to still be present")
}
})
t.Run("A failure deleting one of an article's images aborts the whole delete", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
articleRepo.articles[3] = domainArticle.Article{ID: 3, Images: seedArticleImages(imageRepo, 20, 21)}
imageRepo.failDeleteFor = map[uint32]error{20: errRepositoryFailure}
router := newDeleteArticleCascadesImagesRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/3", nil)
setJwtCookie(t, req, token, 1)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", rec.Code)
}
if rec.Body.String() != "Failed to delete article\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if _, exists := articleRepo.articles[3]; !exists {
t.Errorf("expected article 3 to still be present")
}
if _, exists := imageRepo.images[21]; !exists {
t.Errorf("expected image 21 to still be present")
}
})
}
+84
View File
@@ -0,0 +1,84 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"net/http"
"reflect"
"testing"
)
func TestDeleteArticleKeepsImages(t *testing.T) {
newEnv := func(t *testing.T) *mediaEnv {
env := newMediaEnv(t)
env.seedImage(10, "ten.png", 1, "image/png", 1, 1, hashA, 1)
env.seedImage(11, "eleven.png", 1, "image/png", 1, 1, hashB, 1)
env.seedArticle(1, "Kitchen Renovation", true, env.urlOf(10)+" "+env.urlOf(11))
return env
}
t.Run("Deleting an article leaves every image it used", func(t *testing.T) {
env := newEnv(t)
rec := env.send(t, http.MethodDelete, "/v1/article/1", "", mediaVerifiedUserId)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected status 204, got %d", rec.Code)
}
if _, ok := env.articles.articles[1]; ok {
t.Errorf("expected article 1 to be gone")
}
expectIds(t, env.imageIds(), []uint32{10, 11})
if len(env.images.deleteCalls) != 0 {
t.Errorf("expected image Delete to never be called, got %v", env.images.deleteCalls)
}
})
t.Run("Deleting an unknown article is 404 and touches no image", func(t *testing.T) {
env := newEnv(t)
rec := env.send(t, http.MethodDelete, "/v1/article/9999", "", mediaVerifiedUserId)
if rec.Code != http.StatusNotFound || rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" {
t.Errorf("expected 404 Article not found, got %d %q", rec.Code, rec.Body.String())
}
expectIds(t, env.imageIds(), []uint32{10, 11})
if len(env.images.deleteCalls) != 0 {
t.Errorf("expected the image repository to be untouched")
}
})
t.Run("Deleting still requires authentication", func(t *testing.T) {
env := newEnv(t)
rec := env.send(t, http.MethodDelete, "/v1/article/1", "", 0)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d", rec.Code)
}
if _, ok := env.articles.articles[1]; !ok {
t.Errorf("expected article 1 to still exist")
}
if _, ok := env.images.images[10]; !ok {
t.Errorf("expected image 10 to still exist")
}
})
t.Run("An article repository failure is 500 and everything stays", func(t *testing.T) {
env := newEnv(t)
bodiesBefore := env.bodies()
env.articles.fail.failNext("Delete")
rec := env.send(t, http.MethodDelete, "/v1/article/1", "", mediaVerifiedUserId)
if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Failed to delete article\n" {
t.Errorf("expected 500 Failed to delete article, got %d %q", rec.Code, rec.Body.String())
}
if !reflect.DeepEqual(env.bodies(), bodiesBefore) {
t.Errorf("expected the article to stay")
}
expectIds(t, env.imageIds(), []uint32{10, 11})
if len(env.images.deleteCalls) != 0 {
t.Errorf("expected the image repository to be untouched")
}
})
}
+1 -1
View File
@@ -27,7 +27,7 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAu
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase, getUserUseCase,
) )
+144 -8
View File
@@ -3,36 +3,86 @@ package test
import ( import (
imageDomain "RenewCMS/internal/domain/image" imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"errors"
"io" "io"
"sort"
"strconv"
"strings" "strings"
"testing" "testing"
"time"
) )
var mockImageEpoch = time.Date(2026, time.June, 1, 12, 0, 0, 0, time.UTC)
var mockImageExtensions = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpeg",
"image/webp": ".webp",
"image/svg+xml": ".svg",
}
type mockImageRepo struct { type mockImageRepo struct {
images map[uint32]imageDomain.Image images map[uint32]imageDomain.Image
nextID uint32 nextID uint32
fail failSwitch fail failSwitch
failDeleteFor map[uint32]error failDeleteFor map[uint32]error
deleteCalls []uint32 deleteCalls []uint32
createCalls int
lastContent []byte
} }
func newMockImageRepo() *mockImageRepo { func newMockImageRepo() *mockImageRepo {
return &mockImageRepo{images: make(map[uint32]imageDomain.Image)} return &mockImageRepo{images: make(map[uint32]imageDomain.Image)}
} }
func (m *mockImageRepo) sortedImages() []imageDomain.Image {
result := make([]imageDomain.Image, 0, len(m.images))
for _, img := range m.images {
result = append(result, img)
}
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result
}
func (m *mockImageRepo) Create(input imageDomain.ImageInput) (imageDomain.Image, error) { func (m *mockImageRepo) Create(input imageDomain.ImageInput) (imageDomain.Image, error) {
m.createCalls++
if err := m.fail.trigger("Create"); err != nil { if err := m.fail.trigger("Create"); err != nil {
return imageDomain.Image{}, err return imageDomain.Image{}, err
} }
if input.Content != nil { if input.Content != nil {
_, _ = io.ReadAll(input.Content) m.lastContent, _ = io.ReadAll(input.Content)
}
for m.images[m.nextID+1].ID != 0 {
m.nextID++
} }
m.nextID++ m.nextID++
img := imageDomain.Image{ID: m.nextID, Path: "/static/uploadedImages/" + input.Filename} img := imageDomain.Image{
ID: m.nextID,
Path: "/static/uploadedImages/" + strconv.Itoa(int(m.nextID)) + mockImageExtensions[input.ContentType],
Filename: input.Filename,
Size: input.Size,
ContentType: input.ContentType,
Width: input.Width,
Height: input.Height,
SHA256: input.SHA256,
UploadedByID: input.UploadedByID,
CreatedAt: mockImageEpoch.Add(time.Duration(m.nextID) * time.Minute),
}
m.images[img.ID] = img m.images[img.ID] = img
return img, nil return img, nil
} }
func (m *mockImageRepo) Get(id uint32) (imageDomain.Image, error) {
if err := m.fail.trigger("Get"); err != nil {
return imageDomain.Image{}, err
}
img, ok := m.images[id]
if !ok {
return imageDomain.Image{}, imageDomain.ErrImageNotFound
}
return img, nil
}
func (m *mockImageRepo) Delete(id uint32) error { func (m *mockImageRepo) Delete(id uint32) error {
m.deleteCalls = append(m.deleteCalls, id) m.deleteCalls = append(m.deleteCalls, id)
if err := m.fail.trigger("Delete"); err != nil { if err := m.fail.trigger("Delete"); err != nil {
@@ -48,16 +98,96 @@ func (m *mockImageRepo) Delete(id uint32) error {
return nil return nil
} }
func (m *mockImageRepo) List(query imageDomain.SearchQuery) ([]imageDomain.Image, error) {
if err := m.fail.trigger("List"); err != nil {
return nil, err
}
result := make([]imageDomain.Image, 0)
for _, img := range m.sortedImages() {
if query.Search != "" && !strings.Contains(strings.ToLower(img.Filename), strings.ToLower(query.Search)) {
continue
}
if query.Hash != "" && img.SHA256 != query.Hash {
continue
}
result = append(result, img)
}
return result, nil
}
func (m *mockImageRepo) FindByHash(hash string) ([]imageDomain.Image, error) {
if err := m.fail.trigger("FindByHash"); err != nil {
return nil, err
}
result := make([]imageDomain.Image, 0)
for _, img := range m.sortedImages() {
if hash != "" && img.SHA256 == hash {
result = append(result, img)
}
}
return result, nil
}
func (m *mockImageRepo) Rename(id uint32, filename string) (imageDomain.Image, error) {
if err := m.fail.trigger("Rename"); err != nil {
return imageDomain.Image{}, err
}
img, ok := m.images[id]
if !ok {
return imageDomain.Image{}, imageDomain.ErrImageNotFound
}
img.Filename = filename
m.images[id] = img
return img, nil
}
func (m *mockImageRepo) hashCounts() map[string]int {
counts := map[string]int{}
for _, img := range m.images {
if img.SHA256 != "" {
counts[img.SHA256]++
}
}
return counts
}
func (m *mockImageRepo) ListDuplicates() ([]imageDomain.Image, error) {
if err := m.fail.trigger("ListDuplicates"); err != nil {
return nil, err
}
counts := m.hashCounts()
result := make([]imageDomain.Image, 0)
for _, img := range m.sortedImages() {
if img.SHA256 != "" && counts[img.SHA256] > 1 {
result = append(result, img)
}
}
return result, nil
}
func (m *mockImageRepo) Counts() (imageDomain.Counts, error) {
if err := m.fail.trigger("Counts"); err != nil {
return imageDomain.Counts{}, err
}
groups := 0
for _, count := range m.hashCounts() {
if count > 1 {
groups++
}
}
return imageDomain.Counts{All: len(m.images), DuplicateGroups: groups}, nil
}
var _ imageDomain.Repository = &mockImageRepo{} var _ imageDomain.Repository = &mockImageRepo{}
func TestImageUseCases(t *testing.T) { func TestImageUseCases(t *testing.T) {
repo := newMockImageRepo() repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo) createUc := useCases.NewCreateImageUseCase(repo)
deleteUc := useCases.NewDeleteImageUseCase(repo) deleteUc := useCases.NewDeleteMediaUseCase(repo)
t.Run("Create Image", func(t *testing.T) { t.Run("Create Image", func(t *testing.T) {
input := imageDomain.ImageInput{ input := imageDomain.ImageInput{
Content: strings.NewReader("fake-image-data"), Content: strings.NewReader(string(pngBytes(t, 3, 2))),
Filename: "test.png", Filename: "test.png",
ContentType: "image/png", ContentType: "image/png",
} }
@@ -69,21 +199,27 @@ func TestImageUseCases(t *testing.T) {
if res.ID == 0 { if res.ID == 0 {
t.Errorf("Expected valid ID, got 0") t.Errorf("Expected valid ID, got 0")
} }
if !strings.Contains(res.Path, "test.png") { if res.Filename != "test.png" {
t.Errorf("Path mismatch: %s", res.Path) t.Errorf("Filename mismatch: %s", res.Filename)
} }
}) })
t.Run("Delete Image", func(t *testing.T) { t.Run("Delete Image", func(t *testing.T) {
repo.images[99] = imageDomain.Image{ID: 99, Path: "/static/delete-me.png"} repo.images[99] = imageDomain.Image{ID: 99, Path: "/static/delete-me.png"}
err := deleteUc.DeleteImage(99) err := deleteUc.Delete(99)
if err != nil { if err != nil {
t.Fatalf("DeleteImage failed: %v", err) t.Fatalf("Delete failed: %v", err)
} }
if _, exists := repo.images[99]; exists { if _, exists := repo.images[99]; exists {
t.Errorf("Image 99 should have been deleted") t.Errorf("Image 99 should have been deleted")
} }
}) })
t.Run("Delete unknown image", func(t *testing.T) {
if err := deleteUc.Delete(12345); !errors.Is(err, imageDomain.ErrImageNotFound) {
t.Errorf("expected ErrImageNotFound, got %v", err)
}
})
} }
+33 -24
View File
@@ -3,10 +3,37 @@ package test
import ( import (
imageDomain "RenewCMS/internal/domain/image" imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors" "errors"
"testing" "testing"
) )
func validBytesFor(t *testing.T, contentType string) []byte {
t.Helper()
switch contentType {
case "image/png":
return pngBytes(t, 3, 2)
case "image/jpeg":
return jpegBytes(t, 3, 2)
case "image/webp":
return webpBytes(t)
default:
return []byte(svgFixture)
}
}
func paddedTo(data []byte, size int) []byte {
padding := byte(0)
if bytes.HasPrefix(data, []byte("<svg")) {
padding = ' '
}
return append(append([]byte{}, data...), bytes.Repeat([]byte{padding}, size-len(data))...)
}
func inputOf(content []byte, contentType string) imageDomain.ImageInput {
return imageDomain.ImageInput{Content: bytes.NewReader(content), Filename: "upload", ContentType: contentType}
}
func TestImageUploadRules(t *testing.T) { func TestImageUploadRules(t *testing.T) {
t.Run("CreateImage accepts every allowed content type within the size limit", func(t *testing.T) { t.Run("CreateImage accepts every allowed content type within the size limit", func(t *testing.T) {
for _, contentType := range []string{"image/png", "image/jpeg", "image/webp", "image/svg+xml"} { for _, contentType := range []string{"image/png", "image/jpeg", "image/webp", "image/svg+xml"} {
@@ -14,10 +41,7 @@ func TestImageUploadRules(t *testing.T) {
repo := newMockImageRepo() repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo) createUc := useCases.NewCreateImageUseCase(repo)
result, err := createUc.CreateImage(imageDomain.ImageInput{ result, err := createUc.CreateImage(inputOf(paddedTo(validBytesFor(t, contentType), int(imageDomain.MaxImageSize)), contentType))
ContentType: contentType,
Size: imageDomain.MaxImageSize,
})
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
@@ -36,10 +60,7 @@ func TestImageUploadRules(t *testing.T) {
repo := newMockImageRepo() repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo) createUc := useCases.NewCreateImageUseCase(repo)
_, err := createUc.CreateImage(imageDomain.ImageInput{ _, err := createUc.CreateImage(inputOf([]byte("%PDF"), "application/pdf"))
ContentType: "application/pdf",
Size: 1024,
})
if !errors.Is(err, imageDomain.ErrImageTypeNotAllowed) { if !errors.Is(err, imageDomain.ErrImageTypeNotAllowed) {
t.Fatalf("expected ErrImageTypeNotAllowed, got %v", err) t.Fatalf("expected ErrImageTypeNotAllowed, got %v", err)
@@ -53,10 +74,7 @@ func TestImageUploadRules(t *testing.T) {
repo := newMockImageRepo() repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo) createUc := useCases.NewCreateImageUseCase(repo)
_, err := createUc.CreateImage(imageDomain.ImageInput{ _, err := createUc.CreateImage(inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)+1), "image/png"))
ContentType: "image/png",
Size: imageDomain.MaxImageSize + 1,
})
if !errors.Is(err, imageDomain.ErrImageTooLarge) { if !errors.Is(err, imageDomain.ErrImageTooLarge) {
t.Fatalf("expected ErrImageTooLarge, got %v", err) t.Fatalf("expected ErrImageTooLarge, got %v", err)
@@ -70,10 +88,7 @@ func TestImageUploadRules(t *testing.T) {
repo := newMockImageRepo() repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo) createUc := useCases.NewCreateImageUseCase(repo)
_, err := createUc.CreateImage(imageDomain.ImageInput{ _, err := createUc.CreateImage(inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)), "image/png"))
ContentType: "image/png",
Size: imageDomain.MaxImageSize,
})
if err != nil { if err != nil {
t.Fatalf("expected no error at the exact limit, got %v", err) t.Fatalf("expected no error at the exact limit, got %v", err)
} }
@@ -81,10 +96,7 @@ func TestImageUploadRules(t *testing.T) {
t.Fatalf("expected Create to be called once, got %d calls", len(repo.images)) t.Fatalf("expected Create to be called once, got %d calls", len(repo.images))
} }
_, err = createUc.CreateImage(imageDomain.ImageInput{ _, err = createUc.CreateImage(inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)+1), "image/png"))
ContentType: "image/png",
Size: imageDomain.MaxImageSize + 1,
})
if !errors.Is(err, imageDomain.ErrImageTooLarge) { if !errors.Is(err, imageDomain.ErrImageTooLarge) {
t.Fatalf("expected ErrImageTooLarge, got %v", err) t.Fatalf("expected ErrImageTooLarge, got %v", err)
} }
@@ -98,10 +110,7 @@ func TestImageUploadRules(t *testing.T) {
repo.fail.failNextWith("Create", errRepositoryFailure) repo.fail.failNextWith("Create", errRepositoryFailure)
createUc := useCases.NewCreateImageUseCase(repo) createUc := useCases.NewCreateImageUseCase(repo)
_, err := createUc.CreateImage(imageDomain.ImageInput{ _, err := createUc.CreateImage(inputOf(pngBytes(t, 3, 2), "image/png"))
ContentType: "image/png",
Size: 1024,
})
if !errors.Is(err, errRepositoryFailure) { if !errors.Is(err, errRepositoryFailure) {
t.Fatalf("expected the generic repository error, got %v", err) t.Fatalf("expected the generic repository error, got %v", err)
+72
View File
@@ -0,0 +1,72 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/useCases"
"encoding/base64"
"net/http"
"net/url"
"reflect"
"testing"
)
type mediaOrderRecordingArticleRepo struct {
*mockArticleRepo
updated []uint32
}
func (r *mediaOrderRecordingArticleRepo) UpdateBody(id uint32, body string) (domainArticle.Article, error) {
r.updated = append(r.updated, id)
return r.mockArticleRepo.UpdateBody(id, body)
}
func TestMediaCursorBoundaries(t *testing.T) {
seed := func(env *mediaEnv) {
env.seedImage(1, "a.jpg", 10, "image/jpeg", 3, 2, hashA, 1)
env.seedImage(2, "b.jpg", 20, "image/jpeg", 3, 2, hashB, 1)
}
encode := func(raw string) string {
return url.QueryEscape(base64.RawURLEncoding.EncodeToString([]byte(raw)))
}
t.Run("A cursor at offset zero is accepted and starts from the first item", func(t *testing.T) {
env := newMediaEnv(t)
seed(env)
rec := env.send(t, http.MethodGet, "/v1/media/?cursor="+encode("offset:0"), "", mediaVerifiedUserId)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
})
t.Run("A negative cursor offset is refused", func(t *testing.T) {
env := newMediaEnv(t)
seed(env)
rec := env.send(t, http.MethodGet, "/v1/media/?cursor="+encode("offset:-1"), "", mediaVerifiedUserId)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rec.Code)
}
})
}
func TestMediaMergeRewritesArticlesInIdOrder(t *testing.T) {
env := newMediaEnv(t)
env.seedImage(2, "floorboards.jpg", 20, "image/jpeg", 3, 2, hashB, 1)
env.seedImage(3, "floorboards-copy.jpg", 20, "image/jpeg", 3, 2, hashB, 2)
for id := uint32(1); id <= 8; id++ {
env.seedArticle(id, "Article", true, env.urlOf(3))
}
recorder := &mediaOrderRecordingArticleRepo{mockArticleRepo: env.articles}
uc := useCases.NewMergeMediaUseCase(env.images, recorder)
if _, err := uc.Merge(2, []uint32{3}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if want := []uint32{1, 2, 3, 4, 5, 6, 7, 8}; !reflect.DeepEqual(recorder.updated, want) {
t.Errorf("expected updates in id order %v, got %v", want, recorder.updated)
}
}
+160
View File
@@ -0,0 +1,160 @@
package test
import (
"RenewCMS/api"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
const (
mediaVerifiedUserId uint32 = 1
mediaSecondUserId uint32 = 2
mediaUnverifiedUserId uint32 = 3
mediaStaleUserId uint32 = 4
)
type mediaEnv struct {
articles *mockArticleRepo
images *mockImageRepo
users *mockUserRepo
token *jwtauth.JWTAuth
router http.Handler
}
func newMediaEnv(t *testing.T) *mediaEnv {
t.Helper()
validator.InitValidator()
env := &mediaEnv{
articles: newMockArticleRepo(),
images: newMockImageRepo(),
users: newMockUserRepo(),
token: jwtauth.New("HS256", []byte("test-secret"), nil),
}
env.users.users[mediaVerifiedUserId] = user.User{ID: 1, Username: "ines", DisplayName: "Inès Duarte", IsVerified: true}
env.users.users[mediaSecondUserId] = user.User{ID: 2, Username: "marta", IsVerified: true}
env.users.users[mediaUnverifiedUserId] = user.User{ID: 3, Username: "pending", IsVerified: false}
env.users.users[mediaStaleUserId] = user.User{ID: 4, Username: "stale", IsVerified: true, TokenVersion: 1}
container := &api.UseCases{
Token: env.token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(env.images, env.articles),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
}
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
env.router = router
return env
}
func (e *mediaEnv) cookie(t *testing.T, userId uint32, tokenVersion uint32) *http.Cookie {
t.Helper()
_, tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
return &http.Cookie{Name: "jwt", Value: tokenString}
}
func (e *mediaEnv) serve(t *testing.T, req *http.Request, userId uint32) *httptest.ResponseRecorder {
t.Helper()
if userId != 0 {
req.AddCookie(e.cookie(t, userId, 0))
}
rec := httptest.NewRecorder()
e.router.ServeHTTP(rec, req)
return rec
}
func (e *mediaEnv) send(t *testing.T, method string, path string, body string, userId uint32) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
return e.serve(t, req, userId)
}
func (e *mediaEnv) seedImage(id uint32, filename string, size int64, contentType string, width int, height int, hash string, uploadedBy uint32) {
extension := map[string]string{"image/jpeg": ".jpeg", "image/svg+xml": ".svg", "image/png": ".png"}[contentType]
e.images.images[id] = domainImage.Image{
ID: id,
Path: "/static/uploadedImages/img-" + strconv.Itoa(int(id)) + "-" + extension,
Filename: filename,
Size: size,
ContentType: contentType,
Width: width,
Height: height,
SHA256: hash,
UploadedByID: uploadedBy,
CreatedAt: mockImageEpoch.Add(time.Duration(id) * time.Minute),
}
if id > e.images.nextID {
e.images.nextID = id
}
}
func (e *mediaEnv) urlOf(id uint32) string {
return e.images.images[id].Path
}
func (e *mediaEnv) seedArticle(id uint32, title string, online bool, body string) {
e.articles.articles[id] = domainArticle.Article{ID: id, Title: title, IsOnline: online, Body: body}
if id > e.articles.nextID {
e.articles.nextID = id
}
}
func (e *mediaEnv) bodies() map[uint32]string {
result := map[uint32]string{}
for id, a := range e.articles.articles {
result[id] = a.Body
}
return result
}
func (e *mediaEnv) imageIds() []uint32 {
ids := make([]uint32, 0)
for _, img := range e.images.sortedImages() {
ids = append(ids, img.ID)
}
return ids
}
func decodeObject(t *testing.T, body string) map[string]any {
t.Helper()
var decoded map[string]any
if err := json.Unmarshal([]byte(body), &decoded); err != nil {
t.Fatalf("failed to decode %q: %v", body, err)
}
return decoded
}
func numberOf(value any) uint32 {
number, _ := value.(float64)
return uint32(number)
}
+103
View File
@@ -0,0 +1,103 @@
package test
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"image"
"image/color"
"image/jpeg"
"image/png"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
)
const webpFixtureBase64 = "UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA=="
const svgFixture = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>`
func solidImage(width int, height int) image.Image {
canvas := image.NewRGBA(image.Rect(0, 0, width, height))
for x := 0; x < width; x++ {
for y := 0; y < height; y++ {
canvas.Set(x, y, color.RGBA{R: uint8(x * 40), G: uint8(y * 40), B: 200, A: 255})
}
}
return canvas
}
func pngBytes(t *testing.T, width int, height int) []byte {
t.Helper()
buffer := &bytes.Buffer{}
if err := png.Encode(buffer, solidImage(width, height)); err != nil {
t.Fatalf("failed to encode png: %v", err)
}
return buffer.Bytes()
}
func jpegBytes(t *testing.T, width int, height int) []byte {
t.Helper()
buffer := &bytes.Buffer{}
if err := jpeg.Encode(buffer, solidImage(width, height), nil); err != nil {
t.Fatalf("failed to encode jpeg: %v", err)
}
return buffer.Bytes()
}
func webpBytes(t *testing.T) []byte {
t.Helper()
data, err := base64.StdEncoding.DecodeString(webpFixtureBase64)
if err != nil {
t.Fatalf("failed to decode webp fixture: %v", err)
}
return data
}
func paddedPngBytes(t *testing.T, size int) []byte {
t.Helper()
data := pngBytes(t, 3, 2)
return append(data, bytes.Repeat([]byte{0}, size-len(data))...)
}
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func newMultipartFileRequest(t *testing.T, method string, url string, filename string, contentType string, content []byte) *http.Request {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreatePart(map[string][]string{
"Content-Disposition": {`form-data; name="file"; filename="` + filename + `"`},
"Content-Type": {contentType},
})
if err != nil {
t.Fatalf("failed to create multipart part: %v", err)
}
if _, err := io.Copy(part, bytes.NewReader(content)); err != nil {
t.Fatalf("failed to write file content: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("failed to close multipart writer: %v", err)
}
req := httptest.NewRequest(method, url, body)
req.Header.Set("Content-Type", writer.FormDataContentType())
return req
}
func newMultipartWithoutFileRequest(t *testing.T, url string) *http.Request {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
if err := writer.Close(); err != nil {
t.Fatalf("failed to close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, url, body)
req.Header.Set("Content-Type", writer.FormDataContentType())
return req
}
File diff suppressed because it is too large. Load diff
+253
View File
@@ -0,0 +1,253 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"bytes"
"errors"
"os"
"path/filepath"
"testing"
"gorm.io/gorm"
)
func newMediaPersistence(t *testing.T) (*gorm.DB, *persistence.ImageRepository, string) {
t.Helper()
db := openInMemoryDb(t)
if err := db.AutoMigrate(&models.Article{}, &models.Image{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
uploadDir := t.TempDir()
return db, persistence.NewImageRepository(db, uploadDir), uploadDir
}
func createStoredImage(t *testing.T, repo *persistence.ImageRepository, filename string, content []byte, contentType string) domainImage.Image {
t.Helper()
created, err := repo.Create(domainImage.ImageInput{
Content: bytes.NewReader(content),
Filename: filename,
Size: int64(len(content)),
ContentType: contentType,
Width: 3,
Height: 2,
SHA256: sha256Hex(content),
})
if err != nil {
t.Fatalf("Create failed: %v", err)
}
return created
}
func TestMediaImageRepository(t *testing.T) {
t.Run("Create stores the file and the metadata and returns the assigned id", func(t *testing.T) {
_, repo, uploadDir := newMediaPersistence(t)
content := pngBytes(t, 3, 2)
created := createStoredImage(t, repo, "a.png", content, "image/png")
if created.ID == 0 || created.Filename != "a.png" || created.SHA256 != sha256Hex(content) || created.Width != 3 || created.Size != int64(len(content)) {
t.Errorf("unexpected image %+v", created)
}
onDisk, err := os.ReadFile(filepath.Join(uploadDir, filepath.Base(created.Path)))
if err != nil || !bytes.Equal(onDisk, content) {
t.Errorf("expected the file on disk, got %v", err)
}
got, err := repo.Get(created.ID)
if err != nil || got.SHA256 != created.SHA256 {
t.Errorf("Get returned %+v, %v", got, err)
}
})
t.Run("Get reports a missing image as ErrImageNotFound", func(t *testing.T) {
_, repo, _ := newMediaPersistence(t)
if _, err := repo.Get(9); !errors.Is(err, domainImage.ErrImageNotFound) {
t.Errorf("expected ErrImageNotFound, got %v", err)
}
})
t.Run("Delete removes the file and the row", func(t *testing.T) {
_, repo, uploadDir := newMediaPersistence(t)
created := createStoredImage(t, repo, "a.png", pngBytes(t, 3, 2), "image/png")
if err := repo.Delete(created.ID); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if _, err := os.Stat(filepath.Join(uploadDir, filepath.Base(created.Path))); !os.IsNotExist(err) {
t.Errorf("expected the file to be gone")
}
if _, err := repo.Get(created.ID); !errors.Is(err, domainImage.ErrImageNotFound) {
t.Errorf("expected the row to be gone, got %v", err)
}
})
t.Run("A file already missing from disk still deletes the row", func(t *testing.T) {
_, repo, uploadDir := newMediaPersistence(t)
created := createStoredImage(t, repo, "a.png", pngBytes(t, 3, 2), "image/png")
if err := os.Remove(filepath.Join(uploadDir, filepath.Base(created.Path))); err != nil {
t.Fatalf("failed to remove the file: %v", err)
}
if err := repo.Delete(created.ID); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if _, err := repo.Get(created.ID); !errors.Is(err, domainImage.ErrImageNotFound) {
t.Errorf("expected the row to be gone, got %v", err)
}
})
t.Run("List, FindByHash, Rename, ListDuplicates and Counts", func(t *testing.T) {
_, repo, _ := newMediaPersistence(t)
same := pngBytes(t, 3, 2)
first := createStoredImage(t, repo, "North-Wall.png", same, "image/png")
second := createStoredImage(t, repo, "copy.png", same, "image/png")
createStoredImage(t, repo, "other.png", pngBytes(t, 4, 4), "image/png")
found, err := repo.List(domainImage.SearchQuery{Search: "NORTH"})
if err != nil || len(found) != 1 || found[0].ID != first.ID {
t.Errorf("unexpected search result %+v, %v", found, err)
}
byHash, err := repo.List(domainImage.SearchQuery{Hash: sha256Hex(same)})
if err != nil || len(byHash) != 2 {
t.Errorf("unexpected hash result %+v, %v", byHash, err)
}
sameContent, err := repo.FindByHash(sha256Hex(same))
if err != nil || len(sameContent) != 2 || sameContent[1].ID != second.ID {
t.Errorf("unexpected FindByHash result %+v, %v", sameContent, err)
}
if none, err := repo.FindByHash(""); err != nil || len(none) != 0 {
t.Errorf("expected no result for an empty hash, got %+v, %v", none, err)
}
duplicates, err := repo.ListDuplicates()
if err != nil || len(duplicates) != 2 {
t.Errorf("unexpected duplicates %+v, %v", duplicates, err)
}
counts, err := repo.Counts()
if err != nil || counts.All != 3 || counts.DuplicateGroups != 1 {
t.Errorf("unexpected counts %+v, %v", counts, err)
}
renamed, err := repo.Rename(first.ID, "renamed.png")
if err != nil || renamed.Filename != "renamed.png" || renamed.Path != first.Path {
t.Errorf("unexpected rename result %+v, %v", renamed, err)
}
if _, err := repo.Rename(9999, "x.png"); !errors.Is(err, domainImage.ErrImageNotFound) {
t.Errorf("expected ErrImageNotFound, got %v", err)
}
})
}
func TestMediaArticleRepositoryFindsBodiesContainingAFilename(t *testing.T) {
db, _, _ := newMediaPersistence(t)
repo := persistence.NewArticleRepository(db)
if _, err := repo.Create(domainArticle.Article{Title: "Kitchen Renovation", Body: "see /static/uploadedImages/aaa.png"}); err != nil {
t.Fatalf("failed to seed article: %v", err)
}
if _, err := repo.Create(domainArticle.Article{Title: "Backyard Notes", Body: "nothing"}); err != nil {
t.Fatalf("failed to seed article: %v", err)
}
found, err := repo.FindByBodyContaining("aaa.png")
if err != nil || len(found) != 1 || found[0].Title != "Kitchen Renovation" {
t.Errorf("unexpected result %+v, %v", found, err)
}
if none, err := repo.FindByBodyContaining("zzz.png"); err != nil || none == nil || len(none) != 0 {
t.Errorf("expected an empty non-nil slice, got %+v, %v", none, err)
}
}
func TestMediaImageBackfill(t *testing.T) {
seedLegacyRow := func(t *testing.T, db *gorm.DB, name string) models.Image {
t.Helper()
row := models.Image{Path: "/static/uploadedImages/" + name}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("failed to seed a legacy row: %v", err)
}
return row
}
writeFile := func(t *testing.T, dir string, name string, content []byte) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), content, 0666); err != nil {
t.Fatalf("failed to write %s: %v", name, err)
}
}
t.Run("Existing rows get metadata and hash from the files on disk", func(t *testing.T) {
db, repo, uploadDir := newMediaPersistence(t)
png, svg := pngBytes(t, 3, 2), []byte(svgFixture)
writeFile(t, uploadDir, "one.png", png)
writeFile(t, uploadDir, "two.svg", svg)
pngRow, svgRow := seedLegacyRow(t, db, "one.png"), seedLegacyRow(t, db, "two.svg")
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
t.Fatalf("backfill failed: %v", err)
}
gotPng, _ := repo.Get(pngRow.ID)
if gotPng.Filename != "one.png" || gotPng.Size != int64(len(png)) || gotPng.ContentType != "image/png" || gotPng.Width != 3 || gotPng.Height != 2 || gotPng.SHA256 != sha256Hex(png) || gotPng.UploadedByID != 0 {
t.Errorf("unexpected png row %+v", gotPng)
}
gotSvg, _ := repo.Get(svgRow.ID)
if gotSvg.ContentType != "image/svg+xml" || gotSvg.Width != 0 || gotSvg.Height != 0 || gotSvg.SHA256 != sha256Hex(svg) {
t.Errorf("unexpected svg row %+v", gotSvg)
}
})
t.Run("A row whose file is missing does not stop the boot", func(t *testing.T) {
db, repo, uploadDir := newMediaPersistence(t)
row := seedLegacyRow(t, db, "gone.png")
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
t.Fatalf("backfill failed: %v", err)
}
got, _ := repo.Get(row.ID)
if got.Filename != "" || got.SHA256 != "" {
t.Errorf("expected the row to be left as is, got %+v", got)
}
})
t.Run("An unreadable file leaves its row as is and the others are still filled", func(t *testing.T) {
db, repo, uploadDir := newMediaPersistence(t)
writeFile(t, uploadDir, "broken.png", []byte("not an image"))
writeFile(t, uploadDir, "fine.png", pngBytes(t, 3, 2))
broken, fine := seedLegacyRow(t, db, "broken.png"), seedLegacyRow(t, db, "fine.png")
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
t.Fatalf("backfill failed: %v", err)
}
if got, _ := repo.Get(broken.ID); got.Filename != "" {
t.Errorf("expected the broken row to be left as is, got %+v", got)
}
if got, _ := repo.Get(fine.ID); got.Filename != "fine.png" {
t.Errorf("expected the fine row to be filled, got %+v", got)
}
})
t.Run("The backfill is idempotent", func(t *testing.T) {
db, repo, uploadDir := newMediaPersistence(t)
writeFile(t, uploadDir, "one.png", pngBytes(t, 3, 2))
row := seedLegacyRow(t, db, "one.png")
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
t.Fatalf("backfill failed: %v", err)
}
afterFirst, _ := repo.Get(row.ID)
if _, err := repo.Rename(row.ID, "renamed.png"); err != nil {
t.Fatal(err)
}
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
t.Fatalf("second backfill failed: %v", err)
}
afterSecond, _ := repo.Get(row.ID)
if afterSecond.Filename != "renamed.png" || afterSecond.SHA256 != afterFirst.SHA256 {
t.Errorf("expected the second run to change nothing, got %+v", afterSecond)
}
})
}
+1 -1
View File
@@ -129,7 +129,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase, getUserUseCase,
) )
+1 -1
View File
@@ -45,7 +45,7 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase, getUserUseCase,
) )
+1 -1
View File
@@ -27,7 +27,7 @@ func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUser
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase, getUserUseCase,
) )
-196
View File
@@ -1,196 +0,0 @@
package test
import (
"RenewCMS/api/handlers/image"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newRemoveArticleImageRouter(articleRepo *mockArticleRepo, imageRepo *mockImageRepo, token *jwtauth.JWTAuth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
imageHandler := image.NewHandler(
useCases.NewCreateImageUseCase(imageRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewDeleteImageUseCase(imageRepo),
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Delete("/{id}/image/{imageId}", imageHandler.DeleteImage)
})
})
})
})
return r
}
func setRemoveImageJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTAuth) {
t.Helper()
_, tokenString, err := token.Encode(map[string]any{"user_id": uint32(1)})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
}
func TestRemoveArticleImageHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
t.Run("Removing an image from an article it belongs to succeeds", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update", Images: []*domainImage.Image{{ID: 10}}}
imageRepo := newMockImageRepo()
imageRepo.images[10] = domainImage.Image{ID: 10, Path: "/static/uploadedImages/before.png"}
router := newRemoveArticleImageRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/1/image/10", nil)
setRemoveImageJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected status 204, got %d, body %q", rec.Code, rec.Body.String())
}
if rec.Body.Len() != 0 {
t.Errorf("expected empty body, got %q", rec.Body.String())
}
if _, ok := imageRepo.images[10]; ok {
t.Errorf("expected image 10 to have been removed")
}
})
t.Run("Removing an image from an unknown article answers 404 and never touches the image repository", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
imageRepo.images[10] = domainImage.Image{ID: 10, Path: "/static/uploadedImages/before.png"}
router := newRemoveArticleImageRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/9999/image/10", nil)
setRemoveImageJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", rec.Code)
}
if rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if len(imageRepo.deleteCalls) != 0 {
t.Errorf("expected Delete to never be called, got %d calls", len(imageRepo.deleteCalls))
}
if _, ok := imageRepo.images[10]; !ok {
t.Errorf("expected image 10 to still exist")
}
})
t.Run("Removing an unknown image from an existing article answers 404", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
imageRepo := newMockImageRepo()
router := newRemoveArticleImageRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/1/image/9999", nil)
setRemoveImageJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", rec.Code)
}
if rec.Body.String() != apperr.ImageNotFoundErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
})
t.Run("Removing an image id that belongs to a different article still succeeds, because the check is shallow", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
articleRepo.articles[2] = domainArticle.Article{ID: 2, Images: []*domainImage.Image{{ID: 10}}}
imageRepo := newMockImageRepo()
imageRepo.images[10] = domainImage.Image{ID: 10, Path: "/static/uploadedImages/before.png"}
router := newRemoveArticleImageRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/1/image/10", nil)
setRemoveImageJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected status 204, got %d, body %q", rec.Code, rec.Body.String())
}
if _, ok := imageRepo.images[10]; ok {
t.Errorf("expected image 10 to have been removed")
}
})
t.Run("Removing an image still requires authentication", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update", Images: []*domainImage.Image{{ID: 10}}}
imageRepo := newMockImageRepo()
imageRepo.images[10] = domainImage.Image{ID: 10, Path: "/static/uploadedImages/before.png"}
router := newRemoveArticleImageRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/1/image/10", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d", rec.Code)
}
if _, ok := imageRepo.images[10]; !ok {
t.Errorf("expected image 10 to still exist")
}
})
t.Run("A malformed id in the URL answers 400 before either repository is touched", func(t *testing.T) {
testCases := []struct {
name string
articleId string
imageId string
}{
{"malformed article id", "abc", "10"},
{"malformed image id", "1", "abc"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
router := newRemoveArticleImageRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/"+tc.articleId+"/image/"+tc.imageId, nil)
setRemoveImageJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rec.Code)
}
if rec.Body.String() != apperr.IdUint32ErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
})
}
})
}
@@ -0,0 +1,76 @@
package test
import (
"net/http"
"testing"
)
func TestRemoveLegacyArticleImageAliasHttp(t *testing.T) {
newEnv := func(t *testing.T) *mediaEnv {
env := newMediaEnv(t)
env.seedArticle(1, "Backyard Renovation Update", false, "unchanged body")
return env
}
t.Run("The removed routes are not found", func(t *testing.T) {
examples := []struct {
name string
method string
path string
}{
{"POST /v1/article/1/image", http.MethodPost, "/v1/article/1/image"},
{"DELETE /v1/article/1/image/10", http.MethodDelete, "/v1/article/1/image/10"},
}
for _, example := range examples {
t.Run(example.name, func(t *testing.T) {
env := newEnv(t)
req := newMultipartFileRequest(t, example.method, example.path, "before.png", "image/png", pngBytes(t, 3, 2))
rec := env.serve(t, req, mediaVerifiedUserId)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", rec.Code)
}
if env.images.createCalls != 0 {
t.Errorf("expected Create to never be called, got %d calls", env.images.createCalls)
}
if len(env.images.images) != 0 {
t.Errorf("expected no image to exist, got %d", len(env.images.images))
}
})
}
})
t.Run("Uploading still works through the media route", func(t *testing.T) {
env := newEnv(t)
req := newMultipartFileRequest(t, http.MethodPost, "/v1/media/", "before.png", "image/png", pngBytes(t, 3, 2))
rec := env.serve(t, req, mediaVerifiedUserId)
if rec.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d, body %q", rec.Code, rec.Body.String())
}
got := decodeObject(t, rec.Body.String())
if got["url"] == nil || numberOf(got["id"]) != 1 {
t.Errorf("expected a library item with a url and id 1, got %v", got)
}
listing := env.send(t, http.MethodGet, "/v1/media/", "", mediaVerifiedUserId)
items := decodeObject(t, listing.Body.String())["items"].([]any)
if len(items) != 1 {
t.Errorf("expected the list to hold the upload, got %v", items)
}
})
t.Run("The rest of the article router is unchanged", func(t *testing.T) {
env := newEnv(t)
rec := env.send(t, http.MethodPatch, "/v1/article/1/publish", "", mediaVerifiedUserId)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body %q", rec.Code, rec.Body.String())
}
if !env.articles.articles[1].IsOnline {
t.Errorf("expected article 1 to be online")
}
})
}
+6 -2
View File
@@ -136,7 +136,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo), GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo), ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, imageRepo), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo),
GetUserUseCase: useCases.NewGetUserUseCase(userRepo), GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"), CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher),
@@ -144,7 +144,11 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
ListUsersUseCase: useCases.NewListUsersUseCase(userRepo), ListUsersUseCase: useCases.NewListUsersUseCase(userRepo),
SendMailUseCase: useCases.NewSendMailUseCase(mailRepo), SendMailUseCase: useCases.NewSendMailUseCase(mailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo), CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo),
DeleteImageUseCase: useCases.NewDeleteImageUseCase(imageRepo), ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo),
} }
} }
+3 -3
View File
@@ -64,7 +64,7 @@ func TestServeUploadedImagesHttp(t *testing.T) {
if got := rec.Header().Get("Content-Type"); got != example.contentType { if got := rec.Header().Get("Content-Type"); got != example.contentType {
t.Fatalf("expected Content-Type %q, got %q", example.contentType, got) t.Fatalf("expected Content-Type %q, got %q", example.contentType, got)
} }
if got := rec.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" { if got := rec.Header().Get("Cache-Control"); got != "public, no-cache" {
t.Fatalf("unexpected Cache-Control: %q", got) t.Fatalf("unexpected Cache-Control: %q", got)
} }
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
@@ -96,7 +96,7 @@ func TestServeUploadedImagesHttp(t *testing.T) {
if got := rec.Header().Get("Content-Type"); got != "image/svg+xml" { if got := rec.Header().Get("Content-Type"); got != "image/svg+xml" {
t.Fatalf("expected Content-Type image/svg+xml, got %q", got) t.Fatalf("expected Content-Type image/svg+xml, got %q", got)
} }
if got := rec.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" { if got := rec.Header().Get("Cache-Control"); got != "public, no-cache" {
t.Fatalf("unexpected Cache-Control: %q", got) t.Fatalf("unexpected Cache-Control: %q", got)
} }
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
@@ -237,7 +237,7 @@ func TestServeUploadedImagesHttp(t *testing.T) {
if got := rec.Header().Get("Content-Type"); got != "image/webp" { if got := rec.Header().Get("Content-Type"); got != "image/webp" {
t.Fatalf("expected Content-Type image/webp, got %q", got) t.Fatalf("expected Content-Type image/webp, got %q", got)
} }
if got := rec.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" { if got := rec.Header().Get("Cache-Control"); got != "public, no-cache" {
t.Fatalf("unexpected Cache-Control: %q", got) t.Fatalf("unexpected Cache-Control: %q", got)
} }
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
+1 -1
View File
@@ -26,7 +26,7 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, imageRepo), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase, getUserUseCase,
) )
-359
View File
@@ -1,359 +0,0 @@
package test
import (
"RenewCMS/api/handlers/image"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newUploadArticleImageRouter(articleRepo *mockArticleRepo, imageRepo *mockImageRepo, token *jwtauth.JWTAuth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
imageHandler := image.NewHandler(
useCases.NewCreateImageUseCase(imageRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewDeleteImageUseCase(imageRepo),
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Post("/{id}/image", imageHandler.PostImage)
})
})
})
})
return r
}
func setUploadJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTAuth) {
t.Helper()
_, tokenString, err := token.Encode(map[string]any{"user_id": uint32(1)})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
}
func newMultipartImageUploadRequest(t *testing.T, url string, filename string, contentType string) *http.Request {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreatePart(map[string][]string{
"Content-Disposition": {`form-data; name="file"; filename="` + filename + `"`},
"Content-Type": {contentType},
})
if err != nil {
t.Fatalf("failed to create multipart part: %v", err)
}
if _, err := io.WriteString(part, "fake-image-data"); err != nil {
t.Fatalf("failed to write file content: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("failed to close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, url, body)
req.Header.Set("Content-Type", writer.FormDataContentType())
return req
}
func TestUploadArticleImageHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
t.Run("Uploading an allowed image type to an existing article succeeds", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
articleRepo.images[1] = &domainImage.Image{ID: 1, Path: "/static/uploadedImages/before.png"}
imageRepo := newMockImageRepo()
router := newUploadArticleImageRouter(articleRepo, imageRepo, token)
req := newMultipartImageUploadRequest(t, "/v1/article/1/image", "before.png", "image/png")
setUploadJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d, body %q", rec.Code, rec.Body.String())
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if got["location"] != "/static/uploadedImages/before.png" {
t.Errorf("expected location %q, got %q", "/static/uploadedImages/before.png", got["location"])
}
if len(imageRepo.images) != 1 {
t.Fatalf("expected the image repository to gain exactly one entry, got %d", len(imageRepo.images))
}
var storedImageID uint32
found := false
for id, img := range imageRepo.images {
if img.Path == "/static/uploadedImages/before.png" {
storedImageID = id
found = true
}
}
if !found {
t.Fatalf("expected the image repository to contain the new image")
}
gotId, ok := got["id"].(float64)
if !ok || uint32(gotId) != storedImageID {
t.Errorf("expected id %v, got %v", storedImageID, got["id"])
}
updatedArticle := articleRepo.articles[1]
imageAttached := false
for _, img := range updatedArticle.Images {
if img.ID == storedImageID {
imageAttached = true
}
}
if !imageAttached {
t.Errorf("expected article 1 to reference the new image's id, got %+v", updatedArticle.Images)
}
})
t.Run("Uploading to an unknown article answers 404 and creates no image", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
router := newUploadArticleImageRouter(articleRepo, imageRepo, token)
req := newMultipartImageUploadRequest(t, "/v1/article/9999/image", "before.png", "image/png")
setUploadJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", rec.Code)
}
if rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if len(imageRepo.images) != 0 {
t.Errorf("expected no image to be created, got %d", len(imageRepo.images))
}
})
t.Run("Uploading a disallowed content type answers 400 before the article is touched", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
imageRepo := newMockImageRepo()
router := newUploadArticleImageRouter(articleRepo, imageRepo, token)
req := newMultipartImageUploadRequest(t, "/v1/article/1/image", "notes.pdf", "application/pdf")
setUploadJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rec.Code)
}
if rec.Body.String() != "the file must be a PNG, JPEG, WEBP, or SVG image\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if len(imageRepo.images) != 0 {
t.Errorf("expected no image to be created, got %d", len(imageRepo.images))
}
if len(articleRepo.articles[1].Images) != 0 {
t.Errorf("expected article 1 to still have an empty Images slice, got %+v", articleRepo.articles[1].Images)
}
})
t.Run("Uploading an image still requires authentication", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
imageRepo := newMockImageRepo()
router := newUploadArticleImageRouter(articleRepo, imageRepo, token)
req := newMultipartImageUploadRequest(t, "/v1/article/1/image", "before.png", "image/png")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d", rec.Code)
}
if len(imageRepo.images) != 0 {
t.Errorf("expected no image to be created, got %d", len(imageRepo.images))
}
if len(articleRepo.articles[1].Images) != 0 {
t.Errorf("expected article 1 to still have an empty Images slice, got %+v", articleRepo.articles[1].Images)
}
})
t.Run("Uploading with a non-numeric article id answers 400", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
router := newUploadArticleImageRouter(articleRepo, imageRepo, token)
req := newMultipartImageUploadRequest(t, "/v1/article/abc/image", "before.png", "image/png")
setUploadJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rec.Code)
}
if rec.Body.String() != apperr.IdUint32ErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
})
t.Run("Uploading without a file field answers 400", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
imageRepo := newMockImageRepo()
router := newUploadArticleImageRouter(articleRepo, imageRepo, token)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
if err := writer.Close(); err != nil {
t.Fatalf("failed to close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/v1/article/1/image", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
setUploadJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rec.Code)
}
if len(imageRepo.images) != 0 {
t.Errorf("expected no image to be created, got %d", len(imageRepo.images))
}
})
t.Run("A repository failure while fetching the article answers 500", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
articleRepo.fail.failNext("Get")
imageRepo := newMockImageRepo()
router := newUploadArticleImageRouter(articleRepo, imageRepo, token)
req := newMultipartImageUploadRequest(t, "/v1/article/1/image", "before.png", "image/png")
setUploadJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", rec.Code)
}
})
t.Run("A repository failure while creating the image answers 500", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
imageRepo := newMockImageRepo()
imageRepo.fail.failNext("Create")
router := newUploadArticleImageRouter(articleRepo, imageRepo, token)
req := newMultipartImageUploadRequest(t, "/v1/article/1/image", "before.png", "image/png")
setUploadJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", rec.Code)
}
})
t.Run("Uploading a file over the size limit answers 400", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
imageRepo := newMockImageRepo()
router := newUploadArticleImageRouter(articleRepo, imageRepo, token)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreatePart(map[string][]string{
"Content-Disposition": {`form-data; name="file"; filename="huge.png"`},
"Content-Type": {"image/png"},
})
if err != nil {
t.Fatalf("failed to create multipart part: %v", err)
}
if _, err := io.Copy(part, strings.NewReader(strings.Repeat("a", int(domainImage.MaxImageSize)+1))); err != nil {
t.Fatalf("failed to write file content: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("failed to close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/v1/article/1/image", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
setUploadJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rec.Code)
}
if rec.Body.String() != domainImage.ErrImageTooLarge.Error()+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if len(imageRepo.images) != 0 {
t.Errorf("expected no image to be created, got %d", len(imageRepo.images))
}
})
t.Run("A repository failure while attaching the image to the article answers 500", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
articleRepo.fail.failNext("AddImage")
imageRepo := newMockImageRepo()
router := newUploadArticleImageRouter(articleRepo, imageRepo, token)
req := newMultipartImageUploadRequest(t, "/v1/article/1/image", "before.png", "image/png")
setUploadJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", rec.Code)
}
})
t.Run("AddImage reporting a missing article after the initial lookup still answers 404", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
articleRepo.fail.failNextWith("AddImage", domainArticle.ErrArticleNotFound)
imageRepo := newMockImageRepo()
router := newUploadArticleImageRouter(articleRepo, imageRepo, token)
req := newMultipartImageUploadRequest(t, "/v1/article/1/image", "before.png", "image/png")
setUploadJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", rec.Code)
}
if rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
})
}
+14 -44
View File
@@ -5,6 +5,7 @@ import (
imageDomain "RenewCMS/internal/domain/image" imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors" "errors"
"fmt" "fmt"
"reflect" "reflect"
@@ -236,7 +237,6 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
newRepo := func() *mockArticleRepo { newRepo := func() *mockArticleRepo {
repo := newMockArticleRepo() repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1, Title: "Existing"} repo.articles[1] = article.Article{ID: 1, Title: "Existing"}
repo.images[1] = &imageDomain.Image{ID: 1, Path: "/static/one.png"}
return repo return repo
} }
@@ -281,47 +281,13 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err) t.Errorf("expected article.ErrArticleNotFound, got %v", err)
} }
}) })
t.Run("AddImage_article1_image1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
err := uc.AddImage(1, 1)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
updated, err := repo.Get(1)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(updated.Images) != 1 || updated.Images[0].ID != 1 {
t.Errorf("expected image 1 attached, got %+v", updated.Images)
}
})
t.Run("AddImage_article9_image1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
err := uc.AddImage(9, 1)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
})
t.Run("AddImage_article1_image9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
err := uc.AddImage(1, 9)
if !errors.Is(err, imageDomain.ErrImageNotFound) {
t.Errorf("expected image.ErrImageNotFound, got %v", err)
}
})
} }
func TestDeleteArticleReportsMissingArticle(t *testing.T) { func TestDeleteArticleReportsMissingArticle(t *testing.T) {
t.Run("id_1", func(t *testing.T) { t.Run("id_1", func(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1} repo.articles[1] = article.Article{ID: 1}
uc := useCases.NewDeleteArticleUseCase(repo, newMockImageRepo()) uc := useCases.NewDeleteArticleUseCase(repo)
err := uc.DeleteArticle(1) err := uc.DeleteArticle(1)
if err != nil { if err != nil {
@@ -332,7 +298,7 @@ func TestDeleteArticleReportsMissingArticle(t *testing.T) {
t.Run("id_9", func(t *testing.T) { t.Run("id_9", func(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1} repo.articles[1] = article.Article{ID: 1}
uc := useCases.NewDeleteArticleUseCase(repo, newMockImageRepo()) uc := useCases.NewDeleteArticleUseCase(repo)
err := uc.DeleteArticle(9) err := uc.DeleteArticle(9)
if !errors.Is(err, article.ErrArticleNotFound) { if !errors.Is(err, article.ErrArticleNotFound) {
@@ -345,7 +311,7 @@ func TestDeleteArticlePropagatesRepositoryFailure(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1} repo.articles[1] = article.Article{ID: 1}
repo.fail.failNext("Delete") repo.fail.failNext("Delete")
uc := useCases.NewDeleteArticleUseCase(repo, newMockImageRepo()) uc := useCases.NewDeleteArticleUseCase(repo)
err := uc.DeleteArticle(1) err := uc.DeleteArticle(1)
if !errors.Is(err, errRepositoryFailure) { if !errors.Is(err, errRepositoryFailure) {
@@ -370,8 +336,12 @@ func TestCreateImageDelegatesContentTypeCheck(t *testing.T) {
repo := newMockImageRepo() repo := newMockImageRepo()
uc := useCases.NewCreateImageUseCase(repo) uc := useCases.NewCreateImageUseCase(repo)
content := []byte("fake-image-data")
if tc.valid {
content = validBytesFor(t, tc.contentType)
}
result, err := uc.CreateImage(imageDomain.ImageInput{ result, err := uc.CreateImage(imageDomain.ImageInput{
Content: strings.NewReader("fake-image-data"), Content: bytes.NewReader(content),
Filename: "test.img", Filename: "test.img",
ContentType: tc.contentType, ContentType: tc.contentType,
}) })
@@ -401,7 +371,7 @@ func TestCreateImagePropagatesRepositoryFailure(t *testing.T) {
uc := useCases.NewCreateImageUseCase(repo) uc := useCases.NewCreateImageUseCase(repo)
_, err := uc.CreateImage(imageDomain.ImageInput{ _, err := uc.CreateImage(imageDomain.ImageInput{
Content: strings.NewReader("data"), Content: bytes.NewReader(pngBytes(t, 3, 2)),
Filename: "test.png", Filename: "test.png",
ContentType: "image/png", ContentType: "image/png",
}) })
@@ -415,9 +385,9 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
t.Run("id_1", func(t *testing.T) { t.Run("id_1", func(t *testing.T) {
repo := newMockImageRepo() repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1} repo.images[1] = imageDomain.Image{ID: 1}
uc := useCases.NewDeleteImageUseCase(repo) uc := useCases.NewDeleteMediaUseCase(repo)
err := uc.DeleteImage(1) err := uc.Delete(1)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -429,9 +399,9 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
t.Run("id_9", func(t *testing.T) { t.Run("id_9", func(t *testing.T) {
repo := newMockImageRepo() repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1} repo.images[1] = imageDomain.Image{ID: 1}
uc := useCases.NewDeleteImageUseCase(repo) uc := useCases.NewDeleteMediaUseCase(repo)
err := uc.DeleteImage(9) err := uc.Delete(9)
if !errors.Is(err, imageDomain.ErrImageNotFound) { if !errors.Is(err, imageDomain.ErrImageNotFound) {
t.Errorf("expected image.ErrImageNotFound, got %v", err) t.Errorf("expected image.ErrImageNotFound, got %v", err)
} }
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff. Show more