1
0
Эх сурвалжийг харах

Feat: the new handbooks (#8524)

* feat(ui): WIP handbooks pane

* feat(ui): WIP handbooks pane

* feat(ui): WIP handbooks pane

* feat(ui): WIP handbooks popup

* feat(ui): WIP dragable & resizable for handbooks popup

* feat(ui): WIP pane navigations for handbooks popup

* feat(ui): WIP pane navigations for handbooks popup

* feat(ui): WIP handbooks markdown body

* feat(ui): WIP handbooks nodes for dashboard render

* feat(ui): WIP watch mode for development

* improve(ui): typos

* feat(ui): WIP enhance watch mode

* feat(ui): WIP support topic conent link local assets

* feat(ui): WIP support slide gallery for demo images & videos.

* fix(ui): parse value about draging position offset

* improve(ui): background color transition of handbook item card

* improve(ui): resizable of handbooks popup

* feat(handbooks): search topics

* improve(handbooks): search results within topics group

* improve(ui): better interaction for handbooks searchbar

* fix(handbooks): conflictive up/down for searchbar interaction

* improve(ux): better interaction for handbooks searchbar

* feat(ux): support youtube video for topic demos media

* fix(ui): container size of youtube video demos

* improve(handbooks): support local video for topic demos

* improve(ui): polish markdown body style for handbooks topic details

* chore: remove debugs

* chore: remove debugs

* improve(ui): polish active style for topic item card

* improve(ui): polish style of demos item

* improve(ui): help buttons still be visible when right sidebar opened

* improve(handbooks): support sub chapters for topic detail

* improve(handbooks): support sub chapters for topic detail

* improve(handbooks): support chapters searching for topics list

* fix: lint

* improve(ui): position of demo slides bullets

* fix(ui): index of chapter select

* improve(handbooks): typo

* fix(dev): lint

* fix(dev): lint

* fix(pdf): remove prefix(`@`) checking for links of org mode page

* feat(ui): WIP handbooks pane

* feat(ui): WIP handbooks pane

* feat(ui): WIP handbooks pane

* feat(ui): WIP handbooks popup

* feat(ui): WIP dragable & resizable for handbooks popup

* feat(ui): WIP pane navigations for handbooks popup

* feat(ui): WIP pane navigations for handbooks popup

* feat(ui): WIP handbooks markdown body

* feat(ui): WIP handbooks nodes for dashboard render

* feat(ui): WIP watch mode for development

* improve(ui): typos

* feat(ui): WIP enhance watch mode

* feat(ui): WIP support topic conent link local assets

* feat(ui): WIP support slide gallery for demo images & videos.

* fix(ui): parse value about draging position offset

* improve(ui): background color transition of handbook item card

* improve(ui): resizable of handbooks popup

* feat(handbooks): search topics

* improve(handbooks): search results within topics group

* improve(ui): better interaction for handbooks searchbar

* fix(handbooks): conflictive up/down for searchbar interaction

* improve(ux): better interaction for handbooks searchbar

* feat(ux): support youtube video for topic demos media

* fix(ui): container size of youtube video demos

* improve(handbooks): support local video for topic demos

* improve(ui): polish markdown body style for handbooks topic details

* chore: remove debugs

* chore: remove debugs

* improve(ui): polish active style for topic item card

* improve(ui): polish style of demos item

* improve(ui): help buttons still be visible when right sidebar opened

* improve(handbooks): support sub chapters for topic detail

* improve(handbooks): support sub chapters for topic detail

* improve(handbooks): support chapters searching for topics list

* fix: lint

* improve(ui): position of demo slides bullets

* fix(ui): index of chapter select

* improve(handbooks): typo

* fix(dev): lint

* fix(dev): lint

* improve(handbook): i18n

* fix(lint): unused translations

* fix: accessibility issues and translations

* fix(handbook): chapters navigation

* enhance(handbook): ux of the chapters select

* enhance(handbook): support link other page with markdown link syntax

* improve(ui): polish ui details of handbook topics card

* fix(handbook): parse key from href with a specific extension

* enhance(handbook): logic of chapters navigation

* enhance(handbook): ui of chapters navigation

* fix: lint

* improve(ui): display nowrap for code text

* fix(handbook): remove unnecessary source map

* fix(ui): missing component key of handbook chapter select

* enhance(handbook): WIP support panes navigation for the external links

* enhance(handbook): support panes navigation for the external links

* improve(ui): footer links of the handbook home pane

* improve(ui): footer links of the handbook home pane

* improve(ui): polish topics card

* improve(handbook): add shortcuts category card for home pane

* improve(ui): WIP the new help menu

* improve(ui): the new help menu

* fix: incorrect help link

* improve(ux): close help menu when click outside

* fix: lint

* fix(lint): remove unused translation

* fix(ui): the link of changelog

* fix(ui): the cover thumb container size of the topic card

* fix(ui): handbook popup overlay index

* enhance(ux): preivew images with lightbox modal for the handbook content

* enhance(ux): bottom border for the handbook content header when then content body scrolled

* fix: missing i18n

* improve(handbook): polish ui details

* fix: lint

* enhance(handbook): polish details

* fix(ui): incorrect safety init

* fix(ui): missing key for the help menu items

* enhance(ui): disable resize for the handbook popup container

* chore: build libs core

* fix(ui): incorrect shortcuts label

* enhance(handbook): cache discord online number

* enhance(handbook): fix heading level sizes

* enhance(handbook): improve paragraph spacing

* enhance(handbook): improve margins of media elements

* enhance(handbook): polish discord button

* enhance(plugin): make headings/font weights/colors look like in the design

* enhance(handbook): writing mode option is only available for develop mode

* enhance(handbook): polish handbook dashboard page

* enhance(handbook): typos

* enhance(ux): get discord online users count from logseq server

* fix(handbooks): incorrect var name

* enhance(handbook): polish details

---------

Co-authored-by: Bad3r <[email protected]>
Co-authored-by: situ2001 <[email protected]>
Co-authored-by: Tienson Qin <[email protected]>
Co-authored-by: Konstantinos Kaloutas <[email protected]>
Charlie 2 жил өмнө
parent
commit
1389836119

+ 1 - 0
resources/js/glide/glide.core.min.css

@@ -0,0 +1 @@
+.glide{position:relative;width:100%;box-sizing:border-box}.glide *{box-sizing:inherit}.glide__track{overflow:hidden}.glide__slides{position:relative;width:100%;list-style:none;backface-visibility:hidden;transform-style:preserve-3d;touch-action:pan-Y;overflow:hidden;margin:0;padding:0;white-space:nowrap;display:flex;flex-wrap:nowrap;will-change:transform}.glide__slides--dragging{user-select:none}.glide__slide{width:100%;height:100%;flex-shrink:0;white-space:normal;user-select:none;-webkit-touch-callout:none;-webkit-tap-highlight-color:transparent}.glide__slide a{user-select:none;-webkit-user-drag:none;-moz-user-select:none;-ms-user-select:none}.glide__arrows{-webkit-touch-callout:none;user-select:none}.glide__bullets{-webkit-touch-callout:none;user-select:none}.glide--rtl{direction:rtl}

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 5 - 0
resources/js/glide/glide.min.js


+ 1 - 0
resources/js/glide/glide.theme.min.css

@@ -0,0 +1 @@
+.glide__arrow{position:absolute;display:block;top:50%;z-index:2;color:#fff;text-transform:uppercase;padding:9px 12px;background-color:transparent;border:2px solid rgba(255,255,255,.5);border-radius:4px;box-shadow:0 .25em .5em 0 rgba(0,0,0,.1);text-shadow:0 .25em .5em rgba(0,0,0,.1);opacity:1;cursor:pointer;transition:opacity 150ms ease,border 300ms ease-in-out;transform:translateY(-50%);line-height:1}.glide__arrow:focus{outline:none}.glide__arrow:hover{border-color:#fff}.glide__arrow--left{left:2em}.glide__arrow--right{right:2em}.glide__arrow--disabled{opacity:.33}.glide__bullets{position:absolute;z-index:2;bottom:2em;left:50%;display:inline-flex;list-style:none;transform:translateX(-50%)}.glide__bullet{background-color:rgba(255,255,255,.5);width:9px;height:9px;padding:0;border-radius:50%;border:2px solid transparent;transition:all 300ms ease-in-out;cursor:pointer;line-height:0;box-shadow:0 .25em .5em 0 rgba(0,0,0,.1);margin:0 .25em}.glide__bullet:focus{outline:none}.glide__bullet:hover,.glide__bullet:focus{border:2px solid #fff;background-color:rgba(255,255,255,.5)}.glide__bullet--active{background-color:#fff}.glide--swipeable{cursor:grab;cursor:-moz-grab;cursor:-webkit-grab}.glide--dragging{cursor:grabbing;cursor:-moz-grabbing;cursor:-webkit-grabbing}

+ 9 - 3
src/electron/electron/url.cljs

@@ -100,7 +100,13 @@
       (= "new-window" url-host)
       (local-url-handler win parsed-url true)
 
+      (= "handbook" url-host)
+      (send-to-renderer :handbook
+                        {:key  (some-> (.-pathname parsed-url) (string/replace-first #"^[\/]+" ""))
+                         :args (some-> (.-searchParams parsed-url) (js/Object.fromEntries))})
+
       :else
-      (send-to-renderer "notification" {:type "error"
-                                        :payload (str "Failed to open link. Cannot match `" url-host
-                                                      "` to any target.")}))))
+      (send-to-renderer :notification
+                        {:type    "error"
+                         :payload (str "Failed to open link. Cannot match `" url-host
+                                       "` to any target.")}))))

