Changeset bf4596d in Klonkt for src/views/pages/post-edit.ejs


Ignore:
Timestamp:
06/25/2026 07:55:32 PM (3 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
49edc72
Parents:
fd95b1b
Message:

feat(editor): post type on top (segmented) + type-aware inputs

Bart's UX feedback: the post-type selector moved from the bottom Meta
section to a prominent segmented control at the top of the editor, above
the body. The selected type now reveals its own input:

  • Audio -> inline upload (drag/drop), transcodes server-side and drops

the track straight into the post (no more /admin/audio detour)

  • Video -> paste a URL -> embed chip
  • Foto -> hint toward cover + insert-image

Reuses the existing /admin/audio/upload endpoint (returns {ok,id}) and the
editor's insertChip(). Adds pedit.* i18n keys (nl/en/de).

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/views/pages/post-edit.ejs

    rfd95b1b rbf4596d  
    1818
    1919  <form method="post" action="<%= _base %><%= isNew ? '/posts/create' : '/posts/' + post.slug + '/save' %>" class="post-edit-form">
     20
     21    <%# ── TYPE (segmented) + type-specific inputs ────────────────
     22        Post type lives at the TOP (above the body), and the selected type
     23        reveals its own input: Audio → inline upload, Video → embed URL,
     24        Foto → cover/insert hint. %>
     25    <% var ptype = post.type || 'post'; %>
     26    <% var TYPE_ICONS = { post: '📝', foto: '📷', video: '🎬', audio: '🎵' }; %>
     27    <section class="pe-card pe-type-card">
     28      <div class="pe-section-title"><%= t('pedit.s_type') %></div>
     29      <input type="hidden" name="type" id="pe-type-input" value="<%= ptype %>">
     30      <div class="pe-typeseg" role="radiogroup" aria-label="<%= t('pedit.s_type') %>">
     31        <% ['post','foto','video','audio'].forEach(function (tt) { %>
     32          <button type="button" class="pe-typeseg-btn<%= ptype === tt ? ' is-active' : '' %>"
     33                  data-type="<%= tt %>" role="radio" aria-checked="<%= ptype === tt ? 'true' : 'false' %>">
     34            <span class="pe-typeseg-ic" aria-hidden="true"><%= TYPE_ICONS[tt] %></span>
     35            <span><%= t('pedit.type_' + tt) %></span>
     36          </button>
     37        <% }); %>
     38      </div>
     39
     40      <%# Audio: inline upload (transcodes + drops [[track]] into the post) %>
     41      <div class="pe-type-panel" data-panel="audio" hidden>
     42        <div class="pe-audio-up" id="pe-audio-drop" tabindex="0" role="button" aria-label="<%= t('pedit.audio_up_drop') %>">
     43          <input type="file" id="pe-audio-file" accept="audio/*,.mp3,.m4a,.ogg,.opus,.flac,.wav" multiple hidden>
     44          <span class="pe-audio-up-ic" aria-hidden="true">🎵</span>
     45          <strong><%= t('pedit.audio_up_drop') %></strong>
     46          <small><%= t('pedit.audio_up_hint') %></small>
     47        </div>
     48        <ul class="pe-audio-list" id="pe-audio-list"></ul>
     49      </div>
     50
     51      <%# Video: paste a URL → embed chip %>
     52      <div class="pe-type-panel" data-panel="video" hidden>
     53        <label class="pe-field">
     54          <span><%= t('pedit.video_up_title') %></span>
     55          <div class="pe-video-row">
     56            <input type="url" id="pe-video-url" inputmode="url" autocapitalize="none" spellcheck="false"
     57                   placeholder="<%= t('pedit.video_up_ph') %>">
     58            <button type="button" class="pe-btn pe-btn-secondary" id="pe-video-insert"><%= t('pedit.video_up_btn') %></button>
     59          </div>
     60        </label>
     61      </div>
     62
     63      <%# Foto: light hint toward cover + insert-image %>
     64      <div class="pe-type-panel" data-panel="foto" hidden>
     65        <p class="pe-foto-hint">🖼 <%= t('pedit.foto_up_hint') %></p>
     66      </div>
     67    </section>
    2068
    2169    <%# ── IDENTITY ─────────────────────────────────────────────── %>
     
    162210    <section class="pe-card">
    163211      <div class="pe-section-title"><%= t('pedit.s_publication') %></div>
    164       <div class="pe-row pe-row-2">
    165         <label class="pe-field">
    166           <span><%= t('pedit.f_status') %></span>
    167           <% var _st = isNew ? 'published' : (post.status || 'draft'); %>
    168           <select name="status">
    169             <option value="published" <%= _st === 'published' ? 'selected' : '' %>><%= t('pedit.status_published') %></option>
    170             <option value="draft"     <%= _st === 'draft'     ? 'selected' : '' %>><%= t('pedit.status_draft') %></option>
    171             <option value="archived"  <%= _st === 'archived'  ? 'selected' : '' %>><%= t('pedit.status_archived') %></option>
    172           </select>
    173         </label>
    174         <label class="pe-field">
    175           <span><%= t('pedit.f_type') %></span>
    176           <select name="type">
    177             <% var ptype = post.type || 'post'; %>
    178             <option value="post"  <%= ptype === 'post'  ? 'selected' : '' %>><%= t('pedit.type_post') %></option>
    179             <option value="foto"  <%= ptype === 'foto'  ? 'selected' : '' %>><%= t('pedit.type_foto') %></option>
    180             <option value="video" <%= ptype === 'video' ? 'selected' : '' %>><%= t('pedit.type_video') %></option>
    181             <option value="audio" <%= ptype === 'audio' ? 'selected' : '' %>><%= t('pedit.type_audio') %></option>
    182           </select>
    183         </label>
    184       </div>
     212      <label class="pe-field">
     213        <span><%= t('pedit.f_status') %></span>
     214        <% var _st = isNew ? 'published' : (post.status || 'draft'); %>
     215        <select name="status">
     216          <option value="published" <%= _st === 'published' ? 'selected' : '' %>><%= t('pedit.status_published') %></option>
     217          <option value="draft"     <%= _st === 'draft'     ? 'selected' : '' %>><%= t('pedit.status_draft') %></option>
     218          <option value="archived"  <%= _st === 'archived'  ? 'selected' : '' %>><%= t('pedit.status_archived') %></option>
     219        </select>
     220      </label>
    185221      <div class="pe-checkboxes">
    186222        <div class="pe-pin">
     
    440476}
    441477
     478/* ─── Post type: segmented control + type-aware panels ──────────── */
     479.pe-typeseg {
     480  display: grid;
     481  grid-template-columns: repeat(4, 1fr);
     482  gap: 0.4rem;
     483}
     484.pe-typeseg-btn {
     485  display: flex; flex-direction: column; align-items: center; gap: 0.25rem;
     486  padding: 0.7rem 0.4rem;
     487  border: 1px solid var(--rule);
     488  border-radius: 9px;
     489  background: var(--paper-2);
     490  color: var(--ink-soft);
     491  font-size: 0.85rem; font-weight: 600;
     492  cursor: pointer;
     493  transition: border-color 120ms, background 120ms, color 120ms;
     494}
     495.pe-typeseg-btn:hover { border-color: var(--accent); color: var(--ink); }
     496.pe-typeseg-btn .pe-typeseg-ic { font-size: 1.3rem; line-height: 1; }
     497.pe-typeseg-btn.is-active {
     498  border-color: var(--accent);
     499  background: color-mix(in srgb, var(--accent) 14%, var(--paper-2));
     500  color: var(--ink);
     501}
     502.pe-typeseg-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
     503@media (max-width: 480px) {
     504  .pe-typeseg-btn { font-size: 0.78rem; padding: 0.6rem 0.25rem; }
     505  .pe-typeseg-btn .pe-typeseg-ic { font-size: 1.15rem; }
     506}
     507
     508.pe-type-panel { margin-top: 0.2rem; }
     509
     510/* Audio drop/upload zone */
     511.pe-audio-up {
     512  display: flex; flex-direction: column; align-items: center; gap: 0.3rem;
     513  text-align: center;
     514  padding: 1.4rem 1rem;
     515  border: 2px dashed var(--rule);
     516  border-radius: 10px;
     517  background: var(--paper-2);
     518  color: var(--ink-soft);
     519  cursor: pointer;
     520  transition: border-color 120ms, background 120ms;
     521}
     522.pe-audio-up:hover,
     523.pe-audio-up:focus-visible { border-color: var(--accent); outline: none; }
     524.pe-audio-up.is-drag {
     525  border-color: var(--accent);
     526  background: color-mix(in srgb, var(--accent) 12%, var(--paper-2));
     527}
     528.pe-audio-up .pe-audio-up-ic { font-size: 1.7rem; line-height: 1; }
     529.pe-audio-up strong { color: var(--ink); font-size: 0.95rem; }
     530.pe-audio-up small { font-size: 0.78rem; color: var(--ink-muted, var(--ink-soft)); max-width: 42ch; }
     531
     532.pe-audio-list { list-style: none; margin: 0.7rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
     533.pe-audio-item {
     534  display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
     535  padding: 0.5rem 0.7rem;
     536  border: 1px solid var(--rule);
     537  border-radius: 7px;
     538  background: var(--paper-2);
     539  font-size: 0.85rem;
     540}
     541.pe-audio-item-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
     542.pe-audio-item-state { flex: none; font-size: 0.8rem; color: var(--ink-soft); }
     543.pe-audio-item.is-done { border-color: color-mix(in srgb, var(--accent) 50%, var(--rule)); }
     544.pe-audio-item.is-done .pe-audio-item-state { color: var(--accent); }
     545.pe-audio-item.is-fail .pe-audio-item-state { color: #d9534f; }
     546
     547/* Video URL row */
     548.pe-video-row { display: flex; gap: 0.5rem; }
     549.pe-video-row input { flex: 1 1 auto; min-width: 0; }
     550.pe-video-row input {
     551  box-sizing: border-box; padding: 0.6rem 0.75rem;
     552  border: 1px solid var(--rule); border-radius: 6px;
     553  background: var(--paper-2); color: var(--ink); font-size: 0.95rem;
     554}
     555.pe-video-row input:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
     556.pe-video-row .pe-btn { flex: none; }
     557
     558.pe-foto-hint { margin: 0; font-size: 0.88rem; color: var(--ink-soft); }
     559
    442560/* ─── Editor frame (WYSIWYG: top toolbar, contenteditable body, status bar) ─── */
    443561.pe-editor-frame {
     
    18171935  }
    18181936
     1937  // ── Post type: segmented control + type-aware panels ──────────
     1938  (function () {
     1939    const typeInput = document.getElementById('pe-type-input');
     1940    const card = document.querySelector('.pe-type-card');
     1941    if (!typeInput || !card) return;
     1942    const seg = card.querySelector('.pe-typeseg');
     1943    const panels = card.querySelectorAll('.pe-type-panel');
     1944
     1945    function applyType(tt) {
     1946      typeInput.value = tt;
     1947      seg.querySelectorAll('.pe-typeseg-btn').forEach(b => {
     1948        const on = b.dataset.type === tt;
     1949        b.classList.toggle('is-active', on);
     1950        b.setAttribute('aria-checked', on ? 'true' : 'false');
     1951      });
     1952      panels.forEach(p => { p.hidden = (p.dataset.panel !== tt); });
     1953    }
     1954    seg.addEventListener('click', (e) => {
     1955      const btn = e.target.closest('.pe-typeseg-btn');
     1956      if (btn) applyType(btn.dataset.type);
     1957    });
     1958    applyType(typeInput.value || 'post');
     1959
     1960    // Video URL → [[embed:url]] chip
     1961    const vBtn = document.getElementById('pe-video-insert');
     1962    const vUrl = document.getElementById('pe-video-url');
     1963    if (vBtn && vUrl) {
     1964      const doInsert = () => {
     1965        const url = (vUrl.value || '').trim();
     1966        if (!/^https?:\/\//i.test(url)) { alert('<%= t('pedit.js_embed_invalid') %>'); return; }
     1967        insertChip('embed', url);
     1968        vUrl.value = '';
     1969      };
     1970      vBtn.addEventListener('click', doInsert);
     1971      vUrl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doInsert(); } });
     1972    }
     1973
     1974    // Audio: inline upload → transcodes server-side → [[track:id]] chip
     1975    const drop = document.getElementById('pe-audio-drop');
     1976    const fileInput = document.getElementById('pe-audio-file');
     1977    const list = document.getElementById('pe-audio-list');
     1978    if (drop && fileInput && list) {
     1979      const pick = () => fileInput.click();
     1980      drop.addEventListener('click', pick);
     1981      drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
     1982      ['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('is-drag'); }));
     1983      ['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('is-drag'); }));
     1984      drop.addEventListener('drop', (e) => { if (e.dataTransfer && e.dataTransfer.files) handleFiles(e.dataTransfer.files); });
     1985      fileInput.addEventListener('change', () => { handleFiles(fileInput.files); fileInput.value = ''; });
     1986
     1987      function clientDuration(f) {
     1988        return new Promise((resolve) => {
     1989          try {
     1990            const u = URL.createObjectURL(f);
     1991            const a = document.createElement('audio');
     1992            a.preload = 'metadata';
     1993            a.onloadedmetadata = () => { URL.revokeObjectURL(u); resolve(Number.isFinite(a.duration) ? Math.round(a.duration) : null); };
     1994            a.onerror = () => { URL.revokeObjectURL(u); resolve(null); };
     1995            a.src = u;
     1996          } catch (_) { resolve(null); }
     1997        });
     1998      }
     1999      async function handleFiles(files) {
     2000        for (const f of Array.from(files || [])) await uploadOne(f);
     2001      }
     2002      async function uploadOne(f) {
     2003        const li = document.createElement('li');
     2004        li.className = 'pe-audio-item';
     2005        const nameEl = document.createElement('span');
     2006        nameEl.className = 'pe-audio-item-name';
     2007        nameEl.textContent = f.name;
     2008        const stateEl = document.createElement('span');
     2009        stateEl.className = 'pe-audio-item-state';
     2010        stateEl.textContent = '⏳ <%= t('pedit.audio_up_busy') %>';
     2011        li.appendChild(nameEl); li.appendChild(stateEl);
     2012        list.appendChild(li);
     2013        try {
     2014          const dur = await clientDuration(f);
     2015          const fd = new FormData();
     2016          fd.append('audio', f);
     2017          if (dur) fd.append('duration', String(dur));
     2018          const res = await fetch('/admin/audio/upload', {
     2019            method: 'POST', body: fd,
     2020            headers: { 'Accept': 'application/json' },
     2021            credentials: 'same-origin',
     2022          });
     2023          const j = await res.json().catch(() => ({}));
     2024          if (!res.ok || !j.ok || !j.id) throw new Error(j.error || ('HTTP ' + res.status));
     2025          insertChip('track', j.id);
     2026          stateEl.textContent = '✓ <%= t('pedit.audio_up_done') %>';
     2027          li.classList.add('is-done');
     2028        } catch (err) {
     2029          stateEl.textContent = '✕ <%= t('pedit.audio_up_fail') %>: ' + err.message;
     2030          li.classList.add('is-fail');
     2031        }
     2032      }
     2033    }
     2034  })();
     2035
    18192036  // ── Submit: serialize editor contents into the hidden field
    18202037  if (form && hiddenField) {
Note: See TracChangeset for help on using the changeset viewer.