lvwerra HF Staff commited on
Commit
bd05441
·
verified ·
1 Parent(s): 242658c

Deploy msgboard

Browse files
Files changed (1) hide show
  1. src/msgboard/dashboard.html +70 -8
src/msgboard/dashboard.html CHANGED
@@ -285,14 +285,14 @@ body.resizing iframe { pointer-events: none; }
285
  .avatar.sm { width: 28px; height: 28px; }
286
  .avatar.xl { width: 72px; height: 72px; }
287
  /* agent presence: a square in the bottom-right corner. Filled = polled in the last minute, empty = not, striped = stuck */
288
- .avatar[data-p]::after { content: ""; position: absolute; right: 2px; bottom: 2px; width: 10px; height: 10px; box-sizing: border-box;
289
- border: 1.5px solid var(--ink); background: var(--bg); z-index: 1; box-shadow: 0 0 0 1px var(--bg); } /* 1 px gap: visible on dark pictures */
290
  .avatar[data-p="live"]::after { background: var(--ink); }
291
- .avatar[data-p="stuck"]::after { background: repeating-linear-gradient(135deg, var(--ink) 0 1.6px, var(--bg) 1.6px 3.4px); }
292
- .avatar.sm[data-p]::after { width: 8px; height: 8px; }
293
  .avatar.xs[data-p]::after, .navlist .avatar[data-p]::after { display: none; } /* too small to read: thread previews, DM list, tables */
294
- .avatar.xl[data-p]::after { width: 18px; height: 18px; right: 4px; bottom: 4px; border-width: 2px; }
295
- .avatar.xl[data-p="stuck"]::after { background: repeating-linear-gradient(135deg, var(--ink) 0 2.4px, var(--bg) 2.4px 5px); }
296
  .person .pres { display: block; color: var(--ink-3); font-size: 12px; overflow: hidden; text-overflow: ellipsis; }
297
  .person .scope { display: inline-block; margin-top: 3px; font: 10.5px var(--mono); border: 1px solid var(--rule-2); padding: 0 5px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; vertical-align: top; }
298
  .listen-table td.m { font: 12px var(--mono); white-space: nowrap; }
@@ -360,6 +360,10 @@ button.fchip:hover { border-color: var(--ink); }
360
  .fchip > button.retry { font: 600 10px var(--mono); text-decoration: underline; }
361
  .cbox .files { margin: 8px 8px 0; max-width: none; }
362
  .cbox.dropping { border-color: var(--ink); border-style: dashed; }
 
 
 
 
363
 
364
  /* attachment viewer */
365
  #viewer { position: fixed; inset: 0; z-index: 20; display: grid; grid-template: minmax(0, 1fr) / minmax(0, 1fr); place-items: center;
@@ -393,6 +397,11 @@ button.fchip:hover { border-color: var(--ink); }
393
  .v-zoom button:disabled { opacity: .35; cursor: default; }
394
  .v-zoom #v-zoom-fit { border-left: 1px solid var(--rule-2); font-size: 11px; }
395
  .v-zoom span { min-width: 44px; text-align: center; font: 11px var(--mono); color: var(--ink); }
 
 
 
 
 
396
  .v-pages { min-width: max-content; }