+ 7 - 1
src/main/electron/listener.cljs

@@ -188,7 +188,13 @@
 
   (safe-api-call "syncAPIServerState"
                  (fn [^js data]
-                   (state/set-state! :electron/server (bean/->clj data)))))
+                   (state/set-state! :electron/server (bean/->clj data))))
+
+
+  (safe-api-call "handbook"
+                 (fn [^js data]
+                   (when-let [k (and data (.-key data))]
+                     (state/open-handbook-pane! k)))))
 
 (defn listen!
   []

+ 2 - 2
src/main/frontend/components/block.cljs

@@ -1338,7 +1338,7 @@
                   url)]
         (if (and (coll? src)
                  (= (first src) "youtube-player"))
-          (youtube/youtube-video (last src))
+          (youtube/youtube-video (last src) nil)
           (when src
             (let [width (min (- (util/get-width) 96) 560)
                   height (int (* width (/ (if (string/includes? src "player.bilibili.com")
@@ -1475,7 +1475,7 @@
                                 :else
                                 (nth (util/safe-re-find text-util/youtube-regex url) 5))]
           (when-not (string/blank? youtube-id)
-            (youtube/youtube-video youtube-id))))
+            (youtube/youtube-video youtube-id nil))))
 
       (= name "youtube-timestamp")
       (when-let [timestamp (first arguments)]

+ 68 - 10
src/main/frontend/components/container.cljs

@@ -1,6 +1,7 @@
 (ns frontend.components.container
   (:require [cljs-drag-n-drop.core :as dnd]
             [clojure.string :as string]
+            [frontend.version :refer [version]]
             [frontend.components.find-in-page :as find-in-page]
             [frontend.components.header :as header]
             [frontend.components.journal :as journal]
@@ -11,6 +12,7 @@
             [frontend.components.select :as select]
             [frontend.components.theme :as theme]
             [frontend.components.widgets :as widgets]
+            [frontend.components.handbooks :as handbooks]
             [frontend.config :as config]
             [frontend.context.i18n :refer [t]]
             [frontend.db :as db]
@@ -35,6 +37,7 @@
             [frontend.util :as util]
             [frontend.util.cursor :as cursor]
             [frontend.components.window-controls :as window-controls]
+            [medley.core :as medley]
             [goog.dom :as gdom]
             [goog.object :as gobj]
             [logseq.common.path :as path]
@@ -696,15 +699,71 @@
                {:on-click state/toggle-document-mode!}
                "D"])))
 
+(def help-menu-items
+  [{:title "Handbook" :icon "book-2" :on-click #(handbooks/toggle-handbooks)}
+   {:title "Keyboard shortcuts" :icon "command" :on-click #(state/sidebar-add-block! (state/get-current-repo) "shortcut-settings" :shortcut-settings)}
+   {:title "Documentation" :icon "help" :href "https://docs.logseq.com/"}
+   :hr
+   {:title "Report bug" :icon "bug" :on-click #(rfe/push-state :bug-report)}
+   {:title "Request feature" :icon "git-pull-request" :href "https://discuss.logseq.com/c/feature-requests/"}
+   {:title "Submit feedback" :icon "messages" :href "https://discuss.logseq.com/c/feedback/13"}
+   :hr
+   {:title "Ask the community" :icon "brand-discord" :href "https://discord.com/invite/KpN4eHY"}
+   {:title "Support forum" :icon "message" :href "https://discuss.logseq.com/"}
+   :hr
+   {:title "Release notes" :icon "asterisk" :href "https://docs.logseq.com/#/page/changelog"}])
+
+(rum/defc help-menu-popup
+  []
+
+  (rum/use-effect!
+    (fn []
+      (state/set-state! :ui/handbooks-open? false))
+    [])
+
+  (rum/use-effect!
+    (fn []
+      (let [h #(state/set-state! :ui/help-open? false)]
+        (.addEventListener js/document.body "click" h)
+        #(.removeEventListener js/document.body "click" h)))
+    [])
+
+  [:div.cp__sidebar-help-menu-popup
+   [:div.list-wrap
+    (for [[idx {:keys [title icon href on-click] :as item}] (medley/indexed help-menu-items)]
+      (case item
+        :hr
+        [:hr.my-2 {:key idx}]
+
+        ;; default
+        [:a.it.flex.items-center.px-4.py-1.select-none
+         {:key      title
+          :on-click (fn []
+                      (cond
+                        (fn? on-click) (on-click)
+                        (string? href) (util/open-url href))
+                      (state/set-state! :ui/help-open? false))}
+         [:span.flex.items-center.pr-2.opacity-40 (ui/icon icon {:size 20})]
+         [:strong.font-normal title]]))]
+   [:div.ft.pl-11.pb-3
+    [:span.opacity.text-xs.opacity-30 "Logseq " version]]])
+
 (rum/defc help-button < rum/reactive
   []
-  (when-not (state/sub :ui/sidebar-open?)
-    [:div.cp__sidebar-help-btn
-     [:div.inner
-      {:title    (t :help-shortcut-title)
-       :on-click (fn []
-                   (state/sidebar-add-block! (state/get-current-repo) "help" :help))}
-      "?"]]))
+  (let [help-open?      (state/sub :ui/help-open?)
+        handbooks-open? (state/sub :ui/handbooks-open?)]
+    [:<>
+     [:div.cp__sidebar-help-btn
+      [:div.inner
+       {:title    (t :help-shortcut-title)
+        :on-click #(state/toggle! :ui/help-open?)}
+       "?"]]
+
+     (when help-open?
+       (help-menu-popup))
+
+     (when handbooks-open?
+       (handbooks/handbooks-popup))]))
 
 (rum/defcs ^:large-vars/cleanup-todo sidebar <
   (mixins/modal :modal/show?)
@@ -828,7 +887,6 @@
                                     :nfs-granted? granted?
                                     :db-restoring? db-restoring?})
       [:a#download.hidden]
-      (when
-       (and (not config/mobile?)
-            (not config/publishing?))
+      (when (and (not config/mobile?)
+                 (not config/publishing?))
         (help-button))])))

+ 34 - 11
src/main/frontend/components/container.css

@@ -487,14 +487,6 @@
   }
 }
 
-html[data-theme='dark'] {
-  #left-sidebar {
-    > .shade-mask {
-      background-color: rgba(0, 0, 0, .15);
-    }
-  }
-}
-
 .settings-modal {
   @apply -m-8 rounded-lg;
   /* box-shadow: inset 0 0 0 1px var(--ls-border-color); */
@@ -527,13 +519,36 @@ html[data-theme='dark'] {
     @apply fixed bottom-4 right-4 sm:right-8;
 
     > .inner {
-      @apply font-bold
-      rounded-full h-8 w-8 flex items-center justify-center font-bold
-      select-none cursor-help;
+      @apply rounded-full h-8 w-8 flex items-center justify-center
+      font-bold select-none cursor-help;
 
       background-color: var(--ls-secondary-background-color);
     }
   }
+
+  &-menu-popup {
+    @apply fixed bottom-14 right-8 z-10 border
+    rounded-lg min-w-[260px] shadow;
+
+    background-color: var(--ls-secondary-background-color);
+    border-color: var(--ls-border-color);
+
+    > .list-wrap {
+      @apply flex flex-col pt-3;
+
+      .it {
+        color: var(--ls-primary-text-color);
+
+        &:active, &:hover {
+          background-color: var(--ls-tertiary-background-color);
+        }
+      }
+    }
+  }
+
+  &-handbook-btn {
+    @apply bottom-16;
+  }
 }
 
 .cp__right-sidebar {
@@ -754,3 +769,11 @@ a.ui__modal-close, a.close {
 a.ui__modal-close:hover, a.close:hover {
   opacity: 1;
 }
+
+html[data-theme='dark'] {
+  #left-sidebar {
+    > .shade-mask {
+      background-color: rgba(0, 0, 0, .15);
+    }
+  }
+}

+ 40 - 0
src/main/frontend/components/handbooks.cljs

@@ -0,0 +1,40 @@
+(ns frontend.components.handbooks
+  (:require [rum.core :as rum]
+            [frontend.state :as state]
+            [frontend.modules.layout.core :as layout]
+            ;[shadow.lazy :as lazy]
+            [frontend.extensions.handbooks.core :as handbooks]))
+
+#_:clj-kondo/ignore
+;(def lazy-handbooks (lazy/loadable frontend.extensions.handbooks.core/content))
+;
+;(rum/defc loadable-handbooks
+;  []
+;  (let [[content set-content] (rum/use-state nil)]
+;
+;    (rum/use-effect!
+;     (fn []
+;       (lazy/load lazy-handbooks #(set-content %))) [])
+;
+;    [:div.cp__handbooks-content
+;     content]))
+
+(rum/defc handbooks-popup
+  []
+  (let [popup-ref (rum/use-ref nil)]
+    (rum/use-effect!
+     (fn []
+       (when-let [^js popup-el (rum/deref popup-ref)]
+         (comp
+          (layout/setup-draggable-container! popup-el nil))))
+     [])
+
+    [:div.cp__handbooks-popup
+     {:data-identity "logseq-handbooks"
+      :ref popup-ref}
+     [:div.cp__handbooks-content-wrap
+      (handbooks/content)]]))
+
+(defn toggle-handbooks
+  []
+  (state/toggle! :ui/handbooks-open?))

+ 1 - 0
src/main/frontend/components/onboarding/index.css

@@ -506,6 +506,7 @@ body[data-page=import] {
 
   &.cp__sidebar-help-btn {
     background-color: rgba(0, 0, 0, .4);
+    z-index: 9;
 
     > .inner {
       opacity: 1;

+ 658 - 0
src/main/frontend/extensions/handbooks/core.cljs

@@ -0,0 +1,658 @@
+(ns frontend.extensions.handbooks.core
+  (:require [clojure.string :as string]
+            [rum.core :as rum]
+            [cljs.core.async :as async :refer [<! >!]]
+            [frontend.ui :as ui]
+            [frontend.state :as state]
+            [frontend.search :as search]
+            [frontend.config :as config]
+            [frontend.handler.notification :as notification]
+            [frontend.extensions.lightbox :as lightbox]
+            [frontend.modules.shortcut.config :as shortcut-config]
+            [frontend.rum :as r]
+            [cljs-bean.core :as bean]
+            [promesa.core :as p]
+            [camel-snake-kebab.core :as csk]
+            [medley.core :as medley]
+            [frontend.util :as util]
+            [frontend.storage :as storage]
+            [frontend.extensions.video.youtube :as youtube]
+            [frontend.context.i18n :refer [t]]
+            [clojure.edn :as edn]))
+
+(defonce *config (atom {}))
+
+(defn get-handbooks-endpoint
+  [resource]
+  (str
+    (if (storage/get :handbooks-dev-watch?)
+      "http://localhost:1337"
+      "https://handbooks.pages.dev")
+    resource))
+
+(defn resolve-asset-url
+  [path]
+  (if (string/starts-with? path "http")
+    path (str (get-handbooks-endpoint "/")
+              (-> path (string/replace-first "./" "")
+                  (string/replace-first #"^/+" "")))))
+
+(defn inflate-content-assets-urls
+  [content]
+  (if-let [matches (and (not (string/blank? content))
+                        (re-seq #"src=\"([^\"]+)\"" content))]
+    (reduce
+      (fn [content matched]
+        (if-let [matched (second matched)]
+          (string/replace content matched (resolve-asset-url matched)) content))
+      content matches)
+    content))
+
+(defn parse-key-from-href
+  [href base]
+  (when (and (string? href)
+             (not (string/blank? href)))
+    (when-let [href (some-> href (string/trim) (string/replace #".edn$" ""))]
+      (some-> (if (string/starts-with? href "@")
+                (string/replace href #"^[@\/]+" "")
+                (util/node-path.join base href))
+              (string/lower-case)
+              (csk/->snake_case_string)))))
+
+(defn parse-parent-key
+  [s]
+  (if (and (string? s) (string/includes? s "/"))
+    (subs s 0 (string/last-index-of s "/"))
+    s))
+
+(defn bind-parent-key
+  [{:keys [key] :as node}]
+  (cond-> node
+          (and (string? key)
+               (string/includes? key "/"))
+          (assoc :parent (parse-parent-key key))))
+
+(defn load-glide-assets!
+  []
+  (p/let [_ (util/css-load$ (str util/JS_ROOT "/glide/glide.core.min.css"))
+          _ (util/css-load$ (str util/JS_ROOT "/glide/glide.theme.min.css"))
+          _ (when-not (aget js/window "Glide")
+              (util/js-load$ (str util/JS_ROOT "/glide/glide.min.js")))]))
+
+(rum/defc topic-card
+  [{:keys [key title description cover] :as _topic} nav-fn! opts]
+  [:button.w-full.topic-card.flex.text-left
+   (merge
+     {:key      key
+      :on-click nav-fn!} opts)
+   (when cover
+     [:div.l.flex.items-center
+      [:img {:src (resolve-asset-url cover)}]])
+   [:div.r.flex.flex-col
+    [:strong title]
+    [:span description]]])
+
+(rum/defc pane-category-topics
+  [handbook-nodes pane-state nav!]
+
+  [:div.pane.pane-category-topics
+   [:div.topics-list
+    (let [category-key (:key (second pane-state))]
+      (when-let [category (get handbook-nodes category-key)]
+        (for [topic (:children category)]
+          (rum/with-key
+            (topic-card topic #(nav! [:topic-detail topic (:title category)] pane-state) nil)
+            (:key topic)))))]])
+
+(rum/defc media-render
+  [src]
+  (let [src (util/trim-safe src)
+        extname (some-> src (util/full-path-extname) (subs 1))
+        youtube-id (and (string/includes? src "youtube.com/watch?v=")
+                        (subs src (+ 2 (string/last-index-of src "v="))))]
+    (cond
+      (and extname (contains? config/video-formats (keyword extname)))
+      [:video {:src src :controls true}]
+
+      (string? youtube-id)
+      (youtube/youtube-video youtube-id {:width "100%" :height 235})
+
+      :else [:img {:src src}])))
+
+(rum/defc chapter-select
+  [topic children on-select]
+  (let [[open?, set-open?] (rum/use-state false)]
+    (rum/use-effect!
+      (fn []
+        (when-let [^js el (js/document.querySelector "[data-identity=logseq-handbooks]")]
+          (let [h #(when-not (some->> (.-target %)
+                                      (.contains (js/document.querySelector ".chapters-select")))
+                     (set-open? false))]
+            (.addEventListener el "click" h)
+            #(.removeEventListener el "click" h))))
+      [])
+
+    [:div.chapters-select.w-full
+     [:a.select-trigger
+      {:on-click #(set-open? (not open?))
+       :tabIndex "0"}
+      [:small "Current chapter"]
+      [:strong (:title topic)]
+      (if open?
+        (ui/icon "chevron-down")
+        (ui/icon "chevron-left"))
+
+      (when open?
+        [:ul
+         (for [c children]
+           (when (and (seq c) (not= (:key c) (:key topic)))
+             [:li {:key (:key c)}
+              [:a.flex {:tabIndex "0" :on-click #(on-select (:key c))}
+               (or (:title c) (:key c))]]))])]]))
+
+(rum/defc ^:large-vars/cleanup-todo pane-topic-detail
+  [handbook-nodes pane-state nav!]
+
+  (let [[deps-pending?, set-deps-pending?] (rum/use-state false)
+        *id-ref (rum/use-ref (str "glide--" (js/Date.now)))]
+
+    ;; load deps assets
+    (rum/use-effect!
+      (fn []
+        (set-deps-pending? true)
+        (-> (load-glide-assets!)
+            (p/then (fn [] (js/setTimeout
+                             #(when (js/document.getElementById (rum/deref *id-ref))
+                                (doto (js/window.Glide. (str "#" (rum/deref *id-ref))) (.mount))) 50)))
+            (p/finally #(set-deps-pending? false))))
+      [])
+
+    (rum/use-effect!
+      (fn []
+        (js/setTimeout #(some-> (js/document.querySelector ".cp__handbooks-content")
+                                (.scrollTo 0 0))))
+      [pane-state])
+
+    (when-let [topic-key (:key (second pane-state))]
+      (when-let [topic (get handbook-nodes topic-key)]
+        (let [chapters (:children topic)
+              has-chapters? (seq chapters)
+              topic (if has-chapters? (first chapters) topic)
+              parent (get handbook-nodes (:parent (bind-parent-key topic)))
+              chapters (or chapters (:children parent))
+              parent-key (:key parent)
+              parent-category? (not (string/includes? parent-key "/"))
+              show-chapters? (and (not parent-category?) (seq chapters))
+
+              chapters-len (count chapters)
+              chapter-current-idx (when-not (zero? chapters-len)
+                                    (util/find-index #(= (:key %) (:key topic)) chapters))]
+
+          (when-not deps-pending?
+            [:div.pane.pane-topic-detail
+             (when-not show-chapters?
+               [:h1.text-2xl.pb-3.font-semibold (:title topic)])
+
+             ;; chapters list
+             (when show-chapters?
+               [:div.chapters-wrap.py-2
+                (chapter-select
+                  topic chapters
+                  (fn [k]
+                    (when-let [chapter (get handbook-nodes k)]
+                      (nav! [:topic-detail chapter (:title parent)] pane-state))))])
+
+             ;; demos gallery
+             (when-let [demos (:demos topic)]
+               (let [demos (cond-> demos
+                                   (string? demos) (list))]
+                 (if (> (count demos) 1)
+                   [:div.flex.demos.glide
+                    {:id (rum/deref *id-ref)}
+
+                    [:div.glide__track {:data-glide-el "track"}
+                     [:div.glide__slides
+                      (for [demo demos]
+                        [:div.item.glide__slide
+                         (media-render (resolve-asset-url demo))])]]
+
+                    [:div.glide__bullets {:data-glide-el "controls[nav]"}
+                     (map-indexed
+                       (fn [idx _]
+                         [:button.glide__bullet {:data-glide-dir (str "=" idx)}
+                          (inc idx)])
+                       demos)]]
+
+                   [:div.flex.demos.pt-1
+                    (media-render (resolve-asset-url (first demos)))])))
+
+             [:div.content-wrap
+              (when-let [content (:content topic)]
+                [:<>
+                 [:div.content.markdown-body
+                  {:dangerouslySetInnerHTML {:__html (inflate-content-assets-urls content)}
+                   :on-click                (fn [^js e]
+                                              (when-let [target (.-target e)]
+                                                (if-let [^js img (.closest target "img")]
+                                                  (lightbox/preview-images! [{:src (.-src img)
+                                                                              :w   (.-naturalWidth img)
+                                                                              :h   (.-naturalHeight img)}])
+                                                  (when-let [link (some-> (.closest target "a") (.getAttribute "href"))]
+                                                    (when-let [to-k (and (not (string/starts-with? link "http"))
+                                                                         (parse-key-from-href link parent-key))]
+                                                      (if-let [to (get handbook-nodes to-k)]
+                                                        (nav! [:topic-detail to (:title parent)] pane-state)
+                                                        (js/console.error "ERROR: handbook link resource not found: " to-k link))
+                                                      (util/stop e))))))}]
+
+                 (when-let [idx (and (> chapters-len 1) chapter-current-idx)]
+                   (let [prev (when-not (zero? idx) (dec idx))
+                         next (when-not (= idx (dec chapters-len)) (inc idx))]
+
+                     [:div.controls.flex.justify-between.pt-4
+                      [:div (when prev (ui/button [:span.flex.items-center (ui/icon "arrow-left") "Prev chapter"]
+                                                  :small? true :on-click #(nav! [:topic-detail (nth chapters prev) (:title parent)] pane-state)))]
+                      [:div (when next (ui/button [:span.flex.items-center "Next chapter" (ui/icon "arrow-right")]
+                                                  :small? true :on-click #(nav! [:topic-detail (nth chapters next) (:title parent)] pane-state)))]]))])]]))))))
+
+(rum/defc pane-dashboard
+  [handbooks-nodes pane-state nav-to-pane!]
+  (when-let [root (get handbooks-nodes "__root")]
+    [:div.pane.dashboard-pane
+     (when-let [popular-topics (:popular-topics root)]
+       [:<>
+        [:h2 (t :handbook/popular-topics)]
+        [:div.topics-list
+         (for [topic-key popular-topics]
+           (when-let [topic (and (string? topic-key)
+                                 (->> (util/safe-lower-case topic-key)
+                                      (csk/->snake_case_string)
+                                      (get handbooks-nodes)))]
+             (topic-card topic #(nav-to-pane! [:topic-detail topic (t :handbook/title)] [:dashboard]) nil)))]])
+
+     [:h2 (t :handbook/help-categories)]
+     [:div.categories-list
+      (let [categories (:children root)
+            categories (conj (vec categories)
+                             {:key      :ls-shortcuts
+                              :title    [:span "Keyboard shortcuts"]
+                              :children [:span (->> (vals @shortcut-config/*config)
+                                                    (map count)
+                                                    (apply +))
+                                         " shortcuts"]
+                              :color    "#2563EB"
+                              :icon     "command"})]
+        (for [{:keys [key title children color icon] :as category} categories
+              :let [total (if counted? (count children) 0)]]
+          [:button.category-card.text-left
+           {:key      key
+            :style    {:border-left-color (or (ui/->block-background-color color) "var(--ls-secondary-background-color)")}
+            :data-total total
+            :on-click #(if (= key :ls-shortcuts)
+                         (do (state/toggle! :ui/handbooks-open?)
+                             (state/open-right-sidebar!)
+                             (state/sidebar-add-block! (state/get-current-repo) "shortcut-settings" :shortcut-settings))
+                         (nav-to-pane! [:topics category title] pane-state))}
+           [:div.icon-wrap
+            (ui/icon (or icon "chart-bubble") {:size 20})]
+           [:div.text-wrap
+            [:strong title]
+            (cond
+              (vector? children)
+              children
+
+              :else
+              [:span (str total " " (util/safe-lower-case (t :handbook/topics)))])]]))]]))
+
+(rum/defc pane-settings
+  [dev-watch? set-dev-watch?]
+  [:div.pane.pane-settings
+   [:div.item
+    [:p.flex.items-center.space-x-3.mb-0
+     [:strong "Writing mode (preview in time)"]
+     (ui/toggle dev-watch? #(set-dev-watch? (not dev-watch?)) true)]
+    [:small.opacity-30 (str "Resources from " (get-handbooks-endpoint "/"))]]])
+
+(rum/defc search-bar
+  [pane-state nav! handbooks-nodes search-state set-search-state!]
+  (let [*input-ref (rum/use-ref nil)
+        [q, set-q!] (rum/use-state "")
+        [results, set-results!] (rum/use-state nil)
+        [selected, set-selected!] (rum/use-state 0)
+        select-fn! #(when-let [ldx (and (seq results) (dec (count results)))]
+                      (set-selected!
+                        (case %
+                          :up (if (zero? selected) ldx (max (dec selected) 0))
+                          :down (if (= selected ldx) 0 (min (inc selected) ldx))
+                          :dune)))
+
+        q (util/trim-safe q)
+        active? (not (string/blank? (util/trim-safe q)))
+        reset-q! #(->> "" (set! (.-value (rum/deref *input-ref))) (set-q!))
+        focus-q! #(some-> (rum/deref *input-ref) (.focus))]
+
+    (rum/use-effect!
+      #(focus-q!)
+      [pane-state])
+
+    (rum/use-effect!
+      (fn []
+        (let [pane-nodes (:children (second pane-state))
+              pane-nodes (and (seq pane-nodes)
+                              (mapcat #(conj (:children %) %) pane-nodes))]
+
+          (set-search-state!
+            (merge search-state {:active? active?}))
+
+          (if (and (seq handbooks-nodes) active?)
+            (-> (or pane-nodes
+                    ;; global
+                    (vals (dissoc handbooks-nodes "__root")))
+                (search/fuzzy-search q :limit 30 :extract-fn :title)
+                (set-results!))
+            (set-results! nil))
+
+          (set-selected! 0)))
+      [q])
+
+    [:div.search
+     [:div.input-wrap.relative
+      [:span.icon.absolute.opacity-90
+       {:style {:top 6 :left 7}}
+       (ui/icon "search" {:size 12})]
+
+      [:input {:placeholder   (t :handbook/search)
+               :auto-focus    true
+               :default-value q
+               :on-change     #(set-q! (util/evalue %))
+               :on-key-down   #(case (.-keyCode %)
+                                 ;; ESC
+                                 27
+                                 (if-not active?
+                                   (state/toggle! :ui/handbooks-open?)
+                                   (reset-q!))
+
+                                 ;; Up
+                                 38
+                                 (do
+                                   (util/stop %)
+                                   (select-fn! :up))
+
+                                 ;; Down
+                                 40
+                                 (do
+                                   (util/stop %)
+                                   (select-fn! :down))
+
+                                 ;; Enter
+                                 13
+                                 (when-let [topic (and active? (nth results selected))]
+                                   (util/stop %)
+                                   (nav! [:topic-detail topic (:title topic)] pane-state))
+
+                                 :dune)
+               :ref           *input-ref}]
+
+      (when active?
+        [:button.icon.absolute.opacity-50.hover:opacity-80.select-none
+         {:style    {:right 6 :top 7}
+          :on-click #(do (reset-q!) (focus-q!))}
+         (ui/icon "x" {:size 12})])]
+
+     (when (:active? search-state)
+       [:div.search-results-wrap
+        [:div.results-wrap
+         (for [[idx topic] (medley/indexed results)]
+           (rum/with-key
+             (topic-card topic #(nav! [:topic-detail topic (:title topic)] pane-state)
+                         {:class (util/classnames [{:active (= selected idx)}])})
+             (:key topic)))]])]))
+
+(rum/defc link-card
+  [opts child]
+
+  (let [{:keys [href]} opts]
+    [:div.link-card
+     (cond-> opts
+             (string? href)
+             (assoc :on-click #(util/open-url href)))
+     child]))
+
+;(rum/defc related-topics
+;  []
+;  [:div.related-topics
+;   (link-card {} [:strong.text-md "How to do something?"])])
+
+(def panes-mapping
+  {:dashboard    [pane-dashboard]
+   :topics       [pane-category-topics]
+   :topic-detail [pane-topic-detail]
+   :settings     [pane-settings]})
+
+
+(defonce discord-endpoint "https://plugins.logseq.io/ds")
+
+(rum/defc footer-link-cards
+  []
+  (let [[config _] (r/use-atom *config)
+        discord-count (:discord-online config)]
+
+    (rum/use-effect!
+      (fn []
+        (when (or (nil? discord-count)
+                  (> (- (js/Date.now) (:discord-online-created config)) (* 10 60 1000)))
+          (-> (js/window.fetch discord-endpoint)
+              (p/then #(.json %))
+              (p/then #(when-let [count (.-approximate_presence_count ^js %)]
+                         (swap! *config assoc
+                                :discord-online (.toLocaleString count)
+                                :discord-online-created (js/Date.now)))))))
+      [discord-count])
+
+    [:<>
+     ;; more links
+     [:div.flex.space-x-3
+      {:style {:padding-top "4px"}}
+      (link-card
+        {:class "flex-1" :href "https://discord.gg/KpN4eHY"}
+        [:div.inner.flex.space-x-1.flex-col
+         (ui/icon "brand-discord" {:class "opacity-30" :size 26})
+         [:h1.font-medium.py-1 "Chat on Discord"]
+         [:h2.text-xs.leading-4.opacity-40 "Ask quick questions, meet fellow users, and learn new workflows."]
+         [:small.flex.items-center.pt-1.5
+          [:i.block.rounded-full.bg-green-500 {:style {:width "8px" :height "8px"}}]
+          [:span.pl-2.opacity-90
+           [:strong.opacity-60 (or discord-count "?")]
+           [:span.opacity-70.font-light " users online"]]]])
+
+      (link-card
+        {:class "flex-1" :href "https://discuss.logseq.com"}
+        [:div.inner.flex.space-x-1.flex-col
+         (ui/icon "message-dots" {:class "opacity-30" :size 26})
+         [:h1.font-medium.py-1 "Visit the forum"]
+         [:h2.text-xs.leading-4.opacity-40 "Give feedback, request features, and have in-depth conversations."]
+         [:small.flex.items-center.pt-1.5
+          [:i.flex.items-center.opacity-50 (ui/icon "bolt" {:size 14})]
+          [:span.pl-1.opacity-90
+           [:strong.opacity-60 "800+"]
+           [:span.opacity-70.font-light " monthly posts"]]]])]]))
+
+(rum/defc ^:large-vars/data-var content
+  []
+  (let [[active-pane-state, set-active-pane-state!]
+        (rum/use-state [:dashboard nil (t :handbook/title)])
+
+        [handbooks-state, set-handbooks-state!]
+        (rum/use-state nil)
+
+        [handbooks-nodes, set-handbooks-nodes!]
+        (rum/use-state nil)
+
+        [history-state, set-history-state!]
+        (rum/use-state ())
+
+        [dev-watch?, set-dev-watch?]
+        (rum/use-state (storage/get :handbooks-dev-watch?))
+
+        [search-state, set-search-state!]
+        (rum/use-state {:active? false})
+
+        reset-handbooks! #(set-handbooks-state! {:status nil :data nil :error nil})
+        update-handbooks! #(set-handbooks-state! (fn [v] (merge v %)))
+        load-handbooks! (fn []
+                          (when-not (= :pending (:status handbooks-state))
+                            (reset-handbooks!)
+                            (update-handbooks! {:status :pending})
+                            (-> (p/let [^js res (js/fetch (get-handbooks-endpoint "/handbooks.edn"))
+                                        data (.text res)]
+                                  (update-handbooks! {:data (edn/read-string data)}))
+                                (p/catch #(update-handbooks! {:error (str %)}))
+                                (p/finally #(update-handbooks! {:status :completed})))))
+
+        active-pane-name (first active-pane-state)
+        pane-render (first (get panes-mapping active-pane-name))
+        pane-dashboard? (= :dashboard active-pane-name)
+        pane-settings? (= :settings active-pane-name)
+        pane-topic? (= :topic-detail active-pane-name)
+        force-nav-dashboard! (fn []
+                               (set-active-pane-state! [:dashboard])
+                               (set-history-state! '()))
+
+        handbooks-loaded? (and (seq (:data handbooks-state))
+                               (= :completed (:status handbooks-state)))
+        handbooks-data (:data handbooks-state)
+        nav-to-pane! (fn [next-state prev-state]
+                       (let [next-key (:key (second next-state))
+                             prev-key (:key (second prev-state))
+                             in-chapters? (and prev-key next-key (string/includes? prev-key "/")
+                                               (or (string/starts-with? next-key prev-key)
+                                                   (apply = (map parse-parent-key [prev-key next-key]))))]
+                         (when-not in-chapters?
+                           (set-history-state!
+                             (conj (sequence history-state) prev-state))))
+                       (set-active-pane-state! next-state))
+
+        [scrolled?, set-scrolled!] (rum/use-state false)
+        on-scroll (rum/use-memo #(util/debounce 100 (fn [^js e] (set-scrolled! (not (< (.. e -target -scrollTop) 10))))) [])]
+
+    ;; load handbooks
+    (rum/use-effect!
+      #(load-handbooks!)
+      [])
+
+    ;; navigation sentry
+    (rum/use-effect!
+      (fn []
+        (when (seq handbooks-nodes)
+          (let [c (:handbook/route-chan @state/state)]
+            (async/go-loop []
+                           (let [v (<! c)]
+                             (when (not= v :return)
+                               (when-let [to (get handbooks-nodes v)]
+                                 (nav-to-pane! [:topic-detail to (t :handbook/title)] [:dashboard]))
+                               (recur))))
+            #(async/go (>! c :return)))))
+      [handbooks-nodes])
+
+    (rum/use-effect!
+      (fn []
+        (let [*cnt-len (atom 0)
+              check! (fn []
+                       (-> (p/let [^js res (js/fetch (get-handbooks-endpoint "/handbooks.edn") #js{:method "HEAD"})]
+                             (when-let [cl (.get (.-headers res) "content-length")]
+                               (when (not= @*cnt-len cl)
+                                 (println "[Handbooks] dev reload!")
+                                 (load-handbooks!))
+                               (reset! *cnt-len cl)))
+                           (p/catch #(println "[Handbooks] dev check Error:" %))))
+              timer0 (if dev-watch?
+                       (js/setInterval check! 2000) 0)]
+          #(js/clearInterval timer0)))
+      [dev-watch?])
+
+    (rum/use-effect!
+      (fn []
+        (when handbooks-data
+          (let [nodes (->> (tree-seq map? :children handbooks-data)
+                           (reduce #(assoc %1 (or (:key %2) "__root") (bind-parent-key %2)) {}))]
+            (set-handbooks-nodes! nodes)
+            (set! (.-handbook-nodes js/window) (bean/->js nodes)))))
+      [handbooks-data])
+
+    [:div.cp__handbooks-content
+     {:class     (util/classnames [{:search-active (:active? search-state)
+                                    :scrolled      scrolled?}])
+      :on-scroll on-scroll}
+     [:div.pane-wrap
+      [:div.hd.flex.justify-between.select-none.draggable-handle
+       [:h1.text-xl.flex.items-center.font-bold
+        (if pane-dashboard?
+          [:span (t :handbook/title)]
+          [:button.active:opacity-80.flex.items-center.cursor-pointer
+           {:on-click (fn [] (let [prev (first history-state)
+                                   prev (cond-> prev
+                                                (nil? (seq prev))
+                                                [:dashboard])]
+                               (set-active-pane-state! prev)
+                               (set-history-state! (rest history-state))))}
+           [:span.pr-2.flex.items-center (ui/icon "chevron-left")]
+           (let [title (or (last active-pane-state) (t :handbook/title) "")]
+             [:span.truncate.title {:title title} title])])]
+
+       [:div.flex.items-center.space-x-3
+        (when (> (count history-state) 1)
+          [:a.flex.items-center {:aria-label (t :handbook/home) :tabIndex "0" :on-click #(force-nav-dashboard!)} (ui/icon "home")])
+        (when pane-topic?
+          [:a.flex.items-center
+           {:aria-label "Copy topic link" :tabIndex "0"
+            :on-click   (fn []
+                          (let [s (str "logseq://handbook/" (:key (second active-pane-state)))]
+                            (util/copy-to-clipboard! s)
+                            (notification/show!
+                              [:div [:strong.block "Handbook link copied!"]
+                               [:label.opacity-50 s]] :success)))}
+           (ui/icon "copy")])
+        (when (state/developer-mode?)
+          [:a.flex.items-center {:aria-label (t :handbook/settings)
+                                 :tabIndex   "0"
+                                 :on-click   #(nav-to-pane! [:settings nil "Settings"] active-pane-state)}
+           (ui/icon "settings")])
+        [:a.flex.items-center {:aria-label (t :handbook/close) :tabIndex "0" :on-click #(state/toggle! :ui/handbooks-open?)}
+         (ui/icon "x")]]]
+
+      (when (and (not pane-settings?) (not handbooks-loaded?))
+        [:div.flex.items-center.justify-center.pt-32
+         (if-not (:error handbooks-state)
+           (ui/loading "Loading ...")
+           [:code (:error handbooks-state)])])
+
+      (when (or pane-settings? handbooks-loaded?)
+        [:<>
+         ;; search bar
+         (when (or pane-dashboard? (= :topics active-pane-name))
+           (search-bar active-pane-state nav-to-pane!
+                       handbooks-nodes search-state set-search-state!))
+
+         ;; entry pane
+         (when pane-render
+           (apply pane-render
+                  (case active-pane-name
+                    :settings
+                    [dev-watch? #(do (set-dev-watch? %)
+                                     (storage/set :handbooks-dev-watch? %))]
+
+                    ;; default inputs
+                    [handbooks-nodes active-pane-state nav-to-pane!])))])]
+
+     (when handbooks-loaded?
+       ;; footer
+       (when pane-dashboard?
+         [:div.ft
+          (footer-link-cards)
+
+          ;; TODO: how to get related topics?
+          ;(when (= :topic-detail active-pane)
+          ;  [:<>
+          ;   [:h2.uppercase.opacity-60 "Related"]
+          ;   (related-topics)])
+          ]))]))

+ 412 - 0
src/main/frontend/extensions/handbooks/handbooks.css

@@ -0,0 +1,412 @@
+.cp__handbooks {
+  &-content {
+    @apply flex flex-col justify-between flex-1 overflow-y-auto;
+
+    -webkit-font-smoothing: antialiased;
+    overflow-y: overlay;
+
+    &-wrap {
+      @apply flex justify-center flex-col flex-1 h-full overflow-y-auto relative;
+    }
+
+    .hd {
+      @apply dark:text-white px-3 pt-3 pb-2 sticky top-0 left-0 z-[4]
+      transition-shadow duration-200;
+
+      background-color: var(--ls-tertiary-background-color);
+
+      .title {
+        text-align: left;
+        width: 266px;
+      }
+    }
+
+    &.scrolled {
+      .hd {
+        box-shadow: -3px 4px 6px -6px #ccc;
+      }
+    }
+
+    .search {
+      @apply flex flex-col pb-[6px] mb-0;
+
+      > .input-wrap {
+        @apply mx-4 mb-2 flex rounded-lg mt-1.5;
+
+        border: 3px solid var(--ls-primary-background-color);
+        background-color: var(--ls-primary-background-color);
+
+        &:focus-within {
+          border: 3px solid var(--ls-secondary-border-color);
+        }
+
+        > input {
+          @apply text-base leading-none w-full border-none py-[7px] px-[24px] bg-transparent
+          focus:outline-0 dark:text-gray-100 font-medium;
+        }
+
+      }
+
+      > .search-results-wrap {
+        @apply px-4 py-1;
+      }
+    }
+
+    .pane {
+      @apply py-1 px-4 dark:text-gray-50;
+    }
+
+    .pane > h2, .ft > h2 {
+      @apply py-2 text-base font-medium dark:text-gray-100;
+    }
+
+    .ft {
+      @apply px-4 pt-4 pb-2;
+
+      background-color: var(--ls-quaternary-background-color);
+
+      /*noinspection ALL*/
+
+      svg {
+        stroke-width: 1.5px;
+      }
+    }
+
+    .topic-card, .link-card {
+      @apply text-sm px-3 py-2.5 rounded-lg cursor-pointer
+      mb-2 active:opacity-90 select-none items-center;
+
+      background-color: var(--ls-secondary-background-color);
+      border: 1px solid var(--ls-border-color);
+      transition: background-color .3s;
+
+      > .l {
+        @apply pr-2.5 w-[80px] min-h-[64px] bg-transparent rounded overflow-hidden;
+
+        img {
+          mix-blend-mode: luminosity;
+          opacity: .8;
+          float: left;
+          width: 100%;
+        }
+      }
+
+      > .r {
+        @apply leading-none flex-1;
+
+        > strong {
+          @apply font-medium text-sm pt-0.5 pb-[1px] opacity-90 leading-5 dark:text-gray-5;
+        }
+
+        > span {
+          @apply text-xs opacity-40 leading-4;
+        }
+      }
+
+      &:hover, &.active {
+        background-color: var(--ls-primary-background-color);
+        border-color: var(--ls-secondary-border-color);
+
+        > .l {
+          img {
+            mix-blend-mode: unset;
+            opacity: 1;
+          }
+        }
+      }
+    }
+
+    .link-card {
+      @apply dark:text-gray-100;
+
+      border-color: var(--ls-tertiary-border-color);
+
+      &:hover {
+        border-color: var(--ls-tertiary-border-color);
+      }
+
+      &.as-primary {
+        @apply bg-indigo-500 text-white;
+      }
+    }
+
+    .category-card {
+      @apply flex rounded px-2 py-3 active:opacity-90 cursor-pointer transition-colors items-end;
+
+      border-left: 4px solid var(--ls-secondary-background-color);
+      background-color: var(--ls-secondary-background-color);
+
+      &[data-total="0"] {
+        @apply hidden;
+      }
+
+      &:hover, &:active {
+        background-color: var(--ls-primary-background-color);
+      }
+
+      > .icon-wrap {
+        @apply flex justify-end pr-2 pb-[2px] opacity-20;
+      }
+
+      > .text-wrap {
+        @apply flex flex-col min-h-[48px] justify-end;
+
+        > strong {
+          @apply font-medium leading-tight text-sm;
+        }
+
+        > span {
+          @apply text-xs pt-1;
+
+          color: var(--ls-primary-text-color);
+        }
+      }
+    }
+
+    .categories-list {
+      @apply grid grid-cols-2 gap-3;
+    }
+
+    .pane-topic-detail {
+      @apply flex flex-col h-full;
+
+      > h1 {
+        @apply pb-1;
+      }
+
+      > .demos {
+        img, video {
+          @apply w-full;
+        }
+
+        &.glide {
+          @apply mb-[10px];
+        }
+
+        .glide__slide {
+          background-color: var(--ls-secondary-border-color);
+        }
+      }
+
+      .content {
+        @apply overflow-hidden pt-1 leading-6;
+
+        &-wrap {
+          @apply flex flex-col justify-around;
+        }
+      }
+
+      iframe {
+        margin: 0;
+      }
+    }
+
+    .glide {
+      &__bullets {
+        @apply w-full bottom-0 left-0 transform-none
+        flex items-center justify-end pb-2 pr-1;
+      }
+
+      &__bullet {
+        @apply dark:text-black;
+
+        width: 24px;
+        height: 24px;
+        font-size: 13px;
+        border: none;
+        margin: 0 5px;
+      }
+
+      &--swipeable {
+        cursor: default !important;
+      }
+    }
+
+    &.search-active {
+      .pane {
+        &:not(.pane-topic-detail) {
+          display: none;
+        }
+      }
+
+      .ft {
+        display: none;
+      }
+    }
+
+    .markdown-body {
+      @apply pt-4;
+
+      -webkit-font-smoothing: initial;
+
+      h1 {
+        @apply py-1 text-2xl font-bold;
+      }
+
+      h2 {
+        @apply py-1 text-xl font-bold;
+      }
+
+      h3, h4 {
+        @apply py-1 text-lg font-semibold;
+      }
+
+      h4 {
+        @apply text-base;
+      }
+
+      h5 {
+        @apply py-0.5 font-semibold text-sm;
+      }
+
+      h6 {
+        @apply py-0.5 text-xs font-semibold;
+      }
+
+      p {
+        @apply leading-[1.6rem] my-[0.75rem];
+      }
+
+      blockquote {
+        margin: 0;
+      }
+    }
+
+    .chapters {
+      &-wrap {
+
+      }
+
+      &-select {
+        .select-trigger {
+          @apply relative flex flex-col rounded py-2 px-3 leading-5 select-none z-[1];
+
+          color: var(--ls-primary-text-color);
+          background-color: var(--ls-secondary-background-color);
+
+          small {
+            @apply text-[11px] opacity-50 pl-0.5;
+          }
+
+          strong {
+            @apply text-sm dark:text-gray-100;
+          }
+
+          .ui__icon {
+            @apply absolute right-2 top-5 opacity-70;
+          }
+
+          &:active {
+            .ui__icon, strong {
+              @apply opacity-80;
+            }
+          }
+
+          ul {
+            @apply absolute top-[58px] left-0 w-full list-none m-0 rounded-b py-2;
+
+            background-color: var(--ls-secondary-background-color);
+            transform: translateY(-5px);
+            max-height: 300px;
+            overflow: auto;
+
+            li {
+              @apply list-none px-3 py-1 transition-colors text-sm;
+
+              &:hover {
+                background-color: var(--ls-tertiary-background-color);
+              }
+            }
+          }
+        }
+      }
+    }
+
+    :not(pre) > code {
+      white-space: nowrap;
+    }
+
+    img {
+      @apply cursor-pointer active:opacity-80;
+    }
+
+    img, video {
+      @apply inline-block my-1;
+    }
+  }
+
+  &-popup {
+    @apply fixed rounded-lg overflow-hidden
+    z-[19] shadow-lg flex justify-center flex-col;
+
+    background-color: var(--ls-tertiary-background-color);
+    border: 1px solid var(--ls-tertiary-background-color);
+    touch-action: none;
+    height: 686px;
+    max-height: 86vh;
+    width: 420px;
+    right: 32px;
+    bottom: 58px;
+  }
+}
+
+html[data-theme="light"] {
+  .cp__handbooks-popup {
+    background-color: var(--ls-primary-background-color);
+
+    .input-wrap {
+      background-color: #f1f1f1;
+
+      &:focus-within {
+        background-color: transparent;
+      }
+    }
+
+    .topic-card, :not(.as-primary).link-card {
+      &:hover, &.active {
+        background-color: var(--ls-tertiary-background-color);
+        border-color: var(--ls-secondary-border-color);
+      }
+    }
+  }
+
+  .cp__handbooks-content {
+    .hd {
+      background-color: var(--ls-primary-background-color);
+    }
+
+    .ft {
+      background-color: var(--ls-primary-background-color);
+    }
+
+    .search {
+      background-color: var(--ls-primary-background-color);
+    }
+
+    .chapters-select {
+      .select-trigger {
+        background-color: var(--ls-tertiary-background-color);
+      }
+    }
+
+    .categories-list {
+      .category-card {
+        &:hover, &:active {
+          background-color: var(--ls-tertiary-background-color);
+        }
+      }
+    }
+
+    ul {
+      list-style: unset;
+
+      ul {
+        list-style: circle;
+
+        ul {
+          list-style: square;
+        }
+      }
+    }
+  }
+}

+ 24 - 24
src/main/frontend/extensions/video/youtube.cljs

@@ -28,17 +28,17 @@
 
 (defn register-player [state]
   (try
-    (let [id (first (:rum/args state))
-         node (rum/dom-node state)]
-     (when node
-       (let [player (js/window.YT.Player.
-                     node
-                     (clj->js
-                      {:events
-                       {"onReady" (fn [_e] (js/console.log id " ready"))}}))]
-         (state/update-state! [:youtube/players]
-                              (fn [players]
-                                (assoc players id player))))))
+    (let [id   (first (:rum/args state))
+          node (rum/dom-node state)]
+      (when node
+        (let [player (js/window.YT.Player.
+                      node
+                      (clj->js
+                       {:events
+                        {"onReady" (fn [_e] (js/console.log id " ready"))}}))]
+          (state/update-state! [:youtube/players]
+                               (fn [players]
+                                 (assoc players id player))))))
     (catch :default _e
       nil)))
 
@@ -51,14 +51,14 @@
        (<! (load-youtube-api))
        (register-player state))
      state)}
-  [state id]
-  (let [width  (min (- (util/get-width) 96)
-                    560)
-        height (int (* width (/ 315 560)))]
+  [state id {:keys [width height] :as _opts}]
+  (let [width  (or width (min (- (util/get-width) 96)
+                              560))
+        height (or height (int (* width (/ 315 560))))]
     [:iframe
      {:id                (str "youtube-player-" id)
       :allow-full-screen "allowfullscreen"
-      :allow "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope"
+      :allow             "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope"
       :frame-border      "0"
       :src               (str "https://www.youtube.com/embed/" id "?enablejsapi=1")
       :height            height
@@ -141,13 +141,13 @@ Remember: You can paste a raw YouTube url as embedded video on mobile."
   (re-matches #"^(?:(\d+):)?([0-5]?\d):([0-5]?\d)$" "123:22:23") ;; => ["123:22:23" "123" "22" "23"]
   (re-matches #"^(?:(\d+):)?([0-5]?\d):([0-5]?\d)$" "30:23") ;; => ["30:23" nil "30" "23"]
 
-  (parse-timestamp "01:23") ;; => 83
+ (parse-timestamp "01:23")                                  ;; => 83
 
-  (parse-timestamp "01:01:23") ;; => 3683
+ (parse-timestamp "01:01:23")                               ;; => 3683
 
-  ;; seconds->display
-  ;; https://stackoverflow.com/questions/1322732/convert-seconds-to-hh-mm-ss-with-javascript
-  (seconds->display 129600) ;; => "36:00:00"
-  (seconds->display 13545) ;; => "03:45:45"
-  (seconds->display 18) ;; => "00:18"
-  )
+ ;; seconds->display
+ ;; https://stackoverflow.com/questions/1322732/convert-seconds-to-hh-mm-ss-with-javascript
+ (seconds->display 129600)                                  ;; => "36:00:00"
+ (seconds->display 13545)                                   ;; => "03:45:45"
+ (seconds->display 18)                                      ;; => "00:18"
+ )

+ 1 - 5
src/main/frontend/handler/ui.cljs

@@ -68,11 +68,7 @@
 
 (defn toggle-help!
   []
-  (when-let [current-repo (state/get-current-repo)]
-    (let [id "help"]
-      (if (state/sidebar-block-exists? id)
-        (state/sidebar-remove-block! id)
-        (state/sidebar-add-block! current-repo id :help)))))
+  (state/toggle! :ui/help-open?))
 
 (defn toggle-settings-modal!
   []

+ 76 - 68
src/main/frontend/modules/layout/core.cljs

@@ -16,19 +16,19 @@
   [identity]
   (when-let [^js/HTMLElement container (and (> (count @*movable-containers) 1)
                                             (get @*movable-containers identity))]
-    (let [zdx (->> @*movable-containers
-                   (map (fn [[_ ^js el]]
-                          (let [^js c (js/getComputedStyle el)
-                                v1 (.-visibility c)
-                                v2 (.-display c)]
-                            (when-let [z (and (= "visible" v1)
-                                              (not= "none" v2)
-                                              (.-zIndex c))]
-                              z))))
-                   (remove nil?))
-          zdx (bean/->js zdx)
-          zdx (and zdx (js/Math.max.apply nil zdx))
-          zdx' (util/safe-parse-int (.. container -style -zIndex))]
+    (let [zdx  (->> @*movable-containers
+                    (map (fn [[_ ^js el]]
+                           (let [^js c (js/getComputedStyle el)
+                                 v1    (.-visibility c)
+                                 v2    (.-display c)]
+                             (when-let [z (and (= "visible" v1)
+                                               (not= "none" v2)
+                                               (.-zIndex c))]
+                               z))))
+                    (remove nil?))
+          zdx  (bean/->js zdx)
+          zdx  (and zdx (js/Math.max.apply nil zdx))
+          zdx' (some-> (.. container -style -zIndex) (parse-long))]
 
       (when (or (nil? zdx') (not= zdx zdx'))
         (set! (.. container -style -zIndex) (inc zdx))))))
@@ -36,35 +36,38 @@
 (defn ^:export setup-draggable-container!
   [^js/HTMLElement el callback]
   (when-let [^js/HTMLElement handle (.querySelector el ".draggable-handle")]
-    (let [^js cls (.-classList el)
-          ^js ds (.-dataset el)
+    (let [^js cls  (.-classList el)
+          ^js ds   (.-dataset el)
           identity (.-identity ds)
-          ing? "is-dragging"]
+          ing?     "is-dragging"]
 
       ;; draggable
       (-> (js/interact handle)
           (.draggable
-            (bean/->js
-              {:listeners
-               {:move (fn [^js/MouseEvent e]
-                        (let [^js dset (.-dataset el)
-                              dx (.-dx e)
-                              dy (.-dy e)
-                              dx' (util/safe-parse-float (.-dx dset))
-                              dy' (util/safe-parse-float (.-dy dset))
-                              x (+ dx (if dx' dx' 0))
-                              y (+ dy (if dy' dy' 0))]
-
-                          ;; update container position
-                          (set! (.. el -style -transform) (str "translate(" x "px, " y "px)"))
-
-                          ;; cache dx dy
-                          (set! (.. el -dataset -dx) x)
-                          (set! (.. el -dataset -dy) y)))}}))
+           (bean/->js
+            {:listeners
+             {:move (fn [^js/MouseEvent e]
+                      (let [^js dset (.-dataset el)
+                            dx       (.-dx e)
+                            dy       (.-dy e)
+                            dx'      (.-dx dset)
+                            dy'      (.-dy dset)
+                            dx'      (and dx' (util/safe-parse-float dx'))
+                            dy'      (and dy' (util/safe-parse-float dy'))
+                            x        (+ dx (or dx' 0))
+                            y        (+ dy (or dy' 0))]
+
+                        ;; update container position
+                        (set! (.. el -style -transform) (str "translate(" x "px, " y "px)"))
+
+                        ;; cache dx dy
+                        (set! (.. el -dataset -dx) x)
+                        (set! (.. el -dataset -dy) y)))}}))
           (.on "dragstart" (fn [] (.add cls ing?)))
           (.on "dragend" (fn [e]
                            (.remove cls ing?)
-                           (callback (bean/->js (calc-layout-data el e))))))
+                           (when (fn? callback)
+                             (callback (bean/->js (calc-layout-data el e)))))))
       ;; manager
       (swap! *movable-containers assoc identity el)
 
@@ -72,45 +75,50 @@
 
 (defn ^:export setup-resizable-container!
   [^js/HTMLElement el callback]
-  (let [^js cls (.-classList el)
-        ^js ds (.-dataset el)
+  (let [^js cls  (.-classList el)
+        ^js ds   (.-dataset el)
         identity (.-identity ds)
-        ing? "is-resizing"]
+        ing?     "is-resizing"]
 
     ;; resizable
     (-> (js/interact el)
         (.resizable
-          (bean/->js
-            {:edges
-             {:left true :top true :bottom true :right true}
-
-             :listeners
-             {:start (fn [] (.add cls ing?))
-              :end   (fn [e] (.remove cls ing?) (callback (bean/->js (calc-layout-data el e))))
-              :move  (fn [^js/MouseEvent e]
-                       (let [^js dset (.-dataset el)
-                             w (.. e -rect -width)
-                             h (.. e -rect -height)
-
-                             ;; update position from top/left
-                             dx (.. e -deltaRect -left)
-                             dy (.. e -deltaRect -top)
-
-                             dx' (util/safe-parse-float (.-dx dset))
-                             dy' (util/safe-parse-float (.-dy dset))
-
-                             x (+ dx (if dx' dx' 0))
-                             y (+ dy (if dy' dy' 0))]
-
-                         ;; update container position
-                         (set! (.. el -style -transform) (str "translate(" x "px, " y "px)"))
-
-                         ;; update container size
-                         (set! (.. el -style -width) (str w "px"))
-                         (set! (.. el -style -height) (str h "px"))
-
-                         (set! (. dset -dx) x)
-                         (set! (. dset -dy) y)))}})))
+         (bean/->js
+          {:edges
+           {:left true :top true :bottom true :right true}
+
+           :listeners
+           {:start (fn [] (.add cls ing?))
+            :end   (fn [e]
+                     (.remove cls ing?)
+                     (when (fn? callback)
+                       (callback (bean/->js (calc-layout-data el e)))))
+            :move  (fn [^js/MouseEvent e]
+                     (let [^js dset (.-dataset el)
+                           w        (.. e -rect -width)
+                           h        (.. e -rect -height)
+
+                           ;; update position from top/left
+                           dx       (.. e -deltaRect -left)
+                           dy       (.. e -deltaRect -top)
+
+                           dx'      (.-dx dset)
+                           dy'      (.-dy dset)
+                           dx'      (and dx' (util/safe-parse-float dx'))
+                           dy'      (and dy' (util/safe-parse-float dy'))
+
+                           x        (+ dx (or dx' 0))
+                           y        (+ dy (or dy' 0))]
+
+                       ;; update container position
+                       (set! (.. el -style -transform) (str "translate(" x "px, " y "px)"))
+
+                       ;; update container size
+                       (set! (.. el -style -width) (str w "px"))
+                       (set! (.. el -style -height) (str h "px"))
+
+                       (set! (. dset -dx) x)
+                       (set! (. dset -dy) y)))}})))
 
     ;; manager
     (swap! *movable-containers assoc identity el)

+ 21 - 1
src/main/frontend/state.cljs

@@ -2,7 +2,7 @@
   "Provides main application state, fns associated to set and state based rum
   cursors"
   (:require [cljs-bean.core :as bean]
-            [cljs.core.async :as async :refer [<!]]
+            [cljs.core.async :as async :refer [<! >!]]
             [cljs.spec.alpha :as s]
             [clojure.string :as string]
             [dommy.core :as dom]
@@ -74,6 +74,9 @@
      :ui/navigation-item-collapsed?         {}
 
      ;; right sidebar
+     :ui/handbooks-open?                    false
+     :ui/help-open?                         false
+     :ui/fullscreen?                        false
      :ui/settings-open?                     false
      :ui/sidebar-open?                      false
      :ui/sidebar-width                      "40%"
@@ -285,6 +288,8 @@
      :graph/importing                       nil
      :graph/importing-state                 {}
 
+     :handbook/route-chan                   (async/chan (async/sliding-buffer 1))
+
      :whiteboard/onboarding-whiteboard?     (or (storage/get :ls-onboarding-whiteboard?) false)
      :whiteboard/onboarding-tour?           (or (storage/get :whiteboard-onboarding-tour?) false)
      :whiteboard/last-persisted-at          {}
@@ -2190,6 +2195,21 @@ Similar to re-frame subscriptions"
   []
   (storage/remove :user-groups))
 
+(defn handbook-open?
+  []
+  (:ui/handbooks-open? @state))
+
+(defn get-handbook-route-chan
+  []
+  (:handbook/route-chan @state))
+
+(defn open-handbook-pane!
+  [k]
+  (when-not (handbook-open?)
+    (set-state! :ui/handbooks-open? true))
+  (js/setTimeout #(async/go
+                    (>! (get-handbook-route-chan) k))))
+
 (defn set-page-properties-changed!
   [page-name]
   (when-not (string/blank? page-name)

+ 7 - 1
src/main/frontend/ui.cljs

@@ -64,6 +64,12 @@
    "purple"
    "gray"])
 
+(defn ->block-background-color
+ [color]
+ (if (some #{color} built-in-colors)
+   (str "var(--ls-highlight-color-" color ")")
+   color))
+
 (defn built-in-color?
   [color]
   (some #{color} built-in-colors))
@@ -1038,7 +1044,7 @@
            :as   option}]
   (let [klass (if-not intent ".bg-indigo-600.hover:bg-indigo-700.focus:border-indigo-700.active:bg-indigo-700.text-center" intent)
         klass (if background (string/replace klass "indigo" background) klass)
-        klass (if small? (str klass ".px-2.py-1") klass)
+        klass (if small? (str klass ".is-small") klass)
         klass (if large? (str klass ".text-base") klass)
         klass (if disabled? (str klass "disabled:opacity-75") klass)]
     [:button.ui__button

+ 3 - 3
src/main/frontend/ui.css

@@ -275,7 +275,7 @@ html.is-mobile {
 
 .ui__button {
   @apply inline-flex items-center px-3 py-2 border border-transparent
-  text-sm leading-4 font-medium rounded-md text-white
+  text-sm leading-4 font-medium rounded-[6px] text-white
   focus:outline-none transition ease-in-out duration-150;
 
   &:disabled {
@@ -317,8 +317,8 @@ html.is-mobile {
     border: 1px solid;
   }
 
-  &.p-1 {
-    padding: 0.25rem 0.5rem !important;
+  &.is-small {
+    @apply px-2.5 py-1;
   }
 }
 

+ 24 - 0
src/main/frontend/util.cljc

@@ -225,6 +225,13 @@
   [pred coll]
   (first (filter pred coll)))
 
+(defn find-index
+  "Find first index of an element in list"
+  [pred-or-val coll]
+  (let [pred (if (fn? pred-or-val) pred-or-val #(= pred-or-val %))]
+    (reduce-kv #(if (pred %3) (reduced %2) %1) -1
+               (cond-> coll (list? coll) (vec)))))
+
 ;; (defn format
 ;;   [fmt & args]
 ;;   (apply gstring/format fmt args))
@@ -1435,6 +1442,23 @@
       (fn [resolve]
         (load url resolve)))))
 
+#?(:cljs
+   (defn css-load$
+     ([url] (css-load$ url nil))
+     ([url id]
+      (p/create
+       (fn [resolve reject]
+         (let [id (str "css-load-" (or id url))]
+           (if-not (gdom/getElement id)
+             (let [^js link (js/document.createElement "link")]
+               (set! (.-id link) id)
+               (set! (.-rel link) "stylesheet")
+               (set! (.-href link) url)
+               (set! (.-onload link) resolve)
+               (set! (.-onerror link) reject)
+               (.append (.-head js/document) link))
+             (resolve))))))))
+
 #?(:cljs
    (defn copy-image-to-clipboard
      [src]

+ 8 - 0
src/resources/dicts/en.edn

@@ -17,6 +17,14 @@
  :on-boarding/tour-whiteboard-home-description "Whiteboards have their own section in the app where you can see them at a glance, create new ones or delete them easily."
  :on-boarding/tour-whiteboard-new "{1} Create new whiteboard"
  :on-boarding/tour-whiteboard-new-description "There are multiple ways of creating a new whiteboard. One of them is always right here in the dashboard."
+ :handbook/title "Help"
+ :handbook/topics "Topics"
+ :handbook/popular-topics "Popular topics"
+ :handbook/help-categories "Help categories"
+ :handbook/search "Search"
+ :handbook/home "Home"
+ :handbook/settings "Settings"
+ :handbook/close "Close"
  :on-boarding/tour-whiteboard-btn-next "Next"
  :on-boarding/tour-whiteboard-btn-back "Back"
  :on-boarding/tour-whiteboard-btn-finish "Finish"

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно