From 128ebfe0830c1aee5130a1363accfe92778f3e56 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Mon, 1 Jun 2026 12:12:55 -0400 Subject: [PATCH] =?UTF-8?q?[IMP]=20dms:=20modernized=20kanban=20=E2=80=94?= =?UTF-8?q?=20card=20redesign,=20density=20tiers,=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the file kanban into a denser, file-type-aware card grid with a per-user density toggle (comfortable / compact / list, persisted) and a new dms_file_name view widget for the card title. Card clicks open the shared side preview pane (same state as the list view). The file-type accent palette lives in dms_ext_palette.scss. Folds the old file_kanban_controller into the renderer/view. Stacks on the list preview-pane work (imports FilePreviewPane + use_stored_state). --- dms/__manifest__.py | 2 + .../js/views/file_kanban_controller.esm.js | 12 - .../src/js/views/file_kanban_controller.xml | 25 - .../js/views/file_kanban_name_widget.esm.js | 100 ++++ .../src/js/views/file_kanban_name_widget.xml | 25 + .../src/js/views/file_kanban_record.esm.js | 73 ++- .../src/js/views/file_kanban_renderer.esm.js | 79 +++- .../src/js/views/file_kanban_renderer.xml | 87 +++- .../src/js/views/file_kanban_view.esm.js | 1 - dms/static/src/scss/dms_ext_palette.scss | 158 +++++++ dms/static/src/scss/file_kanban.scss | 438 ++++++++++++++++++ dms/views/dms_file.xml | 114 +++-- 12 files changed, 976 insertions(+), 138 deletions(-) delete mode 100644 dms/static/src/js/views/file_kanban_controller.esm.js delete mode 100644 dms/static/src/js/views/file_kanban_controller.xml create mode 100644 dms/static/src/js/views/file_kanban_name_widget.esm.js create mode 100644 dms/static/src/js/views/file_kanban_name_widget.xml create mode 100644 dms/static/src/scss/dms_ext_palette.scss create mode 100644 dms/static/src/scss/file_kanban.scss diff --git a/dms/__manifest__.py b/dms/__manifest__.py index 777785397..43ccdf4dc 100644 --- a/dms/__manifest__.py +++ b/dms/__manifest__.py @@ -56,6 +56,8 @@ "dms/static/src/js/components/preview/*.xml", "dms/static/src/js/views/*.xml", # SCSS + "dms/static/src/scss/dms_ext_palette.scss", + "dms/static/src/scss/file_kanban.scss", "dms/static/src/scss/file_preview_pane.scss", ], "web.assets_frontend": [ diff --git a/dms/static/src/js/views/file_kanban_controller.esm.js b/dms/static/src/js/views/file_kanban_controller.esm.js deleted file mode 100644 index ae3d05ee2..000000000 --- a/dms/static/src/js/views/file_kanban_controller.esm.js +++ /dev/null @@ -1,12 +0,0 @@ -// /** ******************************************************************************** -// Copyright 2020 Creu Blanca -// Copyright 2017-2019 MuK IT GmbH -// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). -// **********************************************************************************/ - -import {KanbanController} from "@web/views/kanban/kanban_controller"; -export class FileKanbanController extends KanbanController { - setup() { - super.setup(); - } -} diff --git a/dms/static/src/js/views/file_kanban_controller.xml b/dms/static/src/js/views/file_kanban_controller.xml deleted file mode 100644 index da14578ff..000000000 --- a/dms/static/src/js/views/file_kanban_controller.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - -
- - -
-
-
diff --git a/dms/static/src/js/views/file_kanban_name_widget.esm.js b/dms/static/src/js/views/file_kanban_name_widget.esm.js new file mode 100644 index 000000000..555626ec7 --- /dev/null +++ b/dms/static/src/js/views/file_kanban_name_widget.esm.js @@ -0,0 +1,100 @@ +// /** ******************************************************************************** +// Copyright 2026 ledoent — Don Kendall +// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +// +// Inline quick-rename for DMS file kanban cards (F3). Double-click the +// name → editable input → Enter commits, Esc cancels, blur commits-or- +// cancels based on whether the draft changed. macOS Finder / GNOME Files +// convention. +// +// Implemented as a *view widget* rather than inline kanban-arch markup: +// Odoo 19 forbids every owl event/ref directive (t-on-*, t-ref) inside an +// ir.ui.view arch because the arch is DB-editable. Widget templates are +// static assets, so the event wiring is allowed here. +// **********************************************************************************/ +import {registry} from "@web/core/registry"; +import {standardWidgetProps} from "@web/views/widgets/standard_widget_props"; +import {useService} from "@web/core/utils/hooks"; +import {Component, useEffect, useRef, useState} from "@odoo/owl"; + +export class DmsFileNameWidget extends Component { + static template = "dms.FileNameWidget"; + static props = {...standardWidgetProps}; + + setup() { + this.renameState = useState({active: false}); + this.input = useRef("input"); + this.notification = useService("notification"); + // Focus + select the input the moment it mounts so the user types + // over the existing name immediately. + useEffect( + (el) => { + if (el) { + el.focus(); + el.select?.(); + } + }, + () => [this.input.el] + ); + } + + get name() { + return this.props.record.data.name || ""; + } + + startRename(ev) { + // Swallow the dblclick so it doesn't bubble to the card's global + // click handler (which would open the side-pane preview on top). + ev?.stopPropagation?.(); + ev?.preventDefault?.(); + this.renameState.active = true; + } + + cancelRename() { + this.renameState.active = false; + } + + async commitRename() { + const next = (this.input.el?.value ?? "").trim(); + const current = this.name; + if (!next || next === current) { + this.cancelRename(); + return; + } + try { + await this.props.record.update({name: next}); + await this.props.record.save({noReload: true, savePoint: false}); + // The preview pane reads via its own ORM call keyed on recordId; + // a rename leaves recordId unchanged, so nudge it to re-fetch if + // this record is the one on screen. + this.env.dmsKanbanPreview?.notifyChanged?.(this.props.record.resId); + } catch (e) { + this.notification.add(e.message || "Rename failed", {type: "danger"}); + } + this.cancelRename(); + } + + onKeydown(ev) { + if (ev.key === "Enter") { + ev.preventDefault(); + this.commitRename(); + } else if (ev.key === "Escape") { + ev.preventDefault(); + this.cancelRename(); + } + // Don't let the renderer-level Escape (which closes the preview pane) + // also fire from inside the rename input. + ev.stopPropagation(); + } + + stopEvent(ev) { + ev.stopPropagation(); + } +} + +export const dmsFileNameWidget = { + component: DmsFileNameWidget, + extractProps: () => ({}), +}; + +registry.category("view_widgets").add("dms_file_name", dmsFileNameWidget); diff --git a/dms/static/src/js/views/file_kanban_name_widget.xml b/dms/static/src/js/views/file_kanban_name_widget.xml new file mode 100644 index 000000000..73e3cf8af --- /dev/null +++ b/dms/static/src/js/views/file_kanban_name_widget.xml @@ -0,0 +1,25 @@ + + + + + + + +
+ + + diff --git a/dms/static/src/js/views/file_kanban_record.esm.js b/dms/static/src/js/views/file_kanban_record.esm.js index 002ec429e..63280880b 100644 --- a/dms/static/src/js/views/file_kanban_record.esm.js +++ b/dms/static/src/js/views/file_kanban_record.esm.js @@ -1,57 +1,46 @@ // /** ******************************************************************************** // Copyright 2024 Subteno - Timothée Vannier (https://www.subteno.com). +// Copyright 2026 ledoent — Don Kendall // License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). // **********************************************************************************/ import {KanbanRecord} from "@web/views/kanban/kanban_record"; -import {useFileViewer} from "@web/core/file_viewer/file_viewer_hook"; -import {useService} from "@web/core/utils/hooks"; - -const videoReadableTypes = ["x-matroska", "mp4", "webm"]; -const audioReadableTypes = ["mp3", "ogg", "wav", "aac", "mpa", "flac", "m4a"]; export class FileKanbanRecord extends KanbanRecord { - setup() { - super.setup(); - this.store = useService("mail.store"); - this.fileViewer = useFileViewer(); - } - - isVideo(mimetype) { - return videoReadableTypes.includes(mimetype); - } - - isAudio(mimetype) { - return audioReadableTypes.includes(mimetype); - } - /** * @override * - * Override to open the preview upon clicking the image, if compatible. + * Every kanban click — including the file icon — routes through the + * renderer's side-pane preview state. Previously the icon had its own + * branch that opened Odoo's built-in modal `fileViewer`; that detour + * was inconsistent with the rest of the card (which already selected + * for the side-pane) and meant the registered handler chain in + * `dms.preview_handlers` never saw the click. + * + * Inline rename (double-click the name) is owned by the dms_file_name + * view widget; it stops propagation on its own clicks so they never + * reach this handler. */ - onGlobalClick(ev) { - const self = this; - - if (ev.target.closest(".o_kanban_dms_file_preview")) { - const file_type = self.props.record.data.name.split(".")[1]; - let mimetype = ""; - - if (self.isVideo(file_type)) { - mimetype = `video/${file_type}`; - } else if (self.isAudio(file_type)) { - mimetype = "audio/mpeg"; - } else { - mimetype = self.props.record.data.mimetype; - } + /** + * @override + * Mark the card active while it's the one shown in the side-pane + * preview, mirroring the list view's selected-row accent. Reads the + * reactive selectedId during render so the class updates when the + * selection moves to another card. + */ + getRecordClasses() { + let classes = super.getRecordClasses(); + const selectedId = this.env.dmsKanbanPreview?.selectedId?.(); + if (selectedId && selectedId === this.props.record.resId) { + classes += " o_dms_preview_selected_card"; + } + return classes; + } - const attachment = this.store["ir.attachment"].insert({ - id: self.props.record.data.id, - filename: self.props.record.data.name, - name: self.props.record.data.name, - mimetype: mimetype, - model_name: self.props.record.resModel, - }); - this.fileViewer.open(attachment); + onGlobalClick(ev) { + if (this.env.dmsKanbanPreview && this.props.record.resId) { + this.env.dmsKanbanPreview.select(this.props.record.resId); + ev.preventDefault?.(); + ev.stopPropagation?.(); return; } return super.onGlobalClick(ev); diff --git a/dms/static/src/js/views/file_kanban_renderer.esm.js b/dms/static/src/js/views/file_kanban_renderer.esm.js index 157f74fe3..3c6798b80 100644 --- a/dms/static/src/js/views/file_kanban_renderer.esm.js +++ b/dms/static/src/js/views/file_kanban_renderer.esm.js @@ -1,17 +1,86 @@ // /** ******************************************************************************** // Copyright 2020 Creu Blanca +// Copyright 2026 ledoent — Don Kendall // License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). // **********************************************************************************/ +import {useExternalListener, useSubEnv} from "@odoo/owl"; import {FileKanbanRecord} from "./file_kanban_record.esm"; +import {FilePreviewPane} from "../components/preview/file_preview_pane.esm"; import {KanbanRenderer} from "@web/views/kanban/kanban_renderer"; +import {useDmsPreviewState, useStoredState} from "../utils/use_stored_state.esm"; + +// Density tiers: "comfortable" (default), "compact", "list". +export const DMS_KANBAN_DEFAULT_DENSITY = "comfortable"; +const DMS_KANBAN_DENSITY_KEY = "dms_kanban_density"; +const DMS_KANBAN_PREVIEW_KEY = "dms_kanban_preview_pane"; +const DMS_KANBAN_DENSITY_OPTIONS = [ + {value: "comfortable", label: "Comfortable", icon: "fa-th-large"}, + {value: "compact", label: "Compact", icon: "fa-th"}, + {value: "list", label: "List", icon: "fa-bars"}, +]; +const DMS_DENSITY_VALUES = new Set(DMS_KANBAN_DENSITY_OPTIONS.map((o) => o.value)); export class FileKanbanRenderer extends KanbanRenderer { + static template = "dms.KanbanRenderer"; + static components = { + ...KanbanRenderer.components, + KanbanRecord: FileKanbanRecord, + FilePreviewPane, + }; + setup() { super.setup(); + // Density: persisted string with tampered-value defence. + this._density = useStoredState( + DMS_KANBAN_DENSITY_KEY, + DMS_KANBAN_DEFAULT_DENSITY, + { + deserializer: (v) => + DMS_DENSITY_VALUES.has(v) ? v : DMS_KANBAN_DEFAULT_DENSITY, + } + ); + // Side-pane: shared with the list renderer via useDmsPreviewState. + this.previewState = useDmsPreviewState(DMS_KANBAN_PREVIEW_KEY); + // Expose select callback to descendant FileKanbanRecord instances via + // env so card clicks route into the renderer's preview state without + // the records needing a direct reference up the tree. + useSubEnv({ + dmsKanbanPreview: { + select: (resId) => this.previewState.select(resId), + isOpen: () => this.previewState.open, + // The currently-previewed record id (reactive read) so a card + // can mark itself active — mirrors the list's selected-row rail. + selectedId: () => + this.previewState.open ? this.previewState.recordId : null, + // A card renamed its record in place — re-fetch the pane if + // that record is the one being previewed. + notifyChanged: (resId) => this.previewState.notifyChanged(resId), + }, + }); + useExternalListener(window, "keydown", (ev) => { + if (ev.key === "Escape" && this.previewState.open) { + this.closePreview(); + } + }); + } + + get density() { + return this._density.value; + } + + get densityOptions() { + return DMS_KANBAN_DENSITY_OPTIONS; } -} -FileKanbanRenderer.components = { - ...KanbanRenderer.components, - KanbanRecord: FileKanbanRecord, -}; + setDensity(value) { + this._density.value = value; + } + + togglePreview() { + this.previewState.toggle(); + } + + closePreview() { + this.previewState.close(); + } +} diff --git a/dms/static/src/js/views/file_kanban_renderer.xml b/dms/static/src/js/views/file_kanban_renderer.xml index 8ba228c1f..6268081fc 100644 --- a/dms/static/src/js/views/file_kanban_renderer.xml +++ b/dms/static/src/js/views/file_kanban_renderer.xml @@ -1,19 +1,94 @@ -
- +
+
+ +
+
Drop files to upload
+
+ Release to add to the current directory +
+ + density + + + + +
+
+ + +
+ +
@@ -26,7 +101,7 @@ @@ -37,12 +112,12 @@ t-ref="fileInput" multiple="1" accept="*" - t-on-change="onChangeFileInput" + t-on-change="this.onChangeFileInput" /> diff --git a/dms/static/src/js/views/file_kanban_view.esm.js b/dms/static/src/js/views/file_kanban_view.esm.js index 0fb1f52b0..fdc681559 100644 --- a/dms/static/src/js/views/file_kanban_view.esm.js +++ b/dms/static/src/js/views/file_kanban_view.esm.js @@ -17,7 +17,6 @@ import {registry} from "@web/core/registry"; patch(FileKanbanRenderer.prototype, createFileDropZoneExtension()); patch(KanbanController.prototype, createFileUploadExtension()); -FileKanbanRenderer.template = "dms.KanbanRenderer"; export const FileKanbanView = { ...kanbanView, diff --git a/dms/static/src/scss/dms_ext_palette.scss b/dms/static/src/scss/dms_ext_palette.scss new file mode 100644 index 000000000..63d5081f4 --- /dev/null +++ b/dms/static/src/scss/dms_ext_palette.scss @@ -0,0 +1,158 @@ +// Copyright 2026 ledoent — Don Kendall +// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +// +// Per-extension accent palette — single source of truth. +// +// Sets `--dms-accent` (CSS custom property) on any card whose `data-ext` +// attribute matches a known extension family. The variable cascades to +// descendant elements (spine, ext pill, mime icon) so each surface that +// adopts the data-ext attribute pattern automatically inherits the right +// colour without duplicating the map. +// +// This file is loaded in BOTH `web.assets_backend` (kanban / list / form) +// and `web.assets_frontend` (portal) — see __manifest__.py. +// +// To support a new card surface, just give it `data-ext` and include its +// selector in the per-rule selector list below. The default accent for +// unknown extensions is set on the card class itself in its own SCSS +// (e.g. `.o_kanban_dms_card { --dms-accent: var(--bs-secondary-color); }`). + +// Documents — crimson +.o_kanban_dms_card[data-ext="pdf"], +.o_dms_portal_card--file[data-ext="pdf"] { + --dms-accent: #d6336c; +} + +// Word-family — blue +.o_kanban_dms_card[data-ext="doc"], +.o_kanban_dms_card[data-ext="docx"], +.o_kanban_dms_card[data-ext="odt"], +.o_kanban_dms_card[data-ext="rtf"], +.o_dms_portal_card--file[data-ext="doc"], +.o_dms_portal_card--file[data-ext="docx"], +.o_dms_portal_card--file[data-ext="odt"], +.o_dms_portal_card--file[data-ext="rtf"] { + --dms-accent: #1971c2; +} + +// Spreadsheets — green +.o_kanban_dms_card[data-ext="xls"], +.o_kanban_dms_card[data-ext="xlsx"], +.o_kanban_dms_card[data-ext="ods"], +.o_kanban_dms_card[data-ext="csv"], +.o_dms_portal_card--file[data-ext="xls"], +.o_dms_portal_card--file[data-ext="xlsx"], +.o_dms_portal_card--file[data-ext="ods"], +.o_dms_portal_card--file[data-ext="csv"] { + --dms-accent: #2f9e44; +} + +// Presentations — orange +.o_kanban_dms_card[data-ext="ppt"], +.o_kanban_dms_card[data-ext="pptx"], +.o_kanban_dms_card[data-ext="odp"], +.o_dms_portal_card--file[data-ext="ppt"], +.o_dms_portal_card--file[data-ext="pptx"], +.o_dms_portal_card--file[data-ext="odp"] { + --dms-accent: #e8590c; +} + +// Plain text / markdown — slate +.o_kanban_dms_card[data-ext="txt"], +.o_kanban_dms_card[data-ext="md"], +.o_kanban_dms_card[data-ext="rst"], +.o_dms_portal_card--file[data-ext="txt"], +.o_dms_portal_card--file[data-ext="md"], +.o_dms_portal_card--file[data-ext="rst"] { + --dms-accent: #495057; +} + +// Code (compiled / scripted) — teal +.o_kanban_dms_card[data-ext="py"], +.o_kanban_dms_card[data-ext="js"], +.o_kanban_dms_card[data-ext="ts"], +.o_kanban_dms_card[data-ext="java"], +.o_kanban_dms_card[data-ext="c"], +.o_kanban_dms_card[data-ext="cc"], +.o_kanban_dms_card[data-ext="cpp"], +.o_kanban_dms_card[data-ext="cs"], +.o_kanban_dms_card[data-ext="h"], +.o_kanban_dms_card[data-ext="hpp"], +.o_kanban_dms_card[data-ext="go"], +.o_kanban_dms_card[data-ext="rs"], +.o_kanban_dms_card[data-ext="rb"], +.o_kanban_dms_card[data-ext="php"], +.o_kanban_dms_card[data-ext="sh"], +.o_kanban_dms_card[data-ext="bash"], +.o_kanban_dms_card[data-ext="aj"], +.o_kanban_dms_card[data-ext="groovy"], +.o_kanban_dms_card[data-ext="coffee"], +.o_kanban_dms_card[data-ext="cbl"], +.o_kanban_dms_card[data-ext="f"], +.o_kanban_dms_card[data-ext="f90"] { + --dms-accent: #1098ad; +} + +// Web markup / styles — purple +.o_kanban_dms_card[data-ext="html"], +.o_kanban_dms_card[data-ext="htm"], +.o_kanban_dms_card[data-ext="xml"], +.o_kanban_dms_card[data-ext="css"], +.o_kanban_dms_card[data-ext="scss"], +.o_kanban_dms_card[data-ext="sass"], +.o_kanban_dms_card[data-ext="less"], +.o_kanban_dms_card[data-ext="json"], +.o_kanban_dms_card[data-ext="yaml"], +.o_kanban_dms_card[data-ext="yml"], +.o_kanban_dms_card[data-ext="toml"] { + --dms-accent: #7048e8; +} + +// Archives — violet +.o_kanban_dms_card[data-ext="zip"], +.o_kanban_dms_card[data-ext="tar"], +.o_kanban_dms_card[data-ext="gz"], +.o_kanban_dms_card[data-ext="rar"], +.o_dms_portal_card--file[data-ext="zip"], +.o_dms_portal_card--file[data-ext="tar"], +.o_dms_portal_card--file[data-ext="gz"], +.o_dms_portal_card--file[data-ext="rar"] { + --dms-accent: #5f3dc4; +} + +// Images — same crimson as PDFs (image previews are visually strong on +// their own; the accent just adds family identity). +.o_kanban_dms_card[data-ext="jpg"], +.o_kanban_dms_card[data-ext="jpeg"], +.o_kanban_dms_card[data-ext="png"], +.o_kanban_dms_card[data-ext="gif"], +.o_kanban_dms_card[data-ext="svg"], +.o_kanban_dms_card[data-ext="webp"], +.o_dms_portal_card--file[data-ext="jpg"], +.o_dms_portal_card--file[data-ext="jpeg"], +.o_dms_portal_card--file[data-ext="png"], +.o_dms_portal_card--file[data-ext="gif"], +.o_dms_portal_card--file[data-ext="svg"], +.o_dms_portal_card--file[data-ext="webp"] { + --dms-accent: #d6336c; +} + +// Media (audio + video) — magenta +.o_kanban_dms_card[data-ext="mp4"], +.o_kanban_dms_card[data-ext="mov"], +.o_kanban_dms_card[data-ext="mkv"], +.o_kanban_dms_card[data-ext="webm"], +.o_kanban_dms_card[data-ext="mp3"], +.o_kanban_dms_card[data-ext="wav"], +.o_kanban_dms_card[data-ext="flac"], +.o_kanban_dms_card[data-ext="ogg"], +.o_dms_portal_card--file[data-ext="mp4"], +.o_dms_portal_card--file[data-ext="mov"], +.o_dms_portal_card--file[data-ext="mkv"], +.o_dms_portal_card--file[data-ext="webm"], +.o_dms_portal_card--file[data-ext="mp3"], +.o_dms_portal_card--file[data-ext="wav"], +.o_dms_portal_card--file[data-ext="flac"], +.o_dms_portal_card--file[data-ext="ogg"] { + --dms-accent: #ae3ec9; +} diff --git a/dms/static/src/scss/file_kanban.scss b/dms/static/src/scss/file_kanban.scss new file mode 100644 index 000000000..f23a1ba3a --- /dev/null +++ b/dms/static/src/scss/file_kanban.scss @@ -0,0 +1,438 @@ +// Copyright 2026 ledoent — Don Kendall +// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +// Denser, mime-aware file kanban card. +// +// Density tiers are applied via [data-density] on the kanban view root. +// Default = "comfortable"; "compact" and "list" are toggled via the bar in +// the top-right of the kanban area. Selection persists in localStorage. + +.mk_file_kanban_view { + position: relative; + + // ---- Card chrome (applies to every density) ---- + .o_kanban_dms_card { + // Default accent for unknown extensions / no mimetype info. + --dms-accent: var(--bs-secondary-color, #6c757d); + + display: flex; + flex-direction: column; + gap: 8px; + border-left: 3px solid var(--dms-accent); + transition: + transform 140ms ease, + box-shadow 140ms ease, + border-left-width 140ms ease; + } + + // Subtle hover lift — cards feel tactile, the accent thickens to draw + // the eye to the hovered card without disturbing density. + .o_kanban_record:hover .o_kanban_dms_card { + transform: translateY(-1px); + box-shadow: + 0 2px 4px rgba(0, 0, 0, 0.04), + 0 4px 12px rgba(0, 0, 0, 0.06); + border-left-width: 4px; + } + + // Selected: this card is the one open in the side-pane preview. Primary + // rail + tint, distinct from the per-extension accent, mirroring the + // list view's o_dms_preview_selected_row so the card↔pane mapping stays + // legible across densities. + .o_kanban_record.o_dms_preview_selected_card .o_kanban_dms_card { + --dms-accent: var(--bs-primary, #714b67); + border-left-width: 4px; + background-color: rgba(113, 75, 103, 0.06); + box-shadow: inset 0 0 0 1px rgba(113, 75, 103, 0.18); + } + + // Empty many2many tags widget renders a wrapper div with no content; + // hide it so cards without tags don't carry empty vertical space. + .o_kanban_dms_card__body .o_field_many2many_tags:empty, + .o_kanban_dms_card__body .o_field_many2many_tags:has(.o_input_dropdown:only-child) { + display: none; + } + + // The actual card chrome (thumb / body / meta sub-styles below). + .o_kanban_dms_card { + &__thumb { + position: relative; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--bs-tertiary-bg, #f4f5f7); + border-radius: 4px; + overflow: hidden; + + img { + max-width: 100%; + max-height: 100%; + object-fit: cover; + } + } + + &__lock { + position: absolute; + top: 4px; + right: 4px; + padding: 2px 5px; + background-color: rgba(255, 255, 255, 0.92); + border-radius: 3px; + font-size: 0.75rem; + color: var(--bs-warning-text-emphasis, #997404); + } + + // Extension pill — bottom-right of the thumb. Tinted using the + // per-extension accent so the pill reinforces the spine without a + // second source of truth for colour. Hidden in compact/list densities. + &__ext_pill { + position: absolute; + bottom: 3px; + right: 3px; + padding: 0 5px; + font-family: var( + --bs-font-monospace, + ui-monospace, + "SFMono-Regular", + Menlo, + monospace + ); + font-size: 0.62rem; + font-weight: 600; + line-height: 1.5; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #fff; + background-color: var(--dms-accent, var(--bs-secondary, #6c757d)); + border-radius: 3px; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.12); + pointer-events: none; + } + + &__body { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; // allow text-truncate on the name + } + + &__name { + display: block; + // min-width:0 lets the name shrink (and thus ellipsis-truncate) + // when it is a flex child in the compact/list row layouts; a no-op + // in the comfortable column layout. + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + // Inline rename input (F3). Sits in the same flow slot as the name + // span so the card geometry doesn't shift on dblclick. Bootstrap's + // form-control-sm sets the height; we strip the rounded chrome to + // keep it visually close to the read-mode label. + &__name_input { + display: block; + width: 100%; + font-weight: 600; + font-size: inherit; + padding: 2px 6px; + border: 1px solid var(--bs-primary, #714b67); + border-radius: 3px; + background: var(--bs-body-bg, #fff); + + &:focus { + outline: none; + box-shadow: 0 0 0 2px rgba(113, 75, 103, 0.2); + } + } + + // Directory path subtitle — sits directly under the filename so + // duplicate filenames across directories are disambiguated at a + // glance. Hidden in compact/list densities. + &__subtitle { + display: block; + font-size: 0.72rem; + line-height: 1.2; + margin-top: -2px; + } + + &__meta { + display: flex; + align-items: center; + gap: 6px; + margin-top: auto; + padding-top: 4px; + border-top: 1px solid + var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075)); + font-size: 0.75rem; + color: var(--bs-secondary-color, #6c757d); + } + + &__avatar { + width: 20px; + height: 20px; + border-radius: 50%; + object-fit: cover; + } + + // The inline-rename name is rendered by the dms_file_name view widget, + // which Odoo wraps in a
. That wrapper would + // become the flex child of the card body and, lacking min-width:0, + // refuse to shrink — so a long filename overflows into the meta + // column in compact/list densities. display:contents removes the + // wrapper box so the name/input itself is the flex child, restoring + // the truncation behaviour the &__name rules already define. + .o_widget_dms_file_name { + display: contents; + } + } + + // Density-aware suppression: the subtitle + ext pill belong to the + // comfortable density's "scannable card" composition. Compact + list + // densities prioritise row count, so we hide them there. + .o_kanban_renderer[data-density="compact"], + .o_kanban_renderer[data-density="list"] { + .o_kanban_dms_card__subtitle, + .o_kanban_dms_card__ext_pill { + display: none; + } + } + + // ---- Comfortable (default): vertical card, denser than Odoo's stock ---- + // Odoo's `.o_kanban_record` has its own min-height which would let the + // thumb stretch to fill (flex-column distributes free space). Override + // both the record min-height and pin the thumb to its natural size so + // the card sits as tall as the content requires, no more. + .o_kanban_renderer[data-density="comfortable"], + .o_kanban_renderer:not([data-density]) { + .o_kanban_record { + min-height: 0; + } + + .o_kanban_dms_card { + padding: 8px 12px; + min-height: 0; + gap: 7px; + + // Thumb sized to its content (mime icon or image_128). + // Loses the placeholder grey rectangle that dominated the + // previous layout and made small icons look stranded. + &__thumb { + aspect-ratio: auto; + flex: 0 0 auto; + min-height: 0; + padding: 4px 0; + background-color: transparent; + align-self: flex-start; + } + + &__thumb img { + width: 48px; + height: 48px; + } + + // For image files, icon_url returns /web/image/.../image_128 — + // those benefit from a wider thumbnail. The dms.mixins.thumbnail + // cropping is 128×128 so we can show it square at a larger size. + &__thumb img[src*="image_128"] { + width: 76px; + height: 76px; + border-radius: 4px; + } + } + } + + // ---- Compact: row-style with icon + name on a single line ---- + // Replaces the comfortable card's stacked thumb+body+meta with a + // horizontal row inside each card, halving vertical footprint. + .o_kanban_renderer[data-density="compact"] { + .o_kanban_record { + min-height: unset; + } + + .o_kanban_dms_card { + flex-direction: row; + align-items: center; + padding: 4px 8px; + gap: 10px; + min-height: 0; + + &__thumb { + flex: 0 0 32px; + width: 32px; + height: 32px; + aspect-ratio: 1; + min-height: 0; + padding: 0; + background-color: transparent; + } + + &__thumb img { + width: 24px; + height: 24px; + } + + &__lock { + top: 0; + right: 0; + font-size: 0.65rem; + } + + &__body { + flex: 1 1 auto; + flex-direction: row; + align-items: center; + gap: 10px; + } + + &__name { + flex: 1 1 auto; + font-size: 0.85rem; + } + + &__meta { + margin-top: 0; + padding-top: 0; + border-top: none; + flex: 0 0 auto; + font-size: 0.7rem; + } + } + } + + // ---- List: horizontal row, icon + meta inline ---- + .o_kanban_renderer[data-density="list"] { + // The renderer IS the flex container — no nested descendant needed. + // nowrap is essential: the renderer is a fixed-height flex item (it + // fills the space below the toolbar and owns the vertical scroll), so a + // column flex-container that still inherited core's `flex-wrap: wrap` + // would spill rows sideways into extra columns instead of stacking + + // scrolling. Stack in one column and let overflow-y handle the rest. + display: flex; + flex-direction: column; + // !important to beat core's `.flex-wrap` Bootstrap utility class on the + // renderer (which is itself !important) — without it the column still + // wraps rows sideways into extra columns. + flex-wrap: nowrap !important; + gap: 2px; + + .o_kanban_record { + width: 100% !important; + max-width: 100% !important; + min-height: unset; + margin: 0; + // Keep each row's natural height; without this the column's + // default flex-shrink squeezes 44 rows into the fixed height + // (≈12px each) instead of overflowing into the scroll. + flex: 0 0 auto; + } + + .o_kanban_dms_card { + flex-direction: row; + align-items: center; + padding: 6px 10px; + gap: 12px; + + &__thumb { + flex: 0 0 32px; + width: 32px; + height: 32px; + aspect-ratio: 1; + min-height: 32px; + } + + &__thumb img { + width: 24px; + height: 24px; + } + + &__body { + flex: 1 1 auto; + flex-direction: row; + align-items: center; + gap: 12px; + } + + &__meta { + margin-top: 0; + padding-top: 0; + border-top: none; + flex: 0 0 auto; + } + } + } + + // ---- Animated dropzone (drag-over overlay) ---- + // + // Replaces the default static cloud icon with a marching-dashed-border + // overlay + pulsing icon + title + hint. Triggered by the renderer's + // existing `dragState.showDragZone` state — no JS change. + .o_dms_dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + background: + linear-gradient(rgba(113, 75, 103, 0.06), rgba(113, 75, 103, 0.1)), + repeating-linear-gradient( + 45deg, + transparent, + transparent 8px, + rgba(113, 75, 103, 0.06) 8px, + rgba(113, 75, 103, 0.06) 16px + ); + border-radius: 8px; + outline: 3px dashed var(--bs-primary, #714b67); + outline-offset: -8px; + animation: o_dms_dropzone_march 1s linear infinite; + z-index: 10; + + &__icon { + width: 96px; + height: 96px; + border-radius: 50%; + background: rgba(113, 75, 103, 0.12); + display: flex; + align-items: center; + justify-content: center; + animation: o_dms_dropzone_pulse 1.5s ease-in-out infinite; + + i.fa { + font-size: 3rem; + color: var(--bs-primary, #714b67); + } + } + + &__title { + font-size: 1.2rem; + font-weight: 600; + color: var(--bs-primary, #714b67); + } + + &__hint { + font-size: 0.85rem; + color: var(--bs-secondary-color, #6c757d); + } + } +} + +@keyframes o_dms_dropzone_march { + from { + outline-offset: -8px; + } + to { + outline-offset: -16px; + } +} + +@keyframes o_dms_dropzone_pulse { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.08); + } +} diff --git a/dms/views/dms_file.xml b/dms/views/dms_file.xml index ed44ad578..30180f77a 100644 --- a/dms/views/dms_file.xml +++ b/dms/views/dms_file.xml @@ -93,15 +93,16 @@ js_class="file_kanban" class="mk_file_kanban_view o_kanban_small_column o_kanban_mobile" banner_route="/onboarding/document_onboarding_file" - highlight_color="color" > + + @@ -211,53 +212,72 @@
- -
-
-
- - Icon - -
-
- - + + +
+ -
-
- - - - -
-
+
+ + + + +
+
+ + +
+ +
+
+ + +