mirror of
https://github.com/logseq/logseq.git
synced 2026-02-01 22:47:36 +00:00
Align asset picker UI with Figma design and improve icon picker UX
- Add topbar wrapper divs for easier DOM inspection - Match asset picker dimensions with icon picker (380px width, consistent height) - Add floating action buttons (Add asset via URL, Upload asset) - Use 5-column CSS grid for scannable image thumbnails - Preserve search query when switching tabs - Remove unnecessary section headers from Emoji/Icon tabs - Pass page-title to icon picker for avatar initials - Fix image icon sizing in page title and sidebar contexts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2570,6 +2570,7 @@
|
||||
:logseq.property/icon
|
||||
{:type :none})))
|
||||
:del-btn? true ;; Always show delete when icon displayed
|
||||
:page-title (:block/title block)
|
||||
:icon-props {:size 16}})])))))
|
||||
|
||||
(when-not table?
|
||||
@@ -3077,6 +3078,7 @@
|
||||
(:db/id block)
|
||||
:logseq.property.class/default-icon)))))
|
||||
:del-btn? (boolean icon')
|
||||
:page-title (:block/title block)
|
||||
:icon-props {:style {:width "1lh"
|
||||
:height "1lh"
|
||||
:font-size (cond
|
||||
|
||||
@@ -213,6 +213,15 @@
|
||||
height: initial;
|
||||
}
|
||||
|
||||
/* Ensure page title has correct font-size in both editing and non-editing mode */
|
||||
.ls-page-title-container {
|
||||
font-size: var(--ls-page-title-size, 32px) !important;
|
||||
}
|
||||
|
||||
.ls-page-title-container .block-title-wrap {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.block-title-wrap {
|
||||
@apply w-full inline;
|
||||
|
||||
@@ -1097,6 +1106,21 @@ html.is-mac {
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
/* Image icons in page title - fill the button like avatars do */
|
||||
.ls-page-title .ls-page-icon .image-icon {
|
||||
width: 2rem !important; /* 32px, matching avatar size */
|
||||
height: 2rem !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ls-page-title .ls-page-icon .image-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Scale up avatars in page title context */
|
||||
.ls-page-title .ls-page-icon .ui__avatar {
|
||||
width: 2rem; /* 32px */
|
||||
|
||||
@@ -244,6 +244,18 @@
|
||||
> .ui__icon {
|
||||
@apply mr-0;
|
||||
}
|
||||
|
||||
/* Image icons in sidebar - fit without cropping, match container width */
|
||||
.image-icon {
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
[camel-snake-kebab.core :as csk]
|
||||
[cljs-bean.core :as bean]
|
||||
[clojure.string :as string]
|
||||
[electron.ipc :as ipc]
|
||||
[frontend.colors :as colors]
|
||||
[frontend.config :as config]
|
||||
[frontend.date :as date]
|
||||
@@ -16,7 +17,6 @@
|
||||
[frontend.fs :as fs]
|
||||
[frontend.handler.assets :as assets-handler]
|
||||
[frontend.handler.editor :as editor-handler]
|
||||
[electron.ipc :as ipc]
|
||||
[frontend.search :as search]
|
||||
[frontend.state :as state]
|
||||
[frontend.storage :as storage]
|
||||
@@ -166,7 +166,7 @@
|
||||
|
||||
url
|
||||
[:span.ui__icon.image-icon.flex.items-center.justify-center
|
||||
{:style {:max-width size :max-height size}}
|
||||
{:style {:width size :height size}}
|
||||
[:img
|
||||
{:src url
|
||||
:loading "lazy"
|
||||
@@ -178,6 +178,52 @@
|
||||
[:span.ui__icon.image-icon.bg-gray-04.animate-pulse
|
||||
{:style {:width size :height size}}])))
|
||||
|
||||
(rum/defcs avatar-image-cp < rum/reactive
|
||||
(rum/local nil ::url)
|
||||
(rum/local false ::error)
|
||||
(rum/local nil ::loaded-for)
|
||||
{:did-mount (fn [state]
|
||||
(let [[asset-uuid asset-type _avatar-data _opts] (:rum/args state)
|
||||
*url (::url state)
|
||||
*error (::error state)
|
||||
*loaded-for (::loaded-for state)]
|
||||
(load-image-url! asset-uuid asset-type *url *error *loaded-for))
|
||||
state)
|
||||
:did-update (fn [state]
|
||||
(let [[asset-uuid asset-type _avatar-data _opts] (:rum/args state)
|
||||
*loaded-for (::loaded-for state)]
|
||||
(when (not= @*loaded-for [asset-uuid asset-type])
|
||||
(let [*url (::url state)
|
||||
*error (::error state)]
|
||||
(load-image-url! asset-uuid asset-type *url *error *loaded-for))))
|
||||
state)}
|
||||
"Renders an avatar with an image, with initials as fallback.
|
||||
Uses shui/avatar for circular display with object-fit: cover."
|
||||
[state _asset-uuid _asset-type avatar-data opts]
|
||||
(let [url @(::url state)
|
||||
;; Fallback data from avatar
|
||||
avatar-value (get avatar-data :value "")
|
||||
backgroundColor (or (get avatar-data :backgroundColor)
|
||||
(colors/variable :gray :09))
|
||||
color (or (get avatar-data :color)
|
||||
(colors/variable :gray :09))
|
||||
display-text (subs avatar-value 0 (min 3 (count avatar-value)))
|
||||
bg-color-rgba (convert-bg-color-to-rgba backgroundColor)
|
||||
font-size (if (<= (:size opts 20) 16) "8px" "14px")]
|
||||
(shui/avatar
|
||||
{:class "w-5 h-5"}
|
||||
;; Image (shows when loaded, circular with cover fit)
|
||||
(when url
|
||||
(shui/avatar-image {:src url
|
||||
:style {:object-fit "cover"}}))
|
||||
;; Fallback (shows while loading or on error)
|
||||
(shui/avatar-fallback
|
||||
{:style {:background-color bg-color-rgba
|
||||
:font-size font-size
|
||||
:font-weight "500"
|
||||
:color color}}
|
||||
display-text))))
|
||||
|
||||
(defn icon
|
||||
[icon' & [opts]]
|
||||
(let [normalized (or (normalize-icon icon') icon')
|
||||
@@ -210,24 +256,29 @@
|
||||
display-text]])
|
||||
|
||||
(and (map? normalized) (= :avatar (:type normalized)) (get-in normalized [:data :value]))
|
||||
(let [avatar-value (get-in normalized [:data :value])
|
||||
;; Default to gray Radix color
|
||||
backgroundColor (or (get-in normalized [:data :backgroundColor])
|
||||
(colors/variable :gray :09))
|
||||
color (or (get-in normalized [:data :color])
|
||||
(colors/variable :gray :09))
|
||||
display-text (subs avatar-value 0 (min 3 (count avatar-value)))
|
||||
bg-color-rgba (convert-bg-color-to-rgba backgroundColor)
|
||||
;; Font size: 8px for sidebar contexts, 14px for page titles
|
||||
font-size (if (<= (:size opts 20) 16) "8px" "14px")]
|
||||
(shui/avatar
|
||||
{:class "w-5 h-5"}
|
||||
(shui/avatar-fallback
|
||||
{:style {:background-color bg-color-rgba
|
||||
:font-size font-size
|
||||
:font-weight "500"
|
||||
:color color}}
|
||||
display-text)))
|
||||
(let [avatar-data (get normalized :data)
|
||||
asset-uuid (get avatar-data :asset-uuid)
|
||||
asset-type (get avatar-data :asset-type)]
|
||||
(if asset-uuid
|
||||
;; Avatar with image - use async loading component
|
||||
(avatar-image-cp asset-uuid asset-type avatar-data opts)
|
||||
;; Text-only avatar
|
||||
(let [avatar-value (get avatar-data :value)
|
||||
backgroundColor (or (get avatar-data :backgroundColor)
|
||||
(colors/variable :gray :09))
|
||||
color (or (get avatar-data :color)
|
||||
(colors/variable :gray :09))
|
||||
display-text (subs avatar-value 0 (min 3 (count avatar-value)))
|
||||
bg-color-rgba (convert-bg-color-to-rgba backgroundColor)
|
||||
font-size (if (<= (:size opts 20) 16) "8px" "14px")]
|
||||
(shui/avatar
|
||||
{:class "w-5 h-5"}
|
||||
(shui/avatar-fallback
|
||||
{:style {:background-color bg-color-rgba
|
||||
:font-size font-size
|
||||
:font-weight "500"
|
||||
:color color}}
|
||||
display-text)))))
|
||||
|
||||
(and (map? normalized) (= :image (:type normalized)) (get-in normalized [:data :asset-uuid]))
|
||||
(let [asset-uuid (get-in normalized [:data :asset-uuid])
|
||||
@@ -382,13 +433,18 @@
|
||||
:avatar (let [backgroundColor (or (:backgroundColor v)
|
||||
(colors/variable :gray :09))
|
||||
color (or (:color v)
|
||||
(colors/variable :gray :09))]
|
||||
(colors/variable :gray :09))
|
||||
;; Preserve image data if present
|
||||
asset-uuid (or (get-in v [:data :asset-uuid]) (:asset-uuid v))
|
||||
asset-type (or (get-in v [:data :asset-type]) (:asset-type v))]
|
||||
{:type :avatar
|
||||
:id (or id (str "avatar-" value))
|
||||
:label (or label value)
|
||||
:data {:value value
|
||||
:backgroundColor backgroundColor
|
||||
:color color}})
|
||||
:data (cond-> {:value value
|
||||
:backgroundColor backgroundColor
|
||||
:color color}
|
||||
asset-uuid (assoc :asset-uuid asset-uuid)
|
||||
asset-type (assoc :asset-type asset-type))})
|
||||
:image (let [;; Extract asset-uuid, stripping "image-" prefix if present (from :id fallback)
|
||||
raw-uuid (or (get-in v [:data :asset-uuid]) (:asset-uuid v) value)
|
||||
asset-uuid (if (and (string? raw-uuid) (string/starts-with? raw-uuid "image-"))
|
||||
@@ -701,8 +757,8 @@
|
||||
(shui/shortcut keyboard-hint {:style :compact})])])
|
||||
|
||||
(rum/defc pane-section
|
||||
[label icon-items & {:keys [collapsible? keyboard-hint total-count searching? virtual-list? render-item-fn expanded? input-focused?]
|
||||
:or {virtual-list? true collapsible? false expanded? true input-focused? false}
|
||||
[label icon-items & {:keys [collapsible? keyboard-hint total-count searching? virtual-list? render-item-fn expanded? input-focused? show-header?]
|
||||
:or {virtual-list? true collapsible? false expanded? true input-focused? false show-header? true}
|
||||
:as opts}]
|
||||
(let [*el-ref (rum/use-ref nil)
|
||||
render-fn (or render-item-fn render-item)
|
||||
@@ -714,17 +770,18 @@
|
||||
[{:has-virtual-list virtual-list?
|
||||
:searching-result searching?}])}
|
||||
;; Use new collapsible header when collapsible? is true
|
||||
(if collapsible?
|
||||
(section-header {:title label
|
||||
:count (count icon-items)
|
||||
:total-count total-count
|
||||
:expanded? expanded?
|
||||
:keyboard-hint keyboard-hint
|
||||
:on-toggle toggle-fn
|
||||
:input-focused? input-focused?})
|
||||
;; Simple header (current style) for non-collapsible
|
||||
[:div.hd.px-1.pb-1.leading-none
|
||||
[:strong.text-xs.font-medium.text-gray-07.dark:opacity-80 label]])
|
||||
(when show-header?
|
||||
(if collapsible?
|
||||
(section-header {:title label
|
||||
:count (count icon-items)
|
||||
:total-count total-count
|
||||
:expanded? expanded?
|
||||
:keyboard-hint keyboard-hint
|
||||
:on-toggle toggle-fn
|
||||
:input-focused? input-focused?})
|
||||
;; Simple header (current style) for non-collapsible
|
||||
[:div.hd.px-1.pb-1.leading-none
|
||||
[:strong.text-xs.font-medium.text-gray-07.dark:opacity-80 label]]))
|
||||
|
||||
;; Content - only render if expanded or not collapsible
|
||||
(when (or (not collapsible?) expanded?)
|
||||
@@ -762,9 +819,11 @@
|
||||
:data {:value (:id emoji)}})
|
||||
emojis*)]
|
||||
(pane-section
|
||||
(util/format "Emojis (%s)" (count emojis*))
|
||||
"Emojis"
|
||||
icon-items
|
||||
opts)))
|
||||
:show-header? false
|
||||
:on-chosen (:on-chosen opts)
|
||||
:on-hover (:on-hover opts))))
|
||||
|
||||
(rum/defc icons-cp < rum/static
|
||||
[icons opts]
|
||||
@@ -775,9 +834,11 @@
|
||||
:data {:value icon-name}})
|
||||
icons)]
|
||||
(pane-section
|
||||
(util/format "Icons (%s)" (count icons))
|
||||
"Icons"
|
||||
icon-items
|
||||
opts)))
|
||||
:show-header? false
|
||||
:on-chosen (:on-chosen opts)
|
||||
:on-hover (:on-hover opts))))
|
||||
|
||||
(defn get-used-items
|
||||
[]
|
||||
@@ -961,9 +1022,11 @@
|
||||
|
||||
(rum/defcs image-asset-item < rum/reactive
|
||||
(rum/local nil ::url)
|
||||
(rum/local false ::error)
|
||||
{:did-mount (fn [state]
|
||||
(let [[asset _opts] (:rum/args state)
|
||||
*url (::url state)
|
||||
*error (::error state)
|
||||
asset-type (:logseq.property.asset/type asset)
|
||||
asset-uuid (:block/uuid asset)]
|
||||
(when (and asset-uuid asset-type)
|
||||
@@ -971,27 +1034,43 @@
|
||||
asset-path (path/path-join (str "../" common-config/local-assets-dir) file)]
|
||||
(-> (assets-handler/<make-asset-url asset-path)
|
||||
(p/then #(reset! *url %))
|
||||
(p/catch #(js/console.error "Failed to load asset URL" %))))))
|
||||
(p/catch (fn [_err]
|
||||
;; Mark as error so we don't show ghost placeholder
|
||||
(reset! *error true)))))))
|
||||
state)}
|
||||
"Renders a single image asset thumbnail in the asset picker grid"
|
||||
[state asset {:keys [on-chosen]}]
|
||||
"Renders a single image asset thumbnail in the asset picker grid.
|
||||
When avatar-context is provided, renders circular previews and returns avatar data.
|
||||
Returns nil if asset file doesn't exist (ghost asset)."
|
||||
[state asset {:keys [on-chosen avatar-context]}]
|
||||
(let [url @(::url state)
|
||||
error? @(::error state)
|
||||
asset-type (:logseq.property.asset/type asset)
|
||||
asset-uuid (:block/uuid asset)
|
||||
asset-title (or (:block/title asset) (str asset-uuid))]
|
||||
[:button.image-asset-item
|
||||
{:title asset-title
|
||||
:on-click (fn [e]
|
||||
(on-chosen e {:type :image
|
||||
:id (str "image-" asset-uuid)
|
||||
:label asset-title
|
||||
:data {:asset-uuid (str asset-uuid)
|
||||
:asset-type asset-type}}))}
|
||||
(if url
|
||||
[:img
|
||||
{:src url
|
||||
:loading "lazy"}]
|
||||
[:div.bg-gray-04.animate-pulse])]))
|
||||
asset-title (or (:block/title asset) (str asset-uuid))
|
||||
avatar-mode? (some? avatar-context)]
|
||||
;; Don't render ghost assets (db entry exists but file doesn't)
|
||||
(when-not error?
|
||||
[:button.image-asset-item
|
||||
{:title asset-title
|
||||
:class (when avatar-mode? "avatar-mode")
|
||||
:on-click (fn [e]
|
||||
(let [image-data {:asset-uuid (str asset-uuid)
|
||||
:asset-type asset-type}]
|
||||
(on-chosen e
|
||||
(if avatar-context
|
||||
;; Merge image into existing avatar
|
||||
{:type :avatar
|
||||
:id (:id avatar-context)
|
||||
:label (:label avatar-context)
|
||||
:data (merge (:data avatar-context) image-data)}
|
||||
;; Standard image selection
|
||||
{:type :image
|
||||
:id (str "image-" asset-uuid)
|
||||
:label asset-title
|
||||
:data image-data}))))}
|
||||
(if url
|
||||
[:img {:src url :loading "lazy"}]
|
||||
[:div.bg-gray-04.animate-pulse])])))
|
||||
|
||||
(rum/defcs asset-picker < rum/reactive db-mixins/query
|
||||
(rum/local "" ::search-q)
|
||||
@@ -1015,7 +1094,7 @@
|
||||
(p/catch (fn [_err]
|
||||
(reset! *loading? false))))))
|
||||
state)}
|
||||
[state {:keys [on-chosen on-back]}]
|
||||
[state {:keys [on-chosen on-back avatar-context]}]
|
||||
(let [*search-q (::search-q state)
|
||||
*loading? (::loading? state)
|
||||
*loaded-assets (::loaded-assets state)
|
||||
@@ -1033,8 +1112,8 @@
|
||||
(string/lower-case search-q))))
|
||||
assets))
|
||||
asset-count (count filtered-assets)
|
||||
;; Handle file upload - uses api-insert-new-block! approach like tag tables
|
||||
;; to avoid adding assets to today's journal
|
||||
avatar-mode? (some? avatar-context)
|
||||
;; Handle file upload
|
||||
handle-upload (fn [files]
|
||||
(let [repo (state/get-current-repo)
|
||||
image-files (filter (fn [file]
|
||||
@@ -1046,64 +1125,77 @@
|
||||
(contains? config/image-formats ext)))
|
||||
files)]
|
||||
(when (seq image-files)
|
||||
;; Save each image file using the tag-table approach
|
||||
(p/let [entities (p/all (map #(save-image-asset! repo %) image-files))]
|
||||
;; Refresh assets list
|
||||
(p/let [updated-assets (<get-image-assets)]
|
||||
(reset! *loaded-assets (or (seq updated-assets) [])))
|
||||
;; Auto-select the first uploaded image
|
||||
(when-let [first-asset (first (remove nil? entities))]
|
||||
(on-chosen nil {:type :image
|
||||
(let [image-data {:asset-uuid (str (:block/uuid first-asset))
|
||||
:asset-type (:logseq.property.asset/type first-asset)}]
|
||||
(on-chosen nil
|
||||
(if avatar-context
|
||||
{:type :avatar
|
||||
:id (:id avatar-context)
|
||||
:label (:label avatar-context)
|
||||
:data (merge (:data avatar-context) image-data)}
|
||||
{:type :image
|
||||
:id (str "image-" (:block/uuid first-asset))
|
||||
:label (or (:block/title first-asset) "")
|
||||
:data {:asset-uuid (str (:block/uuid first-asset))
|
||||
:asset-type (:logseq.property.asset/type first-asset)}}))))))]
|
||||
:data image-data}))))))))]
|
||||
[:div.asset-picker
|
||||
;; Header with back button
|
||||
[:div.asset-picker-header
|
||||
[:button.back-button
|
||||
{:on-click on-back}
|
||||
(shui/tabler-icon "arrow-left" {:size 16})]
|
||||
[:span.text-sm.font-medium "Select image"]]
|
||||
{:class (when avatar-mode? "avatar-mode")}
|
||||
|
||||
;; Search input
|
||||
[:div.asset-picker-search
|
||||
(shui/input
|
||||
{:placeholder "Search images..."
|
||||
:value search-q
|
||||
:auto-focus true
|
||||
:on-change #(reset! *search-q (util/evalue %))})]
|
||||
;; Topbar: back button + search
|
||||
[:div.asset-picker-topbar
|
||||
[:div.asset-picker-back
|
||||
[:button.back-button
|
||||
{:on-click on-back}
|
||||
(shui/tabler-icon "chevron-left" {:size 16})
|
||||
[:span "Back"]]]
|
||||
(shui/separator {:class "my-0 opacity-50"})
|
||||
[:div.asset-picker-search
|
||||
[:div.search-input
|
||||
(shui/tabler-icon "search" {:size 16 :class "ls-icon-search"})
|
||||
(shui/input
|
||||
{:placeholder "Search images"
|
||||
:value search-q
|
||||
:auto-focus true
|
||||
:on-change #(reset! *search-q (util/evalue %))})]]]
|
||||
|
||||
;; Section header with count
|
||||
;; Section header (matching icon picker style)
|
||||
[:div.asset-picker-section-header
|
||||
[:span (str "Images · " asset-count)]]
|
||||
[:div.section-title
|
||||
[:span.font-bold "Images"]
|
||||
[:span.font-medium (str " · " asset-count)]]]
|
||||
|
||||
;; Asset grid
|
||||
[:div.asset-picker-grid
|
||||
{:class (when avatar-mode? "avatar-mode")}
|
||||
(cond
|
||||
;; Show loading state while fetching from DB worker
|
||||
loading?
|
||||
[:div.flex.flex-col.items-center.justify-center.h-32.text-gray-08
|
||||
[:div.animate-spin (shui/tabler-icon "loader-2" {:size 32})]
|
||||
[:span.text-sm.mt-2 "Loading assets..."]]
|
||||
|
||||
;; Show assets if we have them
|
||||
(seq filtered-assets)
|
||||
(for [asset filtered-assets]
|
||||
(rum/with-key
|
||||
(image-asset-item asset {:on-chosen on-chosen})
|
||||
(image-asset-item asset {:on-chosen on-chosen
|
||||
:avatar-context avatar-context})
|
||||
(str (:block/uuid asset))))
|
||||
|
||||
;; No assets found
|
||||
:else
|
||||
[:div.flex.flex-col.items-center.justify-center.h-32.text-gray-08
|
||||
(shui/tabler-icon "photo-off" {:size 32})
|
||||
[:span.text-sm.mt-2 "No image assets found"]
|
||||
[:span.text-xs.mt-1 "Upload an image to get started"]])]
|
||||
|
||||
;; Upload button at bottom
|
||||
;; Action buttons (floating at bottom)
|
||||
[:div.asset-picker-actions
|
||||
[:label.upload-button
|
||||
[:button.secondary-button
|
||||
{:on-click #(js/console.log "TODO: Add asset via URL")}
|
||||
(shui/tabler-icon "link" {:size 16})
|
||||
[:span "Add asset via URL"]]
|
||||
[:label.primary-button
|
||||
[:input.hidden
|
||||
{:type "file"
|
||||
:accept "image/*"
|
||||
@@ -1111,7 +1203,7 @@
|
||||
:on-change (fn [e]
|
||||
(let [files (array-seq (.-files (.-target e)))]
|
||||
(handle-upload files)))}]
|
||||
(shui/tabler-icon "upload" {:size 16})
|
||||
(shui/tabler-icon "square-plus" {:size 16})
|
||||
[:span "Upload asset"]]]]))
|
||||
|
||||
(rum/defc all-cp < rum/reactive
|
||||
@@ -1163,10 +1255,15 @@
|
||||
:expanded? (get section-states "Icons" true)))]))
|
||||
|
||||
(rum/defc tab-observer
|
||||
[tab {:keys [reset-q!]}]
|
||||
"Re-runs the search when tab changes (if there's a query), preserving the search text."
|
||||
[tab {:keys [q *result]}]
|
||||
(hooks/use-effect!
|
||||
#(reset-q!)
|
||||
[tab])
|
||||
(fn []
|
||||
;; Re-run search with existing query for new tab context
|
||||
(when-not (string/blank? q)
|
||||
(p/let [result (search q tab)]
|
||||
(reset! *result result))))
|
||||
[tab q])
|
||||
nil)
|
||||
|
||||
(rum/defc keyboard-shortcut-observer
|
||||
@@ -1301,9 +1398,16 @@
|
||||
(rum/local false ::select-mode?)
|
||||
(rum/local :all ::tab)
|
||||
(rum/local false ::input-focused?)
|
||||
(rum/local :icon-picker ::view) ;; :icon-picker or :asset-picker
|
||||
{:init (fn [s]
|
||||
(assoc s ::color (atom (storage/get :ls-icon-color-preset))))}
|
||||
(rum/local :icon-picker ::view) ;; Default view, updated in :will-mount for avatars/images
|
||||
{:will-mount (fn [s]
|
||||
(let [opts (first (:rum/args s))
|
||||
icon-value (:icon-value opts)
|
||||
normalized (normalize-icon icon-value)
|
||||
*view (::view s)]
|
||||
;; Avatar and image icons open asset picker directly for image selection
|
||||
(when (contains? #{:avatar :image} (:type normalized))
|
||||
(reset! *view :asset-picker))
|
||||
(assoc s ::color (atom (storage/get :ls-icon-color-preset)))))}
|
||||
[state {:keys [on-chosen del-btn? icon-value page-title] :as opts}]
|
||||
(let [*q (::q state)
|
||||
*result (::result state)
|
||||
@@ -1351,122 +1455,126 @@
|
||||
(asset-picker {:on-chosen (fn [e icon-data]
|
||||
((:on-chosen opts) e icon-data)
|
||||
(reset! *view :icon-picker))
|
||||
:on-back #(reset! *view :icon-picker)})
|
||||
:on-back #(reset! *view :icon-picker)
|
||||
:avatar-context (when (= :avatar (:type normalized-icon-value))
|
||||
normalized-icon-value)})
|
||||
;; Level 1: Icon Picker view
|
||||
[:div.cp__emoji-icon-picker
|
||||
{:data-keep-selection true}
|
||||
;; search section
|
||||
[:div.search-section
|
||||
(tab-observer @*tab {:reset-q! reset-q!})
|
||||
(keyboard-shortcut-observer @*tab *input-focused?)
|
||||
(when @*select-mode?
|
||||
(select-observer *input-ref))
|
||||
[:div.search-input
|
||||
(shui/tabler-icon "search" {:size 16})
|
||||
[(shui/input
|
||||
{:auto-focus true
|
||||
:ref *input-ref
|
||||
:placeholder "Search emojis, icons, assets..."
|
||||
:default-value ""
|
||||
:on-focus #(do (reset! *select-mode? false)
|
||||
(reset! *input-focused? true))
|
||||
:on-blur #(reset! *input-focused? false)
|
||||
:on-key-down (fn [^js e]
|
||||
(case (.-keyCode e)
|
||||
;; esc
|
||||
27 (do (util/stop e)
|
||||
(if (string/blank? @*q)
|
||||
;(some-> (rum/deref *input-ref) (.blur))
|
||||
(shui/popup-hide!)
|
||||
(reset-q!)))
|
||||
38 (do (util/stop e))
|
||||
(9 40) (do
|
||||
(reset! *select-mode? true)
|
||||
(util/stop e))
|
||||
:dune))
|
||||
:on-change (debounce
|
||||
(fn [e]
|
||||
(reset! *q (util/evalue e))
|
||||
(reset! *select-mode? false)
|
||||
(if (string/blank? @*q)
|
||||
(reset! *result {})
|
||||
(p/let [result (search @*q @*tab)]
|
||||
(reset! *result result))))
|
||||
200)})]
|
||||
(when-not (string/blank? @*q)
|
||||
[:a.x {:on-click reset-q!} (shui/tabler-icon "x" {:size 14})])]
|
||||
|
||||
;; color picker (always visible)
|
||||
(color-picker *color (fn [c]
|
||||
(cond
|
||||
(or (= :icon (:type normalized-icon-value))
|
||||
(= :text (:type normalized-icon-value)))
|
||||
(on-chosen nil (assoc-in normalized-icon-value [:data :color] c) true)
|
||||
;; Topbar: search + separator + tabs
|
||||
[:div.icon-picker-topbar
|
||||
[:div.search-section
|
||||
(tab-observer @*tab {:q @*q :*result *result})
|
||||
(keyboard-shortcut-observer @*tab *input-focused?)
|
||||
(when @*select-mode?
|
||||
(select-observer *input-ref))
|
||||
[:div.search-input
|
||||
(shui/tabler-icon "search" {:size 16})
|
||||
[(shui/input
|
||||
{:auto-focus true
|
||||
:ref *input-ref
|
||||
:placeholder "Search emojis, icons, assets..."
|
||||
:default-value ""
|
||||
:on-focus #(do (reset! *select-mode? false)
|
||||
(reset! *input-focused? true))
|
||||
:on-blur #(reset! *input-focused? false)
|
||||
:on-key-down (fn [^js e]
|
||||
(case (.-keyCode e)
|
||||
;; esc
|
||||
27 (do (util/stop e)
|
||||
(if (string/blank? @*q)
|
||||
(shui/popup-hide!)
|
||||
(reset-q!)))
|
||||
38 (do (util/stop e))
|
||||
(9 40) (do
|
||||
(reset! *select-mode? true)
|
||||
(util/stop e))
|
||||
:dune))
|
||||
:on-change (debounce
|
||||
(fn [e]
|
||||
(reset! *q (util/evalue e))
|
||||
(reset! *select-mode? false)
|
||||
(if (string/blank? @*q)
|
||||
(reset! *result {})
|
||||
(p/let [result (search @*q @*tab)]
|
||||
(reset! *result result))))
|
||||
200)})]
|
||||
(when-not (string/blank? @*q)
|
||||
[:a.x {:on-click reset-q!} (shui/tabler-icon "x" {:size 14})])]
|
||||
|
||||
(= :avatar (:type normalized-icon-value))
|
||||
(on-chosen nil (-> normalized-icon-value
|
||||
(assoc-in [:data :color] c)
|
||||
(assoc-in [:data :backgroundColor] c)) true))))
|
||||
;; color picker (always visible)
|
||||
(color-picker *color (fn [c]
|
||||
(cond
|
||||
(or (= :icon (:type normalized-icon-value))
|
||||
(= :text (:type normalized-icon-value)))
|
||||
(on-chosen nil (assoc-in normalized-icon-value [:data :color] c) true)
|
||||
|
||||
;; delete button
|
||||
(when del-btn?
|
||||
(shui/button {:variant :outline :size :sm :data-action "del"
|
||||
:on-click #(on-chosen nil)}
|
||||
(shui/tabler-icon "trash" {:size 17})))]
|
||||
(= :avatar (:type normalized-icon-value))
|
||||
(on-chosen nil (-> normalized-icon-value
|
||||
(assoc-in [:data :color] c)
|
||||
(assoc-in [:data :backgroundColor] c)) true))))
|
||||
|
||||
;; separator
|
||||
(shui/separator {:class "my-0 icon-picker-separator"})
|
||||
;; delete button
|
||||
(when del-btn?
|
||||
(shui/button {:variant :outline :size :sm :data-action "del"
|
||||
:on-click #(on-chosen nil)}
|
||||
(shui/tabler-icon "trash" {:size 17})))]
|
||||
|
||||
;; tabs section
|
||||
[:div.tabs-section
|
||||
(let [tabs [[:all "All"] [:emoji "Emojis"] [:icon "Icons"] [:custom "Custom"]]]
|
||||
(for [[id label] tabs
|
||||
:let [active? (= @*tab id)]]
|
||||
[:button.tab-item
|
||||
{:key (name id)
|
||||
:data-active (when active? "true")
|
||||
:on-mouse-down (fn [e]
|
||||
(util/stop e)
|
||||
(reset! *tab id))}
|
||||
label]))]
|
||||
(shui/separator {:class "my-0 icon-picker-separator"})
|
||||
|
||||
;; body
|
||||
[:div.bd.bd-scroll
|
||||
{:ref *result-ref
|
||||
:class (or (some-> @*tab (name)) "other")}
|
||||
[:div.content-pane
|
||||
(if (seq result)
|
||||
(let [section-states (rum/react *section-states)]
|
||||
[:div.flex.flex-1.flex-col.search-result
|
||||
;; Emojis section
|
||||
(when (seq (:emojis result))
|
||||
(pane-section
|
||||
"Emojis"
|
||||
(:emojis result)
|
||||
(assoc opts
|
||||
:collapsible? true
|
||||
:keyboard-hint "alt mod 2"
|
||||
:total-count (count (:emojis result))
|
||||
:virtual-list? false
|
||||
:expanded? (get section-states "Emojis" true))))
|
||||
[:div.tabs-section
|
||||
(let [tabs [[:all "All"] [:emoji "Emojis"] [:icon "Icons"] [:custom "Custom"]]]
|
||||
(for [[id label] tabs
|
||||
:let [active? (= @*tab id)]]
|
||||
[:button.tab-item
|
||||
{:key (name id)
|
||||
:data-active (when active? "true")
|
||||
:on-mouse-down (fn [e]
|
||||
(util/stop e)
|
||||
(reset! *tab id))}
|
||||
label]))]]
|
||||
|
||||
;; Icons section
|
||||
(when (seq (:icons result))
|
||||
(pane-section
|
||||
"Icons"
|
||||
(:icons result)
|
||||
(assoc opts
|
||||
:collapsible? true
|
||||
:keyboard-hint "alt mod 3"
|
||||
:total-count (count (:icons result))
|
||||
:virtual-list? false
|
||||
:expanded? (get section-states "Icons" true))))])
|
||||
[:div.flex.flex-1.flex-col.gap-1
|
||||
(case @*tab
|
||||
:emoji (emojis-cp emojis opts)
|
||||
:icon (icons-cp (get-tabler-icons) opts)
|
||||
:custom (custom-tab-cp *q page-title *color *view icon-value opts)
|
||||
(all-cp opts))])]]])))
|
||||
;; Body
|
||||
[:div.bd.bd-scroll
|
||||
{:ref *result-ref
|
||||
:class (or (some-> @*tab (name)) "other")}
|
||||
[:div.content-pane
|
||||
;; Custom tab always shows its own content (Text/Avatar/Image buttons)
|
||||
(if (= @*tab :custom)
|
||||
(custom-tab-cp *q page-title *color *view icon-value opts)
|
||||
;; Other tabs: show search results if present, else show tab content
|
||||
(if (seq result)
|
||||
(let [section-states (rum/react *section-states)]
|
||||
[:div.flex.flex-1.flex-col.search-result
|
||||
;; Emojis section
|
||||
(when (seq (:emojis result))
|
||||
(pane-section
|
||||
"Emojis"
|
||||
(:emojis result)
|
||||
(assoc opts
|
||||
:collapsible? true
|
||||
:keyboard-hint "alt mod 2"
|
||||
:total-count (count (:emojis result))
|
||||
:virtual-list? false
|
||||
:expanded? (get section-states "Emojis" true))))
|
||||
|
||||
;; Icons section
|
||||
(when (seq (:icons result))
|
||||
(pane-section
|
||||
"Icons"
|
||||
(:icons result)
|
||||
(assoc opts
|
||||
:collapsible? true
|
||||
:keyboard-hint "alt mod 3"
|
||||
:total-count (count (:icons result))
|
||||
:virtual-list? false
|
||||
:expanded? (get section-states "Icons" true))))])
|
||||
[:div.flex.flex-1.flex-col.gap-1
|
||||
(case @*tab
|
||||
:emoji (emojis-cp emojis opts)
|
||||
:icon (icons-cp (get-tabler-icons) opts)
|
||||
(all-cp opts))]))]]])))
|
||||
|
||||
(rum/defc icon-picker
|
||||
[icon-value {:keys [empty-label disabled? initial-open? del-btn? on-chosen icon-props popup-opts button-opts page-title]}]
|
||||
|
||||
@@ -8,7 +8,12 @@
|
||||
background-color: var(--rx-gray-05) !important;
|
||||
}
|
||||
|
||||
> .search-section {
|
||||
/* Topbar wrapper for easier inspection */
|
||||
.icon-picker-topbar {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
@apply flex items-center gap-2 py-1 pr-3;
|
||||
@apply flex-shrink-0;
|
||||
background-color: var(--lx-gray-03);
|
||||
@@ -176,6 +181,11 @@
|
||||
@apply -m-1;
|
||||
}
|
||||
|
||||
/* Same negative margin to counteract dropdown p-1 padding */
|
||||
.ui__dropdown-menu-content .asset-picker {
|
||||
@apply -m-1;
|
||||
}
|
||||
|
||||
.ls-icon {
|
||||
&-Backlog {
|
||||
@apply text-gray-05;
|
||||
@@ -252,9 +262,10 @@
|
||||
color: var(--rx-gray-11);
|
||||
}
|
||||
|
||||
/* Asset Picker (Level 2 view) */
|
||||
/* Asset Picker (Level 2 view) - Figma aligned */
|
||||
.asset-picker {
|
||||
@apply flex flex-col overflow-hidden;
|
||||
@apply flex flex-col overflow-hidden relative;
|
||||
/* Match icon-picker width for visual continuity */
|
||||
width: 380px;
|
||||
min-width: 380px;
|
||||
min-height: 320px;
|
||||
@@ -262,65 +273,97 @@
|
||||
background-color: var(--lx-gray-02);
|
||||
}
|
||||
|
||||
.asset-picker-header {
|
||||
@apply flex items-center gap-2 px-3 py-2;
|
||||
/* Topbar wrapper for easier inspection */
|
||||
.asset-picker-topbar {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
/* Back button as link-style button */
|
||||
.asset-picker-back {
|
||||
@apply flex-shrink-0;
|
||||
/* py-2 + content height matches icon-picker topbar */
|
||||
padding-top: 8px;
|
||||
padding-bottom: 7px;
|
||||
/* 14px aligns chevron icon with search icon below */
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
background-color: var(--lx-gray-03);
|
||||
border-bottom: 1px solid var(--rx-gray-06);
|
||||
border-bottom: 1px solid var(--rx-gray-05);
|
||||
|
||||
.back-button {
|
||||
@apply p-1 rounded cursor-pointer;
|
||||
@apply flex items-center gap-0.5;
|
||||
@apply text-sm cursor-pointer;
|
||||
@apply border-none bg-transparent p-0;
|
||||
@apply transition-colors duration-150;
|
||||
@apply border-none bg-transparent;
|
||||
color: var(--rx-gray-11);
|
||||
color: var(--rx-gray-10);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--rx-gray-04);
|
||||
color: var(--rx-gray-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Search input matching icon picker */
|
||||
.asset-picker-search {
|
||||
@apply px-3 py-2;
|
||||
@apply py-1;
|
||||
@apply flex-shrink-0;
|
||||
background-color: var(--lx-gray-03);
|
||||
/* Grey line at bottom of header section, matching icon picker */
|
||||
border-bottom: 1px solid var(--rx-gray-06);
|
||||
|
||||
.ui__input {
|
||||
@apply h-8 text-sm;
|
||||
@apply bg-gray-03 border-none;
|
||||
box-shadow: none !important;
|
||||
.search-input {
|
||||
@apply relative flex-1 px-2;
|
||||
|
||||
&:focus {
|
||||
@apply bg-gray-03;
|
||||
.ls-icon-search {
|
||||
@apply absolute left-[14px] top-[8px] opacity-50;
|
||||
}
|
||||
|
||||
.ui__input {
|
||||
@apply leading-none pl-8 outline-none border-none bg-gray-03;
|
||||
@apply focus:bg-gray-03 !h-8;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Section header matching icon picker */
|
||||
.asset-picker-section-header {
|
||||
@apply px-3 py-2 text-xs font-medium;
|
||||
@apply flex items-center justify-between px-4 py-1;
|
||||
@apply text-[10px] tracking-tight;
|
||||
@apply flex-shrink-0;
|
||||
color: var(--rx-gray-10);
|
||||
background-color: var(--lx-gray-02);
|
||||
border-bottom: 1px solid var(--rx-gray-05);
|
||||
color: var(--rx-gray-11);
|
||||
|
||||
.section-title {
|
||||
@apply flex items-center gap-0.5;
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
@apply flex items-center gap-2;
|
||||
|
||||
.shortcut {
|
||||
@apply text-xs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Grid layout - 5 columns for larger, scannable images */
|
||||
.asset-picker-grid {
|
||||
@apply grid gap-2 p-3;
|
||||
@apply flex-1 overflow-y-auto;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
min-height: 120px;
|
||||
align-content: start;
|
||||
/* Add padding at bottom for floating action buttons */
|
||||
padding-bottom: 56px;
|
||||
}
|
||||
|
||||
/* Image items - fill grid cell with proper aspect ratio */
|
||||
.image-asset-item {
|
||||
@apply overflow-hidden rounded;
|
||||
@apply cursor-pointer;
|
||||
@apply overflow-hidden rounded cursor-pointer;
|
||||
@apply transition-all duration-150;
|
||||
@apply border-none p-0;
|
||||
/* Square aspect ratio - explicit since aspect-square not available */
|
||||
width: 100%;
|
||||
padding-bottom: 100%;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 100%; /* Square aspect ratio */
|
||||
border: 2px solid transparent;
|
||||
background-color: var(--rx-gray-04);
|
||||
|
||||
@@ -333,28 +376,69 @@
|
||||
border-color: var(--rx-accent-09);
|
||||
}
|
||||
|
||||
img,
|
||||
> div {
|
||||
@apply absolute inset-0 w-full h-full object-cover;
|
||||
img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.asset-picker-actions {
|
||||
@apply px-3 py-2;
|
||||
@apply flex-shrink-0;
|
||||
border-top: 1px solid var(--rx-gray-06);
|
||||
background-color: var(--lx-gray-03);
|
||||
> div {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.upload-button {
|
||||
@apply flex items-center justify-center gap-2;
|
||||
@apply w-full py-2 px-3 rounded-md;
|
||||
@apply text-sm font-medium cursor-pointer;
|
||||
@apply transition-colors duration-150;
|
||||
background-color: var(--rx-gray-04);
|
||||
color: var(--rx-gray-11);
|
||||
/* Avatar mode - circular previews */
|
||||
&.avatar-mode {
|
||||
@apply rounded-full;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--rx-gray-05);
|
||||
img {
|
||||
@apply rounded-full;
|
||||
}
|
||||
|
||||
> div {
|
||||
@apply rounded-full;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Action buttons - floating at bottom */
|
||||
.asset-picker-actions {
|
||||
@apply flex items-center justify-center gap-2 px-3 py-2;
|
||||
@apply absolute bottom-2 left-1/2 -translate-x-1/2;
|
||||
@apply rounded-lg;
|
||||
background-color: var(--lx-gray-03);
|
||||
border: 1px solid var(--rx-gray-06);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.secondary-button {
|
||||
@apply flex items-center gap-1.5 px-2.5 py-1.5 rounded-md;
|
||||
@apply text-xs font-medium cursor-pointer;
|
||||
@apply transition-colors duration-150 whitespace-nowrap;
|
||||
background-color: var(--rx-gray-06);
|
||||
color: var(--rx-gray-12);
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--rx-gray-07);
|
||||
}
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
@apply flex items-center gap-1.5 px-2.5 py-1.5 rounded-md;
|
||||
@apply text-xs font-medium cursor-pointer;
|
||||
@apply transition-colors duration-150 whitespace-nowrap;
|
||||
background-color: var(--rx-blue-09);
|
||||
color: white;
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--rx-blue-10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user