397
  .v-body iframe { flex: 1; width: 100%; height: 100%; border: 0; background: #fff; }
398
  .v-body.pdf { display: block; background: var(--panel); }
@@ -407,6 +416,16 @@ button.fchip:hover { border-color: var(--ink); }
407
  .v-empty { margin: auto; text-align: center; color: var(--ink-3); font: 12px var(--mono); padding: 40px; }
408
  .v-empty .fprev { width: 64px; height: 64px; font-size: 13px; margin: 0 auto 14px; }
409
  @media (max-width: 720px) { #viewer { padding: 0; } .v-panel { height: 100%; border: 0; } .v-nav span { display: none; } }
 
 
 
 
 
 
 
 
 
 
410
  .replies { display: inline-flex; align-items: center; gap: 8px; margin-top: 5px; padding: 3px 8px 3px 3px; border: 1px solid transparent; background: none; font-size: 12.5px; text-align: left; max-width: 100%; }
411
  .replies:hover { border-color: var(--rule-2); background: var(--bg); }
412
  .replies .faces { display: inline-flex; gap: 3px; }
@@ -804,8 +823,8 @@ dialog input[type=text] { width: 100%; margin: 4px 0 12px; }
804
  <div class="v-head">
805
  <div class="v-title"><b id="v-name"></b><span class="mono" id="v-meta"></span></div>
806
  <div class="v-tabs hidden" id="v-tabs"><button data-view="render" class="on">Preview</button><button data-view="source">Source</button></div>
807
- <div class="v-nav" id="v-nav"><button id="v-prev" aria-label="Previous file">‹</button><span id="v-pos"></span><button id="v-next" aria-label="Next file">›</button></div>
808
- <div class="v-zoom hidden" id="v-zoom" role="group" aria-label="Zoom"><button id="v-zoom-out" title="Zoom out (−)" aria-label="Zoom out">−</button><span id="v-zoom-level">fit</span><button id="v-zoom-in" title="Zoom in (+)" aria-label="Zoom in">+</button><button id="v-zoom-fit" title="Fit to window (0)">fit</button></div>
809
  <button class="btn ghost small hidden" id="v-open" title="Open in the browser's own viewer">Open in new tab</button>
810
  <button class="btn ghost small" id="v-link" title="Copy a link to this file">Copy link</button>
811
  <button class="btn small" id="v-download">Download</button>
@@ -1658,6 +1677,7 @@ async function openChannel(id) {
1658
  const peer = c.kind === "dm" ? dmPeer(c) : null; // a DM shows who it is with, as in the sidebar
1659
  const glyph = observing(c) ? EYE : c.kind === "private" ? LOCK : c.kind === "dm" ? (peer ? avatar(peer, "xs", false) : "@") : "#";
1660
  $("ch-title").innerHTML = `<span class="glyph">${glyph}</span>${esc(channelTitle(c))}`;
 
1661
  $("ch-desc").textContent = "";
1662
  $("members-open").classList.add("hidden");
1663
  $("ch-info").classList.remove("hidden"); $("ch-doc").classList.toggle("hidden", !docsOn()); closeInfo();
@@ -1858,6 +1878,8 @@ async function saveEdit() {
1858
  finally { ed.saving = false; }
1859
  rerenderAfterEdit();
1860
  }
 
 
1861
  function wireEditor(container) {
1862
  const ta = container.querySelector(".edit-box textarea"), ed = state.editing;
1863
  if (!ta || !ed) return;
@@ -1866,6 +1888,7 @@ function wireEditor(container) {
1866
  ta.addEventListener("input", () => { keep(); size(); });
1867
  ta.addEventListener("keyup", keep); ta.addEventListener("click", keep);
1868
  ta.addEventListener("keydown", (e) => {
 
1869
  if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); saveEdit(); }
1870
  else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); cancelEdit(); }
1871
  });
@@ -2351,6 +2374,7 @@ function uploadWithProgress(channelId, item, onProgress) {
2351
  }
2352
  function mountComposer(host, { placeholder, replyTo }) {
2353
  host.classList.remove("hidden");
 
2354
  if (!canWriteHere()) {
2355
  const c = state.current;
2356
  host.innerHTML = `<div class="readonly">${c && c.archived ? "This channel is archived. History is read-only."
@@ -2453,9 +2477,11 @@ function mountComposer(host, { placeholder, replyTo }) {
2453
  box.addEventListener("dragover", (e) => { if ([...e.dataTransfer.types].includes("Files")) { e.preventDefault(); box.classList.add("dropping"); } });
2454
  box.addEventListener("dragleave", (e) => { if (!box.contains(e.relatedTarget)) box.classList.remove("dropping"); });
2455
  box.addEventListener("drop", (e) => { e.preventDefault(); box.classList.remove("dropping"); addFiles([...e.dataTransfer.files]); });
 
2456
  input.addEventListener("input", autosize);
2457
  const assist = attachAssist(host, input, autosize);
2458
  input.addEventListener("keydown", (e) => {
 
2459
  if (assist.handleKey(e)) return; // mention suggestions take Enter, Tab and arrows while open
2460
  if (e.key === "ArrowUp" && !input.value && !e.shiftKey) { // like Slack: up in an empty box edits your last message
2461
  const parent = replyTo(), list = parent ? [state.panel.root, ...state.thread] : state.roots, m = lastOwnMessage(list);
@@ -4127,6 +4153,42 @@ function barPath(x, y0, w, h, roundTop) {
4127
  let anResize = 0;
4128
  window.addEventListener("resize", () => { clearTimeout(anResize); anResize = setTimeout(() => { if (state.view === "analytics") renderAnalytics(); }, 250); });
4129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4130
  // ------------------------------------------------------------------ misc
4131
  $("me-who").onclick = () => state.me && openProfile(state.me.id);
4132
  $("analytics-open").onclick = () => { $("app").classList.remove("nav"); openAnalytics(); };
 
285
  .avatar.sm { width: 28px; height: 28px; }
286
  .avatar.xl { width: 72px; height: 72px; }
287
  /* agent presence: a square in the bottom-right corner. Filled = polled in the last minute, empty = not, striped = stuck */
288
+ .avatar[data-p]::after { content: ""; position: absolute; right: 2px; bottom: 2px; width: 7px; height: 7px; box-sizing: border-box;
289
+ border: 1.2px solid var(--ink); background: var(--bg); z-index: 1; box-shadow: 0 0 0 1px var(--bg); } /* 1 px gap: visible on dark pictures */
290
  .avatar[data-p="live"]::after { background: var(--ink); }
291
+ .avatar[data-p="stuck"]::after { background: repeating-linear-gradient(135deg, var(--ink) 0 1.2px, var(--bg) 1.2px 2.6px); }
292
+ .avatar.sm[data-p]::after { width: 6px; height: 6px; right: 1.5px; bottom: 1.5px; border-width: 1px; }
293
  .avatar.xs[data-p]::after, .navlist .avatar[data-p]::after { display: none; } /* too small to read: thread previews, DM list, tables */
294
+ .avatar.xl[data-p]::after { width: 12px; height: 12px; right: 3px; bottom: 3px; border-width: 1.5px; }
295
+ .avatar.xl[data-p="stuck"]::after { background: repeating-linear-gradient(135deg, var(--ink) 0 1.6px, var(--bg) 1.6px 3.4px); }
296
  .person .pres { display: block; color: var(--ink-3); font-size: 12px; overflow: hidden; text-overflow: ellipsis; }
297
  .person .scope { display: inline-block; margin-top: 3px; font: 10.5px var(--mono); border: 1px solid var(--rule-2); padding: 0 5px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; vertical-align: top; }
298
  .listen-table td.m { font: 12px var(--mono); white-space: nowrap; }
 
360
  .fchip > button.retry { font: 600 10px var(--mono); text-decoration: underline; }
361
  .cbox .files { margin: 8px 8px 0; max-width: none; }
362
  .cbox.dropping { border-color: var(--ink); border-style: dashed; }
363
+ /* drag a file anywhere over the open channel or thread: it goes to that message box */
364
+ .drop-overlay { position: absolute; inset: 0; z-index: 20; display: grid; place-items: center; pointer-events: none;
365
+ background: color-mix(in srgb, var(--bg) 88%, transparent); border: 2px dashed var(--ink); }
366
+ .drop-overlay span { font-weight: 600; font-size: 15px; background: var(--bg); padding: 8px 14px; border: 1px solid var(--ink); }
367
 
368
  /* attachment viewer */
369
  #viewer { position: fixed; inset: 0; z-index: 20; display: grid; grid-template: minmax(0, 1fr) / minmax(0, 1fr); place-items: center;
 
397
  .v-zoom button:disabled { opacity: .35; cursor: default; }
398
  .v-zoom #v-zoom-fit { border-left: 1px solid var(--rule-2); font-size: 11px; }
399
  .v-zoom span { min-width: 44px; text-align: center; font: 11px var(--mono); color: var(--ink); }
400
+ /* every control in the header is the same height; arrows and +/- are drawn, so they sit exactly in the middle */
401
+ .v-icon { width: 11px; height: 11px; fill: none; stroke: currentColor; stroke-width: 1.2; stroke-linecap: square; stroke-linejoin: miter; display: block; }
402
+ .v-nav button, #v-zoom-out, #v-zoom-in { width: 28px; padding: 0; }
403
+ .v-nav button, .v-tabs button, .v-head .btn { height: 28px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; }
404
+ .v-zoom { height: 28px; box-sizing: border-box; } .v-zoom button { height: 100%; display: inline-flex; align-items: center; justify-content: center; }
405
  .v-pages { min-width: max-content; }
406
  .v-body iframe { flex: 1; width: 100%; height: 100%; border: 0; background: #fff; }
407
  .v-body.pdf { display: block; background: var(--panel); }
 
416
  .v-empty { margin: auto; text-align: center; color: var(--ink-3); font: 12px var(--mono); padding: 40px; }
417
  .v-empty .fprev { width: 64px; height: 64px; font-size: 13px; margin: 0 auto 14px; }
418
  @media (max-width: 720px) { #viewer { padding: 0; } .v-panel { height: 100%; border: 0; } .v-nav span { display: none; } }
419
+ /* phones: the file name and the close button on the first row, the controls on a second row */
420
+ @media (max-width: 720px) {
421
+ .v-head { flex-wrap: wrap; gap: 8px 6px; padding: 8px 12px 10px; }
422
+ .v-title { order: 0; flex: 1 1 calc(100% - 44px); flex-direction: column; align-items: flex-start; gap: 1px; }
423
+ .v-head .close { order: 1; font-size: 26px; padding: 0 4px; }
424
+ .v-tabs, .v-nav, .v-zoom, #v-open, #v-link, #v-download { order: 2; }
425
+ .v-head .btn { white-space: nowrap; }
426
+ #v-download { margin-left: auto; }
427
+ .v-zoom span { min-width: 34px; } #v-zoom-fit { padding: 0 6px; } /* keeps the controls on one row at 375 px */
428
+ }
429
  .replies { display: inline-flex; align-items: center; gap: 8px; margin-top: 5px; padding: 3px 8px 3px 3px; border: 1px solid transparent; background: none; font-size: 12.5px; text-align: left; max-width: 100%; }
430
  .replies:hover { border-color: var(--rule-2); background: var(--bg); }
431
  .replies .faces { display: inline-flex; gap: 3px; }
 
823
  <div class="v-head">
824
  <div class="v-title"><b id="v-name"></b><span class="mono" id="v-meta"></span></div>
825
  <div class="v-tabs hidden" id="v-tabs"><button data-view="render" class="on">Preview</button><button data-view="source">Source</button></div>
826
+ <div class="v-nav" id="v-nav"><button id="v-prev" aria-label="Previous file"><svg class="v-icon" viewBox="0 0 12 12" aria-hidden="true"><path d="M7.5 2.5 4 6l3.5 3.5"/></svg></button><span id="v-pos"></span><button id="v-next" aria-label="Next file"><svg class="v-icon" viewBox="0 0 12 12" aria-hidden="true"><path d="M4.5 2.5 8 6 4.5 9.5"/></svg></button></div>
827
+ <div class="v-zoom hidden" id="v-zoom" role="group" aria-label="Zoom"><button id="v-zoom-out" title="Zoom out (−)" aria-label="Zoom out"><svg class="v-icon" viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 6h7"/></svg></button><span id="v-zoom-level">fit</span><button id="v-zoom-in" title="Zoom in (+)" aria-label="Zoom in"><svg class="v-icon" viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 6h7M6 2.5v7"/></svg></button><button id="v-zoom-fit" title="Fit to window (0)">fit</button></div>
828
  <button class="btn ghost small hidden" id="v-open" title="Open in the browser's own viewer">Open in new tab</button>
829
  <button class="btn ghost small" id="v-link" title="Copy a link to this file">Copy link</button>
830
  <button class="btn small" id="v-download">Download</button>
 
1677
  const peer = c.kind === "dm" ? dmPeer(c) : null; // a DM shows who it is with, as in the sidebar
1678
  const glyph = observing(c) ? EYE : c.kind === "private" ? LOCK : c.kind === "dm" ? (peer ? avatar(peer, "xs", false) : "@") : "#";
1679
  $("ch-title").innerHTML = `<span class="glyph">${glyph}</span>${esc(channelTitle(c))}`;
1680
+ if (peer) hydrate($("ch-title")); // load an uploaded picture, as the sidebar does
1681
  $("ch-desc").textContent = "";
1682
  $("members-open").classList.add("hidden");
1683
  $("ch-info").classList.remove("hidden"); $("ch-doc").classList.toggle("hidden", !docsOn()); closeInfo();
 
1878
  finally { ed.saving = false; }
1879
  rerenderAfterEdit();
1880
  }
1881
+ // Phones and tablets keep Enter for newlines, including with a hardware keyboard.
1882
+ const mobileTextInput = () => window.matchMedia("(pointer: coarse)").matches;
1883
  function wireEditor(container) {
1884
  const ta = container.querySelector(".edit-box textarea"), ed = state.editing;
1885
  if (!ta || !ed) return;
 
1888
  ta.addEventListener("input", () => { keep(); size(); });
1889
  ta.addEventListener("keyup", keep); ta.addEventListener("click", keep);
1890
  ta.addEventListener("keydown", (e) => {
1891
+ if (e.key === "Enter" && mobileTextInput()) return;
1892
  if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); saveEdit(); }
1893
  else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); cancelEdit(); }
1894
  });
 
2374
  }
2375
  function mountComposer(host, { placeholder, replyTo }) {
2376
  host.classList.remove("hidden");
2377
+ host.addFiles = null; // set again below only if this box can post
2378
  if (!canWriteHere()) {
2379
  const c = state.current;
2380
  host.innerHTML = `<div class="readonly">${c && c.archived ? "This channel is archived. History is read-only."
 
2477
  box.addEventListener("dragover", (e) => { if ([...e.dataTransfer.types].includes("Files")) { e.preventDefault(); box.classList.add("dropping"); } });
2478
  box.addEventListener("dragleave", (e) => { if (!box.contains(e.relatedTarget)) box.classList.remove("dropping"); });
2479
  box.addEventListener("drop", (e) => { e.preventDefault(); box.classList.remove("dropping"); addFiles([...e.dataTransfer.files]); });
2480
+ host.addFiles = (files) => { addFiles(files); input.focus(); }; // drops anywhere over the channel or thread land here
2481
  input.addEventListener("input", autosize);
2482
  const assist = attachAssist(host, input, autosize);
2483
  input.addEventListener("keydown", (e) => {
2484
+ if (e.key === "Enter" && mobileTextInput()) return;
2485
  if (assist.handleKey(e)) return; // mention suggestions take Enter, Tab and arrows while open
2486
  if (e.key === "ArrowUp" && !input.value && !e.shiftKey) { // like Slack: up in an empty box edits your last message
2487
  const parent = replyTo(), list = parent ? [state.panel.root, ...state.thread] : state.roots, m = lastOwnMessage(list);
 
4153
  let anResize = 0;
4154
  window.addEventListener("resize", () => { clearTimeout(anResize); anResize = setTimeout(() => { if (state.view === "analytics") renderAnalytics(); }, 250); });
4155
 
4156
+ // ------------------------------------------------------------------ drop files anywhere (like Slack)
4157
+ // Over the open channel the file goes to its message box; over an open thread, to the reply box. Nothing is sent
4158
+ // until Send. A drop on a message box itself is handled there (defaultPrevented), so files are never added twice.
4159
+ const hasFiles = (e) => e.dataTransfer && [...e.dataTransfer.types].includes("Files");
4160
+ function dropTarget(zone) {
4161
+ if (zone.id === "panel") {
4162
+ const host = $("panel-composer");
4163
+ return state.panel && state.panel.type === "thread" && host.addFiles ? { host, label: "this thread" } : null;
4164
+ }
4165
+ const c = state.current, host = $("composer");
4166
+ return state.view === "chat" && c && host.addFiles ? { host, label: c.kind === "dm" ? channelTitle(c) : "#" + c.name } : null;
4167
+ }
4168
+ for (const zone of [document.querySelector("main"), $("panel")]) {
4169
+ let overlay = null, depth = 0;
4170
+ const hide = () => { depth = 0; if (overlay) { overlay.remove(); overlay = null; } };
4171
+ zone.addEventListener("dragenter", (e) => {
4172
+ if (!hasFiles(e) || !dropTarget(zone)) return;
4173
+ depth++;
4174
+ if (!overlay) {
4175
+ overlay = document.createElement("div"); overlay.className = "drop-overlay";
4176
+ overlay.innerHTML = `<span>Drop to attach to ${esc(dropTarget(zone).label)}</span>`;
4177
+ zone.append(overlay);
4178
+ }
4179
+ });
4180
+ zone.addEventListener("dragover", (e) => { if (hasFiles(e) && dropTarget(zone)) e.preventDefault(); });
4181
+ zone.addEventListener("dragleave", (e) => { if (overlay && --depth <= 0) hide(); });
4182
+ zone.addEventListener("drop", (e) => {
4183
+ const t = dropTarget(zone); hide();
4184
+ if (e.defaultPrevented || !hasFiles(e) || !t) return;
4185
+ e.preventDefault(); t.host.addFiles([...e.dataTransfer.files]);
4186
+ });
4187
+ }
4188
+ // a file dropped anywhere else must not make the browser leave the board to open it
4189
+ document.addEventListener("dragover", (e) => { if (hasFiles(e)) e.preventDefault(); });
4190
+ document.addEventListener("drop", (e) => { if (hasFiles(e)) e.preventDefault(); });
4191
+
4192
  // ------------------------------------------------------------------ misc
4193
  $("me-who").onclick = () => state.me && openProfile(state.me.id);
4194
  $("analytics-open").onclick = () => { $("app").classList.remove("nav"); openAnalytics(); };