From e83efadc614a28ec00b3948b405eb07a6e078850 Mon Sep 17 00:00:00 2001 From: wdeveloper16 Date: Tue, 14 Apr 2026 07:11:35 +0200 Subject: [PATCH] :sparkles: Add read-only preview mode for saved versions --- backend/src/app/features/file_snapshots.clj | 7 +- .../src/app/rpc/commands/files_snapshot.clj | 37 ++++++++++ frontend/src/app/main/data/persistence.cljs | 6 +- frontend/src/app/main/data/workspace.cljs | 19 ++++- .../src/app/main/data/workspace/versions.cljs | 73 ++++++++++++++++++- frontend/src/app/main/ui/workspace.cljs | 52 +++++++++++++ frontend/src/app/main/ui/workspace.scss | 50 +++++++++++++ .../main/ui/workspace/sidebar/versions.cljs | 44 ++++++++++- frontend/translations/en.po | 12 +++ 9 files changed, 291 insertions(+), 9 deletions(-) diff --git a/backend/src/app/features/file_snapshots.clj b/backend/src/app/features/file_snapshots.clj index 192030cbf8..e013b90d00 100644 --- a/backend/src/app/features/file_snapshots.clj +++ b/backend/src/app/features/file_snapshots.clj @@ -112,8 +112,9 @@ THEN (c.deleted_at IS NULL OR c.deleted_at >= ?::timestamptz) END")) -(defn- get-snapshot - "Get snapshot with decoded data" +(defn get-snapshot-data + "Get a fully decoded snapshot for read-only preview or restoration. + Returns the snapshot map with decoded :data field." [cfg file-id snapshot-id] (let [now (ct/now)] (->> (db/get-with-sql cfg [sql:get-snapshot file-id snapshot-id now] @@ -326,7 +327,7 @@ (sto/resolve cfg {::db/reuse-conn true}) snapshot - (get-snapshot cfg file-id snapshot-id)] + (get-snapshot-data cfg file-id snapshot-id)] (when-not snapshot (ex/raise :type :not-found diff --git a/backend/src/app/rpc/commands/files_snapshot.clj b/backend/src/app/rpc/commands/files_snapshot.clj index 8325772361..658ab92870 100644 --- a/backend/src/app/rpc/commands/files_snapshot.clj +++ b/backend/src/app/rpc/commands/files_snapshot.clj @@ -8,6 +8,7 @@ (:require [app.binfile.common :as bfc] [app.common.exceptions :as ex] + [app.common.features :as-alias cfeat] [app.common.schema :as sm] [app.common.time :as ct] [app.db :as db] @@ -35,6 +36,42 @@ (files/check-read-permissions! conn profile-id file-id) (fsnap/get-visible-snapshots conn file-id)))) +;; --- COMMAND QUERY: get-file-snapshot + +(def ^:private schema:get-file-snapshot + [:map {:title "get-file-snapshot"} + [:file-id ::sm/uuid] + [:id ::sm/uuid] + [:features {:optional true} ::cfeat/features]]) + +(sv/defmethod ::get-file-snapshot + "Retrieve a file bundle with data from a specific snapshot for + read-only preview. Does not modify any database state." + {::doc/added "2.16" + ::sm/params schema:get-file-snapshot + ::sm/result files/schema:file-with-permissions + ::db/transaction true} + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id id] :as params}] + (let [perms (bfc/get-file-permissions conn profile-id file-id)] + (files/check-read-permissions! perms) + (let [snapshot (fsnap/get-snapshot-data cfg file-id id)] + (when-not snapshot + (ex/raise :type :not-found + :code :snapshot-not-found + :hint "unable to find snapshot with the provided id" + :snapshot-id id + :file-id file-id)) + ;; Load current file metadata only (no data decoding) then overlay + ;; the snapshot data so the client receives the same shape as a + ;; normal get-file response but with historical page/object content. + (let [base-file (bfc/get-file cfg file-id :load-data? false)] + (-> base-file + (assoc :data (:data snapshot)) + (assoc :version (:version snapshot)) + (assoc :features (:features snapshot)) + (assoc :revn (:revn snapshot)) + (assoc :permissions perms)))))) + (def ^:private schema:create-file-snapshot [:map [:file-id ::sm/uuid] diff --git a/frontend/src/app/main/data/persistence.cljs b/frontend/src/app/main/data/persistence.cljs index adcc70cbb3..c90c423f96 100644 --- a/frontend/src/app/main/data/persistence.cljs +++ b/frontend/src/app/main/data/persistence.cljs @@ -121,8 +121,10 @@ :features features} permissions (:permissions state)] - ;; Prevent commit changes by a team member without edition permission - (when (:can-edit permissions) + ;; Prevent saving changes when in version preview (read-only) mode + ;; or when the user does not have edition permission. + (when (and (:can-edit permissions) + (not (get-in state [:workspace-global :read-only?]))) (->> (rp/cmd! :update-file params) (rx/mapcat (fn [{:keys [revn lagged] :as response}] (log/debug :hint "changes persisted" :commit-id (dm/str commit-id) :lagged (count lagged)) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 412f1a7e3d..403375d54d 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -231,6 +231,22 @@ (assoc :thumbnails thumbnails) (update :files assoc file-id file))))) +(defn apply-snapshot-data + "Swap the file data in app state with the provided snapshot-file + response. Used by the version preview feature to show historical + file content without modifying the database. + + Also sets workspace-file-version-id to snapshot-id so the WASM + viewport detects the change and reloads shapes into its internal + buffer (it only re-initialises when this value changes)." + [file-id snapshot-id snapshot-file] + (ptk/reify ::apply-snapshot-data + ptk/UpdateEvent + (update [_ state] + (-> state + (update :files assoc file-id snapshot-file) + (assoc :workspace-file-version-id snapshot-id))))) + (defn zoom-to-frame [] (ptk/reify ::zoom-to-frame @@ -508,7 +524,8 @@ :workspace-persistence :workspace-presence :workspace-tokens - :workspace-undo) + :workspace-undo + :workspace-versions) (update :workspace-global dissoc :read-only?) (assoc-in [:workspace-global :options-mode] :design) (update :files d/update-vals #(dissoc % :data)))) diff --git a/frontend/src/app/main/data/workspace/versions.cljs b/frontend/src/app/main/data/workspace/versions.cljs index 85630cfccb..9067483be3 100644 --- a/frontend/src/app/main/data/workspace/versions.cljs +++ b/frontend/src/app/main/data/workspace/versions.cljs @@ -8,8 +8,10 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.logging :as log] [app.common.schema :as sm] [app.common.time :as ct] + [app.common.uuid :as uuid] [app.main.data.event :as ev] [app.main.data.notifications :as ntf] [app.main.data.persistence :as dwp] @@ -24,7 +26,8 @@ (defonce default-state {:status :loading :data nil - :editing nil}) + :editing nil + :preview-id nil}) (declare fetch-versions) @@ -193,6 +196,74 @@ (->> (rp/cmd! :unlock-file-snapshot {:id id}) (rx/map fetch-versions))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; PREVIEW VERSION EVENTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn preview-version + "Load a snapshot into the workspace for read-only preview without + modifying any database state. Sets a read-only flag so no changes + are persisted while previewing." + [id] + (assert (uuid? id) "expected valid uuid for `id`") + (ptk/reify ::preview-version + ptk/UpdateEvent + (update [_ state] + (-> state + (update :workspace-versions assoc :preview-id id) + (update :workspace-global assoc :read-only? true))) + + ptk/WatchEvent + (watch [_ state _] + (let [file-id (:current-file-id state) + page-id (:current-page-id state) + features (get-in state [:files file-id :features])] + (->> (rp/cmd! :get-file-snapshot + {:file-id file-id + :id id + :features features}) + (rx/mapcat + (fn [snapshot-file] + (rx/of + ;; Swap the file data in state with snapshot content. + ;; Passing id sets workspace-file-version-id, which + ;; causes the WASM viewport to reload its shape buffer. + (dw/apply-snapshot-data file-id id snapshot-file) + ;; Re-initialize the page to rebuild its search index + ;; and page-local state with the new snapshot objects. + (dw/initialize-page file-id page-id)))) + (rx/catch (fn [err] + ;; On error roll back the read-only flag so the + ;; user is not stuck in a broken preview state. + (log/error :hint "failed to load snapshot" :cause err :file-id file-id :snapshot-id id) + (rx/of (update-versions-state {:preview-id nil}) + (ptk/reify ::clear-preview-read-only + ptk/UpdateEvent + (update [_ state] + (update state :workspace-global dissoc :read-only?))))))))))) + +(defn exit-preview + "Exit version preview mode and reload the live file data." + [] + (ptk/reify ::exit-preview + ptk/UpdateEvent + (update [_ state] + (-> state + (update :workspace-versions dissoc :preview-id) + (update :workspace-global dissoc :read-only?) + ;; A fresh UUID triggers the WASM viewport to reload its shape + ;; buffer with the live file objects once initialize-workspace + ;; completes. + (assoc :workspace-file-version-id (uuid/next)))) + + ptk/WatchEvent + (watch [_ state _] + (let [team-id (:current-team-id state) + file-id (:current-file-id state)] + ;; Full workspace re-init reloads the live file from the server, + ;; clearing all snapshot data and restoring normal edit mode. + (rx/of (dw/initialize-workspace team-id file-id)))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; PLUGINS SPECIFIC EVENTS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index c0e600b835..620e1922f1 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -7,12 +7,15 @@ (ns app.main.ui.workspace (:require-macros [app.main.style :as stl]) (:require + [app.common.data :as d] [app.common.data.macros :as dm] [app.main.data.common :as dcm] [app.main.data.helpers :as dsh] + [app.main.data.notifications :as ntf] [app.main.data.persistence :as dps] [app.main.data.plugins :as dpl] [app.main.data.workspace :as dw] + [app.main.data.workspace.versions :as dwv] [app.main.features :as features] [app.main.refs :as refs] [app.main.router :as-alias rt] @@ -131,6 +134,54 @@ :overlay true :file-loading true}]) +(def ^:private workspace-versions-ref + (l/derived :workspace-versions st/state)) + +(mf/defc preview-banner* + "Banner shown at the top of the workspace when the user is previewing + a saved version. Provides Exit and Restore actions." + {::mf/private true} + [] + (let [versions-state (mf/deref workspace-versions-ref) + preview-id (:preview-id versions-state) + preview-entry (when preview-id + (d/seek #(= (:id %) preview-id) (:data versions-state))) + preview-label (or (:label preview-entry) + (tr "workspace.versions.preview.unnamed")) + + on-exit + (mf/use-fn + (fn [] + (st/emit! (dwv/exit-preview)))) + + on-restore + (mf/use-fn + (mf/deps preview-id) + (fn [] + (when preview-id + (st/emit! + (ntf/dialog + :content (tr "workspace.versions.restore-warning") + :controls :inline-actions + :cancel {:label (tr "workspace.updates.dismiss") + :callback #(st/emit! (ntf/hide))} + :accept {:label (tr "labels.restore") + :callback #(st/emit! (ntf/hide) + (dwv/exit-preview) + (dwv/restore-version preview-id :version))} + :tag :restore-dialog)))))] + + (when preview-id + [:div {:class (stl/css :preview-banner)} + [:span {:class (stl/css :preview-banner-label)} + (tr "workspace.versions.preview.banner-label" preview-label)] + [:button {:class (stl/css :preview-banner-exit-btn) + :on-click on-exit} + (tr "workspace.versions.preview.exit")] + [:button {:class (stl/css :preview-banner-restore-btn) + :on-click on-restore} + (tr "labels.restore")]]))) + (defn- make-team-ref [team-id] (l/derived (fn [state] @@ -268,6 +319,7 @@ :touch-action "none" :position "relative"}} [:> context-menu*] + [:> preview-banner*] (when (and file-loaded? page-id) [:> workspace-inner* {:page-id page-id diff --git a/frontend/src/app/main/ui/workspace.scss b/frontend/src/app/main/ui/workspace.scss index 5cd617bab4..b0cceb2c87 100644 --- a/frontend/src/app/main/ui/workspace.scss +++ b/frontend/src/app/main/ui/workspace.scss @@ -54,3 +54,53 @@ grid-template-columns: deprecated.$s-20 1fr; flex: 1; } + +.preview-banner { + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + z-index: var(--z-index-modal); + background: var(--color-accent-primary); + color: var(--color-background-primary); + display: flex; + align-items: center; + gap: deprecated.$s-12; + padding: deprecated.$s-8 deprecated.$s-16; + border-radius: 0 0 deprecated.$s-8 deprecated.$s-8; + white-space: nowrap; + box-shadow: 0 deprecated.$s-4 deprecated.$s-16 rgb(0 0 0 / 0.25); +} + +.preview-banner-label { + font-size: deprecated.$fs-12; + font-weight: 600; +} + +.preview-banner-exit-btn, +.preview-banner-restore-btn { + border: none; + border-radius: deprecated.$s-4; + cursor: pointer; + padding: deprecated.$s-4 deprecated.$s-8; + font-size: deprecated.$fs-11; +} + +.preview-banner-exit-btn { + background: rgb(255 255 255 / 0.2); + color: inherit; + + &:hover { + background: rgb(255 255 255 / 0.35); + } +} + +.preview-banner-restore-btn { + background: rgb(255 255 255 / 0.9); + color: var(--color-accent-primary); + font-weight: 600; + + &:hover { + background: rgb(255 255 255); + } +} diff --git a/frontend/src/app/main/ui/workspace/sidebar/versions.cljs b/frontend/src/app/main/ui/workspace/sidebar/versions.cljs index 37edf428cd..e1cc1f454f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/versions.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/versions.cljs @@ -90,7 +90,7 @@ (mf/defc version-entry* {::mf/private true} - [{:keys [entry current-profile on-restore on-delete on-rename on-lock on-unlock on-edit on-cancel-edit is-editing]}] + [{:keys [entry current-profile on-preview on-restore on-delete on-rename on-lock on-unlock on-edit on-cancel-edit is-editing]}] (let [show-menu? (mf/use-state false) profiles (mf/deref refs/profiles) @@ -108,6 +108,13 @@ (fn [event] (on-edit (:id entry) event))) + on-preview + (mf/use-fn + (mf/deps entry on-preview) + (fn [] + (when (fn? on-preview) + (on-preview (:id entry))))) + on-restore (mf/use-fn (mf/deps entry on-restore) @@ -191,6 +198,11 @@ :on-click on-edit} (tr "labels.rename")]) + [:li {:class (stl/css :menu-option) + :role "button" + :on-click on-preview} + (tr "workspace.versions.button.preview")] + [:li {:class (stl/css :menu-option) :role "button" :on-click on-restore} @@ -216,7 +228,7 @@ (tr "labels.delete")])])]])) (mf/defc snapshot-entry* - [{:keys [entry on-pin-snapshot on-restore-snapshot]}] + [{:keys [entry on-pin-snapshot on-restore-snapshot on-preview-snapshot]}] (let [open-menu* (mf/use-state nil) entry-ref (mf/use-ref nil) @@ -243,6 +255,17 @@ (when (fn? on-restore-snapshot) (on-restore-snapshot id event))))) + on-preview-snapshot + (mf/use-fn + (mf/deps on-preview-snapshot) + (fn [event] + (let [node (dom/get-current-target event) + id (-> node + (dom/get-data "id") + (uuid/parse))] + (when (fn? on-preview-snapshot) + (on-preview-snapshot id event))))) + on-open-snapshot-menu (mf/use-fn (mf/deps entry) @@ -266,6 +289,11 @@ :on-close #(reset! open-menu* nil)} [:ul {:class (stl/css :version-options-dropdown) :style {"--offset" (dm/str (:offset @open-menu*) "px")}} + [:li {:class (stl/css :menu-option) + :role "button" + :data-id (dm/str (:snapshot @open-menu*)) + :on-click on-preview-snapshot} + (tr "workspace.versions.button.preview")] [:li {:class (stl/css :menu-option) :role "button" :data-id (dm/str (:snapshot @open-menu*)) @@ -321,6 +349,16 @@ (fn [id label] (st/emit! (dwv/rename-version id label)))) + on-preview-version + (mf/use-fn + (fn [id] + (st/emit! (dwv/preview-version id)))) + + on-preview-snapshot + (mf/use-fn + (fn [id _event] + (st/emit! (dwv/preview-version id)))) + on-restore-version (mf/use-fn (fn [id _event] @@ -415,6 +453,7 @@ :on-edit on-edit-version :on-cancel-edit on-cancel-version-edition :on-rename on-rename-version + :on-preview on-preview-version :on-restore on-restore-version :on-delete on-delete-version :on-lock on-lock-version @@ -423,6 +462,7 @@ :snapshot [:> snapshot-entry* {:key (:index entry) :entry entry + :on-preview-snapshot on-preview-snapshot :on-restore-snapshot on-restore-snapshot :on-pin-snapshot on-pin-version}] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 45b1efd225..794f57f21a 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -8831,6 +8831,9 @@ msgstr "Autosaved %s" msgid "workspace.versions.button.pin" msgstr "Pin version" +msgid "workspace.versions.button.preview" +msgstr "Preview version" + #: src/app/main/ui/workspace/sidebar/versions.cljs:273 msgid "workspace.versions.button.restore" msgstr "Restore version" @@ -8875,6 +8878,15 @@ msgstr "This version is locked by %s and cannot be modified" msgid "workspace.versions.locked-by-you" msgstr "This version is locked by you" +msgid "workspace.versions.preview.banner-label" +msgstr "Previewing: %s" + +msgid "workspace.versions.preview.exit" +msgstr "Exit preview" + +msgid "workspace.versions.preview.unnamed" +msgstr "Unnamed version" + #: src/app/main/ui/workspace/sidebar/versions.cljs:83 msgid "workspace.versions.restore-warning" msgstr "Do you want to restore this version?"