feat: image cropping / resizing T0
This commit is contained in:
1 parent
cb05b38f36
commit
e15f444603
18 files changed
+1869
-46
No files matched your search
@@ -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) |
|
||||
|
||||
Reference in new issue
Block a user