diff --git a/CHANGELOG.md b/CHANGELOG.md index dfae926..606f9b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-19](https://github.com/itk-dev/itk-project-database/pull/19) + Auto-upload files with a progress bar and image preview, view images in an + in-page lightbox, and refresh the media field styling. * [PR-16](https://github.com/itk-dev/itk-project-database/pull/16) Turn the strategies and tags fields into a searchable, shared tag pool where new entries are capitalised and reused as suggestions. diff --git a/assets/app.js b/assets/app.js index 5295070..1c342a4 100644 --- a/assets/app.js +++ b/assets/app.js @@ -24,7 +24,14 @@ function initCollections() { remove.className = "btn btn--danger btn--sm"; remove.dataset.collectionRemove = ""; remove.textContent = collection.dataset.removeLabel || "Remove"; - remove.addEventListener("click", () => item.remove()); + remove.addEventListener("click", () => { + item.remove(); + // Autosave is the only save path now — tell it the form changed so + // the removed row is persisted (and the media frame re-renders). + collection.dispatchEvent( + new Event("change", { bubbles: true }), + ); + }); item.appendChild(remove); }; @@ -137,3 +144,9 @@ document.addEventListener("turbo:load", () => { initContactSelect(); initTermSelect(); }); + +// A turbo-frame swap (the media section reloading after a file upload) replaces +// its collections, so re-bind the add/remove buttons on the fresh markup. +document.addEventListener("turbo:frame-load", () => { + initCollections(); +}); diff --git a/assets/controllers/autosave_controller.js b/assets/controllers/autosave_controller.js index fc20068..3af38c6 100644 --- a/assets/controllers/autosave_controller.js +++ b/assets/controllers/autosave_controller.js @@ -6,11 +6,13 @@ import { Controller } from "@hotwired/stimulus"; * On the edit form it posts changes to the edit endpoint without re-rendering, * so focus and caret are never lost. On the new form (isNew) the first valid * save creates the initiative and the controller swaps to editing that record - * in place (URL + action). File uploads are skipped on autosave and left for an - * explicit Save, which keeps the request equivalent to a manual save. + * in place (URL + action). Picking a file uploads it straight away via the same + * POST; afterwards the media turbo-frame is reloaded so the stored file shows as + * a link and its (now redundant) input is cleared — without that the file would + * linger in the input and re-upload on every later keystroke. */ export default class extends Controller { - static targets = ["status", "statusText", "save"]; + static targets = ["status", "statusText"]; static values = { debounce: { type: Number, default: 800 }, @@ -26,27 +28,25 @@ export default class extends Controller { default: "Save failed — your changes are kept here", }, requiredText: { type: String, default: "Add a title to save" }, - filesHintText: { type: String, default: "Click Save to upload files" }, isNew: { type: Boolean, default: false }, }; initialize() { this.timer = null; - this.inFlight = null; - this.creating = false; + this.xhr = null; + this.busy = false; } disconnect() { window.clearTimeout(this.timer); - this.inFlight?.abort(); + this.xhr?.abort(); } schedule(event) { - // Picking a file can't autosave, but it reveals the Save button to upload it. - this.revealSaveForFiles(); - - // Files are uploaded only on an explicit Save. + // A picked file uploads on its own — save right away, no debounce. if (event?.target?.type === "file") { + window.clearTimeout(this.timer); + this.save(); return; } // No "unsaved" text — the animated pen icon carries that state. @@ -64,25 +64,16 @@ export default class extends Controller { return; } - // A picked-but-unsaved file is left for Save so we never half-upload it. - if (this.hasPendingFile()) { - this.setStatus(this.filesHintTextValue, "unsaved"); - return; - } - - // Hold off while a brand-new initiative is being created so we never - // POST the create twice; the next change saves it once it exists. - if (this.creating) { + // A create or a file upload must run to completion; don't start a second + // save on top of one (it would double-create the draft or cut the upload). + if (this.busy) { return; } - // Edits can supersede an in-flight request; a create must run to completion. - const creating = this.isNewValue; - if (!creating) { - this.inFlight?.abort(); - } - this.inFlight = new AbortController(); - this.creating = creating; + const withFiles = this.hasPendingFile(); + // Supersede any in-flight plain edit; the new POST carries the whole form. + this.xhr?.abort(); + this.busy = this.isNewValue || withFiles; // Reveal the saving spinner only for slow saves (> 2s); a quick save jumps // straight to "Gemt" with no flicker. @@ -91,21 +82,26 @@ export default class extends Controller { 2000, ); - try { - const response = await fetch(this.element.action, { - method: "POST", - body: new FormData(this.element), - headers: { - "X-Autosave": "1", - "X-Requested-With": "XMLHttpRequest", - }, - credentials: "same-origin", - signal: this.inFlight.signal, - }); + // Picking a file drives a progress bar in the upload field via these events. + if (withFiles) { + this.dispatch("uploadstart", { target: document }); + } + let ok = false; - if (201 === response.status) { + try { + const { status, location } = await this.request( + withFiles + ? (percent) => + this.dispatch("uploadprogress", { + target: document, + detail: { percent }, + }) + : null, + ); + + if (201 === status) { + ok = true; // The draft now exists — edit it in place from here on. - const location = response.headers.get("X-Initiative-Location"); if (location) { this.element.action = location; this.isNewValue = false; @@ -120,12 +116,19 @@ export default class extends Controller { `${this.savedTextValue} · ${this.timestamp()}`, "saved", ); - } else if (204 === response.status) { + if (withFiles) { + this.refreshMedia(); + } + } else if (204 === status) { + ok = true; this.setStatus( `${this.savedTextValue} · ${this.timestamp()}`, "saved", ); - } else if (422 === response.status) { + if (withFiles) { + this.refreshMedia(); + } + } else if (422 === status) { this.setStatus(this.errorTextValue, "error"); } else { this.setStatus(this.offlineTextValue, "error"); @@ -136,10 +139,57 @@ export default class extends Controller { } } finally { window.clearTimeout(savingTimer); - this.creating = false; + this.busy = false; + this.xhr = null; + if (withFiles) { + this.dispatch("uploadend", { + target: document, + detail: { ok }, + }); + } } } + // POST the whole form via XHR so file uploads can report real progress. + // Resolves with the status and the X-Initiative-Location header (if any); + // rejects with an AbortError when superseded. + request(onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + this.xhr = xhr; + xhr.open("POST", this.element.action, true); + xhr.setRequestHeader("X-Autosave", "1"); + xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); + + if (onProgress && xhr.upload) { + xhr.upload.addEventListener("progress", (event) => { + if (event.lengthComputable) { + onProgress( + Math.round((event.loaded / event.total) * 100), + ); + } + }); + } + + xhr.addEventListener("load", () => + resolve({ + status: xhr.status, + location: xhr.getResponseHeader("X-Initiative-Location"), + }), + ); + xhr.addEventListener("error", () => + reject(new Error("Network error")), + ); + xhr.addEventListener("abort", () => { + const error = new Error("Aborted"); + error.name = "AbortError"; + reject(error); + }); + + xhr.send(new FormData(this.element)); + }); + } + hasPendingFile() { return Array.from( this.element.querySelectorAll('input[type="file"]'), @@ -152,10 +202,18 @@ export default class extends Controller { ).some((field) => "" === field.value.trim()); } - // Files only upload on an explicit Save, so surface that button while one is staged. - revealSaveForFiles() { - if (this.hasSaveTarget) { - this.saveTarget.hidden = !this.hasPendingFile(); + // Reload just the files/images section so a freshly uploaded file shows as a + // link and its input is reset — the rest of the form keeps its state. + refreshMedia() { + const frame = document.getElementById("initiative-media"); + if (!frame) { + return; + } + if (frame.src) { + frame.reload(); + } else { + // First reload also gives the frame its source (the edit URL). + frame.src = this.element.action; } } diff --git a/assets/controllers/file_field_controller.js b/assets/controllers/file_field_controller.js new file mode 100644 index 0000000..8558c11 --- /dev/null +++ b/assets/controllers/file_field_controller.js @@ -0,0 +1,92 @@ +import { Controller } from "@hotwired/stimulus"; + +/* + * Drives a single upload field's transient UI: once a file is picked it shows + * the name and a progress bar that the autosave controller fills as the form + * (with the file) uploads. On success the media turbo-frame reloads and the + * server-rendered "uploaded" view replaces this; on failure the bar is hidden + * so the user can try again. + */ +export default class extends Controller { + static targets = ["filename", "progress", "bar"]; + + connect() { + this.onStart = this.onStart.bind(this); + this.onProgress = this.onProgress.bind(this); + this.onEnd = this.onEnd.bind(this); + document.addEventListener("autosave:uploadstart", this.onStart); + document.addEventListener("autosave:uploadprogress", this.onProgress); + document.addEventListener("autosave:uploadend", this.onEnd); + } + + disconnect() { + document.removeEventListener("autosave:uploadstart", this.onStart); + document.removeEventListener( + "autosave:uploadprogress", + this.onProgress, + ); + document.removeEventListener("autosave:uploadend", this.onEnd); + } + + get input() { + return this.element.querySelector('input[type="file"]'); + } + + get pending() { + const input = this.input; + return Boolean(input && input.files && input.files.length > 0); + } + + picked() { + if (!this.pending) { + return; + } + if (this.hasFilenameTarget) { + this.filenameTarget.textContent = this.input.files[0].name; + } + this.show(0); + } + + onStart() { + if (this.pending) { + this.show(0); + } + } + + onProgress(event) { + if (this.pending) { + this.show(event.detail.percent); + } + } + + onEnd(event) { + if (!this.pending) { + return; + } + if (event.detail && event.detail.ok) { + // Hold full while the media frame reloads and replaces this field. + this.setBar(100); + } else { + this.hide(); + } + } + + show(percent) { + if (this.hasProgressTarget) { + this.progressTarget.hidden = false; + } + this.setBar(percent); + } + + setBar(percent) { + if (this.hasBarTarget) { + this.barTarget.style.width = `${percent}%`; + } + } + + hide() { + if (this.hasProgressTarget) { + this.progressTarget.hidden = true; + } + } +} diff --git a/assets/controllers/lightbox_controller.js b/assets/controllers/lightbox_controller.js new file mode 100644 index 0000000..58130d6 --- /dev/null +++ b/assets/controllers/lightbox_controller.js @@ -0,0 +1,141 @@ +import { Controller } from "@hotwired/stimulus"; + +/* + * Opens an image preview in an in-page overlay instead of a new tab. Attached to + * the image link; the href is the full-size source. The scroll wheel zooms in and + * out; once zoomed, the image can be dragged to pan. Closes on the backdrop, the + * close button or Escape, and restores focus to the link that opened it. + */ +export default class extends Controller { + static values = { + src: String, + closeLabel: { type: String, default: "Close" }, + }; + + open(event) { + event.preventDefault(); + const src = this.srcValue || this.element.getAttribute("href"); + if (!src) { + return; + } + + const overlay = document.createElement("div"); + overlay.className = "lightbox"; + overlay.setAttribute("role", "dialog"); + overlay.setAttribute("aria-modal", "true"); + + const close = document.createElement("button"); + close.type = "button"; + close.className = "lightbox__close"; + close.setAttribute("aria-label", this.closeLabelValue); + close.textContent = "×"; + + const img = document.createElement("img"); + img.className = "lightbox__img"; + img.src = src; + img.alt = ""; + + overlay.append(close, img); + + const min = 1; + const max = 4; + let zoom = 1; + + const applyZoom = () => { + if (zoom <= 1) { + img.classList.remove("lightbox__img--zoomed"); + img.style.removeProperty("width"); + img.style.removeProperty("max-width"); + img.style.removeProperty("max-height"); + return; + } + // Capture the fitted width before lifting the size constraints, so the + // zoom factor is relative to what the user actually sees. + if (!img.dataset.baseWidth) { + img.dataset.baseWidth = String(img.clientWidth); + } + img.classList.add("lightbox__img--zoomed"); + img.style.maxWidth = "none"; + img.style.maxHeight = "none"; + img.style.width = `${Number(img.dataset.baseWidth) * zoom}px`; + }; + + const onWheel = (event) => { + event.preventDefault(); + // Normalise wheel deltas (line/page modes report small integers) and + // zoom multiplicatively, so a trackpad gesture doesn't jump to max. + let delta = event.deltaY; + if (1 === event.deltaMode) { + delta *= 16; + } else if (2 === event.deltaMode) { + delta *= window.innerHeight; + } + const previous = zoom; + zoom = Math.min( + max, + Math.max(min, zoom * Math.exp(-delta * 0.001)), + ); + if (zoom !== previous) { + applyZoom(); + } + }; + + let dragging = false; + let startX = 0; + let startY = 0; + let startLeft = 0; + let startTop = 0; + const onDown = (event) => { + if (zoom <= 1 || 0 !== event.button) { + return; + } + dragging = true; + startX = event.clientX; + startY = event.clientY; + startLeft = overlay.scrollLeft; + startTop = overlay.scrollTop; + img.style.cursor = "grabbing"; + event.preventDefault(); + }; + const onMove = (event) => { + if (!dragging) { + return; + } + overlay.scrollLeft = startLeft - (event.clientX - startX); + overlay.scrollTop = startTop - (event.clientY - startY); + }; + const onUp = () => { + dragging = false; + img.style.removeProperty("cursor"); + }; + + const dismiss = () => { + overlay.remove(); + document.removeEventListener("keydown", onKey); + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + document.body.classList.remove("is-lightbox-open"); + this.element.focus(); + }; + const onKey = (event) => { + if ("Escape" === event.key) { + dismiss(); + } + }; + + overlay.addEventListener("click", (event) => { + if (event.target === overlay || event.target === close) { + dismiss(); + } + }); + overlay.addEventListener("wheel", onWheel, { passive: false }); + img.addEventListener("mousedown", onDown); + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + document.addEventListener("keydown", onKey); + + document.body.classList.add("is-lightbox-open"); + document.body.appendChild(overlay); + close.focus(); + } +} diff --git a/assets/styles/app.css b/assets/styles/app.css index 7a11e2e..8180910 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -1242,14 +1242,54 @@ textarea { gap: var(--itk-space-3); } +.btn-add { + display: inline-flex; + align-self: flex-start; + align-items: center; + gap: var(--itk-space-2); + padding: var(--itk-space-2) var(--itk-space-3); + font: inherit; + font-size: var(--itk-text-sm); + font-weight: 600; + color: var(--itk-slate-600); + background: none; + border: 1px dashed var(--itk-slate-300); + border-radius: var(--itk-radius-2); + cursor: pointer; + transition: + border-color 0.15s, + background-color 0.15s, + color 0.15s; +} + +.btn-add:hover { + color: var(--itk-blue); + background-color: color-mix(in srgb, var(--itk-blue) 5%, transparent); + border-color: var(--itk-blue); +} + +.btn-add svg { + width: 16px; + height: 16px; +} + .collection__item { display: flex; gap: var(--itk-space-3); - align-items: flex-start; - padding: var(--itk-space-3); - border: 1px solid var(--itk-slate-200); - border-radius: var(--itk-radius-2); - background-color: var(--itk-slate-50); + align-items: center; +} + +[data-collection-remove] { + flex: none; + color: var(--itk-slate-500); + background: none; + border-color: transparent; +} + +[data-collection-remove]:hover { + color: var(--itk-danger); + background-color: color-mix(in srgb, var(--itk-danger) 8%, transparent); + border-color: transparent; } .collection__item .form-grid { @@ -1264,6 +1304,187 @@ textarea { flex: 1; } +.upload-field { + display: flex; + flex-direction: column; + gap: var(--itk-space-2); +} + +.collection__item > .upload-field { + flex: 1; + min-width: 0; +} + +.upload-field__drop { + display: flex; + align-items: center; + gap: var(--itk-space-2); + min-height: 64px; + padding: var(--itk-space-3); + border: 1px dashed var(--itk-slate-300); + border-radius: var(--itk-radius-2); + background-color: var(--itk-surface); + color: var(--itk-slate-500); + font-size: var(--itk-text-sm); + cursor: pointer; + transition: + border-color 0.15s, + background-color 0.15s, + color 0.15s; +} + +.upload-field__drop:hover { + border-color: var(--itk-blue); + color: var(--itk-blue); + background-color: color-mix(in srgb, var(--itk-blue) 5%, transparent); +} + +.upload-field__icon { + width: 18px; + height: 18px; + flex: none; +} + +.upload-field__cta { + font-weight: 600; +} + +.upload-field__filename { + overflow: hidden; + color: var(--itk-slate-700); + text-overflow: ellipsis; + white-space: nowrap; +} + +.upload-field__drop input[type="file"] { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +.upload-field__progress { + height: 6px; + overflow: hidden; + background-color: var(--itk-slate-100); + border-radius: var(--itk-radius-pill); +} + +.upload-field__bar { + width: 0; + height: 100%; + background-color: var(--itk-blue); + border-radius: inherit; + transition: width 0.2s ease; +} + +.upload-done { + display: flex; + align-items: center; + gap: var(--itk-space-3); + min-height: 64px; + padding: var(--itk-space-2); + background-color: var(--itk-surface); + border: 1px solid var(--itk-slate-200); + border-radius: var(--itk-radius-2); +} + +.collection__item--uploaded .upload-field { + display: none; +} + +.upload-done__thumb { + display: block; + flex: none; + width: 48px; + height: 48px; + overflow: hidden; + cursor: pointer; + background-color: var(--itk-slate-100); + border-radius: var(--itk-radius-1); + transition: opacity 0.15s; +} + +.upload-done__thumb:hover { + opacity: 0.85; +} + +.upload-done__thumb img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.upload-done__name { + overflow: hidden; + font-size: var(--itk-text-sm); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.upload-done__name:first-child { + margin-left: var(--itk-space-2); +} + +.lightbox { + position: fixed; + inset: 0; + z-index: 300; + padding: var(--itk-space-6); + overflow: auto; + text-align: center; + white-space: nowrap; + background-color: rgba(17, 19, 24, 0.82); +} + +.lightbox::before { + display: inline-block; + height: 100%; + vertical-align: middle; + content: ""; +} + +.lightbox__img { + display: inline-block; + max-width: 100%; + max-height: 100%; + vertical-align: middle; + border-radius: var(--itk-radius-2); + box-shadow: var(--itk-shadow-3); +} + +.lightbox__img--zoomed { + cursor: grab; +} + +.lightbox__close { + position: fixed; + top: var(--itk-space-4); + right: var(--itk-space-5); + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + font-size: 28px; + line-height: 1; + color: #fff; + cursor: pointer; + background: none; + border: none; +} + +body.is-lightbox-open { + overflow: hidden; +} + .form-actions { display: flex; gap: var(--itk-space-3); diff --git a/src/Form/InitiativeImageType.php b/src/Form/InitiativeImageType.php index 0dba605..8447153 100644 --- a/src/Form/InitiativeImageType.php +++ b/src/Form/InitiativeImageType.php @@ -8,7 +8,6 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\FileType; -use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints as Assert; @@ -34,10 +33,6 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'constraints' => [ new Assert\Image(maxSize: $this->maxImageSize), ], - ]) - ->add('alt', TextType::class, [ - 'label' => 'initiative.image_alt', - 'required' => false, ]); } diff --git a/templates/form/fields.html.twig b/templates/form/fields.html.twig index e280112..653fc1d 100644 --- a/templates/form/fields.html.twig +++ b/templates/form/fields.html.twig @@ -54,3 +54,20 @@ {%- endif -%} {% endblock %} + +{% block file_widget %} +