Compare commits

...
47 changed files with 4911 additions and 201 deletions

No files matched your search

+34 -2
View File
@@ -17,13 +17,15 @@ 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) *Handler {
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,
}
}
@@ -78,7 +80,7 @@ func (h *Handler) PostImage(w http.ResponseWriter, r *http.Request) {
return
}
responseJson, err := json.Marshal(map[string]any{"location": newImage.Path})
responseJson, err := json.Marshal(map[string]any{"id": newImage.ID, "location": newImage.Path})
if err != nil {
writeInternalServerError(w)
return
@@ -87,3 +89,33 @@ func (h *Handler) PostImage(w http.ResponseWriter, r *http.Request) {
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)
}
+1
View File
@@ -4,6 +4,7 @@ const LogsErrorMessage = "Access to the requested resource is forbidden due to i
const BodyErrorMessage = "The request cannot be processed due to a mismatch in the format of the body."
const IdUint32ErrorMessage = "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)."
const ArticleNotFoundErrorMessage = "Article not found"
const ImageNotFoundErrorMessage = "Image not found"
const ArticleTitleErrorMessage = "Article title must be between 3 and 200 characters."
const RegistrationClosedErrorMessage = "You are not allowed to create a user. Log in or reset database."
const RegistrationFailedErrorMessage = "Registration failed. Please try again later."
+2 -1
View File
@@ -33,7 +33,7 @@ func NewArticleRouter(container *UseCases) http.Handler {
r := chi.NewRouter()
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase)
imageHandler := image.NewHandler(container.CreateImageUseCase, container.GetArticleUseCase, container.UpdateArticleUseCase)
imageHandler := image.NewHandler(container.CreateImageUseCase, container.GetArticleUseCase, container.UpdateArticleUseCase, container.DeleteImageUseCase)
r.Group(func(r chi.Router) {
r.Get("/", articleHandler.ListArticles)
@@ -49,6 +49,7 @@ func NewArticleRouter(container *UseCases) http.Handler {
r.Patch("/{id}/publish", articleHandler.PublishArticle)
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
r.Post("/{id}/image", imageHandler.PostImage)
r.Delete("/{id}/image/{imageId}", imageHandler.DeleteImage)
})
return r
}
+1 -1
View File
@@ -28,7 +28,7 @@ The visual spec of record for the back-office SPA and the transactional emails,
| [Home](screens/home.md) | `/home` | 4 | `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` |
| [Editor](screens/editor.md) | `/article/:id/edit` | 19 | `source/RenewCMS Screens Back-office.dc.html` |
| [Editor](screens/editor.md) | `/article/:id/edit` | 22 | `source/RenewCMS Screens Back-office.dc.html` |
| [Integration](screens/integration.md) | `/integration` | 2 | `source/RenewCMS Screens Back-office.dc.html` |
| [Global](screens/global.md) | `any route` | 4 | `source/RenewCMS Screens Back-office.dc.html` |
+19 -3
View File
@@ -252,7 +252,7 @@ Responsive Below lg , the details panel becomes a right slideover, opened with i
### ImageUploader
Opens from the editor's image button in a small modal. Files are checked in the browser before anything is sent. After a successful upload the image goes in at the cursor and the modal closes.
Opens from the editor's image button in a small modal. Files are checked in the browser before anything is sent. A JPEG, PNG or WEBP then goes through a crop-and-resize step before it uploads; an SVG skips straight to uploading. After a successful upload the image goes in at the cursor and the modal closes.
Drop an image here Choose file PNG, JPEG, WEBP, SVG · max 10 MB
@@ -262,9 +262,21 @@ Drop to upload
dragging over
north-wall.jpg 62%
Crop and resize north-wall.jpg · 3.9 MB
Uploading 2.4 of 3.9 MB
crop, default: whole image selected, width "1600 px"
Crop and resize north-wall.jpg · 3.9 MB
crop, ratio locked to 16:9
Crop and resize north-wall.jpg · 3.9 MB
crop, crop narrower than the chosen width
north-wall.jpg 43%
Uploading 180 of 420 KB
uploading
@@ -282,6 +294,10 @@ server error
Built from UModal title="Add an image" › UFileUpload accept="image/png,image/jpeg,image/webp,image/svg+xml" :multiple="false" layout="list" + UProgress size="xs" . Uploads go through XMLHttpRequest , because fetch has no upload progress, as FormData with the field name file . The 201 location is inserted with insertImage(location, '') .
Crop and resize A JPEG, PNG or WEBP opens on a 640px-wide modal (`md.crop`) between the file picker and the upload, replacing the "Add an image" title with "Crop and resize" and the filename/size as the description. The stage shows the image with the crop frame starting as the whole image: a rule-of-thirds grid (lines at 33.3 %/66.6 %), four 12×12 px corner handles, and a pixel-size label (mono, bottom-left, dark pill) that tracks the frame's current size. The frame is `tabindex="0"`, `aria-label="Crop area. Arrow keys move it, Shift and arrow keys resize it."`; dragging moves it, dragging a handle resizes it, arrow keys nudge it by 1 px and Shift+arrow keys resize it.
Aspect ratio is a `radiogroup` of five pills — Original, Free, 16:9, 4:3, 1:1 — plus Rotate and Reset actions above it. Width is a second `radiogroup` — Original, 1600 px (default), 1200 px, 800 px. A mono line below reads "Uploads as {width} × {height} px · about {size}", where the estimated size is shown in KB under 1 MB and in MB above it. When the chosen width is larger than the crop's own width, the output stays at the crop's size (never upscaled) and an info row explains why: "The crop is {crop width} px wide, so it's uploaded at that size. Enlarging it would make it blurry." The footer becomes "Choose another file" (back to the file picker, `data-act="upload-pick"` from `idle`) and "Upload image" (starts the upload with the cropped, resized file). SVGs never see this step: they upload as soon as they're chosen. The "in progress" state afterwards reports the resized file's size, e.g. "Uploading 180 of 420 KB".
Validation Before uploading, the uploader checks both the MIME type and the file extension, and that size ≤ 10 × 1024 × 1024 . The size error names the file size: "north-wall.tif is 14.2 MB. Images can be up to 10 MB." A server 400 shows the same message as a client-side rejection.
Props, emits articleId: number . Emits uploaded(location) , close . A 401 hands off to the global session flow. A 404 means the article was deleted, which the editor handles.
+39 -2
View File
@@ -83,13 +83,50 @@ On mobile, Save and Publish sit in a sticky bottom bar and the details panel ope
- Editor: status "draft", save "dirty"
- Mobile: leading arrow-left button, trailing ellipsis
### Upload: crop and resize
**Trigger:** A JPEG, PNG or WEBP passed the checks, and nothing is sent yet. The crop starts as the whole image and the width defaults to 1600 px. SVGs skip this step.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Modal: **Crop and resize** "north-wall.jpg · 3.9 MB"
- crop: ratio "Original", width "1600 px"
- cancel "Choose another file"
- confirm "Upload image"
- Editor: status "draft", save "dirty"
- Mobile: leading arrow-left button, trailing ellipsis
### Upload: crop to a ratio
**Trigger:** 16:9 chosen. The frame keeps the ratio while it's dragged or resized. Arrow keys move it; Shift and arrow keys resize it.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Modal: **Crop and resize** "north-wall.jpg · 3.9 MB"
- crop: ratio "16:9", width "1600 px"
- cancel "Choose another file"
- confirm "Upload image"
- Editor: status "draft", save "dirty"
- Mobile: leading arrow-left button, trailing ellipsis
### Upload: crop narrower than the width
**Trigger:** The crop is smaller than the chosen width, so it keeps its own size.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Modal: **Crop and resize** "north-wall.jpg · 3.9 MB"
- crop: ratio "1:1", width "1600 px"
- note: "The crop is 640 px wide, so it's uploaded at that size. Enlarging it would make it blurry."
- cancel "Choose another file"
- confirm "Upload image"
- Editor: status "draft", save "dirty"
- Mobile: leading arrow-left button, trailing ellipsis
### Upload: in progress
**Trigger:** A valid file is uploading. Closing the dialog cancels the upload.
**Trigger:** Upload image was pressed. The cropped, resized file is uploading. Closing the dialog cancels the upload.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Modal: **Add an image**
- upload state: state "uploading"
- upload state: state "uploading" ("Uploading 180 of 420 KB")
- cancel "Cancel upload"
- Editor: status "draft", save "dirty"
- Mobile: leading arrow-left button, trailing ellipsis
+59 -4
View File
@@ -128,7 +128,7 @@
<span data-act="preview" style="display:flex;align-items:center;gap:8px;white-space:nowrap;flex-shrink:0;color:var(--ui-text-toned)"><span style="width:32px;height:18px;padding:2px;display:flex;justify-content:{{ pvJust }};background:{{ pvBg }}"><span style="width:14px;height:14px;background:var(--ui-bg)"></span></span>Preview</span>
<span data-act="save" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;white-space:nowrap;flex-shrink:0;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500;opacity:{{ saveOp }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/save.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/save.svg') center / contain no-repeat"></span>Save</span>
<span data-act="publish" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;white-space:nowrap;flex-shrink:0;font-weight:500;background:{{ pub.bg }};color:{{ pub.fg }};border:1px solid {{ pub.bd }};opacity:{{ pub.op }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ pub.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ pub.icon }}.svg') center / contain no-repeat"></span>{{ pub.t }}</span>
<span data-act="overflow" style="width:32px;height:32px;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat"></span></span>
<span data-act="overflow" role="button" aria-label="Delete article" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;white-space:nowrap;flex-shrink:0;font-weight:500;border:1px solid var(--ui-error);color:var(--ui-error);cursor:pointer"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat"></span>Delete</span>
</sc-if>
</sc-if>
</div>
@@ -214,6 +214,41 @@
<sc-if value="{{ s.modal.upload }}">
<div data-act="upload-pick" style="height:180px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:16px;text-align:center;border:{{ up.bd }};background:{{ up.bg }};color:{{ up.fg }}"><span aria-hidden="true" style="display:inline-block;width:24px;height:24px;background-color:{{ up.ic }};-webkit-mask:url('https://api.iconify.design/lucide/{{ up.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ up.icon }}.svg') center / contain no-repeat"></span><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ up.title }}</span><sc-if value="{{ up.progress }}"><div style="width:80%;height:4px;background:var(--ui-bg-accented)"><div style="width:{{ up.progress }};height:4px;background:var(--ui-primary)"></div></div></sc-if><span style="color:var(--ui-text-toned);font-size:12px;max-width:300px">{{ up.text }}</span></div>
</sc-if>
<sc-if value="{{ s.modal.crop }}">
<div style="display:flex;flex-direction:column;gap:16px">
<div style="height:{{ cr.stageH }};padding:16px;background:var(--ui-bg-elevated);display:flex;align-items:center;justify-content:center">
<div role="img" aria-label="north-wall.jpg, 4032 × 3024 px" style="position:relative;height:100%;max-width:100%;aspect-ratio:4/3;overflow:hidden;background:#d8d0c3">
<div style="position:absolute;left:0;right:0;bottom:0;height:32%;background:#8f8373"></div>
<div style="position:absolute;left:56%;top:14%;width:22%;height:42%;background:#f1ece3;border:6px solid #c4b9a8"></div>
<div tabindex="0" aria-label="Crop area. Arrow keys move it, Shift and arrow keys resize it." style="position:absolute;left:{{ cr.l }};top:{{ cr.t }};width:{{ cr.w }};height:{{ cr.h }};box-shadow:0 0 0 2000px rgb(10 10 10 / 0.55);outline:1px solid #ffffff;cursor:move">
<div style="position:absolute;left:33.333%;top:0;bottom:0;width:1px;background:rgb(255 255 255 / 0.45)"></div>
<div style="position:absolute;left:66.666%;top:0;bottom:0;width:1px;background:rgb(255 255 255 / 0.45)"></div>
<div style="position:absolute;top:33.333%;left:0;right:0;height:1px;background:rgb(255 255 255 / 0.45)"></div>
<div style="position:absolute;top:66.666%;left:0;right:0;height:1px;background:rgb(255 255 255 / 0.45)"></div>
<div style="position:absolute;left:-6px;top:-6px;width:12px;height:12px;background:#ffffff;border:1px solid #111111"></div>
<div style="position:absolute;right:-6px;top:-6px;width:12px;height:12px;background:#ffffff;border:1px solid #111111"></div>
<div style="position:absolute;left:-6px;bottom:-6px;width:12px;height:12px;background:#ffffff;border:1px solid #111111"></div>
<div style="position:absolute;right:-6px;bottom:-6px;width:12px;height:12px;background:#ffffff;border:1px solid #111111"></div>
<span style="position:absolute;left:8px;bottom:8px;padding:1px 6px;background:rgb(17 17 17 / 0.8);color:#ffffff;font:400 11px/18px 'IBM Plex Mono',monospace;white-space:nowrap">{{ cr.dims }}</span>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:6px">
<div style="display:flex;align-items:center;gap:4px"><span style="flex:1;font-weight:500;color:var(--ui-text-highlighted)">Aspect ratio</span><span data-act="crop-rotate" style="height:{{ btnH }};padding:0 8px;display:flex;align-items:center;gap:6px;color:var(--ui-text-toned);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/rotate-ccw-square.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/rotate-ccw-square.svg') center / contain no-repeat"></span>Rotate</span><span data-act="crop-reset" style="height:{{ btnH }};padding:0 8px;display:flex;align-items:center;gap:6px;color:var(--ui-text-toned);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/undo-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/undo-2.svg') center / contain no-repeat"></span>Reset</span></div>
<div role="radiogroup" aria-label="Aspect ratio" style="display:flex;border:1px solid var(--ui-border-accented);width:{{ cr.groupW }}">
<sc-for list="{{ cr.ratios }}" as="p" hint-placeholder-count="5"><span role="radio" aria-checked="{{ p.on }}" data-act="{{ p.act }}" style="flex:{{ cr.pillFlex }};height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;font-weight:500;white-space:nowrap;background:{{ p.bg }};color:{{ p.fg }}">{{ p.t }}</span></sc-for>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:6px">
<span style="font-weight:500;color:var(--ui-text-highlighted)">Width</span>
<div role="radiogroup" aria-label="Width" style="display:flex;border:1px solid var(--ui-border-accented);width:{{ cr.groupW }}">
<sc-for list="{{ cr.sizes }}" as="p" hint-placeholder-count="4"><span role="radio" aria-checked="{{ p.on }}" data-act="{{ p.act }}" style="flex:{{ cr.pillFlex }};height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;font-weight:500;white-space:nowrap;background:{{ p.bg }};color:{{ p.fg }}">{{ p.t }}</span></sc-for>
</div>
<span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ cr.out }}</span>
</div>
<sc-if value="{{ cr.note }}"><div role="status" style="display:flex;gap:8px;padding:10px 12px;background:var(--rc-info-soft)"><span style="display:flex;padding-top:2px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ cr.note }}</span></div></sc-if>
</div>
</sc-if>
</div>
<div style="padding:20px 24px;display:flex;flex-direction:{{ mBtnDir }};justify-content:flex-end;gap:8px">
<sc-if value="{{ s.modal.cancel }}"><span data-act="modal-cancel" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:{{ mCancelFg }};font-weight:500">{{ s.modal.cancel }}</span></sc-if>
@@ -264,12 +299,32 @@ class Component extends DCLogic {
const mFields = (md.fields || []).map(f => { const has = f.value; return { ...f, shown: has || f.ph || '', fg: md.loading ? 'var(--ui-text-dimmed)' : has ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', bd: f.err ? 'var(--ui-error)' : f.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)', sh: f.err ? 'inset 0 0 0 1px var(--ui-error)' : f.focus ? 'inset 0 0 0 1px var(--ui-primary)' : 'none', bg: md.loading ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)', h: f.area ? (m ? '120px' : '96px') : (m ? '44px' : '40px'), p: f.area ? '8px 10px' : '0 10px', ai: f.area ? 'flex-start' : 'center', countFg: f.countErr ? 'var(--ui-error)' : 'var(--ui-text-muted)' } })
const U = md.upload || {}
const upMap = {
uploading: { icon: 'image-plus', ic: 'var(--ui-text-toned)', title: 'north-wall.jpg', text: 'Uploading 2.4 of 3.9 MB', progress: '62%', bd: '1px solid var(--ui-border)', bg: 'var(--ui-bg)' },
uploading: { icon: 'image-plus', ic: 'var(--ui-text-toned)', title: 'north-wall.jpg', text: 'Uploading 180 of 420 KB', progress: '43%', bd: '1px solid var(--ui-border)', bg: 'var(--ui-bg)' },
rejected: { icon: 'file-x', ic: 'var(--ui-error)', title: 'north-wall.tif is 14.2 MB', text: 'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.', bd: '1px solid var(--ui-error)', bg: 'var(--ui-bg)' },
idle: { icon: 'image-plus', ic: 'var(--ui-text-toned)', title: 'Drop an image here, or choose a file', text: 'PNG, JPEG, WEBP, SVG · max 10 MB', bd: '1px dashed var(--ui-border-accented)', bg: 'var(--ui-bg)' },
error: { icon: 'circle-x', ic: 'var(--ui-error)', title: 'Upload failed', text: 'The server didn\'t accept the file. Your article hasn\'t changed.', bd: '1px solid var(--ui-error)', bg: 'var(--ui-bg)' }
}
const up = upMap[U.state || 'idle'] || upMap.idle
let cr = {}
if (md.crop) {
const C = md.crop, IW = 4032, IH = 3024
const ri = C.ratio || 0, si = C.size ?? 1
const presets = [[0, 0, 100, 100], [6, 8, 88, 78], [0, 12.5, 100, 75], [0, 0, 100, 100], [12.5, 0, 75, 100]]
const r = C.rect || presets[ri]
const cw = Math.round(IW * r[2] / 100), ch = Math.round(IH * r[3] / 100)
const target = [cw, 1600, 1200, 800][si]
const ow = Math.min(target, cw), oh = Math.round(ch * ow / cw)
const kb = Math.round(ow * oh * 0.22 / 1000)
const pill = (pre, cur) => (t, i) => ({ t, on: i === cur, act: pre + i, bg: i === cur ? 'var(--ui-primary)' : 'transparent', fg: i === cur ? 'var(--ui-bg)' : 'var(--ui-text-toned)' })
cr = {
l: r[0] + '%', t: r[1] + '%', w: r[2] + '%', h: r[3] + '%', dims: fmt(cw) + ' × ' + fmt(ch),
ratios: ['Original', 'Free', '16:9', '4:3', '1:1'].map(pill('crop-r-', ri)),
sizes: ['Original', '1600 px', '1200 px', '800 px'].map(pill('crop-s-', si)),
out: 'Uploads as ' + fmt(ow) + ' × ' + fmt(oh) + ' px · about ' + (kb >= 1000 ? (kb / 1000).toFixed(1) + ' MB' : kb + ' KB'),
note: target > cw ? 'The crop is ' + cw + ' px wide, so it\'s uploaded at that size. Enlarging it would make it blurry.' : '',
stageH: m ? '240px' : '340px', pillFlex: m ? '1 1 0' : '0 0 auto', groupW: m ? '100%' : 'max-content'
}
}
const toasts = (s.toasts || []).map(t => ({ ...t, fg: 'var(--ui-' + t.tone + ')' }))
const endpoints = [
{ path: '/v1/article/', desc: 'Lists every online article.', l1: 'var(--ui-primary)', code: "const res = await fetch('https://cms.renew.example/v1/article/')\nconst articles = await res.json()" },
@@ -281,7 +336,7 @@ class Component extends DCLogic {
const edReady = k === 'editor' && !e.loading && !e.notfound
const edTitle = e.title || 'How we price a bathroom renovation'
return {
s, nav, crumbs, edTitle, edBody: e.body || '', sampleBody: !e.body, tabs, rows, save, counter, pub, mFields, up, toasts, endpoints, toolbar,
cr, s, nav, crumbs, edTitle, edBody: e.body || '', sampleBody: !e.body, tabs, rows, save, counter, pub, mFields, up, toasts, endpoints, toolbar,
desk: !m, mob: m, notBoot: !s.boot,
fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row',
mLead: s.mLead || 'menu',
@@ -302,7 +357,7 @@ class Component extends DCLogic {
saveOp: (dirty && !blocked) ? '1' : '0.4', imgCount: e.imgs != null ? String(e.imgs) : (e.image ? '4' : '3'), apiNote: online ? 'Live. Your website can read this article.' : 'Returns 404 until the article is online.',
copyIcon: s.copied ? 'check' : 'copy', copyText: s.copied ? 'Copied' : 'Copy', copyFg: s.copied ? 'var(--ui-success)' : 'var(--ui-text-highlighted)',
errPad: m ? '40px 20px' : '64px 48px',
modalAlign: m ? 'flex-end' : 'flex-start', modalPad: m ? '16px' : '140px 16px 16px', modalW: m ? '100%' : (md.wide ? '560px' : '460px'), mBtnDir: m ? 'column-reverse' : 'row',
modalAlign: m ? 'flex-end' : 'flex-start', modalPad: m ? '16px' : (md.crop ? '56px 16px 16px' : '140px 16px 16px'), modalW: m ? '100%' : (md.crop ? '640px' : md.wide ? '560px' : '460px'), mBtnDir: m ? 'column-reverse' : 'row',
mConfirmBg: md.tone === 'danger' ? 'var(--ui-error)' : 'var(--ui-primary)', mCancelFg: md.loading ? 'var(--ui-text-dimmed)' : 'var(--ui-text-highlighted)',
hasToasts: toasts.length > 0, tL: m ? '16px' : 'auto', tR: m ? '16px' : '24px', tB: m ? '16px' : '24px', tW: m ? 'auto' : '356px'
}
@@ -216,7 +216,7 @@
<span aria-live="polite" style="display:flex;align-items:center;gap:6px;color:var(--ui-warning)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-dot.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-dot.svg') center / contain no-repeat"></span>Unsaved changes</span>
<span style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/save.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/save.svg') center / contain no-repeat"></span>Save</span>
<span style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Save and publish</span>
<span style="width:32px;height:32px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat"></span></span>
<span role="button" aria-label="Delete article" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-error);color:var(--ui-error);font-weight:500;cursor:pointer"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat"></span>Delete</span>
</div>
<div role="toolbar" aria-label="Formatting" style="padding:6px 16px;display:flex;align-items:center;gap:2px;border-bottom:1px solid var(--ui-border);overflow-x:auto;background:var(--ui-bg)">
<sc-for list="{{ toolbar }}" as="t" hint-placeholder-count="24">
@@ -294,7 +294,14 @@ class Component extends DCLogic {
return
}
if (m.type === 'upload') {
if (m.state === 'crop' && typeof a === 'string') {
if (a === 'modal-cancel') return this.setState({ modal: { ...m, state: 'idle' } })
if (a.startsWith('crop-r-')) return this.setState({ modal: { ...m, ratio: +a.slice(7) } })
if (a.startsWith('crop-s-')) return this.setState({ modal: { ...m, size: +a.slice(7) } })
if (a === 'crop-reset') return this.setState({ modal: { ...m, ratio: 0, size: 1 } })
}
if (a === 'modal-cancel' || a === 'modal-close') return close()
if (a === 'upload-pick' && m.state === 'idle') return this.setState({ modal: { ...m, state: 'crop', ratio: 0, size: 1 } })
if ((a === 'upload-pick' || a === 'modal-confirm') && m.state !== 'uploading') {
this.setState({ modal: { ...m, state: 'uploading' } })
this.later(() => {
@@ -334,7 +341,7 @@ class Component extends DCLogic {
if (s.route === 'integration') return { kind: 'integration', ...T('integration', ['Integration']), mTitle: 'Integration', copied: s.copied }
if (s.route === 'editor') {
const a = this.art(s.cur) || {}, e = s.ed || {}
return { kind: 'editor', ...T('articles', ['Articles', a.t]), mTitle: 'Edit article', mLead: 'arrow-left', mAction: 'ellipsis', ed: { ...e, title: a.t, imgs: (a.imgs || 0) + (e.image ? 1 : 0) }, modal: this.modalS() }
return { kind: 'editor', ...T('articles', ['Articles', a.t]), mTitle: 'Edit article', mLead: 'arrow-left', mAction: 'trash-2', ed: { ...e, title: a.t, imgs: (a.imgs || 0) + (e.image ? 1 : 0) }, modal: this.modalS() }
}
return { kind: 'home', home: { sub: '' } }
}
@@ -347,6 +354,7 @@ class Component extends DCLogic {
const imgs = a.imgs === 0 ? '' : a.imgs === 1 ? ' and its image' : ' and its ' + a.imgs + ' images'
return { title: 'Delete this article?', desc: '"' + a.t + '"' + imgs + ' will be deleted. This can\'t be undone.', cancel: 'Cancel', confirm: m.loading ? 'Deleting…' : 'Delete article', tone: 'danger', loading: m.loading }
}
if (m.type === 'upload' && m.state === 'crop') return { title: 'Crop and resize', desc: 'north-wall.jpg · 3.9 MB', close: true, crop: { ratio: m.ratio, size: m.size }, cancel: 'Choose another file', confirm: 'Upload image' }
if (m.type === 'upload') return { title: 'Add an image', close: true, upload: { state: m.state }, cancel: m.state === 'uploading' ? 'Cancel upload' : 'Cancel' }
if (m.type === 'leave') { const a = this.art(this.state.cur) || {}; return { title: 'Leave without saving?', desc: 'Your changes to "' + a.t + '" haven\'t been saved.', cancel: 'Discard changes', confirm: 'Keep editing' } }
return null
@@ -93,6 +93,7 @@ class Component extends DCLogic {
const nf = (title, body, extra = {}) => [{ label: 'Title', value: title, count: [...title].length + ' / 200', ...(extra.t || {}) }, { label: 'First paragraph', value: body, area: true, ...(extra.b || {}) }]
const ed = { kind: 'editor', nav: 'articles', crumbs: ['Articles', 'How we price a bathroom renovation'], mTitle: 'Edit article', mLead: 'arrow-left', mAction: 'ellipsis' }
const E = (o = {}, extra = {}) => withM({ ...ed, ...extra, ed: { status: 'draft', save: 'saved', ...o } })
const cropM = crop => ({ title: 'Crop and resize', desc: 'north-wall.jpg · 3.9 MB', close: true, crop, cancel: 'Choose another file', confirm: 'Upload image' })
const del = (loading, err) => ({ title: 'Delete this article?', desc: '"Winter checklist for timber windows" and its 3 images will be deleted. This can\'t be undone.', cancel: 'Cancel', confirm: loading ? 'Deleting…' : 'Delete article', tone: 'danger', loading, notice: err })
return [
{ 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: [
@@ -137,7 +138,10 @@ class Component extends DCLogic {
E({ save: 'dirty', len: 10214 }, { name: 'Too long', trigger: 'Over 10 000. Save and Publish are disabled until it\'s shorter.' }),
E({ preview: true }, { name: 'Preview', trigger: 'The Preview switch. Shows the title and body in the content scale, the way a public site might.' }),
E({ save: 'dirty' }, { name: 'Upload: choose', trigger: 'The image button in the toolbar, or a file dropped on the editor.', modal: { title: 'Add an image', close: true, upload: { state: 'idle' }, cancel: 'Cancel' } }),
E({ save: 'dirty' }, { name: 'Upload: in progress', trigger: 'A valid file is uploading. Closing the dialog cancels the upload.', modal: { title: 'Add an image', close: true, upload: { state: 'uploading' }, cancel: 'Cancel upload' } }),
E({ save: 'dirty' }, { name: 'Upload: crop and resize', trigger: 'A JPEG, PNG or WEBP passed the checks, and nothing is sent yet. The crop starts as the whole image and the width defaults to 1600 px. SVGs skip this step.', modal: cropM({ ratio: 0, size: 1 }) }),
E({ save: 'dirty' }, { name: 'Upload: crop to a ratio', trigger: '16:9 chosen. The frame keeps the ratio while it\'s dragged or resized. Arrow keys move it; Shift and arrow keys resize it.', modal: cropM({ ratio: 2, size: 1 }) }),
E({ save: 'dirty' }, { name: 'Upload: crop narrower than the width', trigger: 'The crop is smaller than the chosen width, so it keeps its own size.', modal: cropM({ ratio: 4, size: 1, rect: [42, 34, 15.873, 21.164] }) }),
E({ save: 'dirty' }, { name: 'Upload: in progress', trigger: 'Upload image was pressed. The cropped, resized file is uploading. Closing the dialog cancels the upload.', modal: { title: 'Add an image', close: true, upload: { state: 'uploading' }, cancel: 'Cancel upload' } }),
E({ save: 'dirty' }, { name: 'Upload: rejected', trigger: 'Checked in the browser, so nothing is sent.', modal: { title: 'Add an image', close: true, upload: { state: 'rejected' }, cancel: 'Cancel', confirm: 'Choose another file' } }),
E({ save: 'dirty', image: true }, { name: 'Upload: inserted', trigger: '201: the image goes in at the cursor, and the article now has unsaved changes.', toasts: [T('success', 'circle-check', 'Image added', 'Save to keep it in the article.')] }),
E({ save: 'saving', pending: 'Saving and publishing…' }, { name: 'Publishing', trigger: '"Save and publish": the PATCH body, then the publish request.' }),
+1 -1
View File
@@ -74,7 +74,7 @@ Every state is drawn at desktop and mobile on the screen boards, with final copy
| Editor | `/article/:id/edit` | Near the limit | At least 9 000 HTML characters | [editor](screens/editor.md) |
| Editor | `/article/:id/edit` | Too long | Over 10 000: Save and Publish disabled | [editor](screens/editor.md) |
| Editor | `/article/:id/edit` | Preview | Switch on | [editor](screens/editor.md) |
| Editor | `/article/:id/edit` | Uploading, rejected, inserted | ImageUploader | [editor](screens/editor.md) |
| Editor | `/article/:id/edit` | Choose, crop and resize, uploading, rejected, inserted | ImageUploader | [editor](screens/editor.md) |
| Editor | `/article/:id/edit` | Publishing, unpublishing | PATCH publish or unpublish | [editor](screens/editor.md) |
| Editor | `/article/:id/edit` | Leave guard | Navigating away while dirty | [editor](screens/editor.md) |
| Editor | `/article/:id/edit` | Offline | navigator.onLine is false | [editor](screens/editor.md) |
+131
View File
@@ -0,0 +1,131 @@
Feature: Crop and resize step in the editor's image uploader
As a verified RenewCMS owner adding an image to an article
I want to crop and resize a JPEG, PNG or WEBP before it uploads
So that I control the framing and file size of images in my articles, without needing an
external editor
Background:
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
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
are unchanged and out of scope here
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
description "{filename} · {size}" (e.g. "north-wall.jpg · 3.9 MB")
And the stage shows the chosen image at its natural pixel dimensions with a crop frame that
starts as the whole image: a rule-of-thirds grid (lines at 33.3 %/66.6 %), four 12×12 px
corner handles, and a mono pixel-size label in a dark pill at the frame's bottom-left
corner, tracking the frame's current size
And the frame is reachable by Tab (tabindex="0"), aria-label "Crop area. Arrow keys move it,
Shift and arrow keys resize it."; dragging the frame moves it, dragging a handle resizes
it, arrow keys nudge it by 1 px, and Shift+arrow keys resize it by 1 px
And above the frame, "Aspect ratio" is a radiogroup of five pills — Original, Free, 16:9,
4:3, 1:1 — next to "Rotate" and "Reset" actions; "Width" is a second radiogroup — Original,
1600 px (default), 1200 px, 800 px
And a mono line below reads "Uploads as {width} × {height} px · about {size}" (KB under
1 MB, MB at or above it), recomputed whenever the ratio, width, frame rect or rotation
changes
And the output width is min(the chosen width preset, the crop frame's own pixel width) —
never upscaled — and the output height keeps the crop frame's own aspect ratio; when the
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."
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
file through the unchanged POST /v1/article/{articleId}/image flow)
And "Rotate" turns the image 90° clockwise each activation, cycling 0°→90°→180°→270°→0°, and
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
unrotated image, and the rotation to 0°, without closing the modal
Scenario: A raster file opens the crop-and-resize step instead of uploading immediately
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,
natural dimensions 4032 × 3024 px
Then no request is sent to POST /v1/article/12/image
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
"1600 px" is selected
And the mono line reads "Uploads as 1600 × 1200 px · about 420 KB"
And the footer reads "Choose another file" and "Upload image"
Scenario: An SVG skips the crop step and uploads immediately
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
Then POST /v1/article/12/image is sent immediately with that file
And the crop-and-resize step is never shown
Scenario Outline: Choosing an aspect ratio locks the frame to it
Given the crop-and-resize step is open for "north-wall.jpg" (4032 × 3024 px), ratio
"Original", width "1600 px"
When the user selects ratio "<ratio>"
Then the crop frame becomes <crop_width> × <crop_height> px, centered on the image
And dragging or resizing the frame afterwards keeps that aspect ratio
And the mono line reads "Uploads as <out_width> × <out_height> px · about {size}"
Examples:
| ratio | crop_width | crop_height | out_width | out_height |
| 16:9 | 4032 | 2268 | 1600 | 900 |
| 4:3 | 4032 | 3024 | 1600 | 1200 |
| 1:1 | 3024 | 3024 | 1600 | 1600 |
Scenario: Choosing a narrower width preset recomputes the output without upscaling
Given the crop-and-resize step is open for "north-wall.jpg", ratio "Original", width
"1600 px"
When the user selects width "800 px"
Then the mono line reads "Uploads as 800 × 600 px · about {size}", a smaller estimate than
before
Scenario: A crop narrower than the chosen width is uploaded at its own size, not upscaled
Given the crop-and-resize step is open for "north-wall.jpg", ratio "1:1", width "1600 px"
When the user resizes the crop frame down to 640 × 640 px
Then the mono line reads "Uploads as 640 × 640 px · about {size}"
And an info row reads "The crop is 640 px wide, so it's uploaded at that size. Enlarging it
would make it blurry."
Scenario: Free ratio removes the aspect-ratio constraint
Given the crop-and-resize step is open for "north-wall.jpg", ratio "Original"
When the user selects ratio "Free"
Then the crop frame can be dragged or resized to any width and height independently, no
longer snapping to a fixed ratio
Scenario: Arrow keys move the crop frame and Shift+arrow keys resize it
Given the crop-and-resize step is open for "north-wall.jpg" with the crop frame focused
When the user presses ArrowRight
Then the frame moves 1 px right and the mono line's size is unchanged
When the user presses Shift+ArrowRight
Then the frame's width grows by 1 px and the mono line updates to the new output size
Scenario: Rotate cycles the image in 90° steps and Reset returns to the opened defaults
Given the crop-and-resize step is open for "north-wall.jpg" at ratio "16:9", width "800 px"
When the user activates "Rotate"
Then the image and crop frame rotate 90° clockwise
When the user activates "Reset"
Then ratio returns to "Original", width returns to "1600 px", the frame returns to the
whole, unrotated image, and the mono line reads "Uploads as 1600 × 1200 px · about 420 KB"
Scenario: "Choose another file" returns to the file picker without uploading
Given the crop-and-resize step is open for "north-wall.jpg"
When the user activates "Choose another file"
Then no request is sent to POST /v1/article/12/image
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
Given the user returned to idle via "Choose another file" from a crop step that had ratio
"16:9" and width "800 px"
When the user chooses a new file named "sunset.jpg", content type "image/jpeg", size 2.1 MB,
natural dimensions 3000 × 2000 px
Then the crop-and-resize step reopens with ratio "Original", width "1600 px", and the frame
as the whole new image
Scenario: "Upload image" uploads the cropped, resized file, not the original
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"
When the user activates "Upload image"
Then POST /v1/article/12/image 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
4032 × 3024, 3.9 MB file
And the uploading state shows "Uploading 0.0 of 420 KB"
When the upload reports 180 KB sent of 420 KB
Then the uploading state shows "north-wall.jpg 43%" and "Uploading 180 of 420 KB"
+86
View File
@@ -0,0 +1,86 @@
# QA: Crop and resize step in the editor's image uploader
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount EditorPage inside DashboardShell at `/article/12/edit` with the editor_core.qa.md fixture
article (id 12, "How we price a bathroom renovation", is_online false), GET already resolved
200. Reuse editor_image_upload.qa.md's fake-XHR setup for the upload itself. Stub the chosen
file's decoded natural size (e.g. mock `Image`/`createImageBitmap` or whatever the component
uses to read `naturalWidth`/`naturalHeight`) so tests control it deterministically, and stub the
crop/resize step's own output producer (whatever function turns the frame rect + width preset
into a Blob) as a spy, asserting it is called with the frame's rect in natural-image pixels and
the resolved output width/height, and stubbing its returned Blob's size — without asserting how
it renders internally (canvas/OffscreenCanvas is a coder implementation detail).
1. **A raster file opens "Crop and resize" instead of uploading** — choose `north-wall.jpg`
(image/jpeg, 3.9 MB, natural 4032×3024); assert no XHR is created; assert the modal title is
"Crop and resize", description "north-wall.jpg · 3.9 MB", ratio "Original" and width
"1600 px" selected, mono line "Uploads as 1600 × 1200 px · about 420 KB", footer "Choose
another file" / "Upload image".
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.
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
the feature's Examples table.
4. **Width preset recomputes the output** — select width "800 px"; assert the mono line reads
"Uploads as 800 × 600 px" (assert dimensions exactly; assert only that the size figure changed
to a smaller value, not a specific number).
5. **A crop narrower than the width shows the info row and keeps its own size** — resize the
frame (via its resize API/spy) to 640×640 with ratio "1:1", width "1600 px"; assert the mono
line reads "Uploads as 640 × 640 px · about {size}" and the info row reads exactly "The crop
is 640 px wide, so it's uploaded at that size. Enlarging it would make it blurry."
6. **Free ratio removes the constraint** — select ratio "Free"; assert a subsequent resize call
with independent width/height is accepted without being coerced back to a ratio.
7. **Arrow and Shift+Arrow keys move/resize** — focus the frame; fire `ArrowRight`; assert the
frame's x increases by 1 px and the mono line's size is unchanged; fire `Shift+ArrowRight`;
assert the frame's width increases by 1 px and the mono line updates.
8. **Rotate cycles 90° and Reset restores defaults** — activate "Rotate"; assert the reported
rotation is 90°; activate "Reset"; assert ratio "Original", width "1600 px", the frame back to
the whole unrotated image, and the mono line "Uploads as 1600 × 1200 px · about 420 KB".
9. **"Choose another file" discards the step** — activate it; assert no XHR was created and the
idle state renders.
10. **A newly chosen file reopens at defaults** — from idle after "Choose another file" out of a
16:9/800px step, choose `sunset.jpg` (image/jpeg, 2.1 MB, natural 3000×2000); assert the new
crop step opens with ratio "Original", width "1600 px", frame as the whole new image (not the
previous file's ratio/width).
11. **"Upload image" sends the cropped/resized file** — from the default 1600×1200/420 KB step,
activate "Upload image"; assert the XHR is created with a file whose declared size is 420 KB
(not 3.9 MB); assert the uploading state shows "Uploading 0.0 of 420 KB"; fire an
`upload.progress` with `loaded: 180 * 1024, total: 420 * 1024`; assert "north-wall.jpg 43%"
and "Uploading 180 of 420 KB".
## Visual (manual verification via `task visual:check -- editor` — desktop and mobile, light and
dark)
Reference: `docs/design/screens/editor.md` states "Upload: crop and resize", "Upload: crop to a
ratio", "Upload: crop narrower than the width", and the updated "Upload: in progress"; board
`docs/design/source/RenewCMS Screens Back-office.dc.html`, group "Editor". Add each state's mock
setup to `web/visual/states.ts`, reusing editor_core.qa.md's fixture article (id 12, "How we
price a bathroom renovation", draft) and the sample file "north-wall.jpg · 3.9 MB".
- [ ] Upload: crop and resize — modal "Crop and resize" / "north-wall.jpg · 3.9 MB", ratio
"Original", width "1600 px", cancel "Choose another file", confirm "Upload image".
- [ ] Upload: crop to a ratio — same modal, ratio "16:9" selected, frame locked to that ratio.
- [ ] Upload: crop narrower than the width — ratio "1:1", width "1600 px", frame resized to
640×640, info row "The crop is 640 px wide, so it's uploaded at that size. Enlarging it
would make it blurry."
- [ ] Upload: in progress (updated) — modal "Add an image", uploading "north-wall.jpg 43%" /
"Uploading 180 of 420 KB", cancel "Cancel upload".
- [ ] Mobile (all of the above at 390×844): same modal layout constraints as other editor modals.
- [ ] Keyboard/focus: the crop frame is reachable by Tab; its `aria-label` is "Crop area. Arrow
keys move it, Shift and arrow keys resize it."; Arrow/Shift+Arrow move and resize it without
moving focus elsewhere; the aspect-ratio and width radiogroups follow standard roving-tabindex
radiogroup keyboard behavior (arrow keys between pills, one tab stop per group); "Rotate" and
"Reset" are reachable and named by their visible text.
- [ ] Motion: no auto-close or timed transition in this step; frame drag/resize follows pointer
with no easing delay (not part of the 10 % capture budget, spot-checked only).
## Manual verification
- **Real image decoding and resizing** — Vitest stubs `naturalWidth`/`naturalHeight` and the
output producer; confirm real JPEG/PNG/WEBP files of varying orientations and EXIF rotation
crop, rotate and resize correctly against `task dev` with a live server, since jsdom has no
real canvas/image pipeline.
- **Real POST of the resized file** — confirm the actual uploaded bytes decode to the expected
pixel dimensions server-side, complementing `features/upload_article_image.feature`'s
httptest coverage of the handler in isolation.
+148
View File
@@ -0,0 +1,148 @@
Feature: Removing an inline image from the editor
As a verified RenewCMS owner writing an article
I want to remove an image I've already inserted into the article body,
either through an explicit delete button or by pressing Backspace/Delete
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:
Given docs/design/ has no board or state for removing an inline image;
this feature was added at the user's explicit request ahead of design
coverage, reusing the editor's existing visual language rather than
inventing new components: the trash icon and error color already used
by the article "Delete" button, and the ConfirmDialog component
already used by "Delete this article?" (docs/design/screens/editor.md's
"Delete: confirm" state)
And this slice changes ArticleEditor's exposed insertImage(src, alt) to
insertImage(src, alt, imageId), stamping the inserted <img> node with
the image's numeric id as a data-image-id attribute, persisted in the
saved article body HTML; EditorView passes the id it now receives from
a successful upload (features/remove_article_image.feature's change to
POST .../image's 201 body) through to insertImage
And an image already present in an article's body from before this slice
has no data-image-id attribute and so shows no delete affordance and
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
And there are two equivalent triggers for the same removal flow:
activating that delete button, and pressing Backspace or Delete while
the image node is selected (a Tiptap NodeSelection on the image).
Tiptap's native node deletion on Backspace/Delete is intercepted
before it reaches the document (e.g. via a keyboard-shortcut/keydown
handler registered on the Image node or at the editor level, checked
first while an image node is selected) so the key press never removes
the node directly; instead it triggers exactly the same flow as the
button
And either trigger emits ArticleEditor's new "request-remove-image"
event with the node's numeric image id; ArticleEditor does not call
the API or show any dialog itself, mirroring how "request-image"
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
Given the user is on /article/12/edit with an image in the body carrying
data-image-id "10"
When the user clicks that image
Then a delete button (trash icon, error color) appears at the image's
top-right corner
When the user clicks elsewhere in the editor
Then the delete 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
Given the image with data-image-id "10" is selected on /article/12/edit
When the user <triggers-removal>
Then a dialog titled "Remove this image?" opens, with description "This
image will be removed from the article. This can't be undone.",
cancel "Cancel" and confirm "Remove image"
And no request has been sent to DELETE /v1/article/12/image/10
And the image node is still present in the editor's content (Tiptap's
native node deletion did not run)
Examples:
| triggers-removal |
| activates the image's delete button |
| presses Backspace with the image node selected |
| presses Delete with the image node selected |
Scenario: Cancelling the confirm dialog changes nothing
Given the confirm dialog is open for image id 10 on /article/12/edit
When the user activates "Cancel"
Then the dialog closes
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
Given the confirm dialog is open for image id 10 on /article/12/edit,
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."
Scenario Outline: A failed removal keeps the image and shows the dialog's inline error
Given the confirm dialog is open for image id 10 on /article/12/edit
When the user activates "Remove image"
And DELETE /v1/article/12/image/10 <failure>
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:
| failure |
| resolves with a 404 response |
| resolves with a 500 response |
| 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."
+88
View File
@@ -0,0 +1,88 @@
# QA: Removing an inline image from the editor
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount EditorPage inside DashboardShell at `/article/12/edit` with the
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
node; assert a delete button (trash icon, error color) renders anchored
to it; click elsewhere in the editor; assert the button is gone.
2. **Activating the delete button opens the confirm dialog** — select the
image, activate its delete button; assert a dialog titled "Remove this
image?", description "This image will be removed from the article. This
can't be undone.", cancel "Cancel", confirm "Remove image"; assert no
request was sent yet; assert the image node is still present (Tiptap's
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
No `docs/design/` board or state exists for this trigger (see the feature's
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
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
- **Real DELETE round trip against a running server** — Vitest only mocks
`useApi`'s request; confirm a real removal (204/404/500) against
`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.
+99
View File
@@ -0,0 +1,99 @@
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
@@ -0,0 +1,56 @@
# 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.
+132
View File
@@ -0,0 +1,132 @@
Feature: Password reset confirm screen
As a user who opened the reset link from their email
I want /register/reset/validate?c=CODE&email=EMAIL to let me choose a new password via POST
/v1/auth/password-reset/confirm, checking length and match in the browser first, and to tell
me plainly when the link is incomplete, the code is wrong or expired, or the server failed
So that finishing a password reset is a single form from the email, with the same neutral
"invalid or expired" message whether the code was wrong, reused, or the account doesn't exist
Background:
Given /register/reset/validate is a public route (meta.auth "public") rendered as
ResetConfirmPage inside AuthShell, per docs/design/routes.md
And ResetConfirmPage only calls POST /v1/auth/password-reset/confirm
And on mount, ResetConfirmPage reads the "c" and "email" query parameters once: if either is
absent or an empty string, no request is ever made and the Missing parameters state renders
immediately instead of the form
And when both are present, the form has a read-only Email field (value from the "email" query
parameter, help text "From your reset link."), a New password field (password input with
show/hide, a live checklist item "At least 8 characters" that starts unmet/gray, turns
error/red only after a failed submit, and turns met/green once satisfied), and a Confirm new
password field (password input with show/hide, a live checklist item "Passwords match"
with the same unmet/error/met states)
And New password's length is checked on submit only: fewer than 8 characters leaves its
checklist item in its error state and blocks the request
And Confirm new password's match against New password is checked on blur of that field and
again on submit: a mismatch shows the error "The passwords don't match." and leaves the
"Passwords match" checklist item in its error state, and blocks the request
And a submit that passes both checks calls POST /v1/auth/password-reset/confirm with
{ email, code: c, password: <New password> }; the Confirm new password value is never sent
And on a 200 response, the app navigates to "/login?reason=password-changed", whose
rendering is covered by features/login_screen.feature's "Password changed" scenario, not
re-specified here
And a 403 response (per features/reset_password.feature, covering an unknown email, a wrong
code, an already-used code, or an expired code alike) replaces the whole form with a
ResultPanel
And a 500 response, or a request that never reaches the server, is surfaced with the app's
generic network/server-error alert — icon "i-lucide-circle-x", title "Couldn't reach
RenewCMS", description "Check your connection and try again.", the same copy LoginPage and
RegisterPage already use for the same failure; the form is not replaced, both password
fields keep whatever the user typed, and the form re-enables, since
docs/design/screens/reset-password.md has no state of its own for this response and this
is the app's generic pattern for it (per the user's decision on this slice)
Scenario: Default — both parameters present, an untouched form
When the user navigates to "/register/reset/validate?c=abc123&email=marta%40renew.example"
Then the heading is "Choose a new password"
And the description is "Pick something you haven't used here before. You'll log in with it
next."
And the Email field shows "marta@renew.example", read-only, with help "From your reset link."
And the New password field shows no value, with checklist item "At least 8 characters" in its
unmet state
And the Confirm new password field shows no value, with checklist item "Passwords match" in
its unmet state
And the submit button reads "Change password"
Scenario: Too short — fewer than 8 characters blocks submission
Given the user is on the reset-confirm form with New password "lime" and Confirm new
password "lime"
When the user submits
Then POST /v1/auth/password-reset/confirm is never called
And the New password checklist item "At least 8 characters" shows its error state
And the Confirm new password checklist item "Passwords match" shows its met state, since both
fields hold the same value
Scenario: Mismatch — caught on blur and on submit
Given the user is on the reset-confirm form with New password "limewash2026"
When the user types "limewash2025" into Confirm new password and blurs the field
Then the Confirm new password field shows the error "The passwords don't match." and its
checklist item "Passwords match" shows its error state
When the user submits with these same values
Then POST /v1/auth/password-reset/confirm is never called
And the same mismatch error is still shown
Scenario: Submitting — fields and button are disabled while the request is in flight
Given the user is on the reset-confirm form with New password "limewash2026" and Confirm new
password "limewash2026"
When the user submits and POST /v1/auth/password-reset/confirm has not yet resolved
Then the New password and Confirm new password fields are disabled
And the submit button reads "Changing password…" and shows a loading state
Scenario: Success — a 200 hands off to the log-in screen
Given the user is on "/register/reset/validate?c=abc123&email=marta%40renew.example" with
New password "limewash2026" and Confirm new password "limewash2026"
When the user submits and POST /v1/auth/password-reset/confirm resolves 200
Then the app navigates to "/login?reason=password-changed"
Scenario: Invalid or expired — a 403 replaces the form with a ResultPanel
Given the user is on "/register/reset/validate?c=abc123&email=marta%40renew.example" with
New password "limewash2026" and Confirm new password "limewash2026"
When the user submits and POST /v1/auth/password-reset/confirm resolves 403 with the
apperr.PasswordResetFailedErrorMessage body
Then the form is no longer shown
And a ResultPanel is shown with tone "error", icon "i-lucide-link-2-off", title "This reset
link has expired", text "Reset links work once, for 1 hour. Request a new one and use it
within the hour."
And its actions are a primary "Request a new link" and a secondary "Back to log in"
When the user activates "Request a new link"
Then the app navigates to /register/reset/request
When instead the user activates "Back to log in"
Then the app navigates to /login
Scenario Outline: Server or network error — the form keeps its values and re-enables
Given the user is on "/register/reset/validate?c=abc123&email=marta%40renew.example" with
New password "limewash2026" and Confirm new password "limewash2026"
When the user submits and POST /v1/auth/password-reset/confirm <failure>
Then an error alert reads "Couldn't reach RenewCMS" with description "Check your connection
and try again."
And the form is still shown, not replaced by a ResultPanel
And the New password field still shows "limewash2026" and the Confirm new password field
still shows "limewash2026", both re-enabled
And the submit button reads "Change password" again, no longer loading
Examples:
| failure |
| fails with a 500 response |
| never reaches the server |
Scenario Outline: Missing parameters — either missing parameter blocks the request
When the user navigates to <path>
Then no request is made
And a ResultPanel is shown with tone "error", icon "i-lucide-link-2-off", title "This link
is incomplete", text "Part of the reset link is missing. Open it from your email again, or
request a new one."
And its single action is a primary "Request a new link"
When the user activates "Request a new link"
Then the app navigates to /register/reset/request
Examples:
| path |
| "/register/reset/validate?email=marta%40renew.example" |
| "/register/reset/validate?c=abc123" |
| "/register/reset/validate" |
+83
View File
@@ -0,0 +1,83 @@
# QA: Password reset confirm screen
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi` (`POST /v1/auth/password-reset/confirm`) per case; no real backend, no httptest
server. Mount ResetConfirmPage inside AuthShell with a test router whose current route starts at
the query string each case needs.
1. **Default state** — mount at
"/register/reset/validate?c=abc123&email=marta%40renew.example"; assert heading "Choose a new
password", description, Email "marta@renew.example" read-only with help "From your reset
link.", New password empty with checklist "At least 8 characters" unmet, Confirm new password
empty with checklist "Passwords match" unmet, submit "Change password".
2. **Too short state** — from state 1, set New password and Confirm new password both to "lime";
submit; assert `POST /v1/auth/password-reset/confirm` was never called; assert "At least 8
characters" shows its error state and "Passwords match" shows its met state.
3. **Mismatch state** — from state 1, set New password to "limewash2026", type "limewash2025"
into Confirm new password and blur; assert the error "The passwords don't match." and the
"Passwords match" checklist error state; submit; assert the call was never made and the same
error is still shown.
4. **Submitting state** — from state 1, set both password fields to "limewash2026"; mock the call
with an unresolved promise; submit; assert both fields are disabled and the submit button
reads "Changing password…" with a loading indicator.
5. **Success state** — from state 1, set both password fields to "limewash2026"; mock the call
resolving 200; submit; assert the router's current path is "/login?reason=password-changed"
(the rendering of that page is covered by `login_screen.qa.md`, not re-asserted here beyond
the path/query).
6. **Invalid or expired state** — from state 1, set both password fields to "limewash2026"; mock
the call resolving 403 with the `apperr.PasswordResetFailedErrorMessage` body; submit; assert
the form is no longer rendered; assert a ResultPanel (error, link-2-off, "This reset link has
expired", the text above) with actions "Request a new link" (primary) and "Back to log in"
(secondary); click each in separate sub-cases and assert the router's current path is
/register/reset/request and /login respectively.
7. **Server or network error state** — two sub-cases (500 response, rejected fetch) from state 1,
with both password fields set to "limewash2026"; submit; assert an error alert with icon
"i-lucide-circle-x", title "Couldn't reach RenewCMS", description "Check your connection and
try again." (the same copy `login_screen.qa.md` and `register_screen.qa.md` already assert
for their own server/network cases); assert the form is still rendered (no ResultPanel);
assert both password fields still read "limewash2026" and are re-enabled; assert the submit
button reads "Change password" and is no longer loading.
8. **Missing parameters state** — three sub-cases, one per Examples row (missing "c", missing
"email", missing both); mount at the row's path; assert no request was made; assert a
ResultPanel (error, link-2-off, "This link is incomplete", the text above) with a single
primary action "Request a new link"; click it and assert the router's current path is
/register/reset/request.
| path |
|----------------------------------------------------------|
| /register/reset/validate?email=marta%40renew.example |
| /register/reset/validate?c=abc123 |
| /register/reset/validate |
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Reset password". Run
`task visual:check -- reset-password` for each state.
- [ ] Default state: read-only Email field styling, both checklist items in their unmet/gray
state, matches the board exactly.
- [ ] Too short state: New password's checklist item in its error/red state while Confirm new
password's stays met/green, since both fields match.
- [ ] Mismatch state: inline field error under Confirm new password plus its checklist error
state.
- [ ] Submitting state: disabled field styling and the loading spinner in the submit button.
- [ ] Invalid or expired state: ResultPanel with both actions, primary first (components.md's
safe/primary-on-top rule for stacked mobile buttons).
- [ ] Server or network error state: error alert (circle-x icon, no close button) above the
heading, matching Login's/Register's own server-error alert styling exactly, since this
screen has no board state of its own for it; fields retain their values. This state is not
covered by `task visual:check`'s per-state board comparison (no board frame exists for it);
note that gap in the run's report rather than skipping it silently.
- [ ] Missing parameters state: ResultPanel with a single primary action.
- [ ] Keyboard/focus: Tab order is New password → show/hide → Confirm new password → show/hide →
submit; the ResultPanel's title receives focus on both the Invalid-or-expired and
Missing-parameters states; the mismatch error and the server/network alert are announced
without stealing focus from the field or the heading, respectively.
## Manual verification
- **Real POST /v1/auth/password-reset/confirm against a running server** — Vitest only mocks
`useApi`; confirm the real 200/403 round-trip (including the actual bcrypt code check, 1-hour
expiry, and single-use clearing) against `task dev` with a live SQLite file. The endpoint's own
contract is already covered by `features/reset_password.qa.md`; this slice only re-verifies the
screen consumes it correctly.
+85
View File
@@ -0,0 +1,85 @@
Feature: Password reset request screen
As a RenewCMS user who forgot their password
I want a /register/reset/request screen that emails a reset link via POST
/v1/auth/password-reset, validates the email client-side before sending, and never reveals
whether the address has an account
So that I can ask for a reset link with the same neutral confirmation regardless of whether my
account exists, and see plainly when I'm asking too often or the server failed
Background:
Given /register/reset/request is a public route (meta.auth "public") rendered as
ResetRequestPage inside AuthShell, per docs/design/routes.md
And ResetRequestPage only calls POST /v1/auth/password-reset
And the form has a single Email field (placeholder "you@example.com")
And client-side validation runs on submit only, before any request is sent: Email must be a
well-formed email address (the same pattern RegisterPage already uses), and a failing Email
shows "Enter a full email address, like name@example.com."
And a valid submit calls POST /v1/auth/password-reset with { email }
And every response the API gives — whether the email belongs to an account or not — is the
same 200 with the same body, per features/request_password_reset.feature, so the screen
shows the same Sent state either way
And a 429 response carries a Retry-After header in seconds, surfaced by useApi and displayed
as a live countdown on the submit button, the same mechanism LoginPage's own 429 handling
uses, and the Email field is disabled for the same duration
And a 500 response, or a request that never reaches the server, is surfaced as a
server/network error; the field keeps whatever the user typed and the form re-enables
And a 400 response is not expected once client-side validation passes, since the client
checks the same required,email rule the API enforces; it is out of scope for this slice
Scenario: Default — a fresh, empty form
When the user navigates to /register/reset/request
Then the heading is "Reset your password"
And the description is "Enter the email for your account and we'll send you a link to choose
a new password."
And the Email field shows placeholder "you@example.com" with no value
And the submit button reads "Send reset link"
And the footer reads "Back to log in" linking to /login
Scenario: Validation error — an invalid email blocks the request
Given the user is on /register/reset/request
When the user types "marta@renew" into Email and submits
Then POST /v1/auth/password-reset is never called
And the Email field shows the error "Enter a full email address, like name@example.com."
Scenario: Submitting — the field and button are disabled while the request is in flight
Given the user is on /register/reset/request with Email "marta@renew.example"
When the user submits and POST /v1/auth/password-reset has not yet resolved
Then the Email field is disabled
And the submit button reads "Sending…" and shows a loading state
Scenario: Sent — any 200 shows the same neutral confirmation
Given the user is on /register/reset/request with Email "marta@renew.example"
When the user submits and POST /v1/auth/password-reset resolves 200
Then the form is replaced by a ResultPanel with tone "success", icon "i-lucide-mail-check",
title "Check your email", text "If an account exists for that email, a password reset link
has been sent.", and note "The link expires in 1 hour."
And its single action is a secondary "Back to log in"
When the user activates "Back to log in"
Then the app navigates to /login
Scenario: Too many attempts — the button is disabled with a live countdown
Given the user is on /register/reset/request with Email "marta@renew.example"
When the user submits and POST /v1/auth/password-reset resolves 429 with a Retry-After of 58
seconds
Then an info alert reads "Too many requests" with description "Wait a moment before asking
for another link."
And the Email field still shows "marta@renew.example"
And the submit button reads "Try again in 0:58" and is disabled
And the Email field is disabled while the countdown runs
When 58 seconds elapse
Then the submit button re-enables and reads "Send reset link"
And the Email field is editable again
Scenario Outline: Server or network error — the field keeps its value and re-enables
Given the user is on /register/reset/request with Email "marta@renew.example"
When the user submits and POST /v1/auth/password-reset <failure>
Then an error alert reads "Couldn't send the link" with description "Check your connection
and try again."
And the Email field still shows "marta@renew.example" and is re-enabled
And the submit button reads "Send reset link" again, no longer loading
Examples:
| failure |
| fails with a 500 response |
| never reaches the server |
+59
View File
@@ -0,0 +1,59 @@
# QA: Password reset request screen
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi` (`POST /v1/auth/password-reset`) per case; no real backend, no httptest server.
Mount ResetRequestPage inside AuthShell with a test router.
1. **Default state** — mount at /register/reset/request; assert heading "Reset your password",
description, Email placeholder "you@example.com" with no value, submit "Send reset link",
footer "Back to log in" linking to /login.
2. **Validation error state** — set Email to "marta@renew", submit; assert the mocked
`POST /v1/auth/password-reset` was never called; assert the error "Enter a full email
address, like name@example.com."
3. **Submitting state** — set Email "marta@renew.example"; mock the call with an unresolved
promise; submit; assert the Email field is disabled and the submit button reads "Sending…"
with a loading indicator.
4. **Sent state** — set Email "marta@renew.example"; mock the call resolving 200; submit; assert
the form is replaced by a ResultPanel (success, mail-check, "Check your email", the neutral
text, note "The link expires in 1 hour.") with a single secondary action "Back to log in";
click it and assert the router's current path is /login.
5. **Too many attempts state** — set Email "marta@renew.example"; mock the call resolving 429
with `Retry-After: 58`; submit; assert an info alert ("Too many requests", "Wait a moment
before asking for another link."); assert Email still reads "marta@renew.example"; assert the
submit button reads "Try again in 0:58" and is disabled; assert the Email field is disabled;
advance fake timers 58s; assert the button re-enables and reads "Send reset link" and the
field is editable again.
6. **Server or network error state** — two sub-cases (500 response, rejected fetch) with Email
"marta@renew.example"; submit; assert an error alert ("Couldn't send the link", "Check your
connection and try again."); assert Email still reads "marta@renew.example" and is re-enabled;
assert the submit button reads "Send reset link" and is no longer loading.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Forgot password". Run
`task visual:check -- forgot-password` for each state.
- [ ] Default state: heading, description, Email field, "Send reset link" button, "Back to log
in" footer link, matches the board exactly.
- [ ] Validation error state: the inline field error renders with the same styling as
Register's/Login's own field errors.
- [ ] Submitting state: disabled field styling and the loading spinner in the submit button.
- [ ] Sent state: ResultPanel (mail-check icon) with its single secondary action, note text below
the description.
- [ ] Too many attempts state: info alert (timer icon), disabled field and disabled button with
the live "Try again in m:ss" countdown text.
- [ ] Server error state: error alert (circle-x icon, no close button), field retains its value.
- [ ] Keyboard/focus: Tab order is Email → submit; the alert (when present) is announced via
`role="status"` (info) or `role="alert"` (error) without re-announcing on re-render; the
ResultPanel's title receives focus on the Sent state.
## Manual verification
- **Real POST /v1/auth/password-reset against a running server** — Vitest only mocks `useApi`;
confirm the real 200/429/500 round-trip (including the actual rate limiter and Retry-After
header) against `task dev` with a live SQLite file. The endpoint's own contract is already
covered by `features/request_password_reset.qa.md`; this slice only re-verifies the screen
consumes it correctly.
- **429 countdown drift** — confirm `useRetryAfter`'s live countdown does not drift against a
real elapsed 58-second wait, since Vitest fake timers only prove the logic.
+8 -3
View File
@@ -37,9 +37,12 @@ Feature: Uploading an image to an article
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 {"location": ...}
holding the created image's public path, e.g.
"/static/uploadedImages/<stored-filename>"
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
@@ -51,6 +54,8 @@ Feature: Uploading an image to an article
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
+196
View File
@@ -0,0 +1,196 @@
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())
}
})
}
})
}
+6 -1
View File
@@ -29,6 +29,7 @@ func newUploadArticleImageRouter(articleRepo *mockArticleRepo, imageRepo *mockIm
useCases.NewCreateImageUseCase(imageRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewDeleteImageUseCase(imageRepo),
)
r := chi.NewRouter()
@@ -98,7 +99,7 @@ func TestUploadArticleImageHttp(t *testing.T) {
t.Fatalf("expected status 201, got %d, body %q", rec.Code, rec.Body.String())
}
var got map[string]string
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
@@ -120,6 +121,10 @@ func TestUploadArticleImageHttp(t *testing.T) {
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
@@ -35,6 +35,7 @@ func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mo
useCases.NewCreateImageUseCase(imageRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewDeleteImageUseCase(imageRepo),
)
r := chi.NewRouter()
+3
View File
@@ -9,6 +9,9 @@
"@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "3.31.3",
"@tiptap/extension-drag-handle-vue-3": "^3.31.3",
"@tiptap/extension-image": "3.31.3",
"@tiptap/pm": "3.31.3",
"@tiptap/vue-3": "3.31.3",
"@vueuse/core": "14.4.0",
"pinia": "^3.0.4",
"shiki": "^4.4.3",
+3
View File
@@ -20,6 +20,9 @@
"@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "3.31.3",
"@tiptap/extension-drag-handle-vue-3": "^3.31.3",
"@tiptap/extension-image": "3.31.3",
"@tiptap/pm": "3.31.3",
"@tiptap/vue-3": "3.31.3",
"@vueuse/core": "14.4.0",
"pinia": "^3.0.4",
"shiki": "^4.4.3",
+25 -3
View File
@@ -3,6 +3,7 @@ import { ref } from 'vue'
import type { EditorToolbarItem } from '@nuxt/ui'
import type { Editor } from '@tiptap/core'
import { ArticleIndent } from '@/lib/articleIndent'
import { ArticleImage, findArticleImagePos } from '@/lib/articleImage'
const props = withDefaults(
defineProps<{
@@ -18,10 +19,16 @@ const props = withDefaults(
const emit = defineEmits<{
'update:modelValue': [value: string]
'request-image': []
'request-remove-image': [imageId: number]
}>()
const editorInstance = ref<Editor | null>(null)
const extensions = [ArticleIndent]
const extensions = [
ArticleIndent,
ArticleImage.configure({
onRequestRemove: (imageId: number) => emit('request-remove-image', imageId),
}),
]
function captureEditor(editor: Editor) {
editorInstance.value = editor
@@ -198,8 +205,22 @@ defineExpose({
focus() {
editorInstance.value?.commands.focus()
},
insertImage(src: string, alt: string) {
editorInstance.value?.commands.setImage({ src, alt })
insertImage(src: string, alt: string, imageId: number) {
editorInstance.value?.commands.setArticleImage({ src, alt, imageId })
},
removeImage(imageId: number) {
const editor = editorInstance.value
if (!editor) {
return
}
const pos = findArticleImagePos(editor, imageId)
if (pos === null) {
return
}
editor
.chain()
.deleteRange({ from: pos, to: pos + 1 })
.run()
},
})
</script>
@@ -209,6 +230,7 @@ defineExpose({
v-slot="{ editor }"
:model-value="props.modelValue"
:extensions="extensions"
:image="false"
content-type="html"
:editable="!props.disabled"
:ui="{ base: 'article-content' }"
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
import { articleImageId } from '@/lib/articleImage'
const props = defineProps(nodeViewProps)
const imageId = computed(() => articleImageId(props.node))
const showDeleteButton = computed(() => props.selected && imageId.value !== null)
interface ImageNodeAttrs {
src?: string
alt?: string
title?: string
width?: number | string
height?: number | string
}
const imageAttrs = computed(() => {
const { src, alt, title, width, height } = props.node.attrs as ImageNodeAttrs
return { src, alt, title, width, height, 'data-image-id': props.node.attrs['data-image-id'] }
})
function onDeleteClick() {
if (imageId.value === null) {
return
}
props.extension.options.onRequestRemove(imageId.value)
}
</script>
<template>
<NodeViewWrapper
as="div"
class="relative block w-fit"
:class="{ 'outline-2 outline-primary rounded-md': selected }"
data-testid="article-editor-image"
@dragstart.prevent
>
<img v-bind="imageAttrs" draggable="false" class="block max-w-full" />
<UButton
v-if="showDeleteButton"
data-testid="remove-image-button"
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="xs"
class="absolute top-1 right-1 bg-default"
aria-label="Remove image"
@mousedown.prevent
@click="onDeleteClick"
/>
</NodeViewWrapper>
</template>
+557 -15
View File
@@ -1,13 +1,38 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import {
IMAGE_TYPE_REJECTION_TEXT,
formatMegabytes,
formatUploadProgress,
isSizeRejectionBody,
rejectionTitle,
sizeRejectionText,
validateImageFile,
} from '@/lib/imageUpload'
import {
ASPECT_RATIOS,
WIDTH_PRESETS,
type AspectRatioLabel,
type CropRect,
type WidthPresetLabel,
clampRect,
computeOutputSize,
estimateOutputBytes,
formatEstimatedSize,
moveRect,
nextRotation,
ratioValueFor,
readNaturalImageSize,
rectForRatio,
renderCroppedImage,
resizeRect,
resizeRectTo,
rotateRectBySteps,
rotatedDimensions,
wholeRect,
widthPresetPixels,
} from '@/lib/imageCrop'
type Corner = 'tl' | 'tr' | 'bl' | 'br'
import { useImageUpload } from '@/composables/useImageUpload'
const props = defineProps<{
@@ -16,20 +41,31 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
uploaded: [location: string]
uploaded: [location: string, imageId: number]
close: []
}>()
type Phase = 'idle' | 'dragging-over' | 'uploading' | 'rejected' | 'failed'
type Phase = 'idle' | 'dragging-over' | 'crop' | 'uploading' | 'rejected' | 'failed'
const open = defineModel<boolean>('open', { default: false })
const phase = ref<Phase>('idle')
const chosenFile = ref<File | null>(null)
const lastUploadedFile = ref<File | null>(null)
const pickedFile = ref<File | null>(null)
const rejectionKind = ref<'type' | 'size' | null>(null)
const succeeded = ref(false)
const naturalWidth = ref(0)
const naturalHeight = ref(0)
const rotation = ref(0)
const ratioLabel = ref<AspectRatioLabel>('Original')
const widthLabel = ref<WidthPresetLabel>('1600 px')
const rect = ref<CropRect>({ x: 0, y: 0, width: 0, height: 0 })
const imageBoxRef = ref<HTMLElement | null>(null)
const previewCanvasRef = ref<HTMLCanvasElement | null>(null)
const sourceBitmap = ref<ImageBitmap | null>(null)
const { progress, start, abort } = useImageUpload()
let closeTimer: ReturnType<typeof setTimeout> | null = null
@@ -57,9 +93,68 @@ const rejectedText = computed(() => {
const cancelLabel = computed(() => (phase.value === 'uploading' ? 'Cancel upload' : 'Cancel'))
const showCancel = computed(
() => phase.value !== 'failed' && !(phase.value === 'uploading' && succeeded.value),
() =>
phase.value !== 'failed' &&
phase.value !== 'crop' &&
!(phase.value === 'uploading' && succeeded.value),
)
const modalTitle = computed(() => (phase.value === 'crop' ? 'Crop and resize' : 'Add an image'))
const modalDescription = computed(() => {
if (phase.value !== 'crop' || !chosenFile.value) {
return undefined
}
return `${chosenFile.value.name} · ${formatEstimatedSize(chosenFile.value.size)}`
})
const modalContentClass = computed(
() =>
`divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0 ${
phase.value === 'crop' ? 'sm:max-w-160' : 'sm:max-w-120'
}`,
)
const boundsWidth = computed(
() => rotatedDimensions(naturalWidth.value, naturalHeight.value, rotation.value).width,
)
const boundsHeight = computed(
() => rotatedDimensions(naturalWidth.value, naturalHeight.value, rotation.value).height,
)
const lockedRatioValue = computed(() =>
ratioValueFor(ratioLabel.value, boundsWidth.value, boundsHeight.value),
)
const cropWidth = computed(() => Math.round(rect.value.width))
const cropHeight = computed(() => Math.round(rect.value.height))
const outputSize = computed(() =>
computeOutputSize(cropWidth.value, cropHeight.value, widthPresetPixels(widthLabel.value)),
)
const outputSizeText = computed(() =>
formatEstimatedSize(estimateOutputBytes(outputSize.value.width, outputSize.value.height)),
)
const monoLine = computed(
() =>
`Uploads as ${outputSize.value.width} × ${outputSize.value.height} px · about ${outputSizeText.value}`,
)
const targetWidthPixels = computed(() => widthPresetPixels(widthLabel.value))
const showNarrowNote = computed(
() => targetWidthPixels.value !== null && targetWidthPixels.value > cropWidth.value,
)
const narrowNoteText = computed(
() =>
`The crop is ${cropWidth.value} px wide, so it's uploaded at that size. Enlarging it would make it blurry.`,
)
const dimsLabel = computed(() => `${cropWidth.value} × ${cropHeight.value}`)
function clearCloseTimer() {
if (closeTimer !== null) {
clearTimeout(closeTimer)
@@ -68,10 +163,11 @@ function clearCloseTimer() {
}
async function runUpload(file: File) {
lastUploadedFile.value = file
const outcome = await start(props.articleId, file)
if (outcome.kind === 'success') {
succeeded.value = true
emit('uploaded', outcome.location)
emit('uploaded', outcome.location, outcome.id)
closeTimer = setTimeout(() => {
open.value = false
}, 600)
@@ -85,6 +181,69 @@ async function runUpload(file: File) {
phase.value = 'failed'
}
function closeSourceBitmap() {
if (sourceBitmap.value) {
sourceBitmap.value.close()
sourceBitmap.value = null
}
}
function drawPreview() {
const canvas = previewCanvasRef.value
const bitmap = sourceBitmap.value
if (!canvas || !bitmap) {
return
}
const rotated = rotatedDimensions(naturalWidth.value, naturalHeight.value, rotation.value)
const maxDimension = 1200
const scale = Math.min(1, maxDimension / Math.max(rotated.width, rotated.height))
canvas.width = Math.max(1, Math.round(rotated.width * scale))
canvas.height = Math.max(1, Math.round(rotated.height * scale))
const ctx = canvas.getContext('2d')
if (!ctx) {
return
}
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.save()
ctx.translate(canvas.width / 2, canvas.height / 2)
ctx.rotate((rotation.value * Math.PI) / 180)
ctx.drawImage(
bitmap,
(-naturalWidth.value * scale) / 2,
(-naturalHeight.value * scale) / 2,
naturalWidth.value * scale,
naturalHeight.value * scale,
)
ctx.restore()
}
async function loadPreviewBitmap(file: File) {
closeSourceBitmap()
if (typeof createImageBitmap !== 'function') {
return
}
try {
sourceBitmap.value = await createImageBitmap(file)
} catch {
sourceBitmap.value = null
return
}
await nextTick()
drawPreview()
}
async function openCropStep(file: File) {
const size = await readNaturalImageSize(file)
naturalWidth.value = size.width
naturalHeight.value = size.height
rotation.value = 0
ratioLabel.value = 'Original'
widthLabel.value = '1600 px'
rect.value = wholeRect(size.width, size.height)
phase.value = 'crop'
await loadPreviewBitmap(file)
}
function handleFile(file: File) {
chosenFile.value = file
const result = validateImageFile(file)
@@ -95,8 +254,12 @@ function handleFile(file: File) {
}
rejectionKind.value = null
succeeded.value = false
phase.value = 'uploading'
runUpload(file)
if (file.type === 'image/svg+xml') {
phase.value = 'uploading'
runUpload(file)
return
}
openCropStep(file)
}
function onFileChange() {
@@ -128,21 +291,225 @@ function chooseAnotherFile() {
chosenFile.value = null
pickedFile.value = null
rejectionKind.value = null
closeSourceBitmap()
}
function tryAgain() {
if (!chosenFile.value) {
if (!lastUploadedFile.value) {
return
}
succeeded.value = false
phase.value = 'uploading'
runUpload(chosenFile.value)
runUpload(lastUploadedFile.value)
}
function cancel() {
open.value = false
}
function selectRatio(label: AspectRatioLabel) {
ratioLabel.value = label
if (label === 'Free') {
return
}
rect.value = rectForRatio(label, boundsWidth.value, boundsHeight.value)
}
function selectWidth(label: WidthPresetLabel) {
widthLabel.value = label
}
function rotateImage() {
const dims = rotatedDimensions(naturalWidth.value, naturalHeight.value, rotation.value)
rect.value = rotateRectBySteps(rect.value, dims.width, dims.height, 1)
rotation.value = nextRotation(rotation.value)
drawPreview()
}
function resetCrop() {
rotation.value = 0
ratioLabel.value = 'Original'
widthLabel.value = '1600 px'
rect.value = wholeRect(naturalWidth.value, naturalHeight.value)
drawPreview()
}
function resizeCropTo(width: number, height: number) {
rect.value = resizeRectTo(rect.value, width, height, boundsWidth.value, boundsHeight.value)
}
function stagePixelScale(): number {
const box = imageBoxRef.value?.getBoundingClientRect()
if (!box || box.width === 0) {
return 1
}
return boundsWidth.value / box.width
}
function pointFromEvent(event: MouseEvent | TouchEvent): { x: number; y: number } {
if ('touches' in event && event.touches.length > 0) {
return { x: event.touches[0]!.clientX, y: event.touches[0]!.clientY }
}
if ('changedTouches' in event && event.changedTouches.length > 0) {
return { x: event.changedTouches[0]!.clientX, y: event.changedTouches[0]!.clientY }
}
const mouseEvent = event as MouseEvent
return { x: mouseEvent.clientX, y: mouseEvent.clientY }
}
function bindPointerDrag(onMove: (event: MouseEvent | TouchEvent) => void, onEnd: () => void) {
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onEnd)
window.addEventListener('touchmove', onMove)
window.addEventListener('touchend', onEnd)
}
function unbindPointerDrag(onMove: (event: MouseEvent | TouchEvent) => void, onEnd: () => void) {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onEnd)
window.removeEventListener('touchmove', onMove)
window.removeEventListener('touchend', onEnd)
}
let moveOrigin: { x: number; y: number; rect: CropRect } | null = null
function onFrameDragStart(event: MouseEvent | TouchEvent) {
event.preventDefault()
const point = pointFromEvent(event)
moveOrigin = { x: point.x, y: point.y, rect: { ...rect.value } }
bindPointerDrag(onFrameDragMove, onFrameDragEnd)
}
function onFrameDragMove(event: MouseEvent | TouchEvent) {
if (!moveOrigin) {
return
}
const point = pointFromEvent(event)
const scale = stagePixelScale()
const dx = (point.x - moveOrigin.x) * scale
const dy = (point.y - moveOrigin.y) * scale
rect.value = clampRect(
{ ...moveOrigin.rect, x: moveOrigin.rect.x + dx, y: moveOrigin.rect.y + dy },
boundsWidth.value,
boundsHeight.value,
)
}
function onFrameDragEnd() {
moveOrigin = null
unbindPointerDrag(onFrameDragMove, onFrameDragEnd)
}
let resizeOrigin: {
x: number
y: number
rect: CropRect
corner: Corner
ratio: number | null
} | null = null
function onHandleDragStart(event: MouseEvent | TouchEvent, corner: Corner) {
event.preventDefault()
event.stopPropagation()
const point = pointFromEvent(event)
resizeOrigin = {
x: point.x,
y: point.y,
rect: { ...rect.value },
corner,
ratio: lockedRatioValue.value,
}
bindPointerDrag(onHandleDragMove, onHandleDragEnd)
}
function onHandleDragMove(event: MouseEvent | TouchEvent) {
if (!resizeOrigin) {
return
}
const point = pointFromEvent(event)
const scale = stagePixelScale()
const dx = (point.x - resizeOrigin.x) * scale
const dy = (point.y - resizeOrigin.y) * scale
const { corner, rect: startRect, ratio } = resizeOrigin
let { x, y, width, height } = startRect
if (corner.includes('r')) {
width = startRect.width + dx
} else {
width = startRect.width - dx
x = startRect.x + dx
}
if (corner.includes('b')) {
height = startRect.height + dy
} else {
height = startRect.height - dy
y = startRect.y + dy
}
if (ratio) {
height = width / ratio
if (corner.startsWith('t')) {
y = startRect.y + startRect.height - height
}
}
rect.value = clampRect({ x, y, width, height }, boundsWidth.value, boundsHeight.value)
}
function onHandleDragEnd() {
resizeOrigin = null
unbindPointerDrag(onHandleDragMove, onHandleDragEnd)
}
const ARROW_KEY_DELTAS: Record<string, [number, number]> = {
ArrowRight: [1, 0],
ArrowLeft: [-1, 0],
ArrowUp: [0, -1],
ArrowDown: [0, 1],
}
function onFrameKeydown(event: KeyboardEvent) {
const delta = ARROW_KEY_DELTAS[event.key]
if (!delta) {
return
}
event.preventDefault()
const [dx, dy] = delta
rect.value = event.shiftKey
? resizeRect(
rect.value,
dx,
dy,
boundsWidth.value,
boundsHeight.value,
lockedRatioValue.value,
)
: moveRect(rect.value, dx, dy, boundsWidth.value, boundsHeight.value)
}
async function confirmCropUpload() {
if (!chosenFile.value) {
return
}
const file = chosenFile.value
const output = outputSize.value
const blob = await renderCroppedImage(file, {
rect: rect.value,
rotation: rotation.value,
naturalWidth: naturalWidth.value,
naturalHeight: naturalHeight.value,
outputWidth: output.width,
outputHeight: output.height,
mimeType: file.type,
})
const resizedFile = new File([blob], file.name, { type: file.type })
succeeded.value = false
phase.value = 'uploading'
runUpload(resizedFile)
}
defineExpose({
cropRect: rect,
resizeCropTo,
})
watch(open, (isOpen) => {
if (isOpen) {
phase.value = 'idle'
@@ -159,17 +526,22 @@ watch(open, (isOpen) => {
abort()
}
clearCloseTimer()
closeSourceBitmap()
emit('close')
})
onUnmounted(() => {
closeSourceBitmap()
})
</script>
<template>
<UModal
v-model:open="open"
title="Add an image"
:title="modalTitle"
:description="modalDescription"
:ui="{
content:
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-120 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
content: modalContentClass,
title: 'text-lg/6 font-semibold',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
@@ -204,6 +576,171 @@ watch(open, (isOpen) => {
</UFileUpload>
</div>
<div v-else-if="phase === 'crop'" class="flex flex-col gap-4">
<div
class="flex h-60 items-center justify-center bg-elevated p-4 sm:h-85"
data-testid="crop-stage"
>
<div
ref="imageBoxRef"
class="relative h-full max-w-full"
:style="{ aspectRatio: `${boundsWidth} / ${boundsHeight}` }"
:aria-label="`${chosenFile?.name}, ${boundsWidth} × ${boundsHeight} px`"
role="img"
>
<div class="absolute inset-0 overflow-hidden bg-(--color-sand-200)">
<canvas ref="previewCanvasRef" class="absolute inset-0 h-full w-full" />
<div
class="absolute"
:style="{
left: `${(rect.x / boundsWidth) * 100}%`,
top: `${(rect.y / boundsHeight) * 100}%`,
width: `${(rect.width / boundsWidth) * 100}%`,
height: `${(rect.height / boundsHeight) * 100}%`,
boxShadow: '0 0 0 2000px rgb(10 10 10 / 0.55)',
}"
/>
</div>
<div
data-testid="crop-frame"
tabindex="0"
role="group"
aria-label="Crop area. Arrow keys move it, Shift and arrow keys resize it."
class="absolute cursor-move touch-none outline outline-white"
:style="{
left: `${(rect.x / boundsWidth) * 100}%`,
top: `${(rect.y / boundsHeight) * 100}%`,
width: `${(rect.width / boundsWidth) * 100}%`,
height: `${(rect.height / boundsHeight) * 100}%`,
}"
@keydown="onFrameKeydown"
@mousedown="onFrameDragStart"
@touchstart="onFrameDragStart"
>
<div
class="pointer-events-none absolute top-0 bottom-0 w-px bg-white/45"
style="left: 33.333%"
/>
<div
class="pointer-events-none absolute top-0 bottom-0 w-px bg-white/45"
style="left: 66.666%"
/>
<div
class="pointer-events-none absolute left-0 right-0 h-px bg-white/45"
style="top: 33.333%"
/>
<div
class="pointer-events-none absolute left-0 right-0 h-px bg-white/45"
style="top: 66.666%"
/>
<div
v-for="corner in ['tl', 'tr', 'bl', 'br'] as Corner[]"
:key="corner"
data-testid="crop-handle"
class="absolute size-3 touch-none border border-ink-900 bg-white"
:class="{
'-top-1.5 -left-1.5 cursor-nwse-resize': corner === 'tl',
'-top-1.5 -right-1.5 cursor-nesw-resize': corner === 'tr',
'-bottom-1.5 -left-1.5 cursor-nesw-resize': corner === 'bl',
'-bottom-1.5 -right-1.5 cursor-nwse-resize': corner === 'br',
}"
@mousedown="onHandleDragStart($event, corner)"
@touchstart="onHandleDragStart($event, corner)"
/>
<span
class="absolute bottom-2 left-2 bg-ink-900/80 px-1.5 py-px font-mono text-xs whitespace-nowrap text-white"
>
{{ dimsLabel }}
</span>
</div>
</div>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-1">
<span class="flex-1 font-medium text-highlighted">Aspect ratio</span>
<UButton
color="neutral"
variant="ghost"
size="sm"
icon="i-lucide-rotate-ccw-square"
@click="rotateImage"
>
Rotate
</UButton>
<UButton
color="neutral"
variant="ghost"
size="sm"
icon="i-lucide-undo-2"
@click="resetCrop"
>
Reset
</UButton>
</div>
<div
role="radiogroup"
aria-label="Aspect ratio"
class="flex w-full border border-accented sm:w-max"
>
<button
v-for="ratio in ASPECT_RATIOS"
:key="ratio"
type="button"
role="radio"
:aria-checked="ratioLabel === ratio"
:tabindex="ratioLabel === ratio ? 0 : -1"
class="flex-1 px-3 py-1.5 font-medium whitespace-nowrap sm:flex-none"
:class="
ratioLabel === ratio
? 'bg-primary text-inverted'
: 'bg-transparent text-toned'
"
@click="selectRatio(ratio)"
>
{{ ratio }}
</button>
</div>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">Width</span>
<div
role="radiogroup"
aria-label="Width"
class="flex w-full border border-accented sm:w-max"
>
<button
v-for="widthOption in WIDTH_PRESETS"
:key="widthOption"
type="button"
role="radio"
:aria-checked="widthLabel === widthOption"
:tabindex="widthLabel === widthOption ? 0 : -1"
class="flex-1 px-3 py-1.5 font-medium whitespace-nowrap sm:flex-none"
:class="
widthLabel === widthOption
? 'bg-primary text-inverted'
: 'bg-transparent text-toned'
"
@click="selectWidth(widthOption)"
>
{{ widthOption }}
</button>
</div>
<span class="font-mono text-xs text-muted">{{ monoLine }}</span>
</div>
<div
v-if="showNarrowNote"
role="status"
class="flex gap-2 bg-(--rc-info-soft) p-2.5"
>
<UIcon name="i-lucide-info" class="mt-0.5 size-4 shrink-0 text-info" />
<span class="text-toned">{{ narrowNoteText }}</span>
</div>
</div>
<div v-else-if="phase === 'uploading'" class="flex flex-col gap-3">
<div
class="flex flex-col items-center gap-3 rounded-md border border-default px-6 py-8 text-center"
@@ -226,8 +763,7 @@ watch(open, (isOpen) => {
/>
</div>
<p v-if="!succeeded" class="text-sm text-toned">
Uploading {{ formatMegabytes(progress.loaded) }} of
{{ formatMegabytes(progress.total) }} MB
{{ formatUploadProgress(progress.loaded, progress.total) }}
</p>
</div>
@@ -257,6 +793,12 @@ watch(open, (isOpen) => {
Choose another file
</UButton>
<UButton v-if="phase === 'failed'" @click="tryAgain"> Try again </UButton>
<template v-if="phase === 'crop'">
<UButton color="neutral" variant="outline" @click="chooseAnotherFile">
Choose another file
</UButton>
<UButton @click="confirmCropUpload">Upload image</UButton>
</template>
</div>
</template>
</UModal>
@@ -0,0 +1,108 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import RootComponent from '@/App.vue'
import ArticleEditor from '@/components/ArticleEditor.vue'
vi.mock('@nuxt/ui/composables', () => ({
useOverlay: () => ({ create: () => ({ open: () => Promise.resolve(false) }) }),
useToast: () => ({ add: vi.fn() }),
}))
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountArticleEditor(body = '<p>Hello</p>') {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = vi.fn(() =>
Promise.resolve(
mockResponse(200, {
id: 12,
title: 'T',
body,
images: [],
is_online: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}),
),
) as unknown as typeof fetch
await router.push('/article/12/edit')
const wrapper = mount(RootComponent, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
await router.push('/login')
})
describe('insertImage stamps the inserted image with its numeric id', () => {
it('renders data-image-id on the img and emits the updated body', async () => {
const wrapper = await mountArticleEditor()
const editor = wrapper.findComponent(ArticleEditor)
;(
editor.vm as unknown as { insertImage: (src: string, alt: string, id: number) => void }
).insertImage('/static/uploadedImages/photo.png', 'alt text', 10)
await flushPromises()
expect(editor.props('modelValue')).toContain(
'<img src="/static/uploadedImages/photo.png" alt="alt text" data-image-id="10">',
)
})
})
describe('removeImage removes only the node whose data-image-id matches', () => {
it('deletes the matching image node and leaves the rest of the content', async () => {
const wrapper = await mountArticleEditor()
const editor = wrapper.findComponent(ArticleEditor)
const vm = editor.vm as unknown as {
insertImage: (src: string, alt: string, id: number) => void
removeImage: (id: number) => void
}
vm.insertImage('/static/uploadedImages/a.png', '', 10)
await flushPromises()
vm.insertImage('/static/uploadedImages/b.png', '', 11)
await flushPromises()
vm.removeImage(10)
await flushPromises()
const body = editor.props('modelValue') as string
expect(body).not.toContain('data-image-id="10"')
expect(body).toContain('data-image-id="11"')
})
it('does nothing when no image carries the given id', async () => {
const wrapper = await mountArticleEditor('<p>Hello</p>')
const editor = wrapper.findComponent(ArticleEditor)
const vm = editor.vm as unknown as { removeImage: (id: number) => void }
vm.removeImage(999)
await flushPromises()
expect(editor.emitted('update:modelValue')).toBeFalsy()
})
})
@@ -13,6 +13,15 @@ vi.mock('@/lib/imageUpload', async (importOriginal) => {
}
})
vi.mock('@/lib/imageCrop', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
return {
...actual,
readNaturalImageSize: vi.fn(async () => ({ width: 4032, height: 3024 })),
renderCroppedImage: vi.fn(async () => new Blob([new Uint8Array(430080)])),
}
})
class FakeXMLHttpRequest {
static instances: FakeXMLHttpRequest[] = []
@@ -103,7 +112,7 @@ const Host = defineComponent({
const open = ref(false)
return {
open,
onUploaded: (location: string) => emit('uploaded', location),
onUploaded: (location: string, imageId: number) => emit('uploaded', location, imageId),
onClose: () => emit('close'),
}
},
@@ -141,6 +150,7 @@ async function chooseFile(file: File) {
input.dispatchEvent(new Event('change'))
await nextTick()
await nextTick()
await flushPromises()
}
function findButton(text: string) {
@@ -153,6 +163,12 @@ async function clickButton(text: string) {
findButton(text)?.click()
await nextTick()
await nextTick()
await flushPromises()
}
async function chooseRasterFileAndConfirmUpload(file: File) {
await chooseFile(file)
await clickButton('Upload image')
}
const RealXMLHttpRequest = global.XMLHttpRequest
@@ -247,25 +263,25 @@ describe('"Choose another file" from the rejected state returns to idle', () =>
})
describe('A valid upload shows progress and its cancel action reads "Cancel upload"', () => {
it('sends the file and reports live progress', async () => {
it('sends the resized file and reports live progress', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
const xhr = latestXHR()
expect(xhr.url).toBe('/v1/article/12/image')
expect(xhr.sentBody?.get('file')).toBeInstanceOf(File)
expect(document.body.textContent).toContain('north-wall.jpg 0%')
expect(document.body.textContent).toContain('Uploading 0.0 of 3.9 MB')
expect(document.body.textContent).toContain('Uploading 0.0 of 420 KB')
expect(findButton('Cancel upload')).toBeTruthy()
xhr.fireProgress(mib(2.4), mib(3.9))
xhr.fireProgress(180 * 1024, 420 * 1024)
await nextTick()
expect(document.body.textContent).toContain('north-wall.jpg 62%')
expect(document.body.textContent).toContain('Uploading 2.4 of 3.9 MB')
expect(document.body.textContent).toContain('north-wall.jpg 43%')
expect(document.body.textContent).toContain('Uploading 180 of 420 KB')
wrapper.unmount()
})
})
@@ -274,7 +290,7 @@ describe('Closing the dialog during an upload cancels it', () => {
it('aborts the in-flight XMLHttpRequest and closes the modal', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
const xhr = latestXHR()
await clickButton('Cancel upload')
@@ -290,13 +306,16 @@ describe('A successful upload emits the location and auto-closes 600 ms later',
vi.useFakeTimers()
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
const xhr = latestXHR()
xhr.resolve(201, JSON.stringify({ location: '/static/uploadedImages/north-wall.jpg' }))
xhr.resolve(
201,
JSON.stringify({ id: 7, location: '/static/uploadedImages/north-wall.jpg' }),
)
await vi.advanceTimersByTimeAsync(0)
expect(wrapper.emitted('uploaded')).toEqual([['/static/uploadedImages/north-wall.jpg']])
expect(wrapper.emitted('uploaded')).toEqual([['/static/uploadedImages/north-wall.jpg', 7]])
expect(document.body.textContent).toContain('Image added')
expect(document.querySelector('[role="dialog"]')).toBeTruthy()
@@ -319,7 +338,7 @@ describe('A server-side upload failure keeps the article unchanged and offers a
])('shows the failed state for $name', async ({ trigger }) => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
const xhr = latestXHR()
trigger(xhr)
@@ -339,7 +358,7 @@ describe('"Try again" re-attempts the upload with the same file', () => {
it('resends the same file under field "file"', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
latestXHR().resolve(500, '')
await flushPromises()
@@ -359,7 +378,7 @@ describe('A server 400 for a type the client should have caught shows the same r
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({ ok: true })
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('notes.pdf', 'image/png', 1.2))
await chooseRasterFileAndConfirmUpload(fileFixture('notes.pdf', 'image/png', 1.2))
const xhr = latestXHR()
xhr.resolve(400, 'the file must be a PNG, JPEG, WEBP, or SVG image')
@@ -380,7 +399,7 @@ describe('A server 400 for a size the client should have caught shows the size-n
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({ ok: true })
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('north-wall.tif', 'image/png', 14.2))
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.tif', 'image/png', 14.2))
const xhr = latestXHR()
xhr.resolve(400, 'the file exceeds the maximum upload size')
@@ -0,0 +1,479 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import ImageUploader from '../ImageUploader.vue'
import { renderCroppedImage } from '@/lib/imageCrop'
import type { CropRect } from '@/lib/imageCrop'
vi.mock('@/lib/imageCrop', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
return {
...actual,
readNaturalImageSize: vi.fn(async (file: File) => imageSizeFor(file)),
renderCroppedImage: vi.fn(async () => new Blob([new Uint8Array(430080)])),
}
})
const imageSizes: Record<string, { width: number; height: number }> = {
'north-wall.jpg': { width: 4032, height: 3024 },
'sunset.jpg': { width: 3000, height: 2000 },
}
function imageSizeFor(file: File) {
return imageSizes[file.name] ?? { width: 4032, height: 3024 }
}
class FakeXMLHttpRequest {
static instances: FakeXMLHttpRequest[] = []
method = ''
url = ''
status = 0
responseText = ''
withCredentials = false
sentBody: FormData | null = null
private listeners: Record<string, Array<(event?: unknown) => void>> = {}
private uploadListeners: Record<string, (event: { loaded: number; total: number }) => void> = {}
upload = {
addEventListener: (
type: string,
cb: (event: { loaded: number; total: number }) => void,
) => {
this.uploadListeners[type] = cb
},
}
constructor() {
FakeXMLHttpRequest.instances.push(this)
}
open(method: string, url: string) {
this.method = method
this.url = url
}
send(body: FormData) {
this.sentBody = body
}
abort() {}
addEventListener(type: string, cb: (event?: unknown) => void) {
this.listeners[type] = this.listeners[type] || []
this.listeners[type].push(cb)
}
dispatch(type: string, event?: unknown) {
;(this.listeners[type] || []).forEach((cb) => cb(event))
}
fireProgress(loaded: number, total: number) {
this.uploadListeners.progress?.({ loaded, total })
}
resolve(status: number, body: string) {
this.status = status
this.responseText = body
this.dispatch('load')
}
}
function latestXHR() {
return FakeXMLHttpRequest.instances[FakeXMLHttpRequest.instances.length - 1]
}
function mib(value: number) {
return Math.round(value * 1024 * 1024)
}
function fileFixture(name: string, type: string, sizeMB: number) {
const file = new File(['x'], name, { type })
Object.defineProperty(file, 'size', { value: mib(sizeMB), configurable: true })
return file
}
const Host = defineComponent({
components: { ImageUploader },
props: {
articleId: { type: Number, required: true },
},
emits: ['uploaded', 'close'],
setup(_props, { emit }) {
const open = ref(false)
return {
open,
onUploaded: (location: string) => emit('uploaded', location),
onClose: () => emit('close'),
}
},
template: `<ImageUploader v-model:open="open" :article-id="articleId" @uploaded="onUploaded" @close="onClose" />`,
})
function mountUploader() {
return mount(Host, {
props: { articleId: 12 },
global: { plugins: [uiPlugin] },
attachTo: document.body,
})
}
async function openModal(wrapper: ReturnType<typeof mountUploader>) {
;(wrapper.vm as unknown as { open: boolean }).open = true
await nextTick()
await nextTick()
}
function fileInput() {
return document.body.querySelector<HTMLInputElement>('[data-testid="image-file-input"]')!
}
async function chooseFile(file: File) {
const input = fileInput()
Object.defineProperty(input, 'files', {
value: { 0: file, length: 1 },
configurable: true,
})
input.dispatchEvent(new Event('change'))
await nextTick()
await nextTick()
await nextTick()
}
function findButton(text: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === text,
)
}
async function clickButton(text: string) {
findButton(text)?.click()
await nextTick()
await nextTick()
await nextTick()
}
function radioPill(groupLabel: string, text: string) {
const group = document.body.querySelector<HTMLElement>(
`[role="radiogroup"][aria-label="${groupLabel}"]`,
)!
return Array.from(group.querySelectorAll<HTMLButtonElement>('[role="radio"]')).find(
(pill) => pill.textContent?.trim() === text,
)!
}
async function selectRatioPill(text: string) {
radioPill('Aspect ratio', text).click()
await nextTick()
await nextTick()
}
async function selectWidthPill(text: string) {
radioPill('Width', text).click()
await nextTick()
await nextTick()
}
function cropFrame() {
return document.body.querySelector<HTMLElement>('[data-testid="crop-frame"]')!
}
function north_wall() {
return fileFixture('north-wall.jpg', 'image/jpeg', 3.9)
}
const RealXMLHttpRequest = global.XMLHttpRequest
beforeEach(() => {
FakeXMLHttpRequest.instances = []
global.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest
})
afterEach(() => {
document.body.innerHTML = ''
global.XMLHttpRequest = RealXMLHttpRequest
})
describe('A raster file opens the crop-and-resize step instead of uploading immediately', () => {
it('opens "Crop and resize" with the opened defaults', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(north_wall())
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
expect(document.body.textContent).toContain('Crop and resize')
expect(document.body.textContent).toContain('north-wall.jpg · 3.9 MB')
expect(radioPill('Aspect ratio', 'Original').getAttribute('aria-checked')).toBe('true')
expect(radioPill('Width', '1600 px').getAttribute('aria-checked')).toBe('true')
expect(document.body.textContent).toContain('Uploads as 1600 × 1200 px · about 420 KB')
expect(findButton('Choose another file')).toBeTruthy()
expect(findButton('Upload image')).toBeTruthy()
wrapper.unmount()
})
})
describe('An SVG skips the crop step and uploads immediately', () => {
it('sends the SVG right away', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('diagram.svg', 'image/svg+xml', 0.04))
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
expect(latestXHR().url).toBe('/v1/article/12/image')
expect(document.body.textContent).not.toContain('Crop and resize')
wrapper.unmount()
})
})
describe('Choosing an aspect ratio locks the frame to it', () => {
it.each([
{ ratio: '16:9', cropWidth: 4032, cropHeight: 2268, outWidth: 1600, outHeight: 900 },
{ ratio: '4:3', cropWidth: 4032, cropHeight: 3024, outWidth: 1600, outHeight: 1200 },
{ ratio: '1:1', cropWidth: 3024, cropHeight: 3024, outWidth: 1600, outHeight: 1600 },
])(
'locks the frame for $ratio',
async ({ ratio, cropWidth, cropHeight, outWidth, outHeight }) => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(north_wall())
await selectRatioPill(ratio)
const rect = (
wrapper.findComponent(ImageUploader).vm as unknown as { cropRect: CropRect }
).cropRect
expect(rect.width).toBe(cropWidth)
expect(rect.height).toBe(cropHeight)
expect(document.body.textContent).toContain(
`Uploads as ${outWidth} × ${outHeight} px · about`,
)
wrapper.unmount()
},
)
})
describe('Choosing a narrower width preset recomputes the output without upscaling', () => {
it('shows a smaller estimate', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(north_wall())
await selectWidthPill('800 px')
expect(document.body.textContent).toContain('Uploads as 800 × 600 px · about')
wrapper.unmount()
})
})
describe('A crop narrower than the chosen width is uploaded at its own size, not upscaled', () => {
it('shows the info row and keeps the crop size', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(north_wall())
await selectRatioPill('1:1')
const uploader = wrapper.findComponent(ImageUploader).vm as unknown as {
resizeCropTo: (w: number, h: number) => void
}
uploader.resizeCropTo(640, 640)
await nextTick()
expect(document.body.textContent).toContain('Uploads as 640 × 640 px · about')
expect(document.body.textContent).toContain(
"The crop is 640 px wide, so it's uploaded at that size. Enlarging it would make it blurry.",
)
wrapper.unmount()
})
})
describe('Free ratio removes the aspect-ratio constraint', () => {
it('accepts an independent width and height', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(north_wall())
await selectRatioPill('Free')
const uploader = wrapper.findComponent(ImageUploader).vm as unknown as {
resizeCropTo: (w: number, h: number) => void
cropRect: CropRect
}
uploader.resizeCropTo(1000, 400)
await nextTick()
expect(uploader.cropRect.width).toBe(1000)
expect(uploader.cropRect.height).toBe(400)
wrapper.unmount()
})
})
describe('Arrow keys move the crop frame and Shift+arrow keys resize it', () => {
it('moves 1px on ArrowRight and resizes 1px on Shift+ArrowRight', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(north_wall())
await selectRatioPill('1:1')
const uploader = wrapper.findComponent(ImageUploader).vm as unknown as {
cropRect: CropRect
}
const before = { ...uploader.cropRect }
cropFrame().dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
)
await nextTick()
expect(uploader.cropRect.x).toBe(before.x + 1)
expect(uploader.cropRect.width).toBe(before.width)
expect(document.body.textContent).toContain('Uploads as 1600 × 1600 px · about')
cropFrame().dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', shiftKey: true, bubbles: true }),
)
await nextTick()
expect(uploader.cropRect.width).toBe(before.width + 1)
wrapper.unmount()
})
})
describe('Dragging the crop frame moves it, and dragging a corner handle resizes it', () => {
it('clamps the dragged rect inside the image bounds', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(north_wall())
await selectRatioPill('1:1')
const uploader = wrapper.findComponent(ImageUploader).vm as unknown as {
cropRect: CropRect
}
const before = { ...uploader.cropRect }
cropFrame().dispatchEvent(
new MouseEvent('mousedown', { clientX: 0, clientY: 0, bubbles: true }),
)
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 100, clientY: 40 }))
window.dispatchEvent(new MouseEvent('mouseup'))
await nextTick()
expect(uploader.cropRect.x).toBe(before.x + 100)
expect(uploader.cropRect.y).toBe(before.y)
expect(uploader.cropRect.width).toBe(before.width)
expect(uploader.cropRect.height).toBe(before.height)
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 999, clientY: 0 }))
await nextTick()
expect(uploader.cropRect.x).toBe(before.x + 100)
const handle = document.body.querySelectorAll<HTMLElement>(
'[data-testid="crop-handle"]',
)[3]!
handle.dispatchEvent(new MouseEvent('mousedown', { clientX: 0, clientY: 0, bubbles: true }))
const afterMove = { ...uploader.cropRect }
window.dispatchEvent(new MouseEvent('mousemove', { clientX: -50, clientY: -50 }))
window.dispatchEvent(new MouseEvent('mouseup'))
await nextTick()
expect(uploader.cropRect.width).toBe(afterMove.width - 50)
expect(uploader.cropRect.height).toBe(afterMove.height - 50)
expect(uploader.cropRect.x).toBe(afterMove.x)
expect(uploader.cropRect.y).toBe(afterMove.y)
window.dispatchEvent(new MouseEvent('mousemove', { clientX: -9999, clientY: 0 }))
await nextTick()
expect(uploader.cropRect.width).toBe(afterMove.width - 50)
wrapper.unmount()
})
})
describe('Rotate cycles the image in 90° steps and Reset returns to the opened defaults', () => {
it('rotates then resets', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(north_wall())
await selectRatioPill('16:9')
await selectWidthPill('800 px')
await clickButton('Rotate')
expect(document.body.textContent).toContain('Uploads as')
await clickButton('Reset')
expect(radioPill('Aspect ratio', 'Original').getAttribute('aria-checked')).toBe('true')
expect(radioPill('Width', '1600 px').getAttribute('aria-checked')).toBe('true')
expect(document.body.textContent).toContain('Uploads as 1600 × 1200 px · about 420 KB')
wrapper.unmount()
})
})
describe('"Choose another file" returns to the file picker without uploading', () => {
it('shows the idle state', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(north_wall())
await clickButton('Choose another file')
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
expect(document.body.textContent).toContain('Drop an image here')
wrapper.unmount()
})
})
describe("A newly chosen file opens a fresh crop step, not the previous file's adjustments", () => {
it('reopens at defaults for the new file', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(north_wall())
await selectRatioPill('16:9')
await selectWidthPill('800 px')
await clickButton('Choose another file')
await chooseFile(fileFixture('sunset.jpg', 'image/jpeg', 2.1))
expect(radioPill('Aspect ratio', 'Original').getAttribute('aria-checked')).toBe('true')
expect(radioPill('Width', '1600 px').getAttribute('aria-checked')).toBe('true')
const uploader = wrapper.findComponent(ImageUploader).vm as unknown as {
cropRect: CropRect
}
expect(uploader.cropRect.width).toBe(3000)
expect(uploader.cropRect.height).toBe(2000)
wrapper.unmount()
})
})
describe('"Upload image" uploads the cropped, resized file, not the original', () => {
it('sends the resized file and shows resized progress', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(north_wall())
await clickButton('Upload image')
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
const xhr = latestXHR()
const sentFile = xhr.sentBody?.get('file') as File
expect(sentFile.size).toBe(430080)
expect(vi.mocked(renderCroppedImage)).toHaveBeenCalledWith(
expect.any(File),
expect.objectContaining({
outputWidth: 1600,
outputHeight: 1200,
rect: expect.objectContaining({ width: 4032, height: 3024 }),
}),
)
expect(document.body.textContent).toContain('Uploading 0.0 of 420 KB')
xhr.fireProgress(180 * 1024, 420 * 1024)
await nextTick()
expect(document.body.textContent).toContain('north-wall.jpg 43%')
expect(document.body.textContent).toContain('Uploading 180 of 420 KB')
wrapper.unmount()
})
})
+6 -3
View File
@@ -6,7 +6,7 @@ export interface UploadProgress {
}
export type UploadOutcome =
| { kind: 'success'; location: string }
| { kind: 'success'; location: string; id: number }
| { kind: 'rejected'; body: string }
| { kind: 'failed' }
@@ -32,8 +32,11 @@ export function useImageUpload() {
request.addEventListener('load', () => {
activeRequest = null
if (request.status === 201) {
const body = JSON.parse(request.responseText) as { location: string }
resolve({ kind: 'success', location: body.location })
const body = JSON.parse(request.responseText) as {
id: number
location: string
}
resolve({ kind: 'success', location: body.location, id: body.id })
return
}
if (request.status === 400) {
+248
View File
@@ -0,0 +1,248 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import { ArticleImage, findArticleImagePos } from '../articleImage'
function mountHarness(onRequestRemove = vi.fn()) {
let editor: Editor
const Harness = defineComponent({
components: { EditorContent },
setup() {
editor = new Editor({
extensions: [StarterKit, ArticleImage.configure({ onRequestRemove })],
content: '<p>Hello</p>',
})
return { editor }
},
template: '<EditorContent :editor="editor" />',
})
const wrapper = mount(Harness, { attachTo: document.body })
return { wrapper, onRequestRemove, getEditor: () => editor! }
}
function deleteButton() {
return document.body.querySelector<HTMLButtonElement>('[data-testid="remove-image-button"]')
}
function flush() {
return new Promise((resolve) => setTimeout(resolve, 20))
}
function dispatchKeydown(editor: Editor, key: string) {
editor.view.dom.dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }),
)
}
let activeWrapper: ReturnType<typeof mountHarness>['wrapper'] | null = null
afterEach(() => {
activeWrapper?.unmount()
activeWrapper = null
})
describe('Selecting an inserted image reveals its delete button', () => {
it('shows the delete button when selected and hides it once deselected', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
await flush()
expect(deleteButton()).toBeTruthy()
editor.commands.setTextSelection(0)
await flush()
expect(deleteButton()).toBeFalsy()
})
})
describe('An image with no data-image-id gets no delete affordance', () => {
it('renders no delete button even when the node is selected', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setImage({ src: 'legacy.png', alt: '' })
let pos: number | null = null
editor.state.doc.descendants((node, nodePos) => {
if (node.type.name === 'image') {
pos = nodePos
}
})
editor.commands.setNodeSelection(pos as number)
await flush()
expect(deleteButton()).toBeFalsy()
})
})
describe('Activating the delete button triggers removal without deleting the node', () => {
it('calls onRequestRemove with the image id and leaves the node in place', async () => {
const { wrapper, getEditor, onRequestRemove } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
await flush()
deleteButton()?.click()
expect(onRequestRemove).toHaveBeenCalledWith(10)
expect(findArticleImagePos(editor, 10)).not.toBeNull()
})
})
describe('Pressing Backspace on a selected image triggers removal without deleting the node', () => {
it('calls onRequestRemove and leaves the image node in the document', () => {
const { wrapper, getEditor, onRequestRemove } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
dispatchKeydown(editor, 'Backspace')
expect(onRequestRemove).toHaveBeenCalledWith(10)
expect(findArticleImagePos(editor, 10)).not.toBeNull()
})
})
describe('Pressing Delete on a selected image triggers removal without deleting the node', () => {
it('calls onRequestRemove and leaves the image node in the document', () => {
const { wrapper, getEditor, onRequestRemove } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
dispatchKeydown(editor, 'Delete')
expect(onRequestRemove).toHaveBeenCalledWith(10)
expect(findArticleImagePos(editor, 10)).not.toBeNull()
})
})
describe('Backspace on a pre-existing image with no data-image-id is not intercepted', () => {
it('does not call onRequestRemove, letting native deletion proceed', () => {
const { wrapper, getEditor, onRequestRemove } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setImage({ src: 'legacy.png', alt: '' })
let pos: number | null = null
editor.state.doc.descendants((node, nodePos) => {
if (node.type.name === 'image') {
pos = nodePos
}
})
editor.commands.setNodeSelection(pos as number)
dispatchKeydown(editor, 'Backspace')
expect(onRequestRemove).not.toHaveBeenCalled()
})
})
describe('The custom image node view renders as a block-level element', () => {
it('uses a div wrapper so consecutive images stack vertically', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
await flush()
const node = document.body.querySelector<HTMLElement>(
'[data-testid="article-editor-image"]',
)
expect(node?.tagName).toBe('DIV')
expect(node ? getComputedStyle(node).display : null).toBe('block')
})
})
describe('Selecting an image shows a visible selection outline', () => {
it('applies the selected outline classes to the wrapper when the node is selected', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
await flush()
const node = document.body.querySelector<HTMLElement>(
'[data-testid="article-editor-image"]',
)
expect(node?.classList.contains('outline-primary')).toBe(true)
})
})
describe('The custom image node view preserves width, height and title attributes', () => {
it('renders width, height and title on the img element', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.insertContent({
type: 'image',
attrs: {
src: 'photo.png',
alt: 'alt text',
title: 'a title',
width: 320,
height: 240,
'data-image-id': 10,
},
})
await flush()
const img = document.body.querySelector<HTMLImageElement>(
'[data-testid="article-editor-image"] img',
)
expect(img?.getAttribute('width')).toBe('320')
expect(img?.getAttribute('height')).toBe('240')
expect(img?.getAttribute('title')).toBe('a title')
})
})
describe('The image node is not natively draggable', () => {
it('disables native dragging on the img element', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
await flush()
const node = document.body.querySelector<HTMLElement>(
'[data-testid="article-editor-image"]',
)
const img = node?.querySelector<HTMLImageElement>('img')
expect(img?.getAttribute('draggable')).toBe('false')
})
})
describe('removeImage removes the matching image node from the document', () => {
it('deletes only the node whose data-image-id matches', () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'a.png', alt: '', imageId: 10 })
editor.commands.setArticleImage({ src: 'b.png', alt: '', imageId: 11 })
const pos = findArticleImagePos(editor, 10) as number
editor
.chain()
.focus()
.deleteRange({ from: pos, to: pos + 1 })
.run()
expect(findArticleImagePos(editor, 10)).toBeNull()
expect(findArticleImagePos(editor, 11)).not.toBeNull()
})
})
+186
View File
@@ -0,0 +1,186 @@
import { describe, expect, it } from 'vitest'
import {
clampRect,
computeOutputSize,
estimateOutputBytes,
formatEstimatedSize,
moveRect,
ratioValueFor,
rectForRatio,
resizeRect,
rotateRectBySteps,
rotatedDimensions,
widthPresetPixels,
wholeRect,
} from '../imageCrop'
describe('rectForRatio', () => {
it('centers a 16:9 crop inside a 4032x3024 image', () => {
const rect = rectForRatio('16:9', 4032, 3024)
expect(rect).toEqual({ x: 0, y: 378, width: 4032, height: 2268 })
})
it('centers a 1:1 crop inside a 4032x3024 image', () => {
const rect = rectForRatio('1:1', 4032, 3024)
expect(rect).toEqual({ x: 504, y: 0, width: 3024, height: 3024 })
})
it('returns the whole image for Original', () => {
expect(rectForRatio('Original', 4032, 3024)).toEqual(wholeRect(4032, 3024))
})
})
describe('computeOutputSize', () => {
it('never upscales past the crop width', () => {
expect(computeOutputSize(640, 640, 1600)).toEqual({ width: 640, height: 640 })
})
it('caps to the width preset when the crop is larger', () => {
expect(computeOutputSize(4032, 3024, 1600)).toEqual({ width: 1600, height: 1200 })
})
})
describe('estimateOutputBytes', () => {
it('estimates 420 KB for the default 1600x1200 output', () => {
expect(formatEstimatedSize(estimateOutputBytes(1600, 1200))).toBe('420 KB')
})
})
describe('formatEstimatedSize', () => {
it('stays in KB just under the 1000 KB threshold', () => {
expect(formatEstimatedSize(999 * 1024)).toBe('999 KB')
})
it('switches to MB at and above the 1000 KB threshold', () => {
expect(formatEstimatedSize(1024 * 1024 * 2.5)).toBe('2.5 MB')
})
})
describe('clampRect', () => {
it('rounds fractional coordinates and dimensions to whole pixels', () => {
expect(clampRect({ x: 100.2, y: 50.7, width: 200.4, height: 150.6 }, 4032, 3024)).toEqual({
x: 100,
y: 51,
width: 200,
height: 151,
})
})
it('clamps width and height to the bounds', () => {
expect(clampRect({ x: 0, y: 0, width: 5000, height: 4000 }, 4032, 3024)).toEqual({
x: 0,
y: 0,
width: 4032,
height: 3024,
})
})
it('clamps x and y so the rect stays inside the bounds', () => {
expect(clampRect({ x: 4000, y: 3000, width: 3024, height: 3024 }, 4032, 3024)).toEqual({
x: 1008,
y: 0,
width: 3024,
height: 3024,
})
})
it('never lets width or height drop below 1px', () => {
expect(clampRect({ x: 0, y: 0, width: -10, height: 0 }, 4032, 3024)).toEqual({
x: 0,
y: 0,
width: 1,
height: 1,
})
})
})
describe('moveRect', () => {
it('offsets the rect and clamps it inside the bounds', () => {
const rect = { x: 100, y: 100, width: 200, height: 200 }
expect(moveRect(rect, 50, -50, 4032, 3024)).toEqual({
x: 150,
y: 50,
width: 200,
height: 200,
})
})
it('stops at the bound edge instead of moving past it', () => {
const rect = { x: 0, y: 0, width: 200, height: 200 }
expect(moveRect(rect, -50, -50, 4032, 3024)).toEqual({
x: 0,
y: 0,
width: 200,
height: 200,
})
})
})
describe('resizeRect', () => {
it('grows freely on both axes when no ratio is locked', () => {
const rect = { x: 0, y: 0, width: 200, height: 200 }
expect(resizeRect(rect, 20, 10, 4032, 3024, null)).toEqual({
x: 0,
y: 0,
width: 220,
height: 210,
})
})
it('derives height from width when a ratio is locked and width changes', () => {
const rect = { x: 0, y: 0, width: 200, height: 200 }
expect(resizeRect(rect, 100, 0, 4032, 3024, 16 / 9)).toEqual({
x: 0,
y: 0,
width: 300,
height: 169,
})
})
})
describe('ratioValueFor', () => {
it('returns null for Free so the frame is unconstrained', () => {
expect(ratioValueFor('Free', 4032, 3024)).toBeNull()
})
it('derives Original from the current bounds', () => {
expect(ratioValueFor('Original', 4032, 3024)).toBeCloseTo(4032 / 3024)
})
it('returns the fixed value for a named ratio', () => {
expect(ratioValueFor('1:1', 4032, 3024)).toBe(1)
})
})
describe('widthPresetPixels', () => {
it('returns null for Original so the crop keeps its own width', () => {
expect(widthPresetPixels('Original')).toBeNull()
})
it('returns the pixel target for a fixed preset', () => {
expect(widthPresetPixels('800 px')).toBe(800)
})
})
describe('rotateRectBySteps', () => {
it('rotates a whole-image rect 90deg clockwise into the new bounds', () => {
const rotated = rotateRectBySteps(wholeRect(4032, 3024), 4032, 3024, 1)
expect(rotated).toEqual({ x: 0, y: 0, width: 3024, height: 4032 })
})
it('round-trips back to the original after four steps', () => {
const rect = { x: 42, y: 34, width: 500, height: 300 }
const rotated = rotateRectBySteps(rect, 4032, 3024, 4)
expect(rotated).toEqual(rect)
})
})
describe('rotatedDimensions', () => {
it('swaps dimensions for a 90deg rotation', () => {
expect(rotatedDimensions(4032, 3024, 90)).toEqual({ width: 3024, height: 4032 })
})
it('keeps dimensions for a 180deg rotation', () => {
expect(rotatedDimensions(4032, 3024, 180)).toEqual({ width: 4032, height: 3024 })
})
})
+120
View File
@@ -0,0 +1,120 @@
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import Image from '@tiptap/extension-image'
import { NodeSelection } from '@tiptap/pm/state'
import { VueNodeViewRenderer } from '@tiptap/vue-3'
import ArticleEditorImageNode from '@/components/ArticleEditorImageNode.vue'
export interface ArticleImageOptions {
onRequestRemove: (imageId: number) => void
}
export interface ArticleImageEditorLike {
state: {
selection: unknown
doc: ProseMirrorNode
}
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
articleImage: {
setArticleImage: (options: { src: string; alt?: string; imageId: number }) => ReturnType
}
}
}
export function articleImageId(node: { attrs: Record<string, unknown> }): number | null {
const raw = node.attrs['data-image-id']
if (raw === null || raw === undefined || raw === '') {
return null
}
const id = Number(raw)
return Number.isNaN(id) ? null : id
}
export function selectedArticleImageId(editor: ArticleImageEditorLike): number | null {
const selection = editor.state.selection
if (!(selection instanceof NodeSelection)) {
return null
}
if (selection.node.type.name !== 'image') {
return null
}
return articleImageId(selection.node)
}
export function findArticleImagePos(
editor: ArticleImageEditorLike,
imageId: number,
): number | null {
let targetPos: number | null = null
editor.state.doc.descendants((node, pos) => {
if (targetPos !== null) {
return false
}
if (node.type.name === 'image' && articleImageId(node) === imageId) {
targetPos = pos
return false
}
return true
})
return targetPos
}
export const ArticleImage = Image.extend<ArticleImageOptions>({
addOptions() {
return {
...this.parent?.(),
onRequestRemove: () => {},
}
},
addAttributes() {
return {
...this.parent?.(),
'data-image-id': {
default: null,
parseHTML: (element) => element.getAttribute('data-image-id'),
renderHTML: (attributes) => {
if (!attributes['data-image-id']) {
return {}
}
return { 'data-image-id': attributes['data-image-id'] }
},
},
}
},
addNodeView() {
return VueNodeViewRenderer(ArticleEditorImageNode)
},
addCommands() {
return {
...this.parent?.(),
setArticleImage:
(options: { src: string; alt?: string; imageId: number }) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: {
src: options.src,
alt: options.alt,
'data-image-id': options.imageId,
},
})
},
}
},
addKeyboardShortcuts() {
const triggerRemoval = () => {
const imageId = selectedArticleImageId(this.editor)
if (imageId === null) {
return false
}
this.options.onRequestRemove(imageId)
return true
}
return {
Backspace: triggerRemoval,
Delete: triggerRemoval,
}
},
})
+245
View File
@@ -0,0 +1,245 @@
export interface CropRect {
x: number
y: number
width: number
height: number
}
export const ASPECT_RATIOS = ['Original', 'Free', '16:9', '4:3', '1:1'] as const
export type AspectRatioLabel = (typeof ASPECT_RATIOS)[number]
export const WIDTH_PRESETS = ['Original', '1600 px', '1200 px', '800 px'] as const
export type WidthPresetLabel = (typeof WIDTH_PRESETS)[number]
const FIXED_RATIO_VALUES: Partial<Record<AspectRatioLabel, number>> = {
'16:9': 16 / 9,
'4:3': 4 / 3,
'1:1': 1,
}
const WIDTH_PRESET_PIXELS: Record<WidthPresetLabel, number | null> = {
Original: null,
'1600 px': 1600,
'1200 px': 1200,
'800 px': 800,
}
export function widthPresetPixels(label: WidthPresetLabel): number | null {
return WIDTH_PRESET_PIXELS[label]
}
export function ratioValueFor(
ratio: AspectRatioLabel,
boundsWidth: number,
boundsHeight: number,
): number | null {
if (ratio === 'Free') {
return null
}
if (ratio === 'Original') {
return boundsWidth / boundsHeight
}
return FIXED_RATIO_VALUES[ratio] ?? null
}
export function wholeRect(width: number, height: number): CropRect {
return { x: 0, y: 0, width, height }
}
export function clampRect(rect: CropRect, boundsWidth: number, boundsHeight: number): CropRect {
const width = Math.round(Math.min(Math.max(rect.width, 1), boundsWidth))
const height = Math.round(Math.min(Math.max(rect.height, 1), boundsHeight))
const x = Math.round(Math.min(Math.max(rect.x, 0), boundsWidth - width))
const y = Math.round(Math.min(Math.max(rect.y, 0), boundsHeight - height))
return { x, y, width, height }
}
export function rectForRatio(
ratio: AspectRatioLabel,
boundsWidth: number,
boundsHeight: number,
): CropRect {
if (ratio === 'Original') {
return wholeRect(boundsWidth, boundsHeight)
}
const value = FIXED_RATIO_VALUES[ratio]
if (!value) {
return wholeRect(boundsWidth, boundsHeight)
}
const boundsRatio = boundsWidth / boundsHeight
let width: number
let height: number
if (value > boundsRatio) {
width = boundsWidth
height = Math.round(width / value)
} else {
height = boundsHeight
width = Math.round(height * value)
}
return {
x: Math.round((boundsWidth - width) / 2),
y: Math.round((boundsHeight - height) / 2),
width,
height,
}
}
export function moveRect(
rect: CropRect,
dx: number,
dy: number,
boundsWidth: number,
boundsHeight: number,
): CropRect {
return clampRect({ ...rect, x: rect.x + dx, y: rect.y + dy }, boundsWidth, boundsHeight)
}
export function resizeRect(
rect: CropRect,
dw: number,
dh: number,
boundsWidth: number,
boundsHeight: number,
lockedRatio: number | null,
): CropRect {
let width = rect.width + dw
let height = rect.height + dh
if (lockedRatio) {
if (dw !== 0) {
height = Math.round(width / lockedRatio)
} else if (dh !== 0) {
width = Math.round(height * lockedRatio)
}
}
return clampRect({ ...rect, width, height }, boundsWidth, boundsHeight)
}
export function resizeRectTo(
rect: CropRect,
width: number,
height: number,
boundsWidth: number,
boundsHeight: number,
): CropRect {
return clampRect({ ...rect, width, height }, boundsWidth, boundsHeight)
}
function rotateRectClockwiseOnce(rect: CropRect, width: number, height: number): CropRect {
return { x: height - rect.y - rect.height, y: rect.x, width: rect.height, height: rect.width }
}
export function rotateRectBySteps(
rect: CropRect,
width: number,
height: number,
steps: number,
): CropRect {
let r = rect
let w = width
let h = height
const n = ((steps % 4) + 4) % 4
for (let i = 0; i < n; i += 1) {
r = rotateRectClockwiseOnce(r, w, h)
;[w, h] = [h, w]
}
return r
}
export function rotatedDimensions(width: number, height: number, rotation: number) {
const turns = (((rotation / 90) % 4) + 4) % 4
return turns % 2 === 1 ? { width: height, height: width } : { width, height }
}
export function nextRotation(rotation: number): number {
return (rotation + 90) % 360
}
export function computeOutputSize(
cropWidth: number,
cropHeight: number,
targetWidth: number | null,
) {
const target = targetWidth === null ? cropWidth : targetWidth
const width = Math.min(target, cropWidth)
const height = Math.round((cropHeight * width) / cropWidth)
return { width, height }
}
const ESTIMATED_BYTES_PER_PIXEL = (420 * 1024) / (1600 * 1200)
export function estimateOutputBytes(width: number, height: number): number {
return Math.round(width * height * ESTIMATED_BYTES_PER_PIXEL)
}
export function formatEstimatedSize(bytes: number): string {
const kb = bytes / 1024
if (kb < 1000) {
return `${Math.round(kb)} KB`
}
return `${(kb / 1024).toFixed(1)} MB`
}
export interface NaturalImageSize {
width: number
height: number
}
export async function readNaturalImageSize(file: File): Promise<NaturalImageSize> {
const bitmap = await createImageBitmap(file)
const size = { width: bitmap.width, height: bitmap.height }
bitmap.close()
return size
}
export interface RenderCropOptions {
rect: CropRect
rotation: number
naturalWidth: number
naturalHeight: number
outputWidth: number
outputHeight: number
mimeType: string
}
export async function renderCroppedImage(file: File, options: RenderCropOptions): Promise<Blob> {
const bitmap = await createImageBitmap(file)
const rotated = rotatedDimensions(options.naturalWidth, options.naturalHeight, options.rotation)
const rotatedCanvas = document.createElement('canvas')
rotatedCanvas.width = rotated.width
rotatedCanvas.height = rotated.height
const rotatedCtx = rotatedCanvas.getContext('2d')
if (!rotatedCtx) {
throw new Error('canvas rendering is not supported')
}
rotatedCtx.translate(rotated.width / 2, rotated.height / 2)
rotatedCtx.rotate((options.rotation * Math.PI) / 180)
rotatedCtx.drawImage(bitmap, -options.naturalWidth / 2, -options.naturalHeight / 2)
const canvas = document.createElement('canvas')
canvas.width = options.outputWidth
canvas.height = options.outputHeight
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('canvas rendering is not supported')
}
ctx.drawImage(
rotatedCanvas,
options.rect.x,
options.rect.y,
options.rect.width,
options.rect.height,
0,
0,
options.outputWidth,
options.outputHeight,
)
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob)
return
}
reject(new Error('failed to render the cropped image'))
}, options.mimeType)
})
}
+16
View File
@@ -42,3 +42,19 @@ export function rejectionTitle(filename: string) {
export function sizeRejectionText(filename: string, bytes: number) {
return `${filename} is ${formatMegabytes(bytes)} MB. Images can be up to 10 MB.`
}
export function formatWholeKB(bytes: number): string {
const kb = bytes / 1024
const rounded = Math.round(kb * 10) / 10
if (rounded === 0) {
return '0.0'
}
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
}
export function formatUploadProgress(loadedBytes: number, totalBytes: number): string {
if (totalBytes < 1024 * 1024) {
return `Uploading ${formatWholeKB(loadedBytes)} of ${formatWholeKB(totalBytes)} KB`
}
return `Uploading ${formatMegabytes(loadedBytes)} of ${formatMegabytes(totalBytes)} MB`
}
+34 -2
View File
@@ -221,8 +221,8 @@ function onEditorDrop(event: DragEvent) {
openImageUploader(file)
}
function onImageUploaded(location: string) {
editorRef.value?.insertImage(location, '')
function onImageUploaded(location: string, imageId: number) {
editorRef.value?.insertImage(location, '', imageId)
toast.add({
color: 'success',
icon: 'i-lucide-circle-check',
@@ -231,6 +231,37 @@ function onImageUploaded(location: string) {
})
}
async function performRemoveImage(imageId: number) {
try {
await request(`/v1/article/${articleId}/image/${imageId}`, { method: 'DELETE' })
} catch {
throw new Error("Couldn't remove the image, so it's still there. Try again in a moment.")
}
editorRef.value?.removeImage(imageId)
toast.add({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image removed',
description: 'Save to keep this change.',
})
}
async function onRequestRemoveImage(imageId: number) {
await overlay
.create(ConfirmDialog, {
props: {
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',
action: () => performRemoveImage(imageId),
},
})
.open()
}
function onImageUploaderClosed() {
imageUploaderFile.value = null
}
@@ -426,6 +457,7 @@ onBeforeRouteLeave(async () => {
:article-id="articleId"
@update:model-value="draft.edit"
@request-image="onRequestImage"
@request-remove-image="onRequestRemoveImage"
>
<template #footer>
<CharacterCounter
+263 -2
View File
@@ -1,9 +1,270 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AuthShell from '@/components/AuthShell.vue'
import PasswordStrengthHint from '@/components/PasswordStrengthHint.vue'
import ResultPanel from '@/components/ResultPanel.vue'
import { ApiError, useApi } from '@/composables/useApi'
const route = useRoute()
const router = useRouter()
const api = useApi()
const code = typeof route.query.c === 'string' ? route.query.c : ''
const email = typeof route.query.email === 'string' ? route.query.email : ''
const missingParameters = !code || !email
const password = ref('')
const confirmPassword = ref('')
const passwordShown = ref(false)
const confirmPasswordShown = ref(false)
const submitting = ref(false)
const submitFailed = ref(false)
const mismatchTouched = ref(false)
const serverErrorAlert = ref(false)
const expired = ref(false)
const passwordValid = computed(() => password.value.length >= 8)
const passwordsMatch = computed(() => confirmPassword.value === password.value)
const showLengthError = computed(() => submitFailed.value && !passwordValid.value)
const showMatchError = computed(() => mismatchTouched.value && !passwordsMatch.value)
const passwordRules = computed(() => [{ label: 'At least 8 characters', met: passwordValid.value }])
const confirmPasswordRules = computed(() => [
{ label: 'Passwords match', met: passwordsMatch.value },
])
const confirmError = computed(() => (showMatchError.value ? "The passwords don't match." : ''))
function goToRequest() {
router.push('/register/reset/request')
}
function goToLogin() {
router.push('/login')
}
const expiredActions = [
{ label: 'Request a new link', onClick: goToRequest },
{
label: 'Back to log in',
color: 'neutral' as const,
variant: 'outline' as const,
onClick: goToLogin,
},
]
const missingParametersActions = [{ label: 'Request a new link', onClick: goToRequest }]
const confirmDescription =
"Pick something you haven't used here before. You'll log in with it next."
async function onSubmit() {
if (submitting.value) {
return
}
mismatchTouched.value = true
if (!passwordValid.value || !passwordsMatch.value) {
submitFailed.value = true
return
}
serverErrorAlert.value = false
submitting.value = true
try {
await api.request('/v1/auth/password-reset/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
code,
password: password.value,
}),
})
await router.push('/login?reason=password-changed')
} catch (error) {
if (error instanceof ApiError && error.status === 403) {
expired.value = true
return
}
serverErrorAlert.value = true
} finally {
submitting.value = false
}
}
</script>
<template>
<AuthShell title="Choose a new password">
<p>Reset confirm placeholder.</p>
<AuthShell
:title="expired || missingParameters ? '' : 'Choose a new password'"
:description="expired || missingParameters ? undefined : confirmDescription"
>
<template v-if="serverErrorAlert" #notice>
<UAlert
color="error"
icon="i-lucide-circle-x"
title="Couldn't reach RenewCMS"
description="Check your connection and try again."
variant="soft"
:close="false"
role="alert"
/>
</template>
<template v-if="missingParameters" #default>
<ResultPanel
tone="error"
icon="i-lucide-link-2-off"
title="This link is incomplete"
description="Part of the reset link is missing. Open it from your email again, or request a new one."
:actions="missingParametersActions"
/>
</template>
<template v-else-if="expired" #default>
<ResultPanel
tone="error"
icon="i-lucide-link-2-off"
title="This reset link has expired"
description="Reset links work once, for 1 hour. Request a new one and use it within the hour."
:actions="expiredActions"
/>
</template>
<template v-else #default="{ controlSize, controlClass, inputClass }">
<UForm
:state="{ email, password, confirmPassword }"
class="flex flex-col gap-4.5"
@submit="onSubmit"
>
<UFormField
label="Email"
name="email"
help="From your reset link."
:size="controlSize"
>
<UInput
:model-value="email"
placeholder="you@example.com"
readonly
variant="outline"
color="primary"
:size="controlSize"
:ui="{
base: `${inputClass} bg-elevated text-dimmed ring-(--ui-border)`,
}"
class="w-full"
/>
</UFormField>
<UFormField label="New password" name="password" :size="controlSize">
<template #help>
<PasswordStrengthHint
:rules="passwordRules"
:show-errors="showLengthError"
/>
</template>
<UInput
id="reset-new-password"
v-model="password"
:type="passwordShown ? 'text' : 'password'"
autocomplete="new-password"
:disabled="submitting"
variant="outline"
color="primary"
:size="controlSize"
:ui="{
base: `${inputClass} ${passwordShown ? '' : 'tracking-[2px]'}`,
trailing: 'pe-1',
}"
class="w-full"
>
<template #trailing>
<UButton
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
variant="ghost"
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
/>
</template>
</UInput>
</UFormField>
<UFormField label="Confirm new password" name="confirmPassword" :size="controlSize">
<template #help>
<span
v-if="confirmError"
role="alert"
class="mb-1.5 flex items-center gap-1.5 text-xs/[18px] text-error"
>
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ confirmError }}
</span>
<PasswordStrengthHint
:rules="confirmPasswordRules"
:show-errors="showMatchError"
/>
</template>
<UInput
id="reset-confirm-password"
v-model="confirmPassword"
:type="confirmPasswordShown ? 'text' : 'password'"
autocomplete="new-password"
:disabled="submitting"
variant="outline"
color="primary"
:highlight="!!confirmError"
:size="controlSize"
:ui="{
base: `${inputClass} ${confirmPasswordShown ? '' : 'tracking-[2px]'}`,
trailing: 'pe-1',
}"
class="w-full"
@blur="mismatchTouched = true"
>
<template #trailing>
<UButton
:icon="confirmPasswordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
variant="ghost"
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="
confirmPasswordShown ? 'Hide password' : 'Show password'
"
:aria-pressed="confirmPasswordShown"
@click="confirmPasswordShown = !confirmPasswordShown"
/>
</template>
</UInput>
</UFormField>
<UButton
type="submit"
block
:size="controlSize"
:ui="{ base: controlClass }"
:loading="submitting"
:disabled="submitting"
>
{{ submitting ? 'Changing password…' : 'Change password' }}
</UButton>
</UForm>
</template>
</AuthShell>
</template>
+192 -2
View File
@@ -1,9 +1,199 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import AuthShell from '@/components/AuthShell.vue'
import ResultPanel from '@/components/ResultPanel.vue'
import { ApiError, useApi } from '@/composables/useApi'
import { useRetryAfter } from '@/composables/useRetryAfter'
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
interface AlertState {
tone: 'error' | 'info'
icon: string
title: string
description: string
}
const tooManyAttemptsAlert: AlertState = {
tone: 'info',
icon: 'i-lucide-timer',
title: 'Too many requests',
description: 'Wait a moment before asking for another link.',
}
const serverErrorAlert: AlertState = {
tone: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't send the link",
description: 'Check your connection and try again.',
}
const router = useRouter()
const api = useApi()
const retryAfter = useRetryAfter()
const email = ref('')
const emailError = ref('')
const submitting = ref(false)
const sent = ref(false)
const alert = ref<AlertState | null>(null)
const alertRole = computed(() => (alert.value?.tone === 'error' ? 'alert' : 'status'))
const submitDisabled = computed(() => submitting.value || retryAfter.active.value)
const submitLabel = computed(() => {
if (submitting.value) {
return 'Sending…'
}
if (retryAfter.active.value) {
return `Try again in ${retryAfter.formatted.value}`
}
return 'Send reset link'
})
function validate() {
emailError.value = EMAIL_PATTERN.test(email.value)
? ''
: 'Enter a full email address, like name@example.com.'
return !emailError.value
}
function goToLogin() {
router.push('/login')
}
const resultPanelActions = [
{
label: 'Back to log in',
color: 'neutral' as const,
variant: 'outline' as const,
onClick: goToLogin,
},
]
function handleRequestError(error: unknown) {
if (error instanceof ApiError && error.status === 429) {
alert.value = tooManyAttemptsAlert
retryAfter.start(error.retryAfter ?? 0)
return
}
alert.value = serverErrorAlert
}
async function onSubmit() {
if (submitDisabled.value) {
return
}
if (!validate()) {
return
}
alert.value = null
submitting.value = true
try {
await api.request('/v1/auth/password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value }),
})
sent.value = true
} catch (error) {
handleRequestError(error)
} finally {
submitting.value = false
}
}
const pageTitle = computed(() => (sent.value ? '' : 'Reset your password'))
const pageDescription = computed(() =>
sent.value
? undefined
: "Enter the email for your account and we'll send you a link to choose a new password.",
)
</script>
<template>
<AuthShell title="Reset your password">
<p>Reset request placeholder.</p>
<AuthShell :title="pageTitle" :description="pageDescription">
<template v-if="alert" #notice>
<UAlert
:color="alert.tone"
:icon="alert.icon"
:title="alert.title"
:description="alert.description"
variant="soft"
:close="false"
:role="alertRole"
/>
</template>
<template v-if="sent" #default>
<ResultPanel
tone="success"
icon="i-lucide-mail-check"
title="Check your email"
description="If an account exists for that email, a password reset link has been sent."
:actions="resultPanelActions"
>
<p class="text-sm text-muted">The link expires in 1 hour.</p>
</ResultPanel>
</template>
<template v-else #default="{ controlSize, controlClass, inputClass }">
<UForm :state="{ email }" class="flex flex-col gap-4.5" @submit="onSubmit">
<UFormField
label="Email"
name="email"
:error="emailError || undefined"
:size="controlSize"
>
<template v-if="emailError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
<UInput
v-model="email"
type="email"
placeholder="you@example.com"
autocomplete="email"
:disabled="submitDisabled"
variant="outline"
color="primary"
:highlight="!!emailError"
:size="controlSize"
:ui="{ base: inputClass, trailing: 'pe-1' }"
class="w-full"
/>
</UFormField>
<UButton
type="submit"
block
:size="controlSize"
:ui="{ base: controlClass }"
:loading="submitting"
:disabled="submitDisabled"
>
{{ submitLabel }}
</UButton>
</UForm>
</template>
<template v-if="!sent" #footer>
<RouterLink
to="/login"
class="font-medium text-highlighted underline underline-offset-3"
>
Back to log in
</RouterLink>
</template>
</AuthShell>
</template>
@@ -10,6 +10,15 @@ import RootComponent from '@/App.vue'
const FIXED_NOW = '2026-09-26T11:02:00Z'
vi.mock('@/lib/imageCrop', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
return {
...actual,
readNaturalImageSize: vi.fn(async () => ({ width: 4032, height: 3024 })),
renderCroppedImage: vi.fn(async () => new Blob([new Uint8Array(430080)])),
}
})
class FakeXMLHttpRequest {
static instances: FakeXMLHttpRequest[] = []
@@ -195,8 +204,8 @@ describe("Opening the uploader from the toolbar's image button shows the idle st
})
})
describe('Dropping a valid file onto the editor opens the uploader already uploading it', () => {
it('opens uploading, not idle, and sends the file', async () => {
describe('Dropping a valid file onto the editor opens the uploader already showing the crop step', () => {
it('opens the crop step, and confirming it sends the resized file', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
@@ -209,8 +218,12 @@ describe('Dropping a valid file onto the editor opens the uploader already uploa
wrapper.findComponent(ArticleEditor).element.dispatchEvent(dropEvent)
await flushPromises()
expect(document.body.textContent).toContain('Add an image')
expect(document.body.textContent).toContain('Crop and resize')
expect(document.body.textContent).not.toContain('Drop an image here')
findButton('Upload image')?.click()
await flushPromises()
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
expect(latestXHR().url).toBe('/v1/article/12/image')
expect((latestXHR().sentBody?.get('file') as File).name).toBe('before.png')
@@ -235,6 +248,9 @@ describe('A successful upload inserts the image at the cursor and marks the draf
fileInput.dispatchEvent(new Event('change'))
await flushPromises()
findButton('Upload image')?.click()
await flushPromises()
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
latestXHR().resolve(
201,
+151 -131
View File
@@ -10,11 +10,7 @@ import RootComponent from '@/App.vue'
const FIXED_NOW = '2026-09-26T11:02:00Z'
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
@@ -28,9 +24,7 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -61,17 +55,15 @@ function articleFixture(overrides: Partial<Article> = {}): Article {
function createOverlayMock() {
let props: Record<string, unknown> | null = null
let resolveOpen: ((value: unknown) => void) | null = null
const create = vi.fn(
(_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
},
)
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
})
return {
create,
getProps: () => props,
@@ -120,13 +112,8 @@ function articleEditorBody(wrapper: Awaited<ReturnType<typeof mount>>) {
return wrapper.findComponent(ArticleEditor).props('modelValue')
}
async function typeBody(
wrapper: Awaited<ReturnType<typeof mount>>,
body: string,
) {
await wrapper
.findComponent(ArticleEditor)
.vm.$emit('update:modelValue', body)
async function typeBody(wrapper: Awaited<ReturnType<typeof mount>>, body: string) {
await wrapper.findComponent(ArticleEditor).vm.$emit('update:modelValue', body)
await flushPromises()
}
@@ -161,49 +148,35 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
new Promise((resolve) => {
resolveGet = () =>
resolve(mockResponse(200, articleFixture()))
resolveGet = () => resolve(mockResponse(200, articleFixture()))
}),
})
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
false,
)
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(false)
await vi.advanceTimersByTimeAsync(150)
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
true,
)
expect(wrapper.find('[data-testid="skeleton-content"]').exists()).toBe(
true,
)
expect(wrapper.find('[data-testid="skeleton-details"]').exists()).toBe(
true,
)
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="skeleton-content"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="skeleton-details"]').exists()).toBe(true)
resolveGet?.()
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
false,
)
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(false)
expect(wrapper.text()).toContain('How we price a bathroom renovation')
})
it('never shows a skeleton when the response resolves in under 150 ms', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
Promise.resolve(mockResponse(200, articleFixture())),
'GET /v1/article/12': () => Promise.resolve(mockResponse(200, articleFixture())),
})
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
false,
)
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(false)
expect(wrapper.text()).toContain('How we price a bathroom renovation')
})
})
@@ -216,19 +189,14 @@ describe('Saved — a freshly loaded article, draft or online', () => {
'reads the Saved state for is_online $is_online',
async ({ is_online, badge, publishLabel }) => {
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
mockResponse(200, articleFixture({ is_online })),
'GET /v1/article/12': () => mockResponse(200, articleFixture({ is_online })),
})
expect(wrapper.text()).toContain('Articles')
expect(wrapper.text()).toContain(
'How we price a bathroom renovation',
)
expect(wrapper.text()).toContain('How we price a bathroom renovation')
expect(wrapper.text()).toContain(badge)
expect(wrapper.text()).toContain('Saved at 11:02')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
expect(wrapper.text()).toContain(publishLabel)
expect(wrapper.text()).toContain('4 318 / 10 000')
},
@@ -244,9 +212,7 @@ describe('Unsaved changes — any edit marks the article dirty', () => {
await typeBody(wrapper, paragraphOfLength(4320))
expect(wrapper.text()).toContain('Unsaved changes')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
expect(wrapper.text()).toContain('Save and publish')
})
})
@@ -274,9 +240,7 @@ describe('Saving — Save sends the PATCH and disables itself while in flight',
body: paragraphOfLength(4320),
})
expect(wrapper.text()).toContain('Saving…')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
})
})
@@ -296,9 +260,7 @@ describe('Ctrl/⌘ S saves the same way as clicking Save', () => {
throw new Error(`Unexpected fetch call: ${routeFor(url, init)}`)
}) as unknown as typeof fetch
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 's', ctrlKey: true }),
)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true }))
await flushPromises()
expect(JSON.parse(patchBody ?? '{}')).toEqual({
@@ -335,9 +297,7 @@ describe('A successful save returns to the Saved state with the new time', () =>
await flushPromises()
expect(wrapper.text()).toContain('Saved at 11:30')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
})
})
@@ -374,14 +334,8 @@ describe('Save failed — a 500 or a network failure keeps the changes and offer
await flushPromises()
expect(wrapper.text()).toContain('Save failed')
expect(
wrapper
.findAll('button')
.some((button) => button.text() === 'Retry'),
).toBe(true)
expect(articleEditorBody(wrapper)).toBe(
paragraph('Revised opening paragraph.'),
)
expect(wrapper.findAll('button').some((button) => button.text() === 'Retry')).toBe(true)
expect(articleEditorBody(wrapper)).toBe(paragraph('Revised opening paragraph.'))
})
})
@@ -406,9 +360,7 @@ describe('Retry resends the same body and can recover', () => {
await saveButton(wrapper).trigger('click')
await flushPromises()
const retryButton = wrapper
.findAll('button')
.find((button) => button.text() === 'Retry')
const retryButton = wrapper.findAll('button').find((button) => button.text() === 'Retry')
await retryButton?.trigger('click')
await flushPromises()
@@ -444,14 +396,8 @@ describe('Near the limit — the counter warns at 9000 characters without blocki
await typeBody(wrapper, paragraphOfLength(9240))
expect(wrapper.text()).toContain('9 240 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-warning-icon"]')
.exists(),
).toBe(true)
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(true)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
})
})
@@ -466,26 +412,16 @@ describe('Too long — over 10000 characters disables Save and Publish until sho
expect(wrapper.text()).toContain(
'214 characters over. Shorten the text or remove an image. Formatting counts too.',
)
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
const publishButton = wrapper
.findAll('button')
.find(
(button) =>
button.text() === 'Publish' ||
button.text() === 'Save and publish',
)
expect((publishButton?.element as HTMLButtonElement).disabled).toBe(
true,
)
.find((button) => button.text() === 'Publish' || button.text() === 'Save and publish')
expect((publishButton?.element as HTMLButtonElement).disabled).toBe(true)
await typeBody(wrapper, paragraphOfLength(9000))
expect(wrapper.text()).not.toContain('characters over')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
})
})
@@ -501,12 +437,10 @@ describe('Preview shows the rendered title and body without the editing surface'
await flushPromises()
expect(wrapper.findComponent(ArticleEditor).exists()).toBe(false)
expect(
wrapper.find('[data-testid="article-preview"]').text(),
).toContain('How we price a bathroom renovation')
expect(
wrapper.find('[data-testid="article-preview"]').html(),
).toContain('Some body')
expect(wrapper.find('[data-testid="article-preview"]').text()).toContain(
'How we price a bathroom renovation',
)
expect(wrapper.find('[data-testid="article-preview"]').html()).toContain('Some body')
await previewSwitch.trigger('click')
await flushPromises()
@@ -523,24 +457,18 @@ describe('Offline blocks Save and Publish, and recovers when the connection retu
})
await typeBody(wrapper, paragraphOfLength(4320))
const onLineSpy = vi
.spyOn(navigator, 'onLine', 'get')
.mockReturnValue(false)
const onLineSpy = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
expect(wrapper.text()).toContain('Waiting for connection')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
onLineSpy.mockReturnValue(true)
window.dispatchEvent(new Event('online'))
await flushPromises()
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
expect(wrapper.text()).toContain('Unsaved changes')
onLineSpy.mockRestore()
})
@@ -553,9 +481,7 @@ describe('Leaving with unsaved changes prompts, and "Discard changes" proceeds',
})
await typeBody(wrapper, paragraphOfLength(4320))
const articlesLink = wrapper
.findAll('a')
.find((a) => a.attributes('href') === '/article')
const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article')
expect(articlesLink).toBeTruthy()
const navigation = router.push('/article')
@@ -584,9 +510,7 @@ describe('Leaving with unsaved changes, "Keep editing" stays on the page', () =>
})
await typeBody(wrapper, paragraphOfLength(4320))
const articlesLink = wrapper
.findAll('a')
.find((a) => a.attributes('href') === '/article')
const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article')
expect(articlesLink).toBeTruthy()
const navigation = router.push('/article')
@@ -627,9 +551,7 @@ describe('Leaving a clean article never prompts', () => {
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
const articlesLink = wrapper
.findAll('a')
.find((a) => a.attributes('href') === '/article')
const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article')
await articlesLink?.trigger('click')
await flushPromises()
@@ -644,18 +566,14 @@ describe('Session expired mid-save keeps the draft and restores it after logging
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': () => mockResponse(401),
})
const body = paragraph(
'Paragraph added just before the session expired.',
)
const body = paragraph('Paragraph added just before the session expired.')
await typeBody(wrapper, body)
await saveButton(wrapper).trigger('click')
await flushPromises()
expect(sessionStorage.getItem('renewcms:article-draft:12')).toBe(body)
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query.redirect).toBe(
'/article/12/edit',
)
expect(router.currentRoute.value.query.redirect).toBe('/article/12/edit')
expect(router.currentRoute.value.query.reason).toBe('expired')
const session = useSessionStore()
@@ -685,6 +603,108 @@ describe('Session expired mid-save keeps the draft and restores it after logging
})
})
function articleWithImageFixture(overrides: Partial<Article> = {}) {
return articleFixture({
body: '<p>Body</p><img src="/static/uploadedImages/before.png" alt="" data-image-id="10">',
...overrides,
})
}
async function requestRemoveImage(wrapper: Awaited<ReturnType<typeof mount>>, imageId = 10) {
await wrapper.findComponent(ArticleEditor).vm.$emit('request-remove-image', imageId)
await flushPromises()
}
describe('Requesting removal of an inline image opens the confirm dialog', () => {
it('opens ConfirmDialog with the exact copy', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
})
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as Record<string, unknown>
expect(props.title).toBe('Remove this image?')
expect(props.description).toBe(
"This image will be removed from the article. This can't be undone.",
)
expect(props.confirmLabel).toBe('Remove image')
expect(props.cancelLabel).toBe('Cancel')
expect(props.pendingLabel).toBe('Removing…')
expect(props.tone).toBe('danger')
})
})
describe('Confirming removes the image, marks the draft dirty, and shows a success toast', () => {
it('removes the image from the content and reads Unsaved changes', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
'DELETE /v1/article/12/image/10': () => mockResponse(204),
})
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
expect(articleEditorBody(wrapper)).not.toContain('data-image-id="10"')
expect(wrapper.text()).toContain('Unsaved changes')
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image removed',
description: 'Save to keep this change.',
})
})
})
describe('A failed removal keeps the image and shows the dialog inline error', () => {
it.each([
{ name: 'a 404 response', handler: () => mockResponse(404) },
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('rejects with the inline error message for $name', async ({ handler }) => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
'DELETE /v1/article/12/image/10': handler,
})
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as { action: () => Promise<void> }
await expect(props.action()).rejects.toThrow(
"Couldn't remove the image, so it's still there. Try again in a moment.",
)
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
expect(wrapper.text()).not.toContain('Unsaved changes')
})
})
describe('Confirming a removal started by Backspace behaves exactly like confirming one started by the button', () => {
it('removes the image, marks the draft dirty, and shows the same success toast', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
'DELETE /v1/article/12/image/10': () => mockResponse(204),
})
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
expect(articleEditorBody(wrapper)).not.toContain('data-image-id="10"')
expect(wrapper.text()).toContain('Unsaved changes')
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image removed',
description: 'Save to keep this change.',
})
})
})
describe('An unknown or deleted article id replaces the editor with an error state', () => {
it('shows the not-found error state and navigates to /article', async () => {
const wrapper = await mountEditor(
@@ -0,0 +1,326 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import ResultPanel from '@/components/ResultPanel.vue'
import ResetConfirmView from '../ResetConfirmView.vue'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name: string) => headers[name] ?? null },
json: async () => body,
} as unknown as Response
}
function routeFor(url: string, init?: RequestInit) {
const method = init?.method ?? 'GET'
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
if (!handler) {
throw new Error(`Unexpected fetch call: ${key}`)
}
return handler()
})
}
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountResetConfirm(
path: string,
handlers: Record<string, () => Response | Promise<Response>> = {},
) {
setActivePinia(createPinia())
const fetchMock = fetchRouter(handlers)
global.fetch = fetchMock as unknown as typeof fetch
await router.push(path)
const wrapper = mount(ResetConfirmView, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
currentWrapper = wrapper
await flushPromises()
return { wrapper, fetchMock }
}
const DEFAULT_PATH = '/register/reset/validate?c=abc123&email=marta%40renew.example'
function newPasswordInput(wrapper: Awaited<ReturnType<typeof mountResetConfirm>>) {
return wrapper.find('#reset-new-password')
}
function confirmPasswordInput(wrapper: Awaited<ReturnType<typeof mountResetConfirm>>) {
return wrapper.find('#reset-confirm-password')
}
async function fillPasswords(
wrapper: Awaited<ReturnType<typeof mountResetConfirm>>,
password: string,
confirm: string,
) {
await newPasswordInput(wrapper).setValue(password)
await confirmPasswordInput(wrapper).setValue(confirm)
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
await router.push('/login')
})
describe('Default — both parameters present, an untouched form', () => {
it('renders the empty form', async () => {
const { wrapper } = await mountResetConfirm(DEFAULT_PATH)
expect(wrapper.find('h1').text()).toBe('Choose a new password')
expect(wrapper.text()).toContain(
"Pick something you haven't used here before. You'll log in with it next.",
)
const emailField = wrapper.find('input[placeholder="you@example.com"]')
expect((emailField.element as HTMLInputElement).value).toBe('marta@renew.example')
expect((emailField.element as HTMLInputElement).readOnly).toBe(true)
expect(wrapper.text()).toContain('From your reset link.')
expect((newPasswordInput(wrapper).element as HTMLInputElement).value).toBe('')
expect(wrapper.text()).toContain('At least 8 characters')
expect((confirmPasswordInput(wrapper).element as HTMLInputElement).value).toBe('')
expect(wrapper.text()).toContain('Passwords match')
expect(wrapper.find('button[type="submit"]').text()).toBe('Change password')
})
})
describe('Too short — fewer than 8 characters blocks submission', () => {
it('never calls the endpoint and marks the length rule as errored', async () => {
const confirm = vi.fn(() => mockResponse(200))
const { wrapper } = await mountResetConfirm(DEFAULT_PATH, {
'POST /v1/auth/password-reset/confirm': confirm,
})
await fillPasswords(wrapper, 'lime', 'lime')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(confirm).not.toHaveBeenCalled()
const items = wrapper.findAll('li')
const lengthItem = items.find((item) => item.text().includes('At least 8 characters'))
expect(lengthItem?.text()).toContain('not met')
const matchItem = items.find((item) => item.text().includes('Passwords match'))
expect(matchItem?.text()).toContain('met')
})
})
describe('Mismatch — caught on blur and on submit', () => {
it('shows the mismatch error and blocks the request', async () => {
const confirm = vi.fn(() => mockResponse(200))
const { wrapper } = await mountResetConfirm(DEFAULT_PATH, {
'POST /v1/auth/password-reset/confirm': confirm,
})
await newPasswordInput(wrapper).setValue('limewash2026')
await confirmPasswordInput(wrapper).setValue('limewash2025')
await confirmPasswordInput(wrapper).trigger('blur')
expect(wrapper.text()).toContain("The passwords don't match.")
const matchItemAfterBlur = wrapper
.findAll('li')
.find((item) => item.text().includes('Passwords match'))
expect(matchItemAfterBlur?.find('svg').classes()).toContain('text-error')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(confirm).not.toHaveBeenCalled()
expect(wrapper.text()).toContain("The passwords don't match.")
const matchItemAfterSubmit = wrapper
.findAll('li')
.find((item) => item.text().includes('Passwords match'))
expect(matchItemAfterSubmit?.find('svg').classes()).toContain('text-error')
})
})
describe('Submitting — fields and button are disabled while the request is in flight', () => {
it('disables the fields and shows the loading label', async () => {
let resolveConfirm: (() => void) | null = null
const { wrapper } = await mountResetConfirm(DEFAULT_PATH, {
'POST /v1/auth/password-reset/confirm': () =>
new Promise((resolve) => {
resolveConfirm = () => resolve(mockResponse(200))
}),
})
await fillPasswords(wrapper, 'limewash2026', 'limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect((newPasswordInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
expect((confirmPasswordInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Changing password…')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(true)
resolveConfirm?.()
await flushPromises()
})
})
describe('Success — a 200 hands off to the log-in screen', () => {
it('navigates to /login?reason=password-changed', async () => {
const { wrapper, fetchMock } = await mountResetConfirm(DEFAULT_PATH, {
'POST /v1/auth/password-reset/confirm': () => mockResponse(200),
})
await fillPasswords(wrapper, 'limewash2026', 'limewash2026')
await wrapper.find('form').trigger('submit')
await vi.waitFor(() =>
expect(router.currentRoute.value.fullPath).toBe('/login?reason=password-changed'),
)
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/password-reset/confirm',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
email: 'marta@renew.example',
code: 'abc123',
password: 'limewash2026',
}),
}),
)
})
})
describe('Invalid or expired — a 403 replaces the form with a ResultPanel', () => {
it('shows the panel and navigates depending on the action', async () => {
const { wrapper } = await mountResetConfirm(DEFAULT_PATH, {
'POST /v1/auth/password-reset/confirm': () => mockResponse(403),
})
await fillPasswords(wrapper, 'limewash2026', 'limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.find('form').exists()).toBe(false)
expect(wrapper.text()).toContain('This reset link has expired')
expect(wrapper.text()).toContain(
'Reset links work once, for 1 hour. Request a new one and use it within the hour.',
)
const resultPanel = wrapper.findComponent(ResultPanel)
expect(resultPanel.props('tone')).toBe('error')
expect(resultPanel.props('icon')).toBe('i-lucide-link-2-off')
const buttons = wrapper.findAll('button')
const requestNew = buttons.find((button) => button.text() === 'Request a new link')
expect(requestNew).toBeTruthy()
await requestNew?.trigger('click')
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register/reset/request'),
)
})
it('navigates to /login when Back to log in is activated', async () => {
const { wrapper } = await mountResetConfirm(DEFAULT_PATH, {
'POST /v1/auth/password-reset/confirm': () => mockResponse(403),
})
await fillPasswords(wrapper, 'limewash2026', 'limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
const buttons = wrapper.findAll('button')
const backToLogin = buttons.find((button) => button.text() === 'Back to log in')
expect(backToLogin).toBeTruthy()
await backToLogin?.trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})
describe('Server or network error — the form keeps its values and re-enables', () => {
it.each([
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows the error alert and keeps the values for $name', async ({ handler }) => {
const confirm = vi.fn(handler)
const { wrapper } = await mountResetConfirm(DEFAULT_PATH, {
'POST /v1/auth/password-reset/confirm': confirm,
})
await fillPasswords(wrapper, 'limewash2026', 'limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(confirm).toHaveBeenCalled()
expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
expect(wrapper.text()).toContain('Check your connection and try again.')
expect(wrapper.find('form').exists()).toBe(true)
expect((newPasswordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026')
expect((confirmPasswordInput(wrapper).element as HTMLInputElement).value).toBe(
'limewash2026',
)
expect((newPasswordInput(wrapper).element as HTMLInputElement).disabled).toBe(false)
expect((confirmPasswordInput(wrapper).element as HTMLInputElement).disabled).toBe(false)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Change password')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(false)
})
})
describe('Missing parameters — either missing parameter blocks the request', () => {
it.each([
{
path: '/register/reset/validate?email=marta%40renew.example',
},
{ path: '/register/reset/validate?c=abc123' },
{ path: '/register/reset/validate' },
])('renders the missing-parameters panel for $path', async ({ path }) => {
const confirm = vi.fn(() => mockResponse(200))
const { wrapper } = await mountResetConfirm(path, {
'POST /v1/auth/password-reset/confirm': confirm,
})
expect(confirm).not.toHaveBeenCalled()
expect(wrapper.find('form').exists()).toBe(false)
expect(wrapper.text()).toContain('This link is incomplete')
expect(wrapper.text()).toContain(
'Part of the reset link is missing. Open it from your email again, or request a new one.',
)
const resultPanel = wrapper.findComponent(ResultPanel)
expect(resultPanel.props('tone')).toBe('error')
expect(resultPanel.props('icon')).toBe('i-lucide-link-2-off')
expect(resultPanel.props('actions')).toHaveLength(1)
const action = wrapper.find('button')
expect(action.text()).toBe('Request a new link')
await action.trigger('click')
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register/reset/request'),
)
})
})
@@ -0,0 +1,222 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import ResultPanel from '@/components/ResultPanel.vue'
import ResetRequestView from '../ResetRequestView.vue'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name: string) => headers[name] ?? null },
json: async () => body,
} as unknown as Response
}
function routeFor(url: string, init?: RequestInit) {
const method = init?.method ?? 'GET'
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
if (!handler) {
throw new Error(`Unexpected fetch call: ${key}`)
}
return handler()
})
}
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountResetRequest(
handlers: Record<string, () => Response | Promise<Response>> = {},
) {
setActivePinia(createPinia())
const fetchMock = fetchRouter(handlers)
global.fetch = fetchMock as unknown as typeof fetch
await router.push('/register/reset/request')
const wrapper = mount(ResetRequestView, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
currentWrapper = wrapper
await flushPromises()
return { wrapper, fetchMock }
}
function emailInput(wrapper: Awaited<ReturnType<typeof mountResetRequest>>) {
return wrapper.find('input[placeholder="you@example.com"]')
}
async function fillAndSubmit(
wrapper: Awaited<ReturnType<typeof mountResetRequest>>,
email: string,
) {
await emailInput(wrapper).setValue(email)
await wrapper.find('form').trigger('submit')
await flushPromises()
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
await router.push('/login')
})
describe('Default — a fresh, empty form', () => {
it('renders the empty state', async () => {
const { wrapper } = await mountResetRequest()
expect(wrapper.find('h1').text()).toBe('Reset your password')
expect(wrapper.text()).toContain(
"Enter the email for your account and we'll send you a link to choose a new password.",
)
expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('')
expect(wrapper.find('button[type="submit"]').text()).toBe('Send reset link')
const footerLink = wrapper.find('a[href="/login"]')
expect(footerLink.exists()).toBe(true)
expect(footerLink.text()).toBe('Back to log in')
})
})
describe('Validation error — an invalid email blocks the request', () => {
it('never calls the endpoint and shows the field error', async () => {
const request = vi.fn(() => mockResponse(200))
const { wrapper } = await mountResetRequest({
'POST /v1/auth/password-reset': request,
})
await fillAndSubmit(wrapper, 'marta@renew')
expect(request).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('Enter a full email address, like name@example.com.')
})
})
describe('Submitting — the field and button are disabled while the request is in flight', () => {
it('disables the field and shows the loading label', async () => {
let resolveRequest: (() => void) | null = null
const { wrapper } = await mountResetRequest({
'POST /v1/auth/password-reset': () =>
new Promise((resolve) => {
resolveRequest = () => resolve(mockResponse(200))
}),
})
await emailInput(wrapper).setValue('marta@renew.example')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Sending…')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(true)
resolveRequest?.()
await flushPromises()
})
})
describe('Sent — any 200 shows the same neutral confirmation', () => {
it('replaces the form with a ResultPanel and navigates back to log in', async () => {
const { wrapper, fetchMock } = await mountResetRequest({
'POST /v1/auth/password-reset': () => mockResponse(200),
})
await fillAndSubmit(wrapper, 'marta@renew.example')
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/password-reset',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ email: 'marta@renew.example' }),
}),
)
expect(wrapper.find('form').exists()).toBe(false)
expect(wrapper.text()).toContain('Check your email')
expect(wrapper.text()).toContain(
'If an account exists for that email, a password reset link has been sent.',
)
expect(wrapper.text()).toContain('The link expires in 1 hour.')
const resultPanel = wrapper.findComponent(ResultPanel)
expect(resultPanel.props('tone')).toBe('success')
expect(resultPanel.props('icon')).toBe('i-lucide-mail-check')
const action = wrapper.find('button')
expect(action.text()).toBe('Back to log in')
await action.trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})
describe('Too many attempts — the button is disabled with a live countdown', () => {
it('counts down from the Retry-After value and re-enables at zero', async () => {
vi.useFakeTimers()
const { wrapper } = await mountResetRequest({
'POST /v1/auth/password-reset': () => mockResponse(429, null, { 'Retry-After': '58' }),
})
await fillAndSubmit(wrapper, 'marta@renew.example')
expect(wrapper.text()).toContain('Too many requests')
expect(wrapper.text()).toContain('Wait a moment before asking for another link.')
expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('marta@renew.example')
const button = wrapper.find('button[type="submit"]')
expect(button.text()).toBe('Try again in 0:58')
expect((button.element as HTMLButtonElement).disabled).toBe(true)
expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
await vi.advanceTimersByTimeAsync(58000)
await flushPromises()
expect(wrapper.find('button[type="submit"]').text()).toBe('Send reset link')
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
false,
)
expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(false)
vi.useRealTimers()
})
})
describe('Server or network error — the field keeps its value and re-enables', () => {
it.each([
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows the error alert and keeps the value for $name', async ({ handler }) => {
const request = vi.fn(handler)
const { wrapper } = await mountResetRequest({
'POST /v1/auth/password-reset': request,
})
await fillAndSubmit(wrapper, 'marta@renew.example')
expect(request).toHaveBeenCalled()
expect(wrapper.text()).toContain("Couldn't send the link")
expect(wrapper.text()).toContain('Check your connection and try again.')
expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('marta@renew.example')
expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(false)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Send reset link')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(false)
})
})
+272 -4
View File
@@ -209,6 +209,10 @@ function mib(value: number) {
return Math.round(value * 1024 * 1024)
}
function kb(value: number) {
return Math.round(value * 1024)
}
async function openImageUploader(page: Page) {
await page.getByRole('button', { name: 'Image', exact: true }).first().click()
await page.getByRole('dialog').getByText('Add an image').waitFor()
@@ -222,6 +226,130 @@ async function chooseImageFile(page: Page, filename: string, mimeType: string, s
})
}
async function chooseRasterImageFile(
page: Page,
filename: string,
mimeType: string,
naturalWidth: number,
naturalHeight: number,
sizeBytes: number,
) {
await page.evaluate(
async ({ filename, mimeType, naturalWidth, naturalHeight, sizeBytes }) => {
const canvas = document.createElement('canvas')
canvas.width = naturalWidth
canvas.height = naturalHeight
const ctx = canvas.getContext('2d')!
ctx.fillStyle = '#d8d0c3'
ctx.fillRect(0, 0, naturalWidth, naturalHeight)
ctx.fillStyle = '#8f8373'
ctx.fillRect(0, naturalHeight * 0.68, naturalWidth, naturalHeight * 0.32)
ctx.fillStyle = '#f1ece3'
ctx.strokeStyle = '#c4b9a8'
ctx.lineWidth = naturalWidth * 0.015
ctx.fillRect(
naturalWidth * 0.56,
naturalHeight * 0.14,
naturalWidth * 0.22,
naturalHeight * 0.42,
)
ctx.strokeRect(
naturalWidth * 0.56,
naturalHeight * 0.14,
naturalWidth * 0.22,
naturalHeight * 0.42,
)
const blob = await new Promise<Blob>((resolve) =>
canvas.toBlob((b) => resolve(b!), mimeType, 0.85),
)
const file = new File([blob], filename, { type: mimeType })
Object.defineProperty(file, 'size', { value: sizeBytes, configurable: true })
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
input.files = dataTransfer.files
input.dispatchEvent(new Event('change', { bubbles: true }))
input.dispatchEvent(new Event('input', { bubbles: true }))
},
{ filename, mimeType, naturalWidth, naturalHeight, sizeBytes },
)
}
async function openCropStep(
page: Page,
filename: string,
mimeType: string,
naturalWidth: number,
naturalHeight: number,
sizeBytes: number,
) {
await openImageUploader(page)
await chooseRasterImageFile(page, filename, mimeType, naturalWidth, naturalHeight, sizeBytes)
await page.getByRole('dialog').getByText('Crop and resize').waitFor()
}
async function selectAspectRatio(page: Page, ratio: string) {
await page
.getByRole('radiogroup', { name: 'Aspect ratio' })
.getByRole('radio', { name: ratio, exact: true })
.click()
}
async function resizeCropFrameTo(
page: Page,
naturalWidth: number,
targetWidthPx: number,
targetHeightPx: number,
) {
const frame = page.locator('[data-testid="crop-frame"]')
const handle = page.locator('[data-testid="crop-handle"]').nth(3)
async function currentSize() {
const box = (await frame.boundingBox())!
const stageWidth = await frame.evaluate(
(el) => el.parentElement!.getBoundingClientRect().width,
)
const scale = stageWidth / naturalWidth
return {
width: Math.round(box.width / scale),
height: Math.round(box.height / scale),
scale,
}
}
for (let attempt = 0; attempt < 6; attempt += 1) {
const frameBox = (await frame.boundingBox())!
const handleBox = (await handle.boundingBox())!
const { width: currentWidth, height: currentHeight, scale } = await currentSize()
if (
Math.abs(currentWidth - targetWidthPx) <= 30 &&
Math.abs(currentHeight - targetHeightPx) <= 30
) {
break
}
const startX = handleBox.x + handleBox.width / 2
const startY = handleBox.y + handleBox.height / 2
const targetX = frameBox.x + targetWidthPx * scale
const targetY = frameBox.y + targetHeightPx * scale
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(targetX, targetY, { steps: 1 })
await page.mouse.up()
await page.waitForTimeout(50)
}
for (let guard = 0; guard < 60; guard += 1) {
const { width: currentWidth, height: currentHeight } = await currentSize()
if (currentWidth === targetWidthPx && currentHeight === targetHeightPx) {
return
}
if (currentWidth !== targetWidthPx) {
await frame.press(currentWidth > targetWidthPx ? 'Shift+ArrowLeft' : 'Shift+ArrowRight')
await page.waitForTimeout(120)
continue
}
await frame.press(currentHeight > targetHeightPx ? 'Shift+ArrowUp' : 'Shift+ArrowDown')
await page.waitForTimeout(120)
}
}
async function mockUploadedImage(page: Page) {
const isDark = await page.evaluate(
() => window.matchMedia('(prefers-color-scheme: dark)').matches,
@@ -392,6 +520,51 @@ async function chooseArticleRowMenuItem(page: Page, label: string) {
await page.getByRole('menuitem', { name: label, exact: true }).click()
}
async function fillResetRequestEmail(page: Page, email: string) {
await page.getByPlaceholder('you@example.com').fill(email)
}
function submitResetRequest(email: string) {
return async (page: Page) => {
await fillResetRequestEmail(page, email)
await page.getByRole('button', { name: 'Send reset link', exact: true }).click()
await page.mouse.move(0, 0)
}
}
function resetRequestWith(response: ApiMock): Omit<VisualState, 'steps'> {
return {
path: '/register/reset/request',
api: {
'POST /v1/auth/password-reset': response,
},
}
}
const resetConfirmPath = '/register/reset/validate?c=abc123&email=marta%40renew.example'
async function fillResetConfirm(page: Page, password: string, confirmPassword: string) {
await page.locator('#reset-new-password').fill(password)
await page.locator('#reset-confirm-password').fill(confirmPassword)
}
function submitResetConfirm(password: string, confirmPassword: string) {
return async (page: Page) => {
await fillResetConfirm(page, password, confirmPassword)
await page.getByRole('button', { name: 'Change password', exact: true }).click()
await page.mouse.move(0, 0)
}
}
function resetConfirmWith(response: ApiMock): Omit<VisualState, 'steps'> {
return {
path: resetConfirmPath,
api: {
'POST /v1/auth/password-reset/confirm': response,
},
}
}
const integrationOrigin = 'https://cms.renew.example'
async function clickBaseUrlCopy(page: Page) {
@@ -912,16 +1085,47 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
await openImageUploader(page)
},
},
'Upload: crop and resize': {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await openCropStep(page, 'north-wall.jpg', 'image/jpeg', 4032, 3024, mib(3.9))
},
},
'Upload: crop to a ratio': {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await openCropStep(page, 'north-wall.jpg', 'image/jpeg', 4032, 3024, mib(3.9))
await selectAspectRatio(page, '16:9')
},
},
'Upload: crop narrower than the width': {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await openCropStep(page, 'north-wall.jpg', 'image/jpeg', 4032, 3024, mib(3.9))
await selectAspectRatio(page, '1:1')
await resizeCropFrameTo(page, 4032, 640, 640)
await page.getByText('The crop is 640 px wide').waitFor()
},
},
'Upload: in progress': {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await openImageUploader(page)
await freezeUploadProgress(page, mib(2.4), mib(3.9))
await chooseImageFile(page, 'north-wall.jpg', 'image/jpeg', mib(3.9))
await page.getByText('north-wall.jpg 62%').waitFor()
await openCropStep(page, 'north-wall.jpg', 'image/jpeg', 4032, 3024, mib(3.9))
await freezeUploadProgress(page, kb(180), kb(420))
await page.getByRole('dialog').getByRole('button', { name: 'Upload image' }).click()
await page.getByText('north-wall.jpg 43%').waitFor()
},
},
'Upload: rejected': {
@@ -1022,4 +1226,68 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
path: '/register/validate',
},
},
'forgot-password': {
Default: { path: '/register/reset/request' },
'Validation error': {
path: '/register/reset/request',
steps: submitResetRequest('marta@renew'),
},
Submitting: {
...resetRequestWith({ status: 200, hang: true }),
steps: submitResetRequest('marta@renew.example'),
},
Sent: {
...resetRequestWith({ status: 200, body: { message: 'ok' } }),
steps: submitResetRequest('marta@renew.example'),
},
'Too many attempts (429)': {
...resetRequestWith({ status: 429, headers: { 'Retry-After': '58' } }),
steps: submitResetRequest('marta@renew.example'),
},
'Server error': {
...resetRequestWith({ status: 500 }),
steps: submitResetRequest('marta@renew.example'),
},
},
'reset-password': {
Default: { path: resetConfirmPath },
'Too short': {
path: resetConfirmPath,
steps: submitResetConfirm('lime', 'lime'),
},
Mismatch: {
path: resetConfirmPath,
steps: async (page) => {
await fillResetConfirm(page, 'limewash2026', 'limewash2025')
await page.locator('#reset-confirm-password').blur()
},
},
Submitting: {
...resetConfirmWith({ status: 200, hang: true }),
steps: submitResetConfirm('limewash2026', 'limewash2026'),
},
Success: {
path: resetConfirmPath,
api: {
'POST /v1/auth/password-reset/confirm': { status: 200, body: { message: 'ok' } },
'GET /v1/auth/registration': registrationClosed,
},
steps: async (page) => {
await submitResetConfirm('limewash2026', 'limewash2026')(page)
await page.waitForURL('**/login**')
await page.getByText('Password changed').waitFor()
},
},
'Invalid or expired (403)': {
...resetConfirmWith({ status: 403 }),
steps: submitResetConfirm('limewash2026', 'limewash2026'),
},
'Missing parameters': {
path: '/register/reset/validate',
},
'Server error': {
...resetConfirmWith({ status: 500 }),
steps: submitResetConfirm('limewash2026', 'limewash2026'),
},
},
}