Changeset 48ebfa7 in Klonkt


Ignore:
Timestamp:
06/21/2026 04:19:21 PM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
5a394e5
Parents:
75c6919
Message:

feat(editor): rotate/crop/mirror images before upload

Cropper.js (locally vendored, lazy-loaded) as an edit dialog that sits between
selecting/dropping an image and uploading — for both the cover and images in
the post body. Rotate (±90°), free crop + zoom, horizontal/vertical mirror;
Apply exports an edited JPG/PNG that is uploaded, Cancel/Esc skips. Animated
GIFs pass through unchanged (canvas would flatten them). NL/EN/DE.

Co-Authored-By: Claude <noreply@…>

Location:
src
Files:
2 added
2 edited

Legend:

Unmodified
Added
Removed
  • src/services/i18n.js

    r75c6919 r48ebfa7  
    753753    'pedit.tp_search_placeholder': 'Zoek op titel of artiest…',
    754754    'pedit.tp_list_aria': 'Tracks',
     755    'imed.title': 'Afbeelding bewerken',
     756    'imed.rotate_left': 'Linksom draaien', 'imed.rotate_right': 'Rechtsom draaien',
     757    'imed.flip_h': 'Horizontaal spiegelen', 'imed.flip_v': 'Verticaal spiegelen',
     758    'imed.zoom_in': 'Inzoomen', 'imed.zoom_out': 'Uitzoomen', 'imed.reset': 'Herstellen',
     759    'imed.cancel': 'Annuleren', 'imed.apply': 'Toepassen',
    755760    'acct.back_home': 'Terug naar home',
    756761    'acct.title': 'Account',
     
    17061711    'pedit.tp_search_placeholder': 'Search by title or artist…',
    17071712    'pedit.tp_list_aria': 'Tracks',
     1713    'imed.title': 'Edit image',
     1714    'imed.rotate_left': 'Rotate left', 'imed.rotate_right': 'Rotate right',
     1715    'imed.flip_h': 'Flip horizontally', 'imed.flip_v': 'Flip vertically',
     1716    'imed.zoom_in': 'Zoom in', 'imed.zoom_out': 'Zoom out', 'imed.reset': 'Reset',
     1717    'imed.cancel': 'Cancel', 'imed.apply': 'Apply',
    17081718    'acct.back_home': 'Back to home',
    17091719    'acct.title': 'Account',
     
    26592669    'pedit.tp_search_placeholder': 'Nach Titel oder Künstler suchen…',
    26602670    'pedit.tp_list_aria': 'Tracks',
     2671    'imed.title': 'Bild bearbeiten',
     2672    'imed.rotate_left': 'Nach links drehen', 'imed.rotate_right': 'Nach rechts drehen',
     2673    'imed.flip_h': 'Horizontal spiegeln', 'imed.flip_v': 'Vertikal spiegeln',
     2674    'imed.zoom_in': 'Vergrößern', 'imed.zoom_out': 'Verkleinern', 'imed.reset': 'Zurücksetzen',
     2675    'imed.cancel': 'Abbrechen', 'imed.apply': 'Übernehmen',
    26612676    'acct.back_home': 'Zurück zur Startseite',
    26622677    'acct.title': 'Konto',
  • src/views/pages/post-edit.ejs

    r75c6919 r48ebfa7  
    947947   tabbalk, anders zou die zweven boven de actiebalk. */
    948948body:has(.post-edit-page) .audio-player { bottom: 0; }
     949
     950/* ── Afbeeldings-editor (draaien / bijsnijden / spiegelen) ── */
     951.imed-backdrop {
     952  position: fixed; inset: 0; z-index: 9999;
     953  display: flex; align-items: center; justify-content: center;
     954  background: rgba(0,0,0,.62); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
     955  padding: 14px;
     956}
     957.imed-modal {
     958  display: flex; flex-direction: column; gap: 12px;
     959  width: 100%; max-width: 640px; max-height: 92vh;
     960  background: var(--paper, #fff); color: var(--ink, #111);
     961  border: 1px solid var(--rule, rgba(128,128,128,.3)); border-radius: 14px;
     962  padding: 14px; box-sizing: border-box;
     963}
     964.imed-stage {
     965  flex: 1 1 auto; min-height: 0;
     966  background: var(--paper-2, rgba(128,128,128,.08)); border-radius: 10px; overflow: hidden;
     967}
     968/* Cropper heeft een blok-img met begrensde hoogte nodig. */
     969.imed-stage img { display: block; max-width: 100%; }
     970.imed-stage .cropper-container { max-height: 64vh; }
     971.imed-tools {
     972  display: flex; flex-wrap: wrap; gap: 6px; justify-content: center;
     973}
     974.imed-tools button {
     975  width: 42px; height: 42px; font-size: 18px; line-height: 1;
     976  border: 1px solid var(--rule, rgba(128,128,128,.4)); border-radius: 10px;
     977  background: var(--paper-2, transparent); color: inherit; cursor: pointer;
     978}
     979.imed-tools button:hover { border-color: var(--accent); }
     980.imed-actions { display: flex; gap: 8px; justify-content: flex-end; }
    949981</style>
    950982
     
    9691001    }
    9701002    return await res.json();   // {url, size, mime}
     1003  }
     1004
     1005  // ── Afbeeldings-editor (draaien / bijsnijden / spiegelen) ──────────
     1006  // Lazy-load Cropper.js (lokaal gevendord) pas bij het eerste gebruik.
     1007  let _cropperReady = null;
     1008  function ensureCropper() {
     1009    if (window.Cropper) return Promise.resolve();
     1010    if (_cropperReady) return _cropperReady;
     1011    _cropperReady = new Promise((resolve, reject) => {
     1012      if (!document.querySelector('link[data-cropper-css]')) {
     1013        const l = document.createElement('link');
     1014        l.rel = 'stylesheet'; l.href = '/assets/vendor/cropper.min.css'; l.setAttribute('data-cropper-css', '');
     1015        document.head.appendChild(l);
     1016      }
     1017      const s = document.createElement('script');
     1018      s.src = '/assets/vendor/cropper.min.js';
     1019      s.onload = () => resolve();
     1020      s.onerror = () => reject(new Error('cropper load failed'));
     1021      document.head.appendChild(s);
     1022    });
     1023    return _cropperReady;
     1024  }
     1025
     1026  // Opent de editor voor een gekozen bestand; resolved met een bewerkt File,
     1027  // of null als de gebruiker annuleert. Animatie-GIF's worden niet door de
     1028  // canvas-editor gehaald (zouden statisch worden) — die uploaden direct.
     1029  async function openImageEditor(file) {
     1030    if (!file || !file.type || !file.type.startsWith('image/')) return file;
     1031    if (file.type === 'image/gif') return file; // behoud animatie
     1032    try { await ensureCropper(); } catch (_) { return file; } // editor onbeschikbaar → direct uploaden
     1033
     1034    return new Promise((resolve) => {
     1035      const back = document.createElement('div');
     1036      back.className = 'imed-backdrop';
     1037      back.innerHTML =
     1038        '<div class="imed-modal" role="dialog" aria-modal="true" aria-label="<%= t('imed.title') %>">' +
     1039          '<div class="imed-stage"><img alt=""></div>' +
     1040          '<div class="imed-tools">' +
     1041            '<button type="button" data-act="rl" title="<%= t('imed.rotate_left') %>">⟲</button>' +
     1042            '<button type="button" data-act="rr" title="<%= t('imed.rotate_right') %>">⟳</button>' +
     1043            '<button type="button" data-act="fh" title="<%= t('imed.flip_h') %>">⇆</button>' +
     1044            '<button type="button" data-act="fv" title="<%= t('imed.flip_v') %>">⇅</button>' +
     1045            '<button type="button" data-act="zi" title="<%= t('imed.zoom_in') %>">+</button>' +
     1046            '<button type="button" data-act="zo" title="<%= t('imed.zoom_out') %>">-</button>' +
     1047            '<button type="button" data-act="reset" title="<%= t('imed.reset') %>">↺</button>' +
     1048          '</div>' +
     1049          '<div class="imed-actions">' +
     1050            '<button type="button" data-act="cancel" class="pe-btn pe-btn-secondary"><%= t('imed.cancel') %></button>' +
     1051            '<button type="button" data-act="apply" class="pe-btn pe-btn-primary"><%= t('imed.apply') %></button>' +
     1052          '</div>' +
     1053        '</div>';
     1054      document.body.appendChild(back);
     1055      const img = back.querySelector('img');
     1056      const url = URL.createObjectURL(file);
     1057      let cropper = null, sx = 1, sy = 1;
     1058
     1059      function cleanup() {
     1060        try { if (cropper) cropper.destroy(); } catch (_) {}
     1061        URL.revokeObjectURL(url);
     1062        back.remove();
     1063        document.removeEventListener('keydown', onKey);
     1064      }
     1065      function onKey(e) { if (e.key === 'Escape') { cleanup(); resolve(null); } }
     1066      document.addEventListener('keydown', onKey);
     1067
     1068      img.onload = () => {
     1069        cropper = new Cropper(img, { viewMode: 1, autoCropArea: 1, background: false, responsive: true });
     1070      };
     1071      img.src = url;
     1072
     1073      back.addEventListener('click', (e) => {
     1074        const btn = e.target.closest('[data-act]');
     1075        if (e.target === back) { cleanup(); resolve(null); return; }
     1076        if (!btn || !cropper) return;
     1077        const a = btn.getAttribute('data-act');
     1078        if (a === 'rl') cropper.rotate(-90);
     1079        else if (a === 'rr') cropper.rotate(90);
     1080        else if (a === 'fh') { sx = -sx; cropper.scaleX(sx); }
     1081        else if (a === 'fv') { sy = -sy; cropper.scaleY(sy); }
     1082        else if (a === 'zi') cropper.zoom(0.1);
     1083        else if (a === 'zo') cropper.zoom(-0.1);
     1084        else if (a === 'reset') { sx = 1; sy = 1; cropper.reset(); }
     1085        else if (a === 'cancel') { cleanup(); resolve(null); }
     1086        else if (a === 'apply') {
     1087          const canvas = cropper.getCroppedCanvas({ maxWidth: 3000, maxHeight: 3000, imageSmoothingEnabled: true, imageSmoothingQuality: 'high' });
     1088          const png = (file.type === 'image/png' || file.type === 'image/webp');
     1089          const mime = png ? 'image/png' : 'image/jpeg';
     1090          const ext = png ? '.png' : '.jpg';
     1091          canvas.toBlob((blob) => {
     1092            cleanup();
     1093            if (!blob) { resolve(file); return; }
     1094            const base = (file.name || 'afbeelding').replace(/\.[^.]+$/, '');
     1095            resolve(new File([blob], base + ext, { type: mime }));
     1096          }, mime, 0.92);
     1097        }
     1098      });
     1099    });
    9711100  }
    9721101
     
    9981127    coverField.addEventListener('change', async () => {
    9991128      if (!coverField.files[0]) return;
     1129      const edited = await openImageEditor(coverField.files[0]);
     1130      coverField.value = '';
     1131      if (!edited) return; // geannuleerd
    10001132      coverStatus.classList.remove('is-error');
    10011133      coverStatus.textContent = '<%= t('pedit.js_uploading') %>';
    10021134      try {
    1003         const j = await uploadImage(coverField.files[0]);
     1135        const j = await uploadImage(edited);
    10041136        coverUrl.value = j.url;
    10051137        showCoverPreview(j.url);
     
    14051537  // ── Image upload (button + drag-drop into the editor)
    14061538  async function uploadAndInsertImage(file) {
     1539    const edited = await openImageEditor(file);
     1540    if (!edited) return; // geannuleerd
    14071541    contentStatus.classList.remove('is-error');
    14081542    contentStatus.textContent = '<%= t('pedit.js_uploading') %>';
    14091543    try {
    1410       const j = await uploadImage(file);
     1544      const j = await uploadImage(edited);
    14111545      const img = '<img src="' + j.url + '" alt="">';
    14121546      editor.focus({ preventScroll: true });
Note: See TracChangeset for help on using the changeset viewer.