Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b29d0ed045 | ||
|
|
0fc00ceeff | ||
|
|
e15f444603 | ||
|
|
cb05b38f36 | ||
|
|
938263cd6a | ||
|
|
d4fcfb2bba |
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.' }),
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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."
|
||||
@@ -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.
|
||||
@@ -0,0 +1,136 @@
|
||||
Feature: Wiring the editor's image upload flow
|
||||
|
||||
As a verified RenewCMS owner writing an article
|
||||
I want the editor's image button (and dropping a file onto the editor) to open an upload
|
||||
modal that validates the file, shows progress, and inserts the result at my cursor
|
||||
So that I can add images to an article's body without leaving the editor, with clear
|
||||
feedback when a file is rejected or the upload fails
|
||||
|
||||
Background:
|
||||
Given EditorView.vue listens for ArticleEditor's "request-image" event and, on it, opens
|
||||
ImageUploader (a UModal titled "Add an image") with the current article's id
|
||||
And dropping a file directly onto the editor surface also opens ImageUploader, with that
|
||||
file already provided to it instead of an empty idle state
|
||||
And ImageUploader validates a chosen or dropped file in the browser, checking both its MIME
|
||||
type and its file extension against png/jpeg/webp/svg, and that its size is at most
|
||||
10 × 1024 × 1024 bytes, before sending anything
|
||||
And a file that passes validation is uploaded via a new useImageUpload composable, using
|
||||
XMLHttpRequest (not useApi) so upload progress is observable, as multipart/form-data with
|
||||
the file under the field name "file", to POST /v1/article/{articleId}/image, per
|
||||
features/upload_article_image.feature's already-implemented and already-mounted contract
|
||||
And a 201 response's {"location": "..."} is inserted into ArticleEditor's content at the
|
||||
current cursor position via an exposed insertImage(src, alt) method, with alt ""; this
|
||||
moves useArticleDraft's state to "dirty" the same way any other body edit does
|
||||
And closing the modal while an upload is in flight aborts the XMLHttpRequest
|
||||
And a 401 response is handled entirely by useApi's global handling
|
||||
(features/api_client_global_error_handling.feature) and is out of scope for new scenarios
|
||||
here; the same is true of a 404 (the article was deleted elsewhere), which the editor's
|
||||
existing not-found handling already covers (features/editor_core.feature)
|
||||
|
||||
Scenario: Opening the uploader from the toolbar's image button shows the idle state
|
||||
Given the user is on /article/12/edit with the editor focused
|
||||
When the user activates the toolbar's "Image" button
|
||||
Then ImageUploader opens titled "Add an image"
|
||||
And it shows "Drop an image here", a "Choose file" action, and the help text
|
||||
"PNG, JPEG, WEBP, SVG · max 10 MB"
|
||||
And its cancel action reads "Cancel"
|
||||
And no request has been sent
|
||||
|
||||
Scenario: Dragging a file over the editor surface shows the dragging-over state
|
||||
Given ImageUploader is open in the idle state
|
||||
When the user drags a file over the drop zone
|
||||
Then the drop zone reads "Drop to upload"
|
||||
|
||||
Scenario: Dropping a valid file onto the editor opens the uploader already uploading it
|
||||
Given the user is on /article/12/edit with the editor focused and ImageUploader is not open
|
||||
When the user drops a file named "before.png", content type "image/png", size 3.9 MB, onto
|
||||
the editor surface
|
||||
Then ImageUploader opens titled "Add an image"
|
||||
And POST /v1/article/12/image is sent with that file under the field name "file"
|
||||
And the uploading state is shown, not the idle state
|
||||
|
||||
Scenario Outline: Choosing a file the browser can reject never sends a request
|
||||
Given ImageUploader is open in the idle state for article 12
|
||||
When the user chooses a file named "<filename>", content type "<content_type>", size <size>
|
||||
Then no request is sent to POST /v1/article/12/image
|
||||
And the rejected state shows the title "\"<filename>\" can't be used"
|
||||
And its text reads "<description>"
|
||||
And its confirm action reads "Choose another file" and its cancel action reads "Cancel"
|
||||
|
||||
Examples:
|
||||
| filename | content_type | size | description |
|
||||
| plan.pdf | application/pdf | 1.2 MB | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. |
|
||||
| disguised.png | application/pdf | 1.2 MB | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. |
|
||||
| north-wall.tif | image/png | 14.2 MB | north-wall.tif is 14.2 MB. Images can be up to 10 MB. |
|
||||
|
||||
Scenario: "Choose another file" from the rejected state returns to idle
|
||||
Given ImageUploader is in the rejected state for a file named "plan.pdf"
|
||||
When the user activates "Choose another file"
|
||||
Then the idle state is shown again, with the file picker available
|
||||
|
||||
Scenario: A valid upload shows progress and its cancel action reads "Cancel upload"
|
||||
Given ImageUploader is open in the idle state for article 12
|
||||
When the user chooses a file named "north-wall.jpg", content type "image/jpeg", size 3.9 MB
|
||||
Then POST /v1/article/12/image is sent with that file under the field name "file"
|
||||
And the uploading state shows "north-wall.jpg 0%" and "Uploading 0.0 of 3.9 MB"
|
||||
And its cancel action reads "Cancel upload"
|
||||
When the upload reports 2.4 MB sent of 3.9 MB
|
||||
Then the uploading state shows "north-wall.jpg 62%" and "Uploading 2.4 of 3.9 MB"
|
||||
|
||||
Scenario: Closing the dialog during an upload cancels it
|
||||
Given ImageUploader is uploading a file named "north-wall.jpg" to article 12
|
||||
When the user activates "Cancel upload" (or otherwise closes the modal)
|
||||
Then the in-flight XMLHttpRequest is aborted
|
||||
And the modal closes
|
||||
And the editor's body is unchanged and remains in its previous dirty/clean state
|
||||
|
||||
Scenario: A successful upload inserts the image at the cursor and marks the draft dirty
|
||||
Given the user is on /article/12/edit in the Saved state and ImageUploader is uploading a
|
||||
file named "north-wall.jpg" to article 12
|
||||
When POST /v1/article/12/image resolves 201 with {"location": "/static/uploadedImages/north-wall.jpg"}
|
||||
Then the uploading state briefly shows "Image added"
|
||||
And the image at "/static/uploadedImages/north-wall.jpg" is inserted into the editor's
|
||||
content at the cursor via insertImage
|
||||
And SaveStatus reads "Unsaved changes" and the Save button becomes enabled
|
||||
And a toast appears: color success, icon i-lucide-circle-check, title "Image added",
|
||||
description "Save to keep it in the article."
|
||||
And the modal closes 600 ms after the response resolves
|
||||
|
||||
Scenario Outline: A server-side upload failure keeps the article unchanged and offers a retry
|
||||
Given ImageUploader is uploading a file named "north-wall.jpg" to article 12
|
||||
When POST /v1/article/12/image <failure>
|
||||
Then the failed state shows the title "Upload failed"
|
||||
And its text reads "The server didn't accept the file. Your article hasn't changed."
|
||||
And its confirm action reads "Try again"
|
||||
And the editor's body is unchanged, and no image is inserted
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: "Try again" re-attempts the upload with the same file
|
||||
Given ImageUploader is in the failed state for a file named "north-wall.jpg", article 12,
|
||||
after a 500 response
|
||||
When the user activates "Try again"
|
||||
Then POST /v1/article/12/image is sent again with that same file under the field name "file"
|
||||
And the uploading state is shown
|
||||
|
||||
Scenario: A server 400 for a type the client should have caught shows the same rejected copy
|
||||
Given ImageUploader is uploading a file named "notes.pdf" to article 12 (a type that
|
||||
defensively passed the browser's own check, e.g. a MIME type the browser misreported)
|
||||
When POST /v1/article/12/image resolves 400
|
||||
Then the rejected state shows the title "\"notes.pdf\" can't be used"
|
||||
And its text reads "Use a PNG, JPEG, WEBP or SVG image, up to 10 MB."
|
||||
And its confirm action reads "Choose another file"
|
||||
And the editor's body is unchanged, and no image is inserted
|
||||
|
||||
Scenario: A server 400 for a size the client should have caught shows the same size-named copy
|
||||
Given ImageUploader is uploading a file named "north-wall.tif" to article 12 (a file that
|
||||
defensively passed the browser's own size check somehow, e.g. its reported size changed
|
||||
between selection and send)
|
||||
When POST /v1/article/12/image resolves 400
|
||||
Then the rejected state shows the title "\"north-wall.tif\" can't be used"
|
||||
And its text reads "north-wall.tif is 14.2 MB. Images can be up to 10 MB."
|
||||
And its confirm action reads "Choose another file"
|
||||
And the editor's body is unchanged, and no image is inserted
|
||||
@@ -0,0 +1,90 @@
|
||||
# QA: Wiring the editor's image upload flow
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `XMLHttpRequest` (a fake implementing `open`/`setRequestHeader`/`send`/`abort` and firing
|
||||
`upload.progress`, `load`, `error`) for `POST /v1/article/{id}/image`; useApi/fetch is not
|
||||
involved in this slice. Mount EditorPage inside DashboardShell at `/article/12/edit` with the
|
||||
editor_core.qa.md fixture article (id 12, title "How we price a bathroom renovation", is_online
|
||||
false), GET already resolved 200.
|
||||
|
||||
1. **Toolbar button opens the idle state** — click the toolbar's "Image" button; assert
|
||||
ImageUploader opens titled "Add an image", showing "Drop an image here", "Choose file",
|
||||
"PNG, JPEG, WEBP, SVG · max 10 MB", cancel "Cancel"; assert no XHR was created.
|
||||
2. **Dragging over shows "Drop to upload"** — with the uploader open idle, fire a `dragover` on
|
||||
the drop zone; assert the text changes to "Drop to upload".
|
||||
3. **Dropping a file on the editor opens the uploader already uploading** — with the uploader
|
||||
closed, dispatch a `drop` event with a file `before.png` (image/png, 3.9 MB) on the editor
|
||||
surface; assert ImageUploader opens and an XHR to `/v1/article/12/image` is created with that
|
||||
file, and the uploading state (not idle) is shown.
|
||||
4. **Rejected — Examples-driven on type, extension mismatch, and size** — for each of `plan.pdf`
|
||||
(application/pdf, 1.2 MB), `disguised.png` (application/pdf, 1.2 MB), and `north-wall.tif`
|
||||
(image/png, 14.2 MB), choose the file via the `UFileUpload`; assert no XHR is created and the
|
||||
rejected state shows the exact title/text/confirm/cancel copy from the feature's Examples.
|
||||
5. **"Choose another file" returns to idle** — from the rejected state, click "Choose another
|
||||
file"; assert the idle state re-renders.
|
||||
6. **Uploading shows live progress and "Cancel upload"** — choose `north-wall.jpg` (image/jpeg,
|
||||
3.9 MB); assert the XHR is created with that file under field `file`; assert cancel reads
|
||||
"Cancel upload"; fire an `upload.progress` event with `loaded: 2.4 * 1024 * 1024, total: 3.9 *
|
||||
1024 * 1024`; assert the text reads "north-wall.jpg 62%" and "Uploading 2.4 of 3.9 MB".
|
||||
7. **Closing during an upload aborts the XHR** — from the uploading state, click "Cancel
|
||||
upload"; assert the fake XHR's `abort` was called, the modal closes, and the editor's body
|
||||
and dirty state are unchanged from before the upload started.
|
||||
8. **A 201 inserts at the cursor and marks the draft dirty** — from the uploading state, resolve
|
||||
the fake XHR with status 201 and body `{"location": "/static/uploadedImages/north-wall.jpg"}`;
|
||||
assert `insertImage` (or the editor's exposed method) was called with that location; assert
|
||||
SaveStatus now reads "Unsaved changes" and Save is enabled; assert a toast appears with color
|
||||
success, icon `i-lucide-circle-check`, title "Image added", description "Save to keep it in
|
||||
the article."; advance fake timers 600 ms and assert the modal has closed.
|
||||
9. **A 500 or network failure shows "Upload failed" and keeps the article unchanged, Examples-
|
||||
driven** — for (a) the fake XHR firing `load` with status 500 and (b) firing `error`; assert
|
||||
the failed state shows title "Upload failed", text "The server didn't accept the file. Your
|
||||
article hasn't changed.", confirm "Try again"; assert no `insertImage` call and the editor's
|
||||
body/dirty state is unchanged.
|
||||
10. **"Try again" resends the same file** — from the failed state for `north-wall.jpg`, click
|
||||
"Try again"; assert a new XHR is created for `/v1/article/12/image` with the same file, and
|
||||
the uploading state is shown again.
|
||||
11. **A server 400 for a type the client should have caught shows the rejected copy** — from an
|
||||
uploading state for `notes.pdf`, resolve the fake XHR with status 400; assert the rejected
|
||||
state shows title "\"notes.pdf\" can't be used", text "Use a PNG, JPEG, WEBP or SVG image,
|
||||
up to 10 MB.", confirm "Choose another file"; assert no `insertImage` call.
|
||||
12. **A server 400 for a size the client should have caught shows the size-named rejected copy**
|
||||
— from an uploading state for `north-wall.tif`, resolve the fake XHR with status 400; assert
|
||||
the rejected state shows title "\"north-wall.tif\" can't be used", text "north-wall.tif is
|
||||
14.2 MB. Images can be up to 10 MB.", confirm "Choose another file"; assert no `insertImage`
|
||||
call.
|
||||
|
||||
## Visual (manual verification — desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/screens/editor.md` states "Upload: choose", "Upload: in progress",
|
||||
"Upload: rejected", "Upload: inserted", and `docs/design/source/RenewCMS Screens Back-
|
||||
office.dc.html`. Run `task visual:check -- editor` for each state below; add each state's mock
|
||||
setup to `web/visual/states.ts` first, reusing editor_core.qa.md's fixture article (id 12, title
|
||||
"How we price a bathroom renovation", draft, "Today, 11:02", 3 images) plus the sample file data
|
||||
from the board: filename "north-wall.jpg", size 3.9 MB, progress 62% / 2.4 of 3.9 MB uploaded.
|
||||
|
||||
- [ ] Upload: choose — modal "Add an image", idle state, cancel "Cancel".
|
||||
- [ ] Upload: in progress — modal "Add an image", uploading "north-wall.jpg 62%" / "Uploading
|
||||
2.4 of 3.9 MB", cancel "Cancel upload".
|
||||
- [ ] Upload: rejected — modal "Add an image", rejected state for a sample rejected file per the
|
||||
board's script, cancel "Cancel", confirm "Choose another file".
|
||||
- [ ] Upload: inserted — toast "Image added" / "Save to keep it in the article.", editor status
|
||||
"draft", save "dirty".
|
||||
- [ ] Mobile (all of the above at 390×844): same modal layout constraints as other editor
|
||||
modals (leading arrow-left button, trailing ellipsis in the header behind it).
|
||||
- [ ] Keyboard/focus: the drop zone is reachable by Tab and Enter opens the file picker; focus
|
||||
moves into the modal on open and is trapped there; Esc during "Upload: in progress"
|
||||
behaves like "Cancel upload" (aborts, closes); the progress bar's `role="progressbar"` /
|
||||
`aria-valuenow` updates are announced without spamming on every progress tick.
|
||||
- [ ] Motion: modal open/close follows the standard 240 ms timing; the success state's 600 ms
|
||||
auto-close is not visually checked as a capture (it's a timing behavior, not a static
|
||||
state) — cover only via the Vitest fake-timer assertion above.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real POST round trip against a running server** — Vitest only mocks `XMLHttpRequest`;
|
||||
confirm a real upload (200/400/401/404/500) against `task dev` with a live SQLite file and the
|
||||
real `POST /v1/article/{id}/image` handler, since `features/upload_article_image.feature`'s
|
||||
httptest suite covers the handler in isolation but never through this SPA flow.
|
||||
- **Real drag-and-drop from the OS file picker** — jsdom's `drop` event is synthetic; confirm a
|
||||
real OS-level drag of an image file onto the editor in a real browser.
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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" |
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
"@nuxt/ui": "^4.11.2",
|
||||
"@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",
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
"@nuxt/ui": "^4.11.2",
|
||||
"@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",
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useToast } from '@nuxt/ui/composables'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
|
||||
const props = defineProps<{ article: Article }>()
|
||||
|
||||
defineEmits<{ 'copy-url': [] }>()
|
||||
const emit = defineEmits<{ 'copy-url': [] }>()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
@@ -55,6 +58,28 @@ const apiNote = computed(() =>
|
||||
? 'Live. Your website can read this article.'
|
||||
: 'Returns 404 until the article is online.',
|
||||
)
|
||||
|
||||
async function copyUrl() {
|
||||
const url = `${window.location.origin}${publicUrl.value}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
toast.add({
|
||||
id: `article-link-copied-${props.article.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Link copied',
|
||||
description: `The API URL for "${props.article.title}" is on your clipboard.`,
|
||||
})
|
||||
emit('copy-url')
|
||||
} catch {
|
||||
toast.add({
|
||||
id: `article-link-copy-failed-${props.article.id}`,
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "Couldn't copy the link",
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -89,7 +114,15 @@ const apiNote = computed(() =>
|
||||
class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs"
|
||||
>
|
||||
<span class="flex-1">{{ publicUrl }}</span>
|
||||
<UIcon name="i-lucide-copy" class="size-3.5" />
|
||||
<UButton
|
||||
icon="i-lucide-copy"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Copy the public API URL"
|
||||
class="size-5 p-0"
|
||||
@click="copyUrl"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
|
||||
</div>
|
||||
@@ -127,7 +160,15 @@ const apiNote = computed(() =>
|
||||
class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs"
|
||||
>
|
||||
<span class="flex-1">{{ publicUrl }}</span>
|
||||
<UIcon name="i-lucide-copy" class="size-3.5" />
|
||||
<UButton
|
||||
icon="i-lucide-copy"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Copy the public API URL"
|
||||
class="size-5 p-0"
|
||||
@click="copyUrl"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
@@ -42,9 +49,7 @@ function currentBlockLabel(editor: Editor) {
|
||||
}
|
||||
|
||||
function isDefaultAlignment(editor: Editor) {
|
||||
return !['center', 'right', 'justify'].some((align) =>
|
||||
editor.isActive({ textAlign: align }),
|
||||
)
|
||||
return !['center', 'right', 'justify'].some((align) => editor.isActive({ textAlign: align }))
|
||||
}
|
||||
|
||||
function buildItems(editor: Editor): EditorToolbarItem[][] {
|
||||
@@ -72,8 +77,7 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
|
||||
{
|
||||
icon: 'i-lucide-pilcrow',
|
||||
label: 'Paragraph',
|
||||
onSelect: () =>
|
||||
editor.chain().focus().setParagraph().run(),
|
||||
onSelect: () => editor.chain().focus().setParagraph().run(),
|
||||
},
|
||||
{
|
||||
kind: 'heading',
|
||||
@@ -201,6 +205,23 @@ defineExpose({
|
||||
focus() {
|
||||
editorInstance.value?.commands.focus()
|
||||
},
|
||||
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' }"
|
||||
@@ -216,6 +238,7 @@ defineExpose({
|
||||
@update:model-value="onUpdate"
|
||||
>
|
||||
<span class="hidden">{{ captureEditor(editor) }}</span>
|
||||
<UEditorDragHandle :editor="editor" />
|
||||
<UEditorToolbar
|
||||
:editor="editor"
|
||||
:items="buildItems(editor)"
|
||||
@@ -225,9 +248,7 @@ defineExpose({
|
||||
<div class="col-start-1 row-start-3">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
<div
|
||||
class="col-start-2 row-span-2 row-start-2 hidden min-h-0 lg:block"
|
||||
>
|
||||
<div class="col-start-2 row-span-2 row-start-2 hidden min-h-0 lg:block">
|
||||
<slot name="aside" />
|
||||
</div>
|
||||
</UEditor>
|
||||
@@ -239,16 +260,16 @@ defineExpose({
|
||||
grid-row-start: 2;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 2.5rem 3rem;
|
||||
background: var(--ui-bg);
|
||||
}
|
||||
.article-editor :deep(.ProseMirror) {
|
||||
max-width: 42rem;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
max-width: 48rem;
|
||||
padding: 2.5rem 3rem;
|
||||
outline: none;
|
||||
}
|
||||
@media (max-width: 47.999rem) {
|
||||
.article-editor :deep([data-slot='content']) {
|
||||
.article-editor :deep(.ProseMirror) {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
|
||||
@@ -23,14 +24,27 @@ const router = useRouter()
|
||||
const columnWidths =
|
||||
'[&:nth-child(2)]:w-27.5 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-35 [&:nth-child(5)]:w-11'
|
||||
|
||||
const sorting = ref([{ id: 'updated_at', desc: true }])
|
||||
|
||||
const columns = [
|
||||
{ accessorKey: 'title', header: 'Title' },
|
||||
{ accessorKey: 'is_online', header: 'Status', size: 110 },
|
||||
{ accessorKey: 'created_at', header: 'Created', size: 130 },
|
||||
{ accessorKey: 'updated_at', header: 'Updated', size: 140 },
|
||||
{ accessorKey: 'title', header: 'Title', enableSorting: true },
|
||||
{ accessorKey: 'is_online', header: 'Status', size: 110, enableSorting: true },
|
||||
{ accessorKey: 'created_at', header: 'Created', size: 130, enableSorting: true },
|
||||
{ accessorKey: 'updated_at', header: 'Updated', size: 140, enableSorting: true },
|
||||
{ id: 'actions', header: '', size: 44 },
|
||||
]
|
||||
|
||||
function sortIcon(column: { getIsSorted: () => false | 'asc' | 'desc' }) {
|
||||
const sorted = column.getIsSorted()
|
||||
if (sorted === 'asc') {
|
||||
return 'i-lucide-arrow-up'
|
||||
}
|
||||
if (sorted === 'desc') {
|
||||
return 'i-lucide-arrow-down'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function editPath(article: Article) {
|
||||
return `/article/${article.id}/edit`
|
||||
}
|
||||
@@ -83,7 +97,7 @@ function actionsFor(article: Article) {
|
||||
]
|
||||
}
|
||||
|
||||
function onSelect(row: { original: Article }) {
|
||||
function onSelect(_event: Event, row: { original: Article }) {
|
||||
router.push(editPath(row.original))
|
||||
}
|
||||
</script>
|
||||
@@ -112,6 +126,7 @@ function onSelect(row: { original: Article }) {
|
||||
th: `px-3 ${columnWidths}`,
|
||||
td: `h-11 px-3 py-0 ${columnWidths}`,
|
||||
}"
|
||||
v-model:sorting="sorting"
|
||||
@select="onSelect"
|
||||
>
|
||||
<template #caption>
|
||||
@@ -120,6 +135,50 @@ function onSelect(row: { original: Article }) {
|
||||
</caption>
|
||||
</template>
|
||||
|
||||
<template #title-header="{ column }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-highlighted"
|
||||
@click.stop="column.toggleSorting()"
|
||||
>
|
||||
Title
|
||||
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #is_online-header="{ column }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-highlighted"
|
||||
@click.stop="column.toggleSorting()"
|
||||
>
|
||||
Status
|
||||
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #created_at-header="{ column }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-highlighted"
|
||||
@click.stop="column.toggleSorting()"
|
||||
>
|
||||
Created
|
||||
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #updated_at-header="{ column }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-highlighted"
|
||||
@click.stop="column.toggleSorting()"
|
||||
>
|
||||
Updated
|
||||
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #title-cell="{ row }">
|
||||
<RouterLink
|
||||
:to="editPath(row.original)"
|
||||
@@ -140,13 +199,6 @@ function onSelect(row: { original: Article }) {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #updated_at-header>
|
||||
<span class="inline-flex items-center gap-1 text-highlighted">
|
||||
Updated
|
||||
<UIcon name="i-lucide-arrow-down" class="size-3" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #created_at-cell="{ row }">
|
||||
<span class="text-muted">{{
|
||||
formatAbsoluteDateFromIso(row.original.created_at)
|
||||
|
||||
@@ -8,11 +8,13 @@ const props = withDefaults(
|
||||
min?: number
|
||||
warnAt?: number
|
||||
variant?: 'inline' | 'footer'
|
||||
plainText?: boolean
|
||||
}>(),
|
||||
{
|
||||
min: 0,
|
||||
warnAt: 0.9,
|
||||
variant: 'inline',
|
||||
plainText: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -34,11 +36,14 @@ const status = computed<'normal' | 'warn' | 'over' | 'under'>(() => {
|
||||
return 'normal'
|
||||
})
|
||||
|
||||
const overText = computed(() =>
|
||||
overBy.value === 1
|
||||
? '1 character over. Shorten the text or remove an image. Formatting counts too.'
|
||||
: `${formatThousands(overBy.value)} characters over. Shorten the text or remove an image. Formatting counts too.`,
|
||||
)
|
||||
const overText = computed(() => {
|
||||
const suffix = props.plainText
|
||||
? 'Shorten the text.'
|
||||
: 'Shorten the text or remove an image. Formatting counts too.'
|
||||
return overBy.value === 1
|
||||
? `1 character over. ${suffix}`
|
||||
: `${formatThousands(overBy.value)} characters over. ${suffix}`
|
||||
})
|
||||
|
||||
const warnText = 'Close to the limit. Images and formatting count too.'
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useSidebarCollapsed } from '@/composables/useSidebarCollapsed'
|
||||
import AppBoot from '@/components/AppBoot.vue'
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
import ErrorView from '@/components/ErrorView.vue'
|
||||
@@ -44,7 +45,22 @@ const navItems = computed(() =>
|
||||
|
||||
const routeTitle = computed(() => (route.meta.title as string) ?? '')
|
||||
const pageTitle = computed(() => props.mobileTitle ?? routeTitle.value)
|
||||
const sidebarCollapsed = ref(false)
|
||||
const sidebarCollapsed = useSidebarCollapsed()
|
||||
|
||||
const sidebarUi = computed(() => ({
|
||||
root: sidebarCollapsed.value ? 'min-w-14 px-1 py-3' : 'min-w-14 p-3',
|
||||
header: 'mb-3 h-10 gap-2.5 px-1.5',
|
||||
body: 'gap-0.5 p-0',
|
||||
footer: 'p-0',
|
||||
}))
|
||||
|
||||
const navUi = computed(() => ({
|
||||
list: 'flex flex-col gap-0.5',
|
||||
link: sidebarCollapsed.value
|
||||
? 'h-8 justify-center gap-0 px-0 text-sm'
|
||||
: 'h-8 gap-2.5 px-2.5 py-0 text-sm',
|
||||
linkLeadingIcon: 'size-4',
|
||||
}))
|
||||
const loggingOut = ref(false)
|
||||
|
||||
function onRetry() {
|
||||
@@ -75,12 +91,7 @@ async function onLogout() {
|
||||
:resizable="false"
|
||||
:default-size="14"
|
||||
:collapsed-size="3.5"
|
||||
:ui="{
|
||||
root: 'min-w-14 p-3',
|
||||
header: 'mb-3 h-10 gap-2.5 px-1.5',
|
||||
body: 'gap-0.5 p-0',
|
||||
footer: 'p-0',
|
||||
}"
|
||||
:ui="sidebarUi"
|
||||
>
|
||||
<template #header>
|
||||
<AppLogo :size="26" />
|
||||
@@ -97,11 +108,7 @@ async function onLogout() {
|
||||
:items="navItems"
|
||||
:collapsed="sidebarCollapsed"
|
||||
aria-label="Main"
|
||||
:ui="{
|
||||
list: 'flex flex-col gap-0.5',
|
||||
link: 'h-8 gap-2.5 px-2.5 py-0 text-sm',
|
||||
linkLeadingIcon: 'size-4',
|
||||
}"
|
||||
:ui="navUi"
|
||||
/>
|
||||
|
||||
<template #footer>
|
||||
|
||||
@@ -0,0 +1,805 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import {
|
||||
IMAGE_TYPE_REJECTION_TEXT,
|
||||
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<{
|
||||
articleId: number
|
||||
initialFile?: File | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
uploaded: [location: string, imageId: number]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
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
|
||||
|
||||
const uploadPercent = computed(() => {
|
||||
if (progress.value.total === 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.round((progress.value.loaded / progress.value.total) * 100)
|
||||
})
|
||||
|
||||
const rejectedTitle = computed(() =>
|
||||
chosenFile.value ? rejectionTitle(chosenFile.value.name) : '',
|
||||
)
|
||||
|
||||
const rejectedText = computed(() => {
|
||||
if (!chosenFile.value) {
|
||||
return ''
|
||||
}
|
||||
if (rejectionKind.value === 'size') {
|
||||
return sizeRejectionText(chosenFile.value.name, chosenFile.value.size)
|
||||
}
|
||||
return IMAGE_TYPE_REJECTION_TEXT
|
||||
})
|
||||
|
||||
const cancelLabel = computed(() => (phase.value === 'uploading' ? 'Cancel upload' : 'Cancel'))
|
||||
|
||||
const showCancel = computed(
|
||||
() =>
|
||||
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)
|
||||
closeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
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, outcome.id)
|
||||
closeTimer = setTimeout(() => {
|
||||
open.value = false
|
||||
}, 600)
|
||||
return
|
||||
}
|
||||
if (outcome.kind === 'rejected') {
|
||||
rejectionKind.value = isSizeRejectionBody(outcome.body) ? 'size' : 'type'
|
||||
phase.value = 'rejected'
|
||||
return
|
||||
}
|
||||
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)
|
||||
if (!result.ok) {
|
||||
rejectionKind.value = result.kind
|
||||
phase.value = 'rejected'
|
||||
return
|
||||
}
|
||||
rejectionKind.value = null
|
||||
succeeded.value = false
|
||||
if (file.type === 'image/svg+xml') {
|
||||
phase.value = 'uploading'
|
||||
runUpload(file)
|
||||
return
|
||||
}
|
||||
openCropStep(file)
|
||||
}
|
||||
|
||||
function onFileChange() {
|
||||
if (pickedFile.value) {
|
||||
handleFile(pickedFile.value)
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver() {
|
||||
if (phase.value === 'idle') {
|
||||
phase.value = 'dragging-over'
|
||||
}
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
if (phase.value === 'dragging-over') {
|
||||
phase.value = 'idle'
|
||||
}
|
||||
}
|
||||
|
||||
function onDropOnZone() {
|
||||
if (phase.value === 'dragging-over') {
|
||||
phase.value = 'idle'
|
||||
}
|
||||
}
|
||||
|
||||
function chooseAnotherFile() {
|
||||
phase.value = 'idle'
|
||||
chosenFile.value = null
|
||||
pickedFile.value = null
|
||||
rejectionKind.value = null
|
||||
closeSourceBitmap()
|
||||
}
|
||||
|
||||
function tryAgain() {
|
||||
if (!lastUploadedFile.value) {
|
||||
return
|
||||
}
|
||||
succeeded.value = false
|
||||
phase.value = 'uploading'
|
||||
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'
|
||||
chosenFile.value = null
|
||||
pickedFile.value = null
|
||||
rejectionKind.value = null
|
||||
succeeded.value = false
|
||||
if (props.initialFile) {
|
||||
handleFile(props.initialFile)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (phase.value === 'uploading' && !succeeded.value) {
|
||||
abort()
|
||||
}
|
||||
clearCloseTimer()
|
||||
closeSourceBitmap()
|
||||
emit('close')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
closeSourceBitmap()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
v-model:open="open"
|
||||
:title="modalTitle"
|
||||
:description="modalDescription"
|
||||
:ui="{
|
||||
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',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div
|
||||
v-if="phase === 'idle' || phase === 'dragging-over'"
|
||||
data-testid="image-dropzone"
|
||||
@dragover.prevent="onDragOver"
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@drop.prevent="onDropOnZone"
|
||||
>
|
||||
<UFileUpload
|
||||
v-model="pickedFile"
|
||||
data-testid="image-file-input"
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
:multiple="false"
|
||||
layout="list"
|
||||
:reset="true"
|
||||
@change="onFileChange"
|
||||
>
|
||||
<template #label>
|
||||
{{ phase === 'dragging-over' ? 'Drop to upload' : 'Drop an image here' }}
|
||||
</template>
|
||||
<template #description>PNG, JPEG, WEBP, SVG · max 10 MB</template>
|
||||
<template #actions="{ open: openDialog }">
|
||||
<UButton color="neutral" variant="outline" @click="openDialog()">
|
||||
Choose file
|
||||
</UButton>
|
||||
</template>
|
||||
</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"
|
||||
>
|
||||
<UIcon
|
||||
:name="succeeded ? 'i-lucide-circle-check' : 'i-lucide-image-plus'"
|
||||
:class="succeeded ? 'size-6 text-success' : 'size-6 text-dimmed'"
|
||||
/>
|
||||
<p class="text-sm text-highlighted">
|
||||
<template v-if="succeeded">Image added</template>
|
||||
<template v-else>{{ chosenFile?.name }} {{ uploadPercent }}%</template>
|
||||
</p>
|
||||
<UProgress
|
||||
v-if="!succeeded"
|
||||
:model-value="uploadPercent"
|
||||
size="xs"
|
||||
class="w-full"
|
||||
role="progressbar"
|
||||
:aria-valuenow="uploadPercent"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!succeeded" class="text-sm text-toned">
|
||||
{{ formatUploadProgress(progress.loaded, progress.total) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="phase === 'rejected'" class="flex flex-col gap-1">
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 rounded-md border border-error px-6 py-8 text-center"
|
||||
>
|
||||
<UIcon name="i-lucide-file-x" class="size-6 text-error" />
|
||||
<p class="text-sm font-semibold text-highlighted">{{ rejectedTitle }}</p>
|
||||
<p class="text-sm text-toned">{{ rejectedText }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="phase === 'failed'" class="flex flex-col gap-1">
|
||||
<p class="text-sm font-semibold text-highlighted">Upload failed</p>
|
||||
<p class="text-sm text-toned">
|
||||
The server didn't accept the file. Your article hasn't changed.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<UButton v-if="showCancel" color="neutral" variant="outline" @click="cancel">
|
||||
{{ cancelLabel }}
|
||||
</UButton>
|
||||
<UButton v-if="phase === 'rejected'" @click="chooseAnotherFile">
|
||||
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>
|
||||
</template>
|
||||
@@ -179,6 +179,7 @@ function onCancel() {
|
||||
:value="titleLength"
|
||||
:max="TITLE_MAX_LENGTH"
|
||||
:min="titleCounterMin"
|
||||
plain-text
|
||||
/>
|
||||
</template>
|
||||
<template v-if="titleError" #error="{ error }">
|
||||
|
||||
@@ -63,8 +63,11 @@ const items = computed(() => [
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
block
|
||||
class="h-10 justify-start gap-2.5 px-1.5 font-normal text-highlighted"
|
||||
trailing-icon="i-lucide-chevrons-up-down"
|
||||
:class="[
|
||||
'h-10 gap-2.5 px-1.5 font-normal text-highlighted',
|
||||
collapsed ? 'justify-center' : 'justify-start',
|
||||
]"
|
||||
:trailing-icon="collapsed ? undefined : 'i-lucide-chevrons-up-down'"
|
||||
:ui="{ trailingIcon: 'ms-auto size-3.5 text-default' }"
|
||||
:aria-label="triggerAriaLabel"
|
||||
>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,416 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick, ref } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import ImageUploader from '../ImageUploader.vue'
|
||||
import * as imageUpload from '@/lib/imageUpload'
|
||||
|
||||
vi.mock('@/lib/imageUpload', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/imageUpload')>()
|
||||
return {
|
||||
...actual,
|
||||
validateImageFile: vi.fn(actual.validateImageFile),
|
||||
}
|
||||
})
|
||||
|
||||
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[] = []
|
||||
|
||||
method = ''
|
||||
url = ''
|
||||
status = 0
|
||||
responseText = ''
|
||||
withCredentials = false
|
||||
sentBody: FormData | null = null
|
||||
aborted = false
|
||||
|
||||
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() {
|
||||
this.aborted = true
|
||||
this.dispatch('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')
|
||||
}
|
||||
|
||||
fail() {
|
||||
this.dispatch('error')
|
||||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
initialFile: { type: Object as () => File | null, default: null },
|
||||
},
|
||||
emits: ['uploaded', 'close'],
|
||||
setup(_props, { emit }) {
|
||||
const open = ref(false)
|
||||
return {
|
||||
open,
|
||||
onUploaded: (location: string, imageId: number) => emit('uploaded', location, imageId),
|
||||
onClose: () => emit('close'),
|
||||
}
|
||||
},
|
||||
template: `<ImageUploader v-model:open="open" :article-id="articleId" :initial-file="initialFile" @uploaded="onUploaded" @close="onClose" />`,
|
||||
})
|
||||
|
||||
function mountUploader(props: { articleId?: number; initialFile?: File | null } = {}) {
|
||||
return mount(Host, {
|
||||
props: { articleId: 12, initialFile: null, ...props },
|
||||
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"]')!
|
||||
}
|
||||
|
||||
function dropZone() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="image-dropzone"]')!
|
||||
}
|
||||
|
||||
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 flushPromises()
|
||||
}
|
||||
|
||||
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 flushPromises()
|
||||
}
|
||||
|
||||
async function chooseRasterFileAndConfirmUpload(file: File) {
|
||||
await chooseFile(file)
|
||||
await clickButton('Upload image')
|
||||
}
|
||||
|
||||
const RealXMLHttpRequest = global.XMLHttpRequest
|
||||
|
||||
beforeEach(() => {
|
||||
FakeXMLHttpRequest.instances = []
|
||||
global.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
global.XMLHttpRequest = RealXMLHttpRequest
|
||||
})
|
||||
|
||||
describe('Opening the uploader from the toolbar image button shows the idle state', () => {
|
||||
it('shows the idle copy and sends no request', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
|
||||
expect(document.body.textContent).toContain('Add an image')
|
||||
expect(document.body.textContent).toContain('Drop an image here')
|
||||
expect(document.body.textContent).toContain('PNG, JPEG, WEBP, SVG · max 10 MB')
|
||||
expect(findButton('Choose file')).toBeTruthy()
|
||||
expect(findButton('Cancel')).toBeTruthy()
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dragging a file over the drop zone shows the dragging-over state', () => {
|
||||
it('shows "Drop to upload"', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
|
||||
dropZone().dispatchEvent(new Event('dragover', { bubbles: true, cancelable: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(document.body.textContent).toContain('Drop to upload')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Choosing a file the browser can reject never sends a request', () => {
|
||||
it.each([
|
||||
{
|
||||
filename: 'plan.pdf',
|
||||
contentType: 'application/pdf',
|
||||
sizeMB: 1.2,
|
||||
description: 'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.',
|
||||
},
|
||||
{
|
||||
filename: 'disguised.png',
|
||||
contentType: 'application/pdf',
|
||||
sizeMB: 1.2,
|
||||
description: 'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.',
|
||||
},
|
||||
{
|
||||
filename: 'north-wall.tif',
|
||||
contentType: 'image/png',
|
||||
sizeMB: 14.2,
|
||||
description: 'north-wall.tif is 14.2 MB. Images can be up to 10 MB.',
|
||||
},
|
||||
])(
|
||||
'rejects $filename with the exact copy',
|
||||
async ({ filename, contentType, sizeMB, description }) => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
|
||||
await chooseFile(fileFixture(filename, contentType, sizeMB))
|
||||
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
|
||||
expect(document.body.textContent).toContain(`"${filename}" can't be used`)
|
||||
expect(document.body.textContent).toContain(description)
|
||||
expect(findButton('Choose another file')).toBeTruthy()
|
||||
expect(findButton('Cancel')).toBeTruthy()
|
||||
wrapper.unmount()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('"Choose another file" from the rejected state returns to idle', () => {
|
||||
it('shows the idle state again', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(fileFixture('plan.pdf', 'application/pdf', 1.2))
|
||||
|
||||
await clickButton('Choose another file')
|
||||
|
||||
expect(document.body.textContent).toContain('Drop an image here')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('A valid upload shows progress and its cancel action reads "Cancel upload"', () => {
|
||||
it('sends the resized file and reports live progress', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
|
||||
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 420 KB')
|
||||
expect(findButton('Cancel upload')).toBeTruthy()
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
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 chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
const xhr = latestXHR()
|
||||
|
||||
await clickButton('Cancel upload')
|
||||
|
||||
expect(xhr.aborted).toBe(true)
|
||||
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('A successful upload emits the location and auto-closes 600 ms later', () => {
|
||||
it('shows "Image added" then closes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
const xhr = latestXHR()
|
||||
|
||||
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', 7]])
|
||||
expect(document.body.textContent).toContain('Image added')
|
||||
expect(document.querySelector('[role="dialog"]')).toBeTruthy()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(590)
|
||||
|
||||
expect(document.querySelector('[role="dialog"]')).toBeTruthy()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
|
||||
wrapper.unmount()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('A server-side upload failure keeps the article unchanged and offers a retry', () => {
|
||||
it.each([
|
||||
{ name: 'a 500 response', trigger: (xhr: FakeXMLHttpRequest) => xhr.resolve(500, '') },
|
||||
{ name: 'a network failure', trigger: (xhr: FakeXMLHttpRequest) => xhr.fail() },
|
||||
])('shows the failed state for $name', async ({ trigger }) => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
const xhr = latestXHR()
|
||||
|
||||
trigger(xhr)
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('Upload failed')
|
||||
expect(document.body.textContent).toContain(
|
||||
"The server didn't accept the file. Your article hasn't changed.",
|
||||
)
|
||||
expect(findButton('Try again')).toBeTruthy()
|
||||
expect(wrapper.emitted('uploaded')).toBeFalsy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
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 chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
latestXHR().resolve(500, '')
|
||||
await flushPromises()
|
||||
|
||||
await clickButton('Try again')
|
||||
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(2)
|
||||
const xhr = latestXHR()
|
||||
expect(xhr.url).toBe('/v1/article/12/image')
|
||||
expect((xhr.sentBody?.get('file') as File).name).toBe('north-wall.jpg')
|
||||
expect(document.body.textContent).toContain('north-wall.jpg 0%')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('A server 400 for a type the client should have caught shows the same rejected copy', () => {
|
||||
it('shows the type-rejection copy', async () => {
|
||||
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({ ok: true })
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
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')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain(`"notes.pdf" can't be used`)
|
||||
expect(document.body.textContent).toContain(
|
||||
'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.',
|
||||
)
|
||||
expect(findButton('Choose another file')).toBeTruthy()
|
||||
expect(wrapper.emitted('uploaded')).toBeFalsy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('A server 400 for a size the client should have caught shows the size-named copy', () => {
|
||||
it('shows the size-rejection copy computed from the file', async () => {
|
||||
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({ ok: true })
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.tif', 'image/png', 14.2))
|
||||
const xhr = latestXHR()
|
||||
|
||||
xhr.resolve(400, 'the file exceeds the maximum upload size')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain(`"north-wall.tif" can't be used`)
|
||||
expect(document.body.textContent).toContain(
|
||||
'north-wall.tif is 14.2 MB. Images can be up to 10 MB.',
|
||||
)
|
||||
expect(findButton('Choose another file')).toBeTruthy()
|
||||
expect(wrapper.emitted('uploaded')).toBeFalsy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface UploadProgress {
|
||||
loaded: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type UploadOutcome =
|
||||
| { kind: 'success'; location: string; id: number }
|
||||
| { kind: 'rejected'; body: string }
|
||||
| { kind: 'failed' }
|
||||
|
||||
export function useImageUpload() {
|
||||
const progress = ref<UploadProgress>({ loaded: 0, total: 0 })
|
||||
let activeRequest: XMLHttpRequest | null = null
|
||||
|
||||
function abort() {
|
||||
activeRequest?.abort()
|
||||
activeRequest = null
|
||||
}
|
||||
|
||||
function start(articleId: number, file: File): Promise<UploadOutcome> {
|
||||
progress.value = { loaded: 0, total: file.size }
|
||||
return new Promise((resolve) => {
|
||||
const request = new XMLHttpRequest()
|
||||
activeRequest = request
|
||||
request.open('POST', `/v1/article/${articleId}/image`)
|
||||
request.withCredentials = true
|
||||
request.upload.addEventListener('progress', (event) => {
|
||||
progress.value = { loaded: event.loaded, total: event.total }
|
||||
})
|
||||
request.addEventListener('load', () => {
|
||||
activeRequest = null
|
||||
if (request.status === 201) {
|
||||
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) {
|
||||
resolve({ kind: 'rejected', body: request.responseText })
|
||||
return
|
||||
}
|
||||
resolve({ kind: 'failed' })
|
||||
})
|
||||
request.addEventListener('error', () => {
|
||||
activeRequest = null
|
||||
resolve({ kind: 'failed' })
|
||||
})
|
||||
request.addEventListener('abort', () => {
|
||||
activeRequest = null
|
||||
})
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
request.send(formData)
|
||||
})
|
||||
}
|
||||
|
||||
return { progress, start, abort }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
export function useSidebarCollapsed() {
|
||||
return useStorage('sidebar-collapsed', false)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
export const ALLOWED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']
|
||||
|
||||
export const ALLOWED_IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.svg']
|
||||
|
||||
export const IMAGE_TYPE_REJECTION_TEXT = 'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.'
|
||||
|
||||
export type ImageRejectionKind = 'type' | 'size'
|
||||
|
||||
export type ImageValidationResult = { ok: true } | { ok: false; kind: ImageRejectionKind }
|
||||
|
||||
export function fileExtension(filename: string) {
|
||||
const index = filename.lastIndexOf('.')
|
||||
return index === -1 ? '' : filename.slice(index).toLowerCase()
|
||||
}
|
||||
|
||||
export function validateImageFile(file: File): ImageValidationResult {
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
return { ok: false, kind: 'size' }
|
||||
}
|
||||
const typeAllowed = ALLOWED_IMAGE_MIME_TYPES.includes(file.type)
|
||||
const extensionAllowed = ALLOWED_IMAGE_EXTENSIONS.includes(fileExtension(file.name))
|
||||
if (!typeAllowed || !extensionAllowed) {
|
||||
return { ok: false, kind: 'type' }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
export function formatMegabytes(bytes: number) {
|
||||
return (bytes / (1024 * 1024)).toFixed(1)
|
||||
}
|
||||
|
||||
export function isSizeRejectionBody(body: string) {
|
||||
return body.toLowerCase().includes('exceed')
|
||||
}
|
||||
|
||||
export function rejectionTitle(filename: string) {
|
||||
return `"${filename}" can't be used`
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
+198
-46
@@ -11,6 +11,7 @@ import SaveStatus from '@/components/SaveStatus.vue'
|
||||
import PublishControl from '@/components/PublishControl.vue'
|
||||
import CharacterCounter from '@/components/CharacterCounter.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import ImageUploader from '@/components/ImageUploader.vue'
|
||||
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { useArticleDraft } from '@/composables/useArticleDraft'
|
||||
@@ -34,6 +35,10 @@ const showSkeleton = ref(false)
|
||||
const article = ref<Article | null>(null)
|
||||
const previewOn = ref(false)
|
||||
const detailsOpen = ref(false)
|
||||
const publishPending = ref<string | undefined>(undefined)
|
||||
const editorRef = ref<InstanceType<typeof ArticleEditor> | null>(null)
|
||||
const imageUploaderOpen = ref(false)
|
||||
const imageUploaderFile = ref<File | null>(null)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
@@ -72,15 +77,10 @@ const bodyLength = computed(() => draft.body.value.length)
|
||||
const overLimit = computed(() => bodyLength.value > BODY_MAX_LENGTH)
|
||||
|
||||
const saveEnabled = computed(
|
||||
() =>
|
||||
draft.state.value === 'dirty' &&
|
||||
!overLimit.value &&
|
||||
loadState.value === 'loaded',
|
||||
() => draft.state.value === 'dirty' && !overLimit.value && loadState.value === 'loaded',
|
||||
)
|
||||
|
||||
const publishDisabled = computed(
|
||||
() => overLimit.value || draft.state.value === 'offline',
|
||||
)
|
||||
const publishDisabled = computed(() => overLimit.value || draft.state.value === 'offline')
|
||||
|
||||
async function runSave() {
|
||||
const outcome = await draft.save()
|
||||
@@ -111,6 +111,161 @@ function goBack() {
|
||||
router.push('/article')
|
||||
}
|
||||
|
||||
async function setOnline(makeOnline: boolean) {
|
||||
if (!article.value) {
|
||||
return
|
||||
}
|
||||
publishPending.value = makeOnline ? 'Publishing…' : 'Unpublishing…'
|
||||
try {
|
||||
const updated = await request<Article>(
|
||||
`/v1/article/${articleId}/${makeOnline ? 'publish' : 'unpublish'}`,
|
||||
{ method: 'PATCH' },
|
||||
)
|
||||
article.value = updated
|
||||
toast.add({
|
||||
id: `article-${makeOnline ? 'published' : 'unpublished'}-${articleId}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: makeOnline ? 'Article published' : 'Article unpublished',
|
||||
description: makeOnline
|
||||
? `"${updated.title}" is now online.`
|
||||
: `"${updated.title}" is now offline.`,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
loadState.value = 'notFound'
|
||||
return
|
||||
}
|
||||
toast.add({
|
||||
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${articleId}`,
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: makeOnline ? "Couldn't publish" : "Couldn't unpublish",
|
||||
description: "The server didn't respond. Nothing changed.",
|
||||
duration: 0,
|
||||
actions: [{ label: 'Retry', onClick: () => setOnline(makeOnline) }],
|
||||
})
|
||||
} finally {
|
||||
publishPending.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function handlePublish() {
|
||||
setOnline(true)
|
||||
}
|
||||
|
||||
function handleUnpublish() {
|
||||
setOnline(false)
|
||||
}
|
||||
|
||||
function deleteDescription() {
|
||||
const count = article.value?.images.length ?? 0
|
||||
const imagesPhrase =
|
||||
count === 0 ? '' : count === 1 ? ' and its image' : ` and its ${count} images`
|
||||
return `"${article.value?.title ?? ''}"${imagesPhrase} will be deleted. This can't be undone.`
|
||||
}
|
||||
|
||||
async function performDelete() {
|
||||
try {
|
||||
await request(`/v1/article/${articleId}`, { method: 'DELETE' })
|
||||
toast.add({
|
||||
id: `article-deleted-${articleId}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Article deleted',
|
||||
description: `"${article.value?.title ?? ''}" and its images are gone.`,
|
||||
})
|
||||
router.push('/article')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
loadState.value = 'notFound'
|
||||
return
|
||||
}
|
||||
throw new Error("Couldn't delete the article, so it's still there. Try again in a moment.")
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!article.value) {
|
||||
return
|
||||
}
|
||||
await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
title: 'Delete this article?',
|
||||
description: deleteDescription(),
|
||||
confirmLabel: 'Delete article',
|
||||
cancelLabel: 'Cancel',
|
||||
pendingLabel: 'Deleting…',
|
||||
tone: 'danger',
|
||||
action: performDelete,
|
||||
},
|
||||
})
|
||||
.open()
|
||||
}
|
||||
|
||||
function openImageUploader(file: File | null) {
|
||||
imageUploaderFile.value = file
|
||||
imageUploaderOpen.value = true
|
||||
}
|
||||
|
||||
function onRequestImage() {
|
||||
openImageUploader(null)
|
||||
}
|
||||
|
||||
function onEditorDrop(event: DragEvent) {
|
||||
const file = event.dataTransfer?.files?.[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
openImageUploader(file)
|
||||
}
|
||||
|
||||
function onImageUploaded(location: string, imageId: number) {
|
||||
editorRef.value?.insertImage(location, '', imageId)
|
||||
toast.add({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Image added',
|
||||
description: 'Save to keep it in the article.',
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
|
||||
event.preventDefault()
|
||||
@@ -154,21 +309,14 @@ onBeforeRouteLeave(async () => {
|
||||
<DashboardShell mobile-title="Edit article" full-bleed>
|
||||
<template v-if="loadState === 'loaded'" #breadcrumb>
|
||||
<UBreadcrumb
|
||||
:items="[
|
||||
{ label: 'Articles', to: '/article' },
|
||||
{ label: article?.title ?? '' },
|
||||
]"
|
||||
:items="[{ label: 'Articles', to: '/article' }, { label: article?.title ?? '' }]"
|
||||
color="neutral"
|
||||
class="hidden lg:block"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ErrorView v-if="loadState === 'notFound'" kind="article-not-found" />
|
||||
<ErrorView
|
||||
v-else-if="loadState === 'error'"
|
||||
kind="error"
|
||||
@retry="loadArticle"
|
||||
/>
|
||||
<ErrorView v-else-if="loadState === 'error'" kind="error" @retry="loadArticle" />
|
||||
|
||||
<div
|
||||
v-else-if="loadState === 'loading' && showSkeleton"
|
||||
@@ -187,10 +335,7 @@ onBeforeRouteLeave(async () => {
|
||||
aria-label="Back to Articles"
|
||||
@click="goBack"
|
||||
/>
|
||||
<USkeleton
|
||||
class="h-5 w-80 rounded-none"
|
||||
data-testid="skeleton-title"
|
||||
/>
|
||||
<USkeleton class="h-5 w-80 rounded-none" data-testid="skeleton-title" />
|
||||
<USkeleton class="h-5.5 w-15 rounded-none" />
|
||||
</div>
|
||||
<div
|
||||
@@ -215,10 +360,7 @@ onBeforeRouteLeave(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="loadState === 'loaded' && article"
|
||||
class="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div v-else-if="loadState === 'loaded' && article" class="flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
class="flex flex-shrink-0 flex-wrap items-center gap-3 border-b border-default bg-default px-6 py-3.5"
|
||||
>
|
||||
@@ -230,9 +372,7 @@ onBeforeRouteLeave(async () => {
|
||||
aria-label="Back to Articles"
|
||||
@click="goBack"
|
||||
/>
|
||||
<div
|
||||
class="flex min-w-50 flex-1 flex-wrap items-center gap-2.5"
|
||||
>
|
||||
<div class="flex min-w-50 flex-1 flex-wrap items-center gap-2.5">
|
||||
<h1
|
||||
class="text-[1.125rem]/6.5 font-semibold text-highlighted"
|
||||
title="The title can't be changed after creation."
|
||||
@@ -254,7 +394,7 @@ onBeforeRouteLeave(async () => {
|
||||
root: 'items-center gap-2',
|
||||
container: 'h-auto',
|
||||
base: 'h-4.5 w-8 p-0.5',
|
||||
thumb: 'size-3.5 bg-default data-[state=checked]:translate-x-3.5',
|
||||
thumb: 'size-3.5 bg-default data-[state=checked]:translate-x-2.5',
|
||||
wrapper: 'ms-0',
|
||||
label: 'text-sm font-normal text-toned',
|
||||
}"
|
||||
@@ -274,15 +414,20 @@ onBeforeRouteLeave(async () => {
|
||||
class="h-8 gap-1.5 px-3"
|
||||
:online="article.is_online"
|
||||
:dirty="draft.state.value === 'dirty'"
|
||||
:pending="publishPending"
|
||||
:disabled="publishDisabled"
|
||||
@publish="handlePublish"
|
||||
@unpublish="handleUnpublish"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-ellipsis"
|
||||
color="neutral"
|
||||
icon="i-lucide-trash-2"
|
||||
color="error"
|
||||
variant="ghost"
|
||||
class="size-8 justify-center text-toned"
|
||||
aria-label="More actions"
|
||||
/>
|
||||
class="h-8 gap-1.5 px-3"
|
||||
@click="handleDelete"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -301,18 +446,18 @@ onBeforeRouteLeave(async () => {
|
||||
<div
|
||||
class="hidden w-70 flex-shrink-0 border-l border-default bg-default p-5 lg:block"
|
||||
>
|
||||
<ArticleDetails
|
||||
:article="article"
|
||||
v-model:open="detailsOpen"
|
||||
/>
|
||||
<ArticleDetails :article="article" v-model:open="detailsOpen" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="min-h-0 flex-1" @dragover.prevent @drop.prevent="onEditorDrop">
|
||||
<ArticleEditor
|
||||
v-else
|
||||
class="min-h-0 flex-1"
|
||||
ref="editorRef"
|
||||
class="h-full"
|
||||
:model-value="draft.body.value"
|
||||
:article-id="articleId"
|
||||
@update:model-value="draft.edit"
|
||||
@request-image="onRequestImage"
|
||||
@request-remove-image="onRequestRemoveImage"
|
||||
>
|
||||
<template #footer>
|
||||
<CharacterCounter
|
||||
@@ -323,17 +468,21 @@ onBeforeRouteLeave(async () => {
|
||||
</template>
|
||||
<template #aside>
|
||||
<div class="h-full border-l border-default bg-default p-5">
|
||||
<ArticleDetails
|
||||
:article="article"
|
||||
v-model:open="detailsOpen"
|
||||
/>
|
||||
<ArticleDetails :article="article" v-model:open="detailsOpen" />
|
||||
</div>
|
||||
</template>
|
||||
</ArticleEditor>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-shrink-0 gap-2 border-t border-default bg-default p-3 lg:hidden"
|
||||
>
|
||||
<ImageUploader
|
||||
v-model:open="imageUploaderOpen"
|
||||
:article-id="articleId"
|
||||
:initial-file="imageUploaderFile"
|
||||
@uploaded="onImageUploaded"
|
||||
@close="onImageUploaderClosed"
|
||||
/>
|
||||
|
||||
<div class="flex flex-shrink-0 gap-2 border-t border-default bg-default p-3 lg:hidden">
|
||||
<UButton
|
||||
icon="i-lucide-panel-right"
|
||||
color="neutral"
|
||||
@@ -357,7 +506,10 @@ onBeforeRouteLeave(async () => {
|
||||
hide-icon
|
||||
:online="article.is_online"
|
||||
:dirty="draft.state.value === 'dirty'"
|
||||
:pending="publishPending"
|
||||
:disabled="publishDisabled"
|
||||
@publish="handlePublish"
|
||||
@unpublish="handleUnpublish"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,273 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import RootComponent from '@/App.vue'
|
||||
|
||||
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[] = []
|
||||
|
||||
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() {
|
||||
this.dispatch('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))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
function articleFixture(overrides: Partial<Article> = {}): Article {
|
||||
return {
|
||||
id: 12,
|
||||
title: 'How we price a bathroom renovation',
|
||||
body: '<p>a</p>',
|
||||
images: [],
|
||||
is_online: false,
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: FIXED_NOW,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
let toastAdd: ReturnType<typeof vi.fn>
|
||||
|
||||
vi.mock('@nuxt/ui/composables', () => ({
|
||||
useOverlay: () => ({ create: vi.fn() }),
|
||||
useToast: () => ({ add: toastAdd }),
|
||||
}))
|
||||
|
||||
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
|
||||
|
||||
async function mountEditor(
|
||||
handlers: Record<string, () => Response | Promise<Response>>,
|
||||
path = '/article/12/edit',
|
||||
) {
|
||||
setActivePinia(createPinia())
|
||||
toastAdd = vi.fn()
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.status = 'ready'
|
||||
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
|
||||
await router.push(path)
|
||||
const wrapper = mount(RootComponent, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
plugins: [router, uiPlugin],
|
||||
},
|
||||
})
|
||||
currentWrapper = wrapper
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function saveButton(wrapper: Awaited<ReturnType<typeof mount>>) {
|
||||
return wrapper.find('[data-testid="save-button"]')
|
||||
}
|
||||
|
||||
function findButton(text: string) {
|
||||
return Array.from(document.body.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === text,
|
||||
)
|
||||
}
|
||||
|
||||
function toolbarImageButton(wrapper: Awaited<ReturnType<typeof mount>>) {
|
||||
return wrapper.findAll('button').find((button) => button.text() === 'Image')
|
||||
}
|
||||
|
||||
const RealXMLHttpRequest = global.XMLHttpRequest
|
||||
|
||||
beforeEach(() => {
|
||||
FakeXMLHttpRequest.instances = []
|
||||
global.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
currentWrapper?.unmount()
|
||||
currentWrapper = null
|
||||
sessionStorage.clear()
|
||||
global.XMLHttpRequest = RealXMLHttpRequest
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe("Opening the uploader from the toolbar's image button shows the idle state", () => {
|
||||
it('opens ImageUploader idle and sends no request', async () => {
|
||||
const wrapper = await mountEditor({
|
||||
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
||||
})
|
||||
|
||||
await toolbarImageButton(wrapper)?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('Add an image')
|
||||
expect(document.body.textContent).toContain('Drop an image here')
|
||||
expect(document.body.textContent).toContain('PNG, JPEG, WEBP, SVG · max 10 MB')
|
||||
expect(findButton('Choose file')).toBeTruthy()
|
||||
expect(findButton('Cancel')).toBeTruthy()
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
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()),
|
||||
})
|
||||
|
||||
const file = fileFixture('before.png', 'image/png', 3.9)
|
||||
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||
value: { files: { 0: file, length: 1 } },
|
||||
})
|
||||
wrapper.findComponent(ArticleEditor).element.dispatchEvent(dropEvent)
|
||||
await flushPromises()
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
describe('A successful upload inserts the image at the cursor and marks the draft dirty', () => {
|
||||
it('inserts the image, marks the draft dirty and toasts', async () => {
|
||||
const wrapper = await mountEditor({
|
||||
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
||||
})
|
||||
|
||||
await toolbarImageButton(wrapper)?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const fileInput = document.body.querySelector<HTMLInputElement>('input[type="file"]')!
|
||||
const file = fileFixture('north-wall.jpg', 'image/jpeg', 3.9)
|
||||
Object.defineProperty(fileInput, 'files', {
|
||||
value: { 0: file, length: 1 },
|
||||
configurable: true,
|
||||
})
|
||||
fileInput.dispatchEvent(new Event('change'))
|
||||
await flushPromises()
|
||||
|
||||
findButton('Upload image')?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
|
||||
latestXHR().resolve(
|
||||
201,
|
||||
JSON.stringify({ location: '/static/uploadedImages/north-wall.jpg' }),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
const insertedBody = wrapper.findComponent(ArticleEditor).props('modelValue')
|
||||
expect(insertedBody).toContain('/static/uploadedImages/north-wall.jpg')
|
||||
expect(insertedBody).toContain('<img src="/static/uploadedImages/north-wall.jpg" alt="">')
|
||||
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 added',
|
||||
description: 'Save to keep it in the article.',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,8 +55,7 @@ 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> }) => {
|
||||
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
|
||||
props = options.props
|
||||
return {
|
||||
open: () =>
|
||||
@@ -70,8 +63,7 @@ function createOverlayMock() {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -205,6 +205,191 @@ async function typeExactBodyLength(page: Page, totalLength: number) {
|
||||
await page.keyboard.insertText(inner)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
async function chooseImageFile(page: Page, filename: string, mimeType: string, size: number) {
|
||||
await page.locator('input[type="file"]').setInputFiles({
|
||||
name: filename,
|
||||
mimeType,
|
||||
buffer: Buffer.alloc(size),
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
const background = isDark ? 'rgb(26,26,26)' : 'rgb(244,244,245)'
|
||||
const border = isDark ? 'rgb(64,64,64)' : 'rgb(212,212,216)'
|
||||
await page.route('**/static/uploadedImages/**', async (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="900" height="450"><rect width="100%" height="100%" fill="${background}" stroke="${border}" stroke-width="2" /><text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle" font-family="monospace" font-size="16" fill="${border}">${pathname}</text></svg>`
|
||||
await route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg })
|
||||
})
|
||||
}
|
||||
|
||||
async function freezeUploadProgress(page: Page, loaded: number, total: number) {
|
||||
await page.evaluate(
|
||||
({ loaded, total }) => {
|
||||
const proto = XMLHttpRequest.prototype as unknown as Record<string, unknown>
|
||||
const originalOpen = proto.open as (...args: unknown[]) => void
|
||||
proto.open = function (this: XMLHttpRequest, ...args: unknown[]) {
|
||||
;(this as unknown as { __imageUrl?: string }).__imageUrl = String(args[1])
|
||||
return originalOpen.apply(this, args)
|
||||
}
|
||||
const originalSend = proto.send as (...args: unknown[]) => void
|
||||
proto.send = function (this: XMLHttpRequest, ...args: unknown[]) {
|
||||
const url = (this as unknown as { __imageUrl?: string }).__imageUrl ?? ''
|
||||
if (url.includes('/image')) {
|
||||
setTimeout(() => {
|
||||
const event = new Event('progress')
|
||||
Object.assign(event, { loaded, total, lengthComputable: true })
|
||||
this.upload.dispatchEvent(event)
|
||||
}, 50)
|
||||
return
|
||||
}
|
||||
return originalSend.apply(this, args)
|
||||
}
|
||||
},
|
||||
{ loaded, total },
|
||||
)
|
||||
}
|
||||
|
||||
const newArticleTitle = 'Choosing tiles for a small bathroom'
|
||||
const newArticleBody = 'Small rooms show every grout line, so we start with the floor.'
|
||||
|
||||
@@ -335,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) {
|
||||
@@ -846,6 +1076,88 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
'GET /v1/article/9999': { status: 404 },
|
||||
},
|
||||
},
|
||||
'Upload: choose': {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: editorApi(),
|
||||
steps: async (page) => {
|
||||
await markDirty(page)
|
||||
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 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': {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: editorApi(),
|
||||
steps: async (page) => {
|
||||
await markDirty(page)
|
||||
await openImageUploader(page)
|
||||
await chooseImageFile(page, 'north-wall.tif', 'image/png', mib(14.2))
|
||||
await page.getByText(`"north-wall.tif" can't be used`).waitFor()
|
||||
},
|
||||
},
|
||||
'Upload: inserted': {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: editorApi({
|
||||
'POST /v1/article/12/image': {
|
||||
status: 201,
|
||||
body: { location: '/static/uploadedImages/north-wall.jpg' },
|
||||
},
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await mockUploadedImage(page)
|
||||
await page.getByText('add a margin on materials').click()
|
||||
await page.keyboard.press('Home')
|
||||
await openImageUploader(page)
|
||||
await chooseImageFile(page, 'north-wall.jpg', 'image/jpeg', mib(3.9))
|
||||
await page.getByText('Save to keep it in the article.').first().waitFor()
|
||||
await page.waitForTimeout(650)
|
||||
},
|
||||
},
|
||||
},
|
||||
'new-article': {
|
||||
Default: {
|
||||
@@ -914,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'),
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export default defineConfig({
|
||||
proxy: {
|
||||
'/v1': 'http://localhost:8080',
|
||||
'/healthz': 'http://localhost:8080',
|
||||
'/static': 'http://localhost:8080',
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
Reference in new issue
Block a user