From cf5d8bb50db94ef07f3f727cea4721a370dcd03c Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Tue, 2 Jun 2026 14:43:16 +0300 Subject: [PATCH] feat(ui): limit visible reactions and overhaul retro theme - Limit the number of initially visible reactions per file to 2 and calculate the overflow count on the backend. - Redesign the retro theme CSS to mimic a classic Windows 98 Explorer window, including title bars, toolbars, and sunken panes. - Add local storage persistence for the file browser view preference (list vs. thumbnails). --- backend/libs/handlers/download.go | 16 +- backend/static/css/16-retro.css | 127 ++++++++++- backend/static/css/30-download.css | 303 ++++++++++++++++++++++++-- backend/static/css/90-responsive.css | 83 +++++++ backend/static/js/10-file-browser.js | 53 ++++- backend/static/js/12-reactions.js | 156 ++++++++++--- backend/templates/pages/download.html | 112 ++++++---- 7 files changed, 740 insertions(+), 110 deletions(-) diff --git a/backend/libs/handlers/download.go b/backend/libs/handlers/download.go index 7751c4b..fab31ec 100644 --- a/backend/libs/handlers/download.go +++ b/backend/libs/handlers/download.go @@ -50,6 +50,7 @@ type fileView struct { IconRetroURL string ReactURL string Reactions []reactionView + ReactionMore int Reacted bool } @@ -58,6 +59,7 @@ type reactionView struct { URL string `json:"url"` Label string `json:"label"` Count int `json:"count"` + Visible bool `json:"visible"` } type emojiTabView struct { @@ -354,6 +356,7 @@ func (a *App) fileView(box services.Box, file services.File) fileView { func (a *App) fileViewWithReactions(box services.Box, file services.File, reactions []services.ReactionSummary, reacted bool) fileView { icon := a.fileIcons.lookup(file.Name, file.ContentType) + reactionViews := a.reactionViews(reactions) return fileView{ ID: file.ID, Name: file.Name, @@ -367,7 +370,8 @@ func (a *App) fileViewWithReactions(box services.Box, file services.File, reacti IconURL: fileIconURL("standard", icon.Standard), IconRetroURL: fileIconURL("retro", icon.Retro), ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID), - Reactions: a.reactionViews(reactions), + Reactions: reactionViews, + ReactionMore: reactionOverflowCount(reactionViews), Reacted: reacted, } } @@ -413,17 +417,25 @@ func (a *App) ReactToFile(w http.ResponseWriter, r *http.Request) { func (a *App) reactionViews(reactions []services.ReactionSummary) []reactionView { views := make([]reactionView, 0, len(reactions)) - for _, reaction := range reactions { + for index, reaction := range reactions { views = append(views, reactionView{ EmojiID: reaction.EmojiID, URL: emojiURL(reaction.EmojiID), Label: emojiLabel(reaction.EmojiID), Count: reaction.Count, + Visible: index < 2, }) } return views } +func reactionOverflowCount(reactions []reactionView) int { + if len(reactions) <= 2 { + return 0 + } + return len(reactions) - 2 +} + func (a *App) emojiTabs() ([]emojiTabView, error) { root := a.emojiRoot() entries, err := os.ReadDir(root) diff --git a/backend/static/css/16-retro.css b/backend/static/css/16-retro.css index 8238b5e..4c9b9e6 100644 --- a/backend/static/css/16-retro.css +++ b/backend/static/css/16-retro.css @@ -592,31 +592,140 @@ content: "\23F1 "; } -/* List / Thumbnails / Preview images = a Win98 toolbar (menubar) of flat - buttons that raise on hover and depress when active. */ +/* The file browser becomes a Win98 Explorer window: blue titlebar, grey + toolbar, sunken content pane and flat rows. */ +:root[data-theme="retro"] .file-browser-window { + border: 1px solid #000000; + border-radius: 0; + background: #c0c0c0; + box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf; +} + +:root[data-theme="retro"] .file-browser-titlebar { + min-height: 1.8rem; + margin: 3px 3px 0; + padding: 0.2rem 0.45rem; + border: 0; + background: linear-gradient(to right, #000078 0%, #000078 80%, #0f80cd 100%); + color: #ffffff; +} + +:root[data-theme="retro"] .file-browser-titlebar strong, +:root[data-theme="retro"] .file-browser-titlebar span { + color: #ffffff; + font-size: 0.78rem; +} + +:root[data-theme="retro"] .file-browser-window-actions { + display: none; +} + +:root[data-theme="retro"] .file-browser-toolbar { + justify-content: space-between; + margin: 0 3px; + padding: 3px; + border: 0; + border-bottom: 1px solid #808080; + background: #c0c0c0; +} + :root[data-theme="retro"] .view-toolbar { justify-content: flex-start; gap: 2px; - margin-top: 1rem; - padding: 3px; + margin-top: 0; + padding: 0; background: #c0c0c0; - border: 1px solid #000000; - box-shadow: inset 1px 1px 0 #ffffff, inset -1px -1px 0 #808080; + border: 0; + box-shadow: none; } -:root[data-theme="retro"] .view-toolbar .button { +:root[data-theme="retro"] .view-toolbar .button, +:root[data-theme="retro"] .file-browser-toolbar > .button { + display: inline-grid; + place-items: center; background: transparent; border: 1px solid transparent; box-shadow: none; font-weight: 400; } -:root[data-theme="retro"] .view-toolbar .button:hover { +:root[data-theme="retro"] .view-toolbar .icon-button { + width: 2.2rem; + height: 2rem; + padding: 0; +} + +:root[data-theme="retro"] .view-toolbar .icon-button svg { + margin: 0; + display: block; +} + +:root[data-theme="retro"] .view-toolbar .button:hover, +:root[data-theme="retro"] .file-browser-toolbar > .button:hover { background: #c0c0c0; box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #ffffff; } -:root[data-theme="retro"] .view-toolbar .button.is-active { +:root[data-theme="retro"] .view-toolbar .button.is-active, +:root[data-theme="retro"] .file-browser-toolbar > .button.is-active { background: #d4d0c8; box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff; } + +:root[data-theme="retro"] .file-browser-head { + margin: 0 3px; + border: 0; + border-bottom: 1px solid #808080; + background: #c0c0c0; + color: #000000; + text-transform: none; +} + +:root[data-theme="retro"] .file-browser { + margin: 0 3px 3px; + border: 1px solid #000000; + background: #ffffff; + box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff; +} + +:root[data-theme="retro"] .download-item { + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +:root[data-theme="retro"] .file-open { + border-radius: 0; + color: #000000; +} + +:root[data-theme="retro"] .file-open:hover, +:root[data-theme="retro"] .file-open:focus-visible { + background: transparent; + color: #000000; + outline: 2px solid #000078; + outline-offset: -2px; +} + +:root[data-theme="retro"] .file-media { + border: 0; + border-radius: 0; + background: transparent; +} + +:root[data-theme="retro"] .file-browser.is-thumbs .file-open { + align-content: start; + justify-content: center; +} + +:root[data-theme="retro"] .file-browser.is-thumbs .file-media { + justify-self: center; + align-self: start; +} + +:root[data-theme="retro"] .file-type, +:root[data-theme="retro"] .file-size, +:root[data-theme="retro"] .file-main small { + color: inherit; +} diff --git a/backend/static/css/30-download.css b/backend/static/css/30-download.css index 21755aa..4b01abd 100644 --- a/backend/static/css/30-download.css +++ b/backend/static/css/30-download.css @@ -46,12 +46,22 @@ text-decoration: none; } -.view-toolbar { - display: flex; - justify-content: center; - flex-wrap: wrap; - gap: 0.5rem; - margin-top: 1rem; +.thumb-link { + flex: 0 0 4.75rem; + width: 4.75rem; + aspect-ratio: 16 / 10; + display: block; + overflow: hidden; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 0.2rem); + background: var(--muted); +} + +.thumb-link img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; } .button.is-active { @@ -59,26 +69,148 @@ color: var(--primary-foreground); } +.file-browser-window { + overflow: hidden; + margin-top: 1.25rem; + border: 1px solid color-mix(in srgb, var(--border) 78%, var(--primary)); + border-radius: var(--radius); + background: + linear-gradient(180deg, color-mix(in srgb, var(--card) 94%, transparent), color-mix(in srgb, var(--background) 92%, transparent)); + box-shadow: 0 18px 54px rgba(0, 0, 0, 0.24); + text-align: left; +} + +.file-browser-titlebar { + min-height: 3rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 0.9rem; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--muted) 62%, transparent); +} + +.file-browser-titlebar > div:first-child { + min-width: 0; + display: flex; + align-items: baseline; + gap: 0.6rem; +} + +.file-browser-titlebar strong { + font-size: 0.95rem; +} + +.file-browser-titlebar span { + color: var(--muted-foreground); + font-size: 0.78rem; + white-space: nowrap; +} + +.file-browser-window-actions { + display: inline-flex; + gap: 0.35rem; +} + +.file-browser-window-actions span { + width: 0.72rem; + height: 0.72rem; + border: 1px solid color-mix(in srgb, var(--border) 75%, var(--foreground)); + border-radius: 999px; + background: var(--muted); +} + +.file-browser-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.65rem 0.75rem; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--card) 74%, transparent); +} + +.view-toolbar { + display: inline-flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.view-toolbar .button, +.file-browser-toolbar > .button { + min-height: 2rem; + padding: 0.35rem 0.65rem; + font-size: 0.78rem; +} + +.view-toolbar .icon-button { + width: 2.25rem; + padding-inline: 0; + justify-content: center; +} + +.view-toolbar svg { + width: 0.95rem; + height: 0.95rem; +} + +.file-browser-head { + display: grid; + grid-template-columns: 3rem minmax(0, 1fr) minmax(8rem, 0.38fr) minmax(5rem, 0.18fr) minmax(8rem, 0.32fr); + gap: 0.75rem; + padding: 0.42rem 1rem; + border-bottom: 1px solid var(--border); + color: var(--muted-foreground); + background: color-mix(in srgb, var(--background) 78%, transparent); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; +} + +.file-browser-head span:first-child { + grid-column: 2; +} + .file-browser { + display: grid; + gap: 0; + padding: 0.35rem; transition: opacity 160ms ease; } +.file-browser .download-item { + display: grid; + min-width: 0; + border: 0; + border-radius: calc(var(--radius) - 0.25rem); + background: transparent; + box-shadow: none; + padding: 0; + transform: none; +} + +.file-browser .download-item:hover { + transform: none; +} + .file-card { position: relative; - padding-block: 0.65rem 2.6rem; + padding: 0; } .file-reaction-dock { - position: absolute; - right: 0.65rem; - bottom: 0.55rem; + position: static; z-index: 2; display: inline-flex; align-items: center; justify-content: flex-end; - max-width: calc(100% - 1.3rem); + min-width: 0; + max-width: 100%; gap: 0.35rem; pointer-events: none; + padding-right: 0.65rem; } .file-reactions { @@ -87,10 +219,13 @@ justify-content: flex-end; min-width: 0; gap: 0.25rem; - flex-wrap: wrap; + flex-wrap: nowrap; + white-space: nowrap; } .reaction-pill { + appearance: none; + flex: 0 0 auto; display: inline-flex; align-items: center; gap: 0.2rem; @@ -104,6 +239,11 @@ font-weight: 700; box-shadow: 0 8px 22px rgba(0, 0, 0, 0.24); pointer-events: auto; + cursor: pointer; +} + +.reaction-pill.is-hidden-summary { + display: none; } .reaction-pill img { @@ -112,6 +252,31 @@ display: block; } +.reaction-more { + appearance: none; + flex: 0 0 auto; + min-height: 1.6rem; + padding: 0.16rem 0.45rem; + border: 1px solid color-mix(in srgb, var(--border) 84%, var(--primary)); + border-radius: 999px; + background: color-mix(in srgb, var(--card) 88%, #000); + color: var(--foreground); + font-size: 0.75rem; + font-weight: 800; + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.24); + pointer-events: auto; + cursor: pointer; +} + +.reaction-pill:hover, +.reaction-pill:focus-visible, +.reaction-more:hover, +.reaction-more:focus-visible { + border-color: var(--primary); + background: var(--primary); + color: var(--primary-foreground); +} + .reaction-button { width: 2.1rem; height: 2.1rem; @@ -212,6 +377,30 @@ html.reaction-picker-open body { font-size: 0.75rem; } +.reaction-existing { + padding: 0.55rem 0.7rem 0; +} + +.reaction-existing small, +.reaction-readonly-note { + display: block; + color: var(--muted-foreground); + font-size: 0.74rem; + font-weight: 700; +} + +.reaction-existing-list { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.4rem; +} + +.reaction-readonly-note { + margin: 0; + padding: 0.55rem 0.7rem 0.7rem; +} + .reaction-picker-tabs { display: flex; gap: 0.35rem; @@ -309,11 +498,19 @@ html.reaction-picker-open body { .file-open { min-width: 0; flex: 1; - display: flex; + display: grid; + grid-template-columns: 3rem minmax(0, 1fr) minmax(8rem, 0.38fr) minmax(5rem, 0.18fr); align-items: center; - gap: 0.8rem; + gap: 0.75rem; color: var(--foreground); text-decoration: none; + padding: 0.55rem 0.65rem; + border-radius: calc(var(--radius) - 0.25rem); +} + +.file-open:hover, +.file-open:focus-visible { + background: var(--surface-1-hover); } .file-media { @@ -360,11 +557,14 @@ html.reaction-picker-open body { .file-main { min-width: 0; max-width: 100%; - flex: 1; color: var(--foreground); text-decoration: none; } +.file-name { + min-width: 0; +} + .file-main small { display: block; overflow: hidden; @@ -372,44 +572,101 @@ html.reaction-picker-open body { white-space: nowrap; } +.file-type, +.file-size { + overflow: hidden; + color: var(--muted-foreground); + font-size: 0.78rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-size { + text-align: right; +} + .file-browser.is-thumbs { - grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); + gap: 0.75rem; + padding: 0.75rem; + grid-template-columns: repeat(auto-fill, minmax(8.75rem, 1fr)); +} + +.file-browser-window.is-icon-view .file-browser-head { + display: none; } .file-browser.is-thumbs .file-card { display: grid; + min-height: 13.75rem; min-width: 0; align-content: start; gap: 0.5rem; } +.file-browser.is-list .file-card { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(8rem, 0.32fr); + align-items: center; + min-height: 4.25rem; +} + +.file-browser.is-list .file-card:hover, +.file-browser.is-list .file-card:focus-within { + background: var(--surface-1-hover); +} + +.file-browser.is-list .file-card:hover .file-open, +.file-browser.is-list .file-card:focus-within .file-open { + background: transparent; +} + .file-browser.is-thumbs .file-open { display: grid; - gap: 0.55rem; + grid-template-columns: 1fr; + grid-template-rows: 6.75rem auto; + gap: 1rem; + height: 100%; + min-height: 0; + padding: 0.65rem 0.65rem 3.05rem; text-align: center; justify-items: center; + align-content: start; + overflow: hidden; } .file-browser.is-thumbs .file-media { - width: 100%; - height: auto; + width: min(6.75rem, 76%); + height: 6.75rem; flex-basis: auto; - aspect-ratio: 16 / 10; + aspect-ratio: 1; } .file-browser.is-thumbs .file-icon { - width: 45%; - height: auto; + width: 64%; + height: 64%; } .file-browser.is-thumbs .file-main { width: 100%; + grid-template-columns: 1fr; + gap: 0.25rem; + align-self: start; + padding-top: 0.25rem; } -.file-browser.images-only .file-card:not([data-kind="image"]) { +.file-browser.is-thumbs .file-type, +.file-browser.is-thumbs .file-size { display: none; } +.file-browser.is-thumbs .file-reaction-dock { + position: absolute; + right: 0.6rem; + bottom: 0.65rem; + max-width: calc(100% - 1.2rem); + padding-right: 0; +} + .context-menu { position: fixed; z-index: 30; diff --git a/backend/static/css/90-responsive.css b/backend/static/css/90-responsive.css index cb9507e..825fd34 100644 --- a/backend/static/css/90-responsive.css +++ b/backend/static/css/90-responsive.css @@ -95,6 +95,41 @@ flex: 1; } + .file-browser-toolbar { + align-items: stretch; + } + + .file-browser-toolbar, + .file-browser-toolbar .view-toolbar { + width: 100%; + } + + .file-browser-toolbar .view-toolbar .button, + .file-browser-toolbar > .button { + flex: 1 1 auto; + justify-content: center; + } + + .file-browser-toolbar .view-toolbar .icon-button { + flex: 0 0 2.5rem; + } + + .file-browser-head { + display: none; + } + + .file-open { + grid-template-columns: 3rem minmax(0, 1fr) auto; + } + + .file-type { + display: none; + } + + .file-browser.is-list .file-card { + grid-template-columns: minmax(0, 1fr) minmax(7rem, auto); + } + h1 { font-size: 1.65rem; } @@ -213,6 +248,54 @@ flex-wrap: wrap; } + .file-browser-titlebar { + align-items: flex-start; + } + + .file-browser-titlebar > div:first-child { + flex-direction: column; + align-items: flex-start; + gap: 0.1rem; + } + + .file-browser { + padding: 0.25rem; + } + + .file-open { + grid-template-columns: 2.65rem minmax(0, 1fr); + gap: 0.55rem; + padding: 0.5rem; + } + + .file-media { + width: 2.65rem; + height: 2.65rem; + } + + .file-size { + display: none; + } + + .file-browser.is-list .file-card { + grid-template-columns: 1fr; + gap: 0.25rem; + } + + .file-browser.is-list .file-reaction-dock { + justify-content: flex-end; + padding: 0 0.5rem 0.5rem; + } + + .file-browser.is-thumbs { + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 0.5rem; + } + + .file-browser.is-thumbs .file-open { + height: 100%; + } + .file-actions, .file-browser.is-thumbs .file-actions { width: 100%; diff --git a/backend/static/js/10-file-browser.js b/backend/static/js/10-file-browser.js index 704fba5..05f200c 100644 --- a/backend/static/js/10-file-browser.js +++ b/backend/static/js/10-file-browser.js @@ -1,30 +1,25 @@ (function () { const fileBrowser = document.querySelector("[data-file-browser]"); const viewButtons = document.querySelectorAll("[data-view-button]"); - const previewImages = document.querySelector("[data-preview-images]"); const previewActions = document.querySelectorAll("[data-preview-action]"); const fileContextMenu = document.querySelector("[data-file-context-menu]"); + const fileBrowserWindow = document.querySelector("[data-file-browser-window]"); let ctrlCopyMode = false; let contextFile = null; const contextMenuCloseDistance = 80; + const viewStorageKey = "warpbox.fileBrowser.view"; if (fileBrowser) { + applySavedFileBrowserPreferences(); + viewButtons.forEach((button) => { button.addEventListener("click", () => { const view = button.getAttribute("data-view-button"); - fileBrowser.classList.toggle("is-list", view === "list"); - fileBrowser.classList.toggle("is-thumbs", view === "thumbs"); - viewButtons.forEach((item) => item.classList.toggle("is-active", item === button)); + setFileBrowserView(view); + savePreference(viewStorageKey, view); }); }); - - if (previewImages) { - previewImages.addEventListener("click", () => { - fileBrowser.classList.toggle("images-only"); - previewImages.classList.toggle("is-active"); - }); - } } if (fileBrowser && fileContextMenu) { @@ -188,4 +183,40 @@ y >= rect.top - contextMenuCloseDistance && y <= rect.bottom + contextMenuCloseDistance; } + + function applySavedFileBrowserPreferences() { + const savedView = readPreference(viewStorageKey); + setFileBrowserView(savedView === "list" ? "list" : "thumbs"); + } + + function setFileBrowserView(view) { + const normalized = view === "thumbs" ? "thumbs" : "list"; + fileBrowser.classList.toggle("is-list", normalized === "list"); + fileBrowser.classList.toggle("is-thumbs", normalized === "thumbs"); + if (fileBrowserWindow) { + fileBrowserWindow.classList.toggle("is-list-view", normalized === "list"); + fileBrowserWindow.classList.toggle("is-icon-view", normalized === "thumbs"); + } + viewButtons.forEach((item) => { + const active = item.getAttribute("data-view-button") === normalized; + item.classList.toggle("is-active", active); + item.setAttribute("aria-pressed", active ? "true" : "false"); + }); + } + + function readPreference(key) { + try { + return window.localStorage.getItem(key); + } catch (_) { + return ""; + } + } + + function savePreference(key, value) { + try { + window.localStorage.setItem(key, value); + } catch (_) { + // LocalStorage can be unavailable in private or locked-down browsers. + } + } })(); diff --git a/backend/static/js/12-reactions.js b/backend/static/js/12-reactions.js index c66e8ba..261e8d8 100644 --- a/backend/static/js/12-reactions.js +++ b/backend/static/js/12-reactions.js @@ -3,6 +3,10 @@ const panel = picker ? picker.querySelector(".reaction-picker-panel") : null; const search = picker ? picker.querySelector("[data-reaction-search]") : null; const closeButton = picker ? picker.querySelector("[data-reaction-close]") : null; + const existingSection = picker ? picker.querySelector("[data-reaction-existing]") : null; + const existingList = picker ? picker.querySelector("[data-reaction-existing-list]") : null; + const readonlyNote = picker ? picker.querySelector("[data-reaction-readonly]") : null; + const chooserElements = picker ? Array.from(picker.querySelectorAll(".reaction-picker-tabs, .reaction-search, .reaction-grid-wrap")) : []; const tabs = picker ? Array.from(picker.querySelectorAll("[data-reaction-tab]")) : []; const panels = picker ? Array.from(picker.querySelectorAll("[data-reaction-panel]")) : []; @@ -17,6 +21,36 @@ }); }); + document.addEventListener("click", (event) => { + const pill = event.target.closest("[data-reaction-pill]"); + if (pill) { + event.preventDefault(); + event.stopPropagation(); + const card = pill.closest("[data-reaction-card]") || activeCard; + if (!card) { + return; + } + if (card.dataset.reacted === "true") { + openPickerForCard(card, pill); + return; + } + submitReactionForCard(card, pill.dataset.reactionEmojiId); + return; + } + + const more = event.target.closest("[data-reaction-more]"); + if (!more) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + const card = more.closest("[data-reaction-card]"); + if (card) { + openPickerForCard(card, more); + } + }); + if (!picker || !panel) { return; } @@ -35,10 +69,10 @@ panel.addEventListener("click", async (event) => { const emoji = event.target.closest("[data-emoji-id]"); - if (!emoji || !activeButton || !activeCard) { + if (!emoji || !activeCard || activeCard.dataset.reacted === "true") { return; } - await submitReaction(emoji); + await submitReactionForCard(activeCard, emoji.dataset.emojiId); }); tabs.forEach((tab) => { @@ -62,6 +96,9 @@ if (panel.contains(event.target) || event.target.closest("[data-reaction-button]")) { return; } + if (event.target.closest("[data-reaction-more]") || event.target.closest("[data-reaction-pill]")) { + return; + } closePicker(); }); @@ -78,15 +115,24 @@ }); function openPicker(button) { - activeButton = button; - activeCard = button.closest("[data-reaction-card]"); + openPickerForCard(button.closest("[data-reaction-card]"), button); + } + + function openPickerForCard(card, trigger) { + if (!card) { + return; + } + activeButton = trigger || card.querySelector("[data-reaction-button]"); + activeCard = card; + populateExistingReactions(card); + setPickerReadonly(card.dataset.reacted === "true"); picker.hidden = false; picker.classList.add("is-open"); if (search) { search.value = ""; filterEmoji(""); } - positionPicker(button); + positionPicker(activeButton || card); } function closePicker() { @@ -95,6 +141,7 @@ document.documentElement.classList.remove("reaction-picker-open"); picker.style.left = ""; picker.style.top = ""; + setPickerReadonly(false); activeButton = null; activeCard = null; } @@ -146,12 +193,18 @@ }); } - async function submitReaction(emoji) { + async function submitReactionForCard(card, emojiID) { + if (!card || !emojiID || card.dataset.reacted === "true") { + return; + } const body = new URLSearchParams(); - body.set("emoji_id", emoji.dataset.emojiId); + body.set("emoji_id", emojiID); - activeButton.disabled = true; - const response = await fetch(activeButton.dataset.reactUrl, { + const reactButton = card.querySelector("[data-reaction-button]"); + if (reactButton) { + reactButton.disabled = true; + } + const response = await fetch(card.dataset.reactUrl, { method: "POST", headers: { "Accept": "application/json", @@ -161,14 +214,19 @@ }); if (!response.ok) { - activeButton.disabled = false; + if (reactButton) { + reactButton.disabled = false; + } closePicker(); return; } const payload = await response.json(); - renderReactions(activeCard, payload.reactions || []); - activeButton.remove(); + renderReactions(card, payload.reactions || []); + card.dataset.reacted = "true"; + if (reactButton) { + reactButton.remove(); + } closePicker(); } @@ -179,20 +237,68 @@ } list.replaceChildren(); reactions.forEach((reaction) => { - const pill = document.createElement("span"); - pill.className = "reaction-pill"; - pill.title = reaction.label || reaction.emojiId; - - const image = document.createElement("img"); - image.src = reaction.url; - image.alt = reaction.label || reaction.emojiId; - image.loading = "lazy"; - - const count = document.createElement("span"); - count.textContent = reaction.count; - - pill.append(image, count); + const pill = buildReactionPill(reaction); + if (!reaction.visible) { + pill.classList.add("is-hidden-summary"); + } list.append(pill); }); + const hiddenCount = reactions.length > 2 ? reactions.length - 2 : 0; + if (hiddenCount > 0) { + const more = document.createElement("button"); + more.className = "reaction-more"; + more.type = "button"; + more.dataset.reactionMore = ""; + more.textContent = `+${hiddenCount}`; + more.setAttribute("aria-label", `Show ${hiddenCount} more reactions`); + list.append(more); + } + } + + function buildReactionPill(reaction) { + const pill = document.createElement("button"); + pill.className = "reaction-pill"; + pill.type = "button"; + pill.title = reaction.label || reaction.emojiId; + pill.dataset.reactionPill = ""; + pill.dataset.reactionEmojiId = reaction.emojiId; + pill.dataset.reactionLabel = reaction.label || reaction.emojiId; + pill.dataset.reactionUrl = reaction.url; + pill.dataset.reactionCount = reaction.count; + pill.setAttribute("aria-label", `React with ${reaction.label || reaction.emojiId}`); + + const image = document.createElement("img"); + image.src = reaction.url; + image.alt = reaction.label || reaction.emojiId; + image.loading = "lazy"; + + const count = document.createElement("span"); + count.textContent = reaction.count; + + pill.append(image, count); + return pill; + } + + function populateExistingReactions(card) { + if (!existingSection || !existingList) { + return; + } + existingList.replaceChildren(); + card.querySelectorAll("[data-reaction-pill]").forEach((pill) => { + const clone = pill.cloneNode(true); + clone.classList.remove("is-hidden-summary"); + existingList.append(clone); + }); + existingSection.hidden = existingList.children.length === 0; + } + + function setPickerReadonly(readonly) { + picker.classList.toggle("is-readonly", readonly); + chooserElements.forEach((element) => { + element.hidden = readonly; + }); + if (readonlyNote) { + readonlyNote.hidden = !readonly; + } } })(); diff --git a/backend/templates/pages/download.html b/backend/templates/pages/download.html index 189eb34..3605db2 100644 --- a/backend/templates/pages/download.html +++ b/backend/templates/pages/download.html @@ -45,48 +45,75 @@ {{end}} {{end}} -
- - - -
- -
- {{range .Data.Files}} -
- - - {{if .HasThumbnail}} - - {{else}} - {{if .IconURL}}{{end}} - {{if .IconRetroURL}}{{end}} - {{end}} - - - {{.Name}} - {{.Size}} · {{.ContentType}} - - - {{if not $.Data.Locked}} -
-
- {{range .Reactions}} - - {{.Label}} - {{.Count}} - +
+
+
+ File Browser + {{len .Data.Files}} item{{if ne (len .Data.Files) 1}}s{{end}} +
+ +
+
+
+ + +
+
+ +
+ {{range .Data.Files}} +
- {{end}} -
- {{end}} + {{end}} + + {{end}} +
{{if not .Data.Locked}} + +
{{range $index, $tab := .Data.EmojiTabs}}