diff --git a/src/main/frontend/components/icon.cljs b/src/main/frontend/components/icon.cljs index 2b1fca85af..aec7c32d68 100644 --- a/src/main/frontend/components/icon.cljs +++ b/src/main/frontend/components/icon.cljs @@ -103,6 +103,21 @@ "Common image extensions to try when asset-type is unknown" ["png" "jpg" "jpeg" "gif" "webp" "svg" "bmp" "ico"]) +(defn- worker-not-ready-err? + "True when an error from `entity asset-uuid) - ;; Avatar with image - use async loading component + (if (and (string? asset-uuid) (not (string/blank? asset-uuid))) + ;; Avatar with image — let avatar-image-cp resolve via the + ;; filesystem loader. Don't gate on a renderer-side + ;; `db/entity` check: assets hydrate lazily, so a direct + ;; navigation can find the entity missing while the file + ;; is on disk. The loader retries on transient failures + ;; and shui/avatar-fallback (initials) shows underneath + ;; until the image lands; if the asset is truly gone the + ;; initials persist as the natural error state. (avatar-image-cp asset-uuid asset-type avatar-data opts) ;; Text-only avatar (let [size (or (:size opts) 20) @@ -473,10 +497,16 @@ (assoc :color explicit-color))} display-text))))) - ;; Image with asset - use image icon component. Skip if asset-uuid - ;; is dangling (entity deleted) so we don't crash during async load. + ;; Image with asset — let image-icon-cp resolve via the filesystem + ;; loader. Don't gate on a renderer-side `db/entity` check: + ;; assets hydrate lazily into the renderer DataScript, so a + ;; direct navigation to a page whose icon points at an asset + ;; can find the entity missing while the file is on disk. + ;; image-icon-cp retries the load on transient failures and + ;; shows a `photo-off` icon if the file is truly gone. (and (map? normalized) (= :image (:type normalized)) - (asset-uuid->entity (get-in normalized [:data :asset-uuid]))) + (let [u (get-in normalized [:data :asset-uuid])] + (and (string? u) (not (string/blank? u))))) (let [asset-uuid (get-in normalized [:data :asset-uuid]) asset-type (get-in normalized [:data :asset-type])] (image-icon-cp asset-uuid asset-type opts)) @@ -791,9 +821,14 @@ "True when icon-value would produce a visible element via `icon`. For :icon type this includes verifying that the underlying Tabler component actually exists, which catches stored values whose :id no longer resolves (e.g. data saved from a - stale picker entry before the tabler-icons filter was added). For :image/:avatar - with an asset-uuid, the asset entity must still exist — otherwise the picker - treats the icon as unrenderable (and for avatars falls back to the text value)." + stale picker entry before the tabler-icons filter was added). + + For :image/:avatar we trust the presence of an asset-uuid string rather than + probing the renderer-side entity: assets hydrate lazily, so a synchronous + `db/entity` check races with cold loads (and would also flap the page-title + 'Add icon' button while the entity is still being fetched). The actual + render path resolves via the filesystem loader and surfaces error states + on real failure." [icon-value] (boolean (when-let [normalized (normalize-icon icon-value)] @@ -804,33 +839,38 @@ (and (exists? js/tablerIcons) (some? (gobj/get js/tablerIcons (str "Icon" (csk/->PascalCase v)))))) :text (not (string/blank? (get-in normalized [:data :value]))) - :avatar (or (some? (asset-uuid->entity (get-in normalized [:data :asset-uuid]))) - (not (string/blank? (get-in normalized [:data :value])))) - :image (some? (asset-uuid->entity (get-in normalized [:data :asset-uuid]))) + :avatar (let [u (get-in normalized [:data :asset-uuid])] + (or (and (string? u) (not (string/blank? u))) + (not (string/blank? (get-in normalized [:data :value]))))) + :image (let [u (get-in normalized [:data :asset-uuid])] + (and (string? u) (not (string/blank? u)))) false)))) (defn get-image-assets "Get image assets from frontend Datascript (fast, but may be empty on cold start)" [] (let [image-extensions (set (map name config/image-formats)) - results (db-utils/q '[:find ?uuid ?type ?title ?updated ?checksum + results (db-utils/q '[:find ?uuid ?type ?title ?updated ?checksum ?source-url :where [?e :logseq.property.asset/type ?type] [?e :logseq.property.asset/checksum ?checksum] [?e :block/uuid ?uuid] [(get-else $ ?e :block/title "") ?title] - [(get-else $ ?e :block/updated-at 0) ?updated]])] + [(get-else $ ?e :block/updated-at 0) ?updated] + [(get-else $ ?e :logseq.property.asset/source-url "") ?source-url]])] (->> results - (filter (fn [[_uuid type _title _updated _checksum]] + (filter (fn [[_uuid type _title _updated _checksum _source-url]] (contains? image-extensions (some-> type string/lower-case)))) - (sort-by (fn [[_uuid _type _title updated _checksum]] updated) >) + (sort-by (fn [[_uuid _type _title updated _checksum _source-url]] updated) >) ;; Deduplicate by checksum — keep the most recently updated entry - (medley/distinct-by (fn [[_uuid _type _title _updated checksum]] checksum)) - (map (fn [[uuid type title _updated checksum]] - {:block/uuid uuid - :block/title (if (string/blank? title) (str uuid) title) - :logseq.property.asset/type type - :logseq.property.asset/checksum checksum}))))) + (medley/distinct-by (fn [[_uuid _type _title _updated checksum _source-url]] checksum)) + (map (fn [[uuid type title _updated checksum source-url]] + (cond-> {:block/uuid uuid + :block/title (if (string/blank? title) (str uuid) title) + :logseq.property.asset/type type + :logseq.property.asset/checksum checksum} + (not (string/blank? source-url)) + (assoc :logseq.property.asset/source-url source-url))))))) (defn properties + "Build the property map for source-meta keys to write on the asset entity. + Returns nil when no meta was supplied." + [{:keys [source-url source-name license attribution]}] + (let [pairs (cond-> {} + (and source-url (not (string/blank? source-url))) + (assoc :logseq.property.asset/source-url source-url) + (and source-name (not (string/blank? source-name))) + (assoc :logseq.property.asset/source-name source-name) + (and license (not (string/blank? license))) + (assoc :logseq.property.asset/license license) + (and attribution (not (string/blank? attribution))) + (assoc :logseq.property.asset/attribution attribution))] + (when (seq pairs) pairs))) + (defn save-image-asset! "Save an image file as an asset using api-insert-new-block! approach. Creates the asset as a child of the Asset class page (like tag tables do), - avoiding journal entries." - [repo ^js file] - (p/let [file-name (node-path/basename (.-name file)) - file-name-without-ext* (db-asset/asset-name->title file-name) - file-name-without-ext (if (= file-name-without-ext* "image") - (date/get-date-time-string-2) - file-name-without-ext*) - checksum (assets-handler/get-file-checksum file) - existing-asset (some->> checksum (db-async/type file-name) - asset-class (db/entity :logseq.class/Asset) - block-id (ldb/new-block-id)] - (js/console.log "[DEBUG save-image-asset!] creating" - (pr-str {:block-id block-id :ext ext :size size - :repo-dir repo-dir :asset-dir asset-dir-rpath - :write-path (str asset-dir-rpath "/" block-id "." ext)})) - (when (and ext asset-class) - ;; Write file to disk - (p/let [_ (let [file-path (str block-id "." ext) - file-rpath (str asset-dir-rpath "/" file-path)] - (js/console.log "[DEBUG save-image-asset!] writing file" file-rpath) - (write-asset-file! repo repo-dir file file-rpath)) - _ (js/console.log "[DEBUG save-image-asset!] file written OK, creating block...") - ;; Create block using api-insert-new-block! (same approach as tag tables) - block (editor-handler/api-insert-new-block! - file-name-without-ext - {:page (:block/uuid asset-class) - :custom-uuid block-id - :properties {:block/tags (:db/id asset-class) - :logseq.property.asset/type ext - :logseq.property.asset/checksum checksum - :logseq.property.asset/size size} - :edit-block? false})] - (let [entity (db/entity [:block/uuid (:block/uuid block)])] - (js/console.log "[DEBUG save-image-asset!] done" - (pr-str {:block-uuid (str (:block/uuid block)) - :entity-found? (some? entity) - :entity-type (:logseq.property.asset/type entity)})) - entity))))))) + avoiding journal entries. + Optional source-meta map: {:source-url, :source-name, :license, :attribution} + is persisted as additional asset properties when provided." + ([repo file] (save-image-asset! repo file nil)) + ([repo ^js file source-meta] + (p/let [file-name (node-path/basename (.-name file)) + file-name-without-ext* (db-asset/asset-name->title file-name) + file-name-without-ext (if (= file-name-without-ext* "image") + (date/get-date-time-string-2) + file-name-without-ext*) + checksum (assets-handler/get-file-checksum file) + existing-asset (some->> checksum (db-async/type file-name) + asset-class (db/entity :logseq.class/Asset) + block-id (ldb/new-block-id) + extra-props (source-meta->properties source-meta)] + (js/console.log "[DEBUG save-image-asset!] creating" + (pr-str {:block-id block-id :ext ext :size size + :repo-dir repo-dir :asset-dir asset-dir-rpath + :write-path (str asset-dir-rpath "/" block-id "." ext) + :source-meta? (some? extra-props)})) + (when (and ext asset-class) + ;; Write file to disk + (p/let [_ (let [file-path (str block-id "." ext) + file-rpath (str asset-dir-rpath "/" file-path)] + (js/console.log "[DEBUG save-image-asset!] writing file" file-rpath) + (write-asset-file! repo repo-dir file file-rpath)) + _ (js/console.log "[DEBUG save-image-asset!] file written OK, creating block...") + ;; Create block using api-insert-new-block! (same approach as tag tables) + block (editor-handler/api-insert-new-block! + file-name-without-ext + {:page (:block/uuid asset-class) + :custom-uuid block-id + :properties (merge {:block/tags (:db/id asset-class) + :logseq.property.asset/type ext + :logseq.property.asset/checksum checksum + :logseq.property.asset/size size} + extra-props) + :edit-block? false})] + (let [entity (db/entity [:block/uuid (:block/uuid block)])] + (js/console.log "[DEBUG save-image-asset!] done" + (pr-str {:block-uuid (str (:block/uuid block)) + :entity-found? (some? entity) + :entity-type (:logseq.property.asset/type entity)})) + entity)))))))) (defn size max-url-asset-size)) - (throw (ex-info "File too large" {:kind :too-large :size size :max max-url-asset-size}))) - ;; Create a File object from the ArrayBuffer - (let [ext (or (content-type->extension content-type) "png") - filename (str asset-name "." ext) - blob (js/Blob. #js [data] #js {:type content-type}) - file (js/File. #js [blob] filename #js {:type content-type})] - ;; Delegate to existing save function - (save-image-asset! repo file)))) + (and size (> size max-url-asset-size)) + (throw (ex-info "File too large" {:kind :too-large :size size :max max-url-asset-size}))) + ;; Create a File object from the ArrayBuffer + (let [ext (or (content-type->extension content-type) "png") + filename (str asset-name "." ext) + blob (js/Blob. #js [data] #js {:type content-type}) + file (js/File. #js [blob] filename #js {:type content-type})] + ;; Delegate to existing save function + (save-image-asset! repo file source-meta))))) ;; ============================================================================ ;; Web Image Search (Wikipedia Commons) ;; ============================================================================ -(def ^:private web-image-skip-confirm-key "ls-web-image-skip-confirm") +;; The legacy "Always add without asking" preference is meaningless now that +;; clicks commit directly. Drop the lingering localStorage key once per app +;; load so it doesn't sit around as a footgun for future debugging. +(defonce ^:private web-image-skip-confirm-cleanup + (try (storage/remove "ls-web-image-skip-confirm") (catch :default _ nil))) -(defn- get-web-image-skip-confirm - "Get user preference for skipping web image confirmation" - [] - (boolean (storage/get web-image-skip-confirm-key))) +(def ^:private license-name->spdx + "Static map of Commons LicenseShortName values to SPDX identifiers." + {"CC0" "CC0-1.0" + "CC0 1.0" "CC0-1.0" + "CC BY 1.0" "CC-BY-1.0" + "CC BY 2.0" "CC-BY-2.0" + "CC BY 2.5" "CC-BY-2.5" + "CC BY 3.0" "CC-BY-3.0" + "CC BY 4.0" "CC-BY-4.0" + "CC BY-SA 1.0" "CC-BY-SA-1.0" + "CC BY-SA 2.0" "CC-BY-SA-2.0" + "CC BY-SA 2.5" "CC-BY-SA-2.5" + "CC BY-SA 3.0" "CC-BY-SA-3.0" + "CC BY-SA 4.0" "CC-BY-SA-4.0" + "CC BY-NC 3.0" "CC-BY-NC-3.0" + "CC BY-NC 4.0" "CC-BY-NC-4.0" + "CC BY-NC-SA 3.0" "CC-BY-NC-SA-3.0" + "CC BY-NC-SA 4.0" "CC-BY-NC-SA-4.0" + "GFDL" "GFDL-1.3-or-later" + "Public domain" "Public-Domain"}) -(defn- set-web-image-skip-confirm! - "Set user preference for skipping web image confirmation" - [skip?] - (storage/set web-image-skip-confirm-key skip?)) +(defn- license->spdx + "Normalize a Commons LicenseShortName to an SPDX-style identifier. + Falls back to space→dash replacement for unknown values." + [license] + (when (and license (not (string/blank? license))) + (or (license-name->spdx license) + ;; Fallback: trim, replace internal spaces with dashes + (-> license string/trim (string/replace #"\s+" "-"))))) (defn- license->description "Convert license code to human-readable description" @@ -1283,9 +1370,55 @@ :else "Check license terms")))) +(defn- strip-html + "Strip HTML tags and decode common entities from an extmetadata string. + Wikipedia Commons returns Artist/Credit fields as wikitext-rendered HTML." + [s] + (when (and s (string? s)) + (-> s + (string/replace #"<[^>]+>" "") + (string/replace #"&" "&") + (string/replace #"<" "<") + (string/replace #">" ">") + (string/replace #""" "\"") + (string/replace #"'" "'") + (string/replace #" " " ") + string/trim))) + +(defn- source-name-for + "Map source keyword to display name." + [source] + (case source + :wikipedia "Wikipedia" + :wikipedia-commons "Wikimedia Commons" + "Web")) + +(defn- build-attribution + "Pre-render a TASL credit string. Returns nil when there's nothing useful + to attribute (e.g. Wikipedia PageImages with no metadata)." + [{:keys [title author source license]}] + (let [source-display (source-name-for source) + has-title? (and title (not (string/blank? title)))] + (cond + (and has-title? author license) + (str title " by " author " (" source-display ", " license ")") + + (and has-title? author) + (str title " by " author " (" source-display ")") + + (and has-title? license) + (str title " (" source-display ", " license ")") + + has-title? + (str title " (" source-display ")") + + :else nil))) + (defn- data @@ -1311,19 +1444,25 @@ js->clj) page (first (vals pages))] (if-let [original (get page "original")] - (resolve {:url (get original "source") - :thumb-url (get-in page ["thumbnail" "source"]) - :title query - :source :wikipedia - :license nil ; PageImages doesn't return license - :license-desc nil}) - (resolve nil)))))) + (resolve {:status :ok + :image {:url (get original "source") + :thumb-url (get-in page ["thumbnail" "source"]) + :title query + :source :wikipedia + :license nil ; PageImages doesn't return license + :license-desc nil + :author nil + :source-url (str "https://en.wikipedia.org/wiki/" + (js/encodeURIComponent query))}}) + (resolve {:status :ok :image nil})))))) (.catch (fn [_err] - (resolve nil)))))))) + (resolve {:status :error})))))))) (defn- data @@ -1353,32 +1492,48 @@ js->clj vals)] (resolve - (->> pages - (map (fn [page] - (let [imageinfo (first (get page "imageinfo")) - license (get-in imageinfo ["extmetadata" "LicenseShortName" "value"]) - title (-> (get page "title" "") - (string/replace #"^File:" "") - (string/replace #"\.[^.]+$" ""))] - {:url (get imageinfo "url") - :thumb-url (get imageinfo "thumburl") - :title title - :source :wikipedia-commons - :license license - :license-desc (license->description license)}))) - (filter :url) - vec)))))) + {:status :ok + :images + (->> pages + (map (fn [page] + (let [imageinfo (first (get page "imageinfo")) + ext (get imageinfo "extmetadata") + license-raw (get-in ext ["LicenseShortName" "value"]) + license-spdx (license->spdx license-raw) + artist-html (or (get-in ext ["Artist" "value"]) + (get-in ext ["Credit" "value"])) + author (strip-html artist-html) + title (-> (get page "title" "") + (string/replace #"^File:" "") + (string/replace #"\.[^.]+$" ""))] + {:url (get imageinfo "url") + :thumb-url (get imageinfo "thumburl") + :title title + :source :wikipedia-commons + :license license-spdx + :license-desc (license->description license-raw) + :author (when-not (string/blank? author) author) + :source-url (get imageinfo "descriptionurl")}))) + (filter :url) + vec)}))))) (.catch (fn [_err] - (resolve [])))))))) + (resolve {:status :error})))))))) (defn- on-tile-hover! (apply [text-item])))} - [:div.custom-tab-item-preview + [:div.custom-tab-item-preview {:aria-hidden "true"} (icon text-item {:size 32})] [:span.custom-tab-item-label "Text"]]) @@ -1964,7 +2120,7 @@ (reset! *asset-picker-initial-mode :avatar) (reset! *view :asset-picker)) :on-mouse-over (fn [] (some-> on-tile-hover! (apply [avatar-item])))} - [:div.custom-tab-item-preview + [:div.custom-tab-item-preview {:aria-hidden "true"} (icon avatar-item {:size 32})] [:span.custom-tab-item-label "Avatar"]]) @@ -1981,7 +2137,7 @@ (reset! *asset-picker-initial-mode :image) (reset! *view :asset-picker)) :on-mouse-over (fn [] (some-> on-tile-hover! (apply [image-placeholder-item])))} - [:div.custom-tab-item-preview + [:div.custom-tab-item-preview {:aria-hidden "true"} [:span.image-tile-placeholder {:style {:width 32 :height 32 @@ -2071,38 +2227,6 @@ :else [:div.bg-gray-04.animate-pulse])])) -(rum/defc web-image-item - "Renders a single web image thumbnail with external indicator and tooltip. - Shows license description on hover (simplified for glanceability)." - [{:keys [url thumb-url title license license-desc source] :as web-image} - {:keys [on-click avatar-mode? item-id highlighted? ghost-highlighted?]}] - (let [display-url (or thumb-url url)] - (shui/tooltip-provider - {:delay-duration 300} - (shui/tooltip - (shui/tooltip-trigger - {:as-child true} - [:button.web-image-item - {:data-item-id item-id - :class (util/classnames [{:avatar-mode avatar-mode? - :is-highlighted highlighted? - :is-ghost-highlighted ghost-highlighted?}]) - :on-click (fn [e] (on-click e web-image))} - (if display-url - [:img {:src display-url :loading "lazy"}] - [:div.bg-gray-04.animate-pulse]) - ;; External indicator badge - [:div.external-badge - (shui/tabler-icon "world" {:size 10})]]) - (shui/tooltip-content - {:side "top" :align "center" :class "web-image-tooltip-content" - :show-arrow true :arrow-class-name "web-image-tooltip-arrow"} - [:div.web-image-tooltip {:style {:text-align "center"}} - [:div.font-medium (or title "Web image")] - ;; Show license description only (more glanceable than code) - (when license-desc - [:div.text-xs {:style {:color "var(--lx-gray-11)"}} license-desc])]))))) - (defn- should-use-blur-bg? "Determine if blurred background should be used based on image format. SVGs and potentially transparent formats should not use blur." @@ -2128,40 +2252,34 @@ {:on-click on-close} (shui/tabler-icon "x" {:size 16})]]) -(rum/defcs web-image-confirm-pane < rum/reactive - (rum/local false ::skip-confirm) - (rum/local false ::saving?) - [state {:keys [web-image on-close on-save avatar-context]}] - (let [*skip-confirm (::skip-confirm state) - *saving? (::saving? state) - skip-confirm? @*skip-confirm - saving? @*saving? - {:keys [url thumb-url title license license-desc source]} web-image - avatar-mode? (some? avatar-context) - ;; Build source string with optional license code - source-text (str (case source +(rum/defc web-image-card-content + "Pure-render preview block: blurred-bg + sharp overlay + maximize button + + title + source · license + license badge. Used as the tooltip content for + web-image tiles. No buttons, no checkbox — clicking the tile commits." + [{:keys [url thumb-url title license license-desc source]}] + (let [source-text (str (case source :wikipedia "From: Wikipedia" :wikipedia-commons "From: Wikipedia Commons" "From: Web") (when license (str " · " license))) - ;; Determine if we should use blur based on format display-url (or thumb-url url) use-blur? (should-use-blur-bg? display-url)] - [:div.web-image-confirm-pane - ;; Preview image with blur background layer + [:div.web-image-card [:div.preview-image - ;; Blurred background image (only for opaque formats) (when use-blur? - [:img.blur-bg {:src display-url}]) - ;; Main image with object-fit: contain + [:img.blur-bg {:src display-url :alt ""}]) [:img.preview-img {:src display-url :alt (str title " from " (case source :wikipedia "Wikipedia" :wikipedia-commons "Wikipedia Commons" "Web"))}] - ;; Maximize button - opens full image view [:button.maximize-btn - {:on-click (fn [e] + {:on-pointer-down (fn [e] + ;; Stop the click bubbling to the underlying tile. + ;; Without this, opening the maximize popover would + ;; also commit the image to the page-icon. + (.stopPropagation e)) + :on-click (fn [e] (.stopPropagation e) (shui/popup-show! (.-target e) @@ -2174,52 +2292,74 @@ :content-props {:class "full-image-view-popup" :sideOffset 8}}))} (shui/tabler-icon "arrows-maximize" {:size 16})]] - - ;; Content wrapper - adds padding back for non-preview content [:div.content-wrapper - ;; Image info [:div.image-info [:div.image-title (or title "Web image")] [:div.image-source {:style {:color "var(--lx-gray-11)"}} source-text] - ;; License description badge (when license-desc - [:div.license-badge license-desc])] + [:div.license-badge license-desc])]]])) - ;; Skip confirmation checkbox - [:label.skip-confirm-checkbox - (shui/checkbox - {:checked skip-confirm? - :on-checked-change #(reset! *skip-confirm %)}) - [:span "Always add without asking"]] - - ;; Action buttons - [:div.pane-footer - (shui/button - {:variant :outline - :size :sm - :on-click on-close} - "Cancel") - (shui/button - {:variant :default - :size :sm - :disabled saving? - :on-click (fn [] - (reset! *saving? true) - ;; Save preference if checkbox was checked - (when skip-confirm? - (set-web-image-skip-confirm! true)) - ;; Delegate saving to parent - (on-save web-image))} - (if saving? - [:span.flex.items-center.gap-1 - [:span.animate-spin (shui/tabler-icon "loader-2" {:size 14})] - "Adding..."] - "Add to assets"))]]])) +(rum/defc web-image-item + "Renders a single web image thumbnail with external indicator and rich + hover card. Hover (or keyboard focus) reveals title + source + license + + larger preview + maximize affordance. Click commits." + [{:keys [url thumb-url title license license-desc source] :as web-image} + {:keys [on-click avatar-mode? item-id highlighted? ghost-highlighted?]}] + (let [display-url (or thumb-url url) + ;; Carry full info through the trigger button's aria-label so screen- + ;; reader users get title + license + source without entering the card. + aria (str "Add image: " + (or title "Web image") + (when license (str ", " license)) + (when license-desc + (when-not license (str ", " license-desc))) + ", from " + (case source + :wikipedia "Wikipedia" + :wikipedia-commons "Wikimedia Commons" + "the web"))] + (shui/tooltip-provider + ;; 400ms first-hover delay matches NN/g rich-tooltip guidance and the + ;; existing color-picker hover timings; 100ms on subsequent hovers gives + ;; instant swap when arrowing/hovering between sibling tiles. + {:delay-duration 400 :skip-delay-duration 100} + (shui/tooltip + (shui/tooltip-trigger + {:as-child true} + [:button.web-image-item + {:data-item-id item-id + :aria-label aria + :class (util/classnames [{:avatar-mode avatar-mode? + :is-highlighted highlighted? + :is-ghost-highlighted ghost-highlighted?}]) + :on-click (fn [e] (on-click e web-image))} + (if display-url + [:img {:src display-url :loading "lazy" :alt ""}] + [:div.bg-gray-04.animate-pulse]) + ;; External indicator badge + [:div.external-badge + (shui/tabler-icon "world" {:size 10})] + ;; Touch-only license byline. CSS toggles via @media (hover: none) so + ;; touch users see attribution without a hover affordance. + (when (or license license-desc) + [:div.touch-byline + (str (case source + :wikipedia-commons "Commons" + :wikipedia "Wikipedia" + "Web") + (when license (str " · " license)))])]) + (shui/tooltip-content + {:side "top" :align "center" :class "web-image-card-popup" + :side-offset 8 :collision-padding 8} + (web-image-card-content web-image)))))) (rum/defcs web-images-section < rum/reactive (rum/local nil ::images) (rum/local true ::loading?) (rum/local nil ::current-query) + ;; True when the latest fetch reported a network error (both Wikipedia and + ;; Commons calls failed). Cleared when a fresh fetch begins. + (rum/local false ::search-error?) ;; Generation counter — responses whose id no longer matches are stale ;; (e.g. a "do" prefix response arriving after "donald trump" was issued) ;; and must not overwrite the current images. @@ -2230,21 +2370,24 @@ *loading? (::loading? state) *current-query (::current-query state) *request-id (::request-id state) - publish! (fn [results] + *search-error? (::search-error? state) + publish! (fn [results error?] (reset! *images results) + (reset! *search-error? (boolean error?)) (when *result-sink (reset! *result-sink (vec results))))] (when-not (string/blank? query) (reset! *current-query query) (reset! *loading? true) + (reset! *search-error? false) (let [my-id (swap! *request-id inc)] (-> ( ( ( {} + source-url (assoc :source-url source-url) + source-name (assoc :source-name source-name) + license (assoc :license license) + attribution (assoc :attribution attribution)) + ;; Capture save-id for race protection. A later click supersedes + ;; this one's on-chosen so the icon reflects the LAST pick, even + ;; if saves resolve out of order. + my-save-id (swap! *web-image-save-id inc)] + (-> (> assets + (keep :logseq.property.asset/source-url) + (remove string/blank?) + set) ;; Keyboard navigation: flat-items + sections mirror the icon-picker model. ;; Include only sections that are currently rendered and expanded so ;; flat indices align with visible DOM buttons. @@ -3214,7 +3387,10 @@ (string/blank? search-q)) recently-used-row) web-nav-list (when (not (string/blank? effective-web-query)) - (vec (or web-images []))) + (vec (->> (or web-images []) + (remove (fn [img] + (when-let [u (:source-url img)] + (contains? saved-source-urls u))))))) empty-state? (and available-expanded? (not loading?) (empty? filtered-assets) @@ -3313,10 +3489,10 @@ (not= search-q web-query)) :avatar-context effective-avatar-context :on-select handle-web-image-select - :on-popover-change #(reset! *popover-open? %) :*result-sink *web-images-result :highlighted-id highlighted-id - :ghost-highlighted-id ghost-highlighted-id})) + :ghost-highlighted-id ghost-highlighted-id + :saved-source-urls saved-source-urls})) ;; "Available assets" section — header is hidden when there are no ;; assets at all (the action rows below communicate the zero state on @@ -5662,14 +5838,15 @@ (js/setTimeout #(some-> (rum/deref *trigger-ref) (.click)) 32))) [initial-open?]) - ;; Self-heal: if the stored icon references a deleted asset, rewrite it. - ;; :avatar degrades to text-only; :image clears the icon entirely. - (hooks/use-effect! - (fn [] - (let [healed (heal-dangling-asset-icon icon-value)] - (when (not= healed ::no-change) - (on-chosen nil healed)))) - [icon-value]) + ;; NOTE: an earlier auto-heal use-effect ran `heal-dangling-asset-icon` on + ;; every `[icon-value]` change and called `on-chosen` with the healed value + ;; (nil for :image, stripped data for :avatar). On page reload the asset + ;; entity often isn't hydrated into the renderer's conn yet — the lookup + ;; raced and returned nil, so the heal nuked the icon and persisted the + ;; loss. The renderer already shows nothing when the asset is genuinely + ;; missing (icon.cljs:478-482) without mutating the stored value, which + ;; lets a slow-hydrating asset reappear once it lands. If a user wants to + ;; clear a permanently dangling icon they can use the trash affordance. ;; trigger — render from `effective-icon-value` so the just-committed ;; icon shows immediately, before the entity reactive read catches up.