diff --git a/.storybook/main.js b/.storybook/main.js index a387b3ac50..779444f827 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -24,14 +24,7 @@ module.exports = { }) ); - // Support mjs modules included with Chakra-UI - // https://github.com/storybookjs/storybook/issues/16690#issuecomment-971579785 - config.module.rules.push({ - test: /\.mjs$/, - include: /node_modules/, - type: "javascript/auto", - }) // Return the altered config. return config; } -} +} \ No newline at end of file diff --git a/.storybook/preview.js b/.storybook/preview.js index 8057fd88b1..6fd8fd1235 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,10 +1,13 @@ import React from 'react'; import { themes } from '@storybook/theming'; -import { ChakraProvider } from '@chakra-ui/react'; +import { + OverlayProvider +} from '@react-aria/overlays'; +import { NotificationContainer } from '@/Notifications/Notifications'; - -import { theme } from '../src/js/theme/theme'; +import { classnames } from '../src/js/components/utils/classnames'; +import { GlobalStyles } from '../src/js/components/utils/style/style'; import { badges, Storybook } from '../src/js/components/utils/storybook'; export const parameters = { @@ -62,13 +65,19 @@ export const decorators = [ console.log(context); return ( <> - + + + id="app" + className={classnames( + context.globals.hostType === 'electron' ? 'is-electron' : 'is-browser', + context.viewMode === 'docs' ? 'is-storybook-docs' : 'is-storybook-canvas' + )}> + - + ) }, -]; +]; \ No newline at end of file diff --git a/package.json b/package.json index 7cabeb7114..868648192a 100644 --- a/package.json +++ b/package.json @@ -100,15 +100,22 @@ }, "dependencies": { "@babel/runtime": "^7.15.4", - "@chakra-ui/react": "^1.8.6", - "@emotion/react": "^11", - "@emotion/styled": "^11", "@geometricpanda/storybook-addon-badges": "^0.0.4", "@js-joda/core": "1.12.0", "@js-joda/locale_en-us": "3.1.1", "@js-joda/timezone": "2.2.0", "@material-ui/core": "^4.10.1", "@material-ui/icons": "^4.9.1", + "@react-aria/checkbox": "^3.2.3", + "@react-aria/dialog": "^3.1.4", + "@react-aria/focus": "^3.5.0", + "@react-aria/meter": "^3.1.3", + "@react-aria/overlays": "^3.7.2", + "@react-aria/switch": "^3.1.3", + "@react-aria/tooltip": "^3.1.3", + "@react-aria/visually-hidden": "^3.2.3", + "@react-stately/overlays": "^3.1.3", + "@react-stately/toggle": "^3.2.3", "@sentry/integrations": "^6.17.3", "@sentry/react": "^6.17.3", "@sentry/tracing": "^6.17.3", @@ -118,8 +125,9 @@ "electron-updater": "^4.3.4", "electron-window-state": "^5.0.3", "emoji-picker-element": "^1.8.2", - "framer-motion": "^6", "highlight.js": "^11.1.0", + "iconoir": "^1.0.0", + "iconoir-react": "^2.1.0", "katex": "^0.12.0", "luxon": "^2.0.2", "nedb": "^1.8.0", @@ -129,11 +137,11 @@ "react-colorful": "^5.4.0", "react-day-picker": "^7.4.10", "react-dom": "17.0.1", - "react-error-boundary": "^3.1.4", - "react-focus-lock": "^2.8.1", "react-force-graph-2d": "^1.19.0", "react-highlight.js": "1.0.7", + "react-hot-toast": "^2.1.1", "react-intersection-observer": "^8.32.1", + "styled-components": "^5.3.0", "tslib": "^2.3.1" }, "devDependencies": { diff --git a/playwright.electron.config.ts b/playwright.electron.config.ts index e7e900c146..41290fa5a8 100644 --- a/playwright.electron.config.ts +++ b/playwright.electron.config.ts @@ -18,7 +18,7 @@ const config: PlaywrightTestConfig = { ...baseConfig, workers: 1, use: {}, - projects: [ { + projects: [{ name: 'chromium', use: { browserName: 'chromium', @@ -32,7 +32,7 @@ const config: PlaywrightTestConfig = { mode: 'default', video: false, } - } ], + }], }; export default config; diff --git a/resources/public/index.html b/resources/public/index.html index 177432f2fa..2d291a3e0d 100644 --- a/resources/public/index.html +++ b/resources/public/index.html @@ -5,6 +5,7 @@ + @@ -16,6 +17,7 @@ !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags".split(" "),n=0;n +
diff --git a/shadow-cljs.edn b/shadow-cljs.edn index 739148cfcd..bffefb44b8 100644 --- a/shadow-cljs.edn +++ b/shadow-cljs.edn @@ -10,10 +10,6 @@ :output-dir "resources/public/js/compiled" :asset-path "js/compiled" :modules {:app {:init-fn athens.core/init}} - ;; Don't try to polyfill for generators, we don't try to support older browsers - ;; and it breaks some libraries we use (ForceGraph2D) when other imports change. - ;; https://github.com/thheller/shadow-cljs/issues/854 - :js-options {:babel-preset-config {:targets {:chrome 80}}} :compiler-options {:closure-warnings {:global-this :off} :infer-externs :auto :closure-defines {re-frame.trace.trace-enabled? true} @@ -31,7 +27,6 @@ :output-dir "resources/public/js/compiled" :asset-path "js/compiled" :modules {:renderer {:init-fn athens.core/init}} - :js-options {:babel-preset-config {:targets {:chrome 80}}} :compiler-options {:closure-warnings {:global-this :off} :infer-externs :auto :closure-defines {re-frame.trace.trace-enabled? true} @@ -50,7 +45,6 @@ :main {:target :node-script :output-to "resources/main.js" :main athens.main.core/main - :js-options {:babel-preset-config {:targets {:chrome 80}}} :compiler-options {:output-feature-set :es-next :reader-features #{:electron}}} diff --git a/src/cljs/athens/components.cljs b/src/cljs/athens/components.cljs index e16f8854f9..4fb05b60aa 100644 --- a/src/cljs/athens/components.cljs +++ b/src/cljs/athens/components.cljs @@ -1,15 +1,16 @@ (ns athens.components (:require - ["@chakra-ui/react" :refer [Checkbox Box Button]] ["@material-ui/icons/Edit" :default Edit] [athens.db :as db] [athens.parse-renderer :refer [component]] [athens.reactive :as reactive] + [athens.style :refer [color]] [athens.util :refer [recursively-modify-block-for-embed]] [athens.views.blocks.core :as blocks] [clojure.string :as str] [re-frame.core :as rf :refer [dispatch subscribe]] - [reagent.core :as r])) + [reagent.core :as r] + [stylefy.core :as stylefy :refer [use-style]])) (defn todo-on-click @@ -29,27 +30,25 @@ TODO() - might be a good idea to keep an edit icon at top right for every component." [children] - [:span {:style {:display "contents"} - :on-click (fn [e] (.. e stopPropagation))} + [:span {:on-click (fn [e] + (.. e stopPropagation))} children]) (defmethod component :todo [_content uid] [span-click-stop - [:> Checkbox {:isChecked false - :verticalAlign "middle" - :transform "translateY(-2px)" - :onChange #(todo-on-click uid #"\{\{\[\[TODO\]\]\}\}" "{{[[DONE]]}}")}]]) + [:input {:type "checkbox" + :checked false + :on-change #(todo-on-click uid #"\{\{\[\[TODO\]\]\}\}" "{{[[DONE]]}}")}]]) (defmethod component :done [_content uid] [span-click-stop - [:> Checkbox {:isChecked true - :verticalAlign "middle" - :transform "translateY(-2px)" - :onChange #(todo-on-click uid #"\{\{\[\[DONE\]\]\}\}" "{{[[TODO]]}}")}]]) + [:input {:type "checkbox" + :checked true + :on-change #(todo-on-click uid #"\{\{\[\[DONE\]\]\}\}" "{{[[TODO]]}}")}]]) (defmethod component :youtube @@ -70,11 +69,25 @@ (defmethod component :self [content _uid] [span-click-stop - [:> Button {:variant "link" - :color "red"} + [:button {:style {:color "red" + :font-family "IBM Plex Mono"}} content]]) +(def block-embed-adjustments + {:background (color :background-minus-2 :opacity-med) + :position "relative" + ::stylefy/manual [[:>.block-container {:margin-left "0" + :padding-right "1.3rem" + ::stylefy/manual [[:textarea {:background "transparent"}]]}] + [:>svg {:position "absolute" + :right "5px" + :top "5px" + :font-size "1rem" + :z-index "5" + :cursor "pointer"}]]}) + + (defmethod component :block-embed [content uid] ;; bindings are eval only once in with-let @@ -84,18 +97,7 @@ ;; todo -- not reactive. some cases where delete then ctrl-z doesn't work (if (db/e-by-av :block/uid block-uid) (r/with-let [embed-id (random-uuid)] - [:> Box {:class "block-embed" - :bg "background.basement" - :position "relative" - :sx {"> .block-container" {:ml 0 - :pr "1.3rem" - "textarea" {:background "transparent"}} - "> svg" {:position "absolute" - :right "5px" - :top "5px" - :fontSize "1rem" - :zIndex "5" - :cursor "pointer"}}} + [:div.block-embed (use-style block-embed-adjustments) (let [block (reactive/get-reactive-block-document [:block/uid block-uid])] [:<> [blocks/block-el diff --git a/src/cljs/athens/effects.cljs b/src/cljs/athens/effects.cljs index 2ea70ec0d4..807e88c362 100644 --- a/src/cljs/athens/effects.cljs +++ b/src/cljs/athens/effects.cljs @@ -17,7 +17,8 @@ [goog.dom.selection :refer [setCursorPosition]] [malli.core :as m] [malli.error :as me] - [re-frame.core :as rf])) + [re-frame.core :as rf] + [stylefy.core :as stylefy])) ;; Effects @@ -132,6 +133,12 @@ 100))) +(rf/reg-fx + :stylefy/tag + (fn [[tag properties]] + (stylefy/tag tag properties))) + + (rf/reg-fx :alert/js! (fn [message] diff --git a/src/cljs/athens/electron/db_menu/core.cljs b/src/cljs/athens/electron/db_menu/core.cljs index 23fd9ad777..e2b352feb4 100644 --- a/src/cljs/athens/electron/db_menu/core.cljs +++ b/src/cljs/athens/electron/db_menu/core.cljs @@ -1,84 +1,124 @@ (ns athens.electron.db-menu.core (:require - ["@chakra-ui/react" :refer [Box IconButton Tooltip Heading VStack ButtonGroup PopoverTrigger ButtonGroup Popover PopoverContent Portal Button]] - ["react-focus-lock" :default FocusLock] + ["/components/Button/Button" :refer [Button]] + ["@material-ui/core/Popover" :as Popover] + ["@material-ui/icons/AddCircleOutline" :default AddCircleOutline] [athens.electron.db-menu.db-icon :refer [db-icon]] [athens.electron.db-menu.db-list-item :refer [db-list-item]] - [athens.electron.db-modal :as db-modal] [athens.electron.dialogs :as dialogs] + [athens.style :refer [color DEPTH-SHADOWS]] + [athens.views.dropdown :refer [menu-style menu-separator-style]] [re-frame.core :refer [dispatch subscribe]] - [reagent.core :as r])) + [reagent.core :as r] + [stylefy.core :as stylefy :refer [use-style]])) + + +;; ------------------------------------------------------------------- +;; --- material ui --- + +(def m-popover (r/adapt-react-class (.-default Popover))) + + +;; Style + +(def dropdown-style + {::stylefy/manual [[:.menu {:background (color :background-plus-2) + :color (color :body-text-color) + :border-radius "calc(0.25rem + 0.25rem)" ; Button corner radius + container padding makes "concentric" container radius + :padding "0.25rem" + :display "inline-flex" + :box-shadow [[(:64 DEPTH-SHADOWS) ", 0 0 0 1px rgba(0, 0, 0, 0.05)"]]}]]}) + + +(def db-menu-button-style + {:color (color :body-text-color :opacity-high) + :background "inherit" + :padding "0" + :align-items "stretch" + :justify-content "stretch" + :justify-items "stretch" + :width "1.75em" + :height "1.75em" + :border "1px solid transparent"}) + + +(def current-db-area-style + {:background "rgba(144, 144, 144, 0.05)" + :margin "-0.25rem -0.25rem 0.125rem" + :border-bottom [["1px solid " (color :border-color)]] + :padding "0.25rem"}) + + +(def current-db-tools-style + {:margin-left "2rem"}) ;; Components (defn current-db-tools - ([{:keys [db]} all-dbs merge-open?] - (when-not (:is-remote db) - [:> ButtonGroup {:size "xs" :pr 4 :pl 10 :ml "auto" :width "100%"} - [:> Button {:onClick #(dialogs/move-dialog!)} "Move"] - [:> Button {:mr "auto" :onClick #(reset! merge-open? true)} "Merge from Roam"] - [:> Tooltip {:label "Can't remove last database" :placement "right" :isDisabled (< 1 (count all-dbs))} - [:> Button {:isDisabled (= 1 (count all-dbs)) - :onClick #(dialogs/delete-dialog! db)} - "Remove"]]]))) + ([{:keys [db]} all-dbs] + [:div (use-style current-db-tools-style) + (if (:is-remote db) + [:<> + [:> Button "Import"] + [:> Button "Copy Link"] + [:> Button "Remove"]] + [:<> + [:> Button {:onClick #(dialogs/move-dialog!)} "Move"] + ;; [:> Button {:onClick "Rename"] + [:> Button {:onClick #(if (= 1 (count all-dbs)) + (js/alert "Can't remove last db from the list") + (dialogs/delete-dialog! db))} + "Delete"]])])) (defn db-menu [] - (let [all-dbs @(subscribe [:db-picker/all-dbs]) - merge-open? (r/atom false) - active-db @(subscribe [:db-picker/selected-db]) - inactive-dbs (dissoc all-dbs (:id active-db)) - sync-status (if @(subscribe [:db/synced]) - :running - :synchronising)] - [:<> - [db-modal/merge-modal merge-open?] - [:> Popover {:placement "bottom-start"} - [:> PopoverTrigger - [:> IconButton {:p 0 - :bg "background.floor"} - ;; DB Icon + Dropdown toggle - [db-icon {:db active-db - :status sync-status}]]] - ;; Dropdown menu - [:> Portal - [:> PopoverContent {:overflow-y "auto"} - [:> FocusLock - [:> VStack {:align "stretch" - :overflow "hidden" - :spacing 0} - ;; Show active DB first - [:> Box {:bg "background.floor" - :pb 4} - [db-list-item {:db active-db - :is-current true - :key (:id active-db)}] - [current-db-tools {:db active-db} all-dbs merge-open?]] - ;; Show all inactive DBs and a separator - [:> Heading {:fontSize "xs" - :py 4 - :pb 3 - :borderTop "1px solid" - :borderTopColor "separator.divider" - ;; :bg "background.floor" - :px 10 - :letterSpacing "wide" - :textTransform "uppercase" - :fontWeight "bold" - :color "foreground.secondary"} - "Other databases"] - [:> VStack {:align "stretch" - :spacing 0 - :overflow-y "auto"} - {:align "stretch" :spacing 0} - (doall - (for [[key db] inactive-dbs] - [db-list-item {:db db - :is-current false - :key key}]))] - ;; Add DB control - [:> ButtonGroup {:borderTop "1px solid" :borderTopColor "separator.divider" :p 2 :pt 0 :pl 10 :size "sm" :width "100%" :ml 10 :justifyContent "flex-start"} - [:> Button {:onClick #(dispatch [:modal/toggle])} - "Add Database"]]]]]]]])) + (r/with-let [ele (r/atom nil)] + (let [all-dbs @(subscribe [:db-picker/all-dbs]) + active-db @(subscribe [:db-picker/selected-db]) + inactive-dbs (dissoc all-dbs (:id active-db)) + sync-status (if @(subscribe [:db/synced]) + :running + :synchronising)] + [:<> + ;; DB Icon + Dropdown toggle + [:> Button {:class [(when @ele "is-active")] + :on-click #(reset! ele (.-currentTarget %)) + :style db-menu-button-style} + [db-icon {:db active-db + :status sync-status}]] + ;; Dropdown menu + [m-popover + (merge (use-style dropdown-style) + {:style {:font-size "14px"} + :open (boolean @ele) + :anchorEl @ele + :onClose #(reset! ele nil) + :anchorOrigin #js{:vertical "bottom" + :horizontal "left"} + :marginThreshold 10 + :transformOrigin #js{:vertical "top" + :horizontal "left"} + :classes {:root "backdrop" + :paper "menu"}}) + [:div (use-style (merge menu-style + {:overflow "visible"})) + [:<> + ;; Show active DB first + [:div (use-style current-db-area-style) + [db-list-item {:db active-db + :is-current true + :key (:id active-db)}] + [current-db-tools {:db active-db} all-dbs]] + ;; Show all inactive DBs and a separator + (doall + (for [[key db] inactive-dbs] + [db-list-item {:db db + :is-current false + :key key}])) + [:hr (use-style menu-separator-style)] + ;; Add DB control + [:> Button {:on-click #(dispatch [:modal/toggle])} + [:> AddCircleOutline] + [:span "Add Database"]]]]]]))) diff --git a/src/cljs/athens/electron/db_menu/db_icon.cljs b/src/cljs/athens/electron/db_menu/db_icon.cljs index 14e2e6a6fc..27fef1f938 100644 --- a/src/cljs/athens/electron/db_menu/db_icon.cljs +++ b/src/cljs/athens/electron/db_menu/db_icon.cljs @@ -1,31 +1,30 @@ (ns athens.electron.db-menu.db-icon (:require - ["@chakra-ui/react" :refer [Box]] - [athens.electron.db-menu.status-indicator :refer [status-indicator]])) + [athens.electron.db-menu.status-indicator :refer [status-indicator]] + [stylefy.core :as stylefy :refer [use-style]])) + + +(def db-icon-style + {:position "relative" + :width "1.75em" + :height "1.75em" + ::stylefy/manual [[:text {:font-size "16px"}]]}) (defn db-icon [{:keys [db status]}] - [:> Box {:class "icon" - :position "relative" - :flex "0 0 auto" - :width "1.75em" - :height "1.75em" - :sx {"text" {:fontSize "16px"}}} - [:> Box {:as "svg" - :viewBox "0 0 24 24" - :margin 0} - [:> Box - {:as "rect" - :fill "var(--link-color)" - :height "100%" - :width "100%" + [:div.icon (use-style db-icon-style) + [:svg {:viewBox "0 0 24 24" + :style {:margin 0}} + [:rect + {:fill "var(--link-color)" + :height "24" :rx "4" + :width "24" :x "0" :y "0"}] - [:> Box - {:as "text" - :fill "white" + [:text + {:fill "white" :fontSize "100%" :fontWeight "bold" :textAnchor "middle" diff --git a/src/cljs/athens/electron/db_menu/db_list_item.cljs b/src/cljs/athens/electron/db_menu/db_list_item.cljs index 723cb4e7a1..8b49b4dd36 100644 --- a/src/cljs/athens/electron/db_menu/db_list_item.cljs +++ b/src/cljs/athens/electron/db_menu/db_list_item.cljs @@ -1,103 +1,75 @@ (ns athens.electron.db-menu.db-list-item (:require - ["@chakra-ui/react" :refer [VStack Box Flex Text Button IconButton]] ["@material-ui/icons/Clear" :default Clear] ["@material-ui/icons/Link" :default Link] [athens.electron.db-menu.db-icon :refer [db-icon]] [athens.electron.dialogs :as dialogs] - [re-frame.core :refer [dispatch]])) + [athens.style :refer [color]] + [re-frame.core :refer [dispatch]] + [stylefy.core :as stylefy :refer [use-style]])) -(defn active-db +(def db-list-item-style + {:display "flex" + ::stylefy/manual [[:.icon {:flex "0 0 1.75em" + :font-size "inherit" + :margin "0 0.5em 0 0"}] + [:.body {:display "flex" + :text-align "start" + :flex "1 1 100%" + :padding "0.5rem 0.25rem 0.5rem 0.5rem" + :border-radius "0.25rem" + :font-weight "normal" + :background "inherit" + :color "inherit" + :appearance "none" + :border "none" + :line-height "1.1"} + [:.MuiSvgIcon-root {:opacity "50%"}] + ["&:hover" {:filter "brightness(110%)"}]] + [:.is-current] + [:.label {:display "block" + :overflow "hidden" + :flex "1 1 100%"}] + [:span {:display "block"}] + [:.name {:font-weight "600" + :color "inherit"}] + [:.path {:color (color :body-text-color :opacity-med) + :max-width "100%" + :overflow "hidden" + :text-overflow "ellipsis" + :font-size "12px" + :white-space "nowrap"} + [:svg {:display "inline-block" + :font-size "inherit" + :position "relative" + :top "0.2em" + :margin "auto 0.25em auto 0"}]]]}) + + +(defn db-list-item-content [{:keys [db]}] - [:> Flex {:gap 2 - :p 2 - :borderRadius "none" - :whiteSpace "nowrap" - :height "auto" - :align "stretch" - :background "transparent" - :justifyContent "stretch" - :textAlign "left"} + [:<> [db-icon {:db db}] - [:> VStack {:align "stretch" - :flex "1 1 100%" - :overflow "hidden" - :spacing 0 - :textOverflow "ellipsis"} - [:> Text {:textOverflow "ellipsis" - :overflow "hidden" - :fontWeight "bold"} - (:name db)] - [:> Text {:textOverflow "ellipsis" - :fontSize "sm" - :color "foreground.secondary" - :overflow "hidden" - :title (:id db)} + [:div.label + [:span.name (:name db)] + [:span.path + {:title (:id db)} (when (:is-remote db) [:> Link]) (:id db)]]]) -(defn db-item - [{:keys [db on-click on-remove]}] - [:> Box {:display "grid" - :borderTopWidth "1px" - :borderTopStyle "solid" - :borderTopColor "separator.divider" - :gridTemplateAreas "'main'"} - [:> Button {:onClick (when on-click on-click) - :gridArea "main" - :whiteSpace "nowrap" - :bg "transparent" - :isDisabled (not on-click) - :display "flex" - :gap 2 - :py 2 - :pr 10 - :borderRadius "none" - :height "auto" - :align "stretch" - :justifyContent "stretch" - :_focusVisible {:boxShadow "focusInset"} - :textAlign "left"} - [db-icon {:db db}] - [:> VStack {:align "stretch" - :flex "1 1 100%" - :spacing 1 - :overflow "hidden" - :textOverflow "ellipsis"} - [:> Text {:textOverflow "ellipsis" - :fontWeight "bold" - :overflow "hidden"} (:name db)] - [:> Text {:textOverflow "ellipsis" - :size "sm" - :color "foreground.secondary" - :overflow "hidden" - :title (:id db)} - (when (:is-remote db) - [:> Link]) - (:id db)]]] - (when on-remove - [:> IconButton - {:onClick on-remove - :gridArea "main" - :alignSelf "center" - :justifySelf "flex-end" - :size "sm" - :mr 2 - :bg "transparent"} - [:> Clear]])]) - - (defn db-list-item [{:keys [db is-current]}] (let [remove-db-click-handler (fn [e] (dialogs/delete-dialog! db) (.. e stopPropagation))] - (if is-current - [active-db {:db db}] - [db-item {:db db - :on-click #(dispatch [:db-picker/select-db db]) - :on-remove remove-db-click-handler}]))) + [:div (use-style db-list-item-style) + (if is-current + [:div.body.is-current + [db-list-item-content {:db db}]] + [:button.body.button {:onClick #(dispatch [:db-picker/select-db db])} + [db-list-item-content {:db db}] + [:> Clear {:on-click remove-db-click-handler}]])])) diff --git a/src/cljs/athens/electron/db_menu/status_indicator.cljs b/src/cljs/athens/electron/db_menu/status_indicator.cljs index cab8e5dc60..3aadd72188 100644 --- a/src/cljs/athens/electron/db_menu/status_indicator.cljs +++ b/src/cljs/athens/electron/db_menu/status_indicator.cljs @@ -1,37 +1,37 @@ (ns athens.electron.db-menu.status-indicator (:require - ["@chakra-ui/react" :refer [Box Tooltip]] ["@material-ui/icons/CheckCircle" :default CheckCircle] ["@material-ui/icons/Error" :default Error] - ["@material-ui/icons/Sync" :default Sync])) + ["@material-ui/icons/Sync" :default Sync] + [athens.style :refer [color]] + [stylefy.core :as stylefy :refer [use-style]])) + + +(def status-icon-style + {:background (color :background-minus-2) + :border-radius "100%" + :padding 0 + :margin "0 !important" + :height "12px !important" + :width "12px !important" + :position "absolute" + :bottom "0" + :right "0" + ::stylefy/manual [[".:running"]]}) (defn status-indicator [{:keys [status]}] - [:> Box {:p 0 - :m 0 - :color (cond - (:closed status) "error" - (:running status) "foreground.primary" - :else "foreground.secondary") - :fontSize "1em" - :height "1em" - :width "1em" - :transform "translate(25%, 25%)" - :position "absolute" - :bottom 0 - :right 0 - :borderRadius "full" - :sx {"svg" {:fontSize "1em" - :background "background.floor" - :borderRadius "full"}}} + [:div.status-indicator (use-style status-icon-style + {:class (str status)}) (cond - (= status :closed) [:> Tooltip - {:label "Disconnected"} - [:> Error]] - (= status :running) [:> Tooltip - {:label "Synced"} - [:> CheckCircle]] - :else [:> Tooltip - {:label "Synchronizing..."} - [:> Sync]])]) + (= status :closed) + [:> Error (merge {:style {:color (color :error-color)} + :title "Disconnected"})] + (= status :running) + [:> CheckCircle (merge (use-style status-icon-style) + {:style {:color (color :confirmation-color)} + :title "Synced"})] + :else [:> Sync (merge (use-style status-icon-style) + {:style {:color (color :highlight-color)} + :title "Synchronizing..."})])]) diff --git a/src/cljs/athens/electron/db_modal.cljs b/src/cljs/athens/electron/db_modal.cljs index 725bf6dc6c..237470f197 100644 --- a/src/cljs/athens/electron/db_modal.cljs +++ b/src/cljs/athens/electron/db_modal.cljs @@ -1,15 +1,79 @@ (ns athens.electron.db-modal (:require - ["@chakra-ui/react" :refer [HStack VStack FormControl FormLabel Input Button Box Tabs Tab TabList TabPanel TabPanels Text Modal ModalOverlay Divider VStack Heading ModalContent ModalHeader ModalFooter ModalBody ModalCloseButton ButtonGroup]] + ["/components/Button/Button" :refer [Button]] + ["@material-ui/icons/AddBox" :default AddBox] + ["@material-ui/icons/Close" :default Close] + ["@material-ui/icons/Folder" :default Folder] + ["@material-ui/icons/Group" :default Group] + ["@material-ui/icons/MergeType" :default MergeType] + ["@material-ui/icons/Storage" :default Storage] + ["react-dom" :as react-dom] [athens.electron.dialogs :as dialogs] [athens.electron.utils :as utils] [athens.events :as events] + [athens.style :refer [color]] [athens.subs] [athens.util :refer [js-event->val]] + [athens.views.modal :refer [modal-style]] + [athens.views.textinput :as textinput] [clojure.edn :as edn] [datascript.core :as d] + [komponentit.modal :as modal] [re-frame.core :refer [subscribe dispatch] :as rf] - [reagent.core :as r])) + [reagent.core :as r] + [stylefy.core :as stylefy :refer [use-style]])) + + +(def modal-contents-style + {:display "flex" + :padding "0 1rem 1.5rem 1rem" + :flex-direction "column" + :align-items "center" + :justify-content "flex-start" + :width "500px" + :height "17em" + ::stylefy/manual [[:p {:max-width "24rem" + :text-align "center"}] + [:button.toggle-button {:font-size "18px" + :align-self "flex-start" + :padding-left "0" + :margin-bottom "1rem"}] + [:code {:word-break "break-all"}] + [:.MuiTabs-indicator {:background-color "var(--link-color)"}]]}) + + +(def picker-style + {:display "grid" + :grid-auto-flow "column" + :grid-auto-columns "1fr" + :border-radius "0.5rem" + :flex "0 0 auto" + :font-size "1em" + :margin "0.25rem 0" + :align-self "stretch" + :overflow "hidden" + :transition "box-shadow 0.2s ease, filter 0.2s ease" + :background (color :background-color) + :padding "1px" + ::stylefy/manual [[:&:hover {}] + [:button {:text-align "center" + :appearance "none" + :border "0" + :border-radius "calc(0.5rem - 1px)" + :padding "0.5rem 0.5rem" + :color "inherit" + :display "flex" + :justify-content "center" + :align-items "center" + :position "relative" + :z-index "0" + :background "inherit"} + [:svg {:margin-inline-end "0.25em" :font-size "1.25em"}] + [:&:hover {:filter "contrast(105%)"}] + [:&:active {:filter "contrast(110%)"}] + [:&.active {:background (color :background-plus-2) + :z-index "5" + :box-shadow [["0 1px 5px" (color :shadow-color)]]}]]]}) (defn file-cb @@ -42,150 +106,146 @@ transformed-roam-db (r/atom nil) roam-db-filename (r/atom "")] (fn [] - [:> Modal {:isOpen @open? - :onClose close-modal - :closeOnOverlayClick false - :size "lg"} - [:> ModalOverlay] - [:> ModalContent - [:> ModalHeader - "Merge from Roam"] - [:> ModalCloseButton] - (if (nil? @transformed-roam-db) - (let [inputRef (atom nil)] - [:> ModalBody - [:input {:ref #(reset! inputRef %) - :style {:display "none"} - :type "file" - :accept ".edn" - :on-change #(file-cb % transformed-roam-db roam-db-filename)}] - [:> Heading {:size "md" :as "h2"} "How to merge from Roam"] - [:> Box {:position "relative" - :padding-bottom "56.25%" - :margin "1rem 0 0" - :borderRadius "8px" - :overflow "hidden" - :flex "1 1 100%" - :width "100%"} - [:iframe {:src "https://www.loom.com/embed/787ed48da52c4149b031efb8e17c0939?hide_owner=true&hide_share=true&hide_title=true&hideEmbedTopBar=true" - :frameBorder "0" - :webkitallowfullscreen "true" - :mozallowfullscreen "true" - :allowFullScreen true - :style {:position "absolute" - :top 0 - :left 0 - :width "100%" - :height "100%"}}]] - [:> ModalFooter - [:> ButtonGroup - [:> Button - {:onClick #(.click @inputRef)} - "Upload database"]]]]) - (let [roam-pages (roam-pages @transformed-roam-db) - shared-pages (events/get-shared-pages @transformed-roam-db)] - [:> ModalBody - [:> Text {:size "md"} (str "Your Roam DB had " (count roam-pages)) " pages. " (count shared-pages) " of these pages were also found in your Athens DB. Press Merge to continue merging your DB."] - [:> Divider {:my 4}] - [:> Heading {:size "md" :as "h3"} "Shared Pages"] - [:> VStack {:as "ol" - :align "stretch" - :maxHeight "400px" - :overflowY "auto"} - (for [x shared-pages] - ^{:key x} - [:li [:> Text (str "[[" x "]]")]])] - [:> ModalFooter - [:> ButtonGroup - [:> Button {:onClick (fn [] - (dispatch [:upload/roam-edn @transformed-roam-db @roam-db-filename]) - (close-modal))} - - "Merge"]]]]))]]))) - - -(defn form-container - [content footer] - [:> Box {:as "form" - :display "contents"} - [:> Box {:p 5 :pt 4} - content] - [:> ModalFooter {:borderTop "1px solid" - :borderColor "separator.divider" - :p 2 - :pr 5} footer]]) + [:div (use-style modal-style) + [modal/modal + + {:title [:div.modal__title + [:> MergeType] + [:h4 "Merge Roam DB"] + [:> Button {:on-click close-modal} + [:> Close]]] + + :content [:div (use-style (merge modal-contents-style)) + (if (nil? @transformed-roam-db) + [:<> + [:input {:style {:flex "0 0 auto"} :type "file" :accept ".edn" :on-change #(file-cb % transformed-roam-db roam-db-filename)}] + [:div {:style {:position "relative" + :padding-bottom "56.25%" + :margin "1em 0 0" + :flex "1 1 100%" + :width "100%"}} + [:iframe {:src "https://www.loom.com/embed/787ed48da52c4149b031efb8e17c0939" + :frameBorder "0" + :webkitallowfullscreen "true" + :mozallowfullscreen "true" + :allowFullScreen true + :style {:position "absolute" + :top 0 + :left 0 + :width "100%" + :height "100%"}}]]] + (let [roam-pages (roam-pages @transformed-roam-db) + shared-pages (events/get-shared-pages @transformed-roam-db)] + [:div {:style {:display "flex" :flex-direction "column"}} + [:h6 (str "Your Roam DB had " (count roam-pages)) " pages. " (count shared-pages) " of these pages were also found in your Athens DB. Press Merge to continue merging your DB."] + [:p {:style {:margin "10px 0 0 0"}} "Shared Pages"] + [:ol {:style {:max-height "400px" + :width "100%" + :overflow-y "auto"}} + (for [x shared-pages] + ^{:key x} + [:li (str "[[" x "]]")])] + [:> Button {:style {:align-self "center"} + :is-primary true + :on-click (fn [] + (dispatch [:upload/roam-edn @transformed-roam-db @roam-db-filename]) + (close-modal))} + "Merge"]]))] + + :on-close close-modal}]]))) (defn open-local-comp [loading db] - [form-container - [:> FormControl {:isReadOnly true} - [:> FormLabel (if @loading - "No DB Found At" - "Current database location")] - [:> HStack - [:> Text {:as "output" - :borderRadius "md" - :cursor "default" - :bg "background.floor" - :color "foreground.secondary" - :flex "1 1 100%" - :py 1.5 - :px 2.5 - :display "flex"} - (:id db)] - [:> Button {:isDisabled @loading - :size "sm" - :onClick #(dialogs/move-dialog!)} - "Move"]]] - [:> ButtonGroup - [:> Button {:onClick #(dialogs/open-dialog!)} - "Open from file"]]]) + [:<> + [:h5 {:style {:align-self "flex-start" + :margin-top "2em"}} + (if @loading + "No DB Found At" + "Current Location")] + [:code {:style {:margin "1rem 0 2rem 0"}} (:id db)] + [:div (use-style {:display "flex" + :justify-content "space-between" + :align-items "center" + :width "80%"}) + [:> Button {:is-primary true + :on-click #(dialogs/open-dialog!)} + "Open"] + [:> Button {:disabled @loading + :is-primary true + :on-click #(dialogs/move-dialog!)} + "Move"]]]) (defn create-new-local [state] - [form-container - [:> FormControl - [:> FormLabel "Name"] - [:> Input {:value (:input @state) - :onChange #(swap! state assoc :input (js-event->val %))}]] - [:> ButtonGroup - [:> Button {:value (:input @state) - :isDisabled (clojure.string/blank? (:input @state)) - :onClick #(dialogs/create-dialog! (:input @state))} - "Choose folder"]]]) + [:<> + [:div {:style {:display "flex" + :justify-content "space-between" + :width "100%" + :margin-top "2em" + :margin-bottom "1em"}} + [:h5 "Database Name"] + [textinput/textinput {:value (:input @state) + :placeholder "DB Name" + :on-change #(swap! state assoc :input (js-event->val %))}]] + [:div {:style {:display "flex" + :justify-content "space-between" + :width "100%"}} + [:h5 "New Location"] + [:> Button {:is-primary true + :disabled (clojure.string/blank? (:input @state)) + :on-click #(dialogs/create-dialog! (:input @state))} + "Browse"]]]) (defn join-remote-comp [] - (let [name (r/atom "") - address (r/atom "") + (let [name (r/atom "RTC") + address (r/atom "localhost:3010") password (r/atom "")] (fn [] - [form-container + [:<> (->> - [:> VStack {:spacing 4} - [:> FormControl - [:> FormLabel "Database name"] - [:> Input {:value @name - :onChange #(reset! name (js-event->val %))}]] - [:> FormControl - [:> FormLabel "Remote address"] - [:> Input {:value @address - :onChange #(reset! address (js-event->val %))}]] - [:> FormControl {:flexDirection "row"} - [:> FormLabel "Password"] - [:> Input {:value @password - :type "password" - :onChange #(reset! password (js-event->val %))}]]] + [:div {:style {:width "100%" :margin-top "10px"}} + [:h5 "Database Name"] + [:div {:style {:margin "5px 0" + :display "flex" + :justify-content "space-between"}} + [textinput/textinput {:style {:flex-grow 1 + :padding "5px"} + :type "text" + :value @name + :placeholder "DB name" + :on-change #(reset! name (js-event->val %))}]] + [:h5 "Remote Address"] + [:div {:style {:margin "5px 0" + :display "flex" + :justify-content "space-between"}} + [textinput/textinput {:style {:flex-grow 1 + :padding "5px"} + :type "text" + :value @address + :placeholder "Remote server address" + :on-change #(reset! address (js-event->val %))}]] + [:h5 "Password"] + [:div {:style {:margin "5px 0" + :display "flex" + :justify-content "space-between"}} + [textinput/textinput {:style {:flex-grow 1 + :padding "5px"} + :type "password" + :value @password + :placeholder "Password" + :disabled false + :on-change #(reset! password (js-event->val %))}]]] doall) - [:> ButtonGroup - [:> Button {:type "submit" - :isDisabled (or (clojure.string/blank? @name) - (clojure.string/blank? @address)) - :onClick #(rf/dispatch [:db-picker/add-and-select-db (utils/self-hosted-db @name @address @password)])} - "Join"]]]))) + [:> Button {:is-primary true + :style {:margin-top "0.5rem"} + :disabled (or (clojure.string/blank? @name) + (clojure.string/blank? @address)) + :on-click #(rf/dispatch [:db-picker/add-and-select-db (utils/self-hosted-db @name @address @password)])} + "Join"]]))) (defn window @@ -196,34 +256,46 @@ close-modal (fn [] (when-not @loading (dispatch [:modal/toggle]))) + el (.. js/document (querySelector "#app")) selected-db @(subscribe [:db-picker/selected-db]) - state (r/atom {:input ""})] + state (r/atom {:input "" + :tab-value (if utils/electron? 0 2)})] (fn [] - [:> Modal {:isOpen loading - :motionPreset "scale" - :onClose close-modal} - [:> ModalOverlay] - [:> ModalContent - [:> ModalHeader - "Add Database"] - (when-not @loading - [:> ModalCloseButton]) - [:> ModalBody {:display "contents"} - ;; TODO: this is hacky, we're just hiding the picker and forcing - ;; tab 2 for the web client. Instead we should use Stuart's - ;; redesigned DB picker. - [:> Tabs {:isFitted true - :display "contents" - :defaultIndex (if utils/electron? 0 2)} - (when utils/electron? - [:> TabList - [:> Tab "Open Local"] - [:> Tab "Join Remote"] - [:> Tab "Create New"]]) - [:> TabPanels {:display "contents"} - [:> TabPanel {:display "contents"} - [open-local-comp loading selected-db]] - [:> TabPanel {:display "contents"} - [join-remote-comp]] - [:> TabPanel {:display "contents"} - [create-new-local state]]]]]]]))) + (.createPortal + react-dom + (r/as-element [:div (use-style modal-style) + [modal/modal + {:title [:div.modal__title + [:> Storage] + [:h4 "Database"] + (when-not @loading + [:> Button {:on-click close-modal} [:> Close]])] + :content [:div (use-style modal-contents-style) + ;; TODO: this is hacky, we're just hiding the picker and forcing + ;; tab 2 for the web client. Instead we should use Stuart's + ;; redesigned DB picker. + (when utils/electron? + [:div (use-style picker-style) + [:button {:class (when (= 0 (:tab-value @state)) "active") + :on-click (fn [] (swap! state assoc :tab-value 0))} + [:> Folder] + [:span "Open"]] + [:button {:class (when (= 1 (:tab-value @state)) "active") + :on-click (fn [] (swap! state assoc :tab-value 1))} + [:> AddBox] + [:span "New"]] + [:button {:class (when (= 2 (:tab-value @state)) "active") + :on-click (fn [] (swap! state assoc :tab-value 2))} + [:> Group] + [:span "Join"]]]) + (cond + (= 2 (:tab-value @state)) + [join-remote-comp] + + (= 1 (:tab-value @state)) + [create-new-local state] + + (= 0 (:tab-value @state)) + [open-local-comp loading selected-db])] + :on-close close-modal}]]) + el)))) diff --git a/src/cljs/athens/events.cljs b/src/cljs/athens/events.cljs index 01a5f45305..2ff72d51cf 100644 --- a/src/cljs/athens/events.cljs +++ b/src/cljs/athens/events.cljs @@ -471,6 +471,27 @@ (assoc-in db [:selection :items] ordered-selection)))) +;; Alerts + +(reg-event-db + :alert/set + (fn-traced [db alert] + (assoc db :alert alert))) + + +(reg-event-db + :alert/unset + (fn-traced [db] + (assoc db :alert nil))) + + +;; Use native js/alert rather than custom UI alert +(reg-event-fx + :alert/js + (fn [_ [_ message]] + {:alert/js! message})) + + (reg-event-fx :confirm/js (fn [_ [_ message true-cb false-cb]] @@ -860,9 +881,7 @@ :count (count new-uids)}]))]]}) {}) (catch :default _ - {:fx (util/toast (clj->js {:status "error" - :title "Couldn't undo" - :description "Undo for this operation not supported in Lan-Party, yet."}))})))) + {:fx [[:dispatch [:alert/js "Undo for this operation not supported in Lan-Party, yet."]]]})))) (reg-event-fx @@ -893,9 +912,7 @@ :count (count new-uids)}]))]]}) {}) (catch :default _ - {:fx (util/toast (clj->js {:status "error" - :title "Couldn't redo" - :description "Redo for this operation not supported in Lan-Party, yet."}))})))) + {:fx [[:dispatch [:alert/js "Redo for this operation not supported in Lan-Party, yet."]]]})))) (reg-event-fx @@ -1621,9 +1638,7 @@ (re-find #"text/html" datatype) (.getAsString item (fn [_] #_(prn "getAsString" _)))))) items) {}) - {:fx (util/toast (clj->js {:status "error" - :title "Couldn't paste" - :description "Image paste is not supported in Lan-Party, yet."}))})))) + {:fx [[:dispatch [:alert/js "Image paste not supported in Lan-Party, yet."]]]})))) (reg-event-fx diff --git a/src/cljs/athens/main/core.cljs b/src/cljs/athens/main/core.cljs index e007a782cc..c02e37e1ef 100644 --- a/src/cljs/athens/main/core.cljs +++ b/src/cljs/athens/main/core.cljs @@ -89,7 +89,7 @@ :y (.-y main-window-state) :width (.-width main-window-state) :height (.-height main-window-state) - :minWidth 650 ; Minimum width before clipping in toolbar + :minWidth 800 ; Minimum width before clipping in toolbar :minHeight 300 :backgroundColor "#1A1A1A" :autoHideMenuBar true diff --git a/src/cljs/athens/parse_renderer.cljs b/src/cljs/athens/parse_renderer.cljs index 2aeb207a9b..42eece2c60 100644 --- a/src/cljs/athens/parse_renderer.cljs +++ b/src/cljs/athens/parse_renderer.cljs @@ -1,43 +1,68 @@ ^:cljstyle/ignore (ns athens.parse-renderer (:require - ["@chakra-ui/react" :refer [Link Button Text Box]] - ["katex" :as katex] - ["katex/dist/contrib/mhchem"] - [athens.config :as config] - [athens.parser.impl :as parser-impl] - [athens.reactive :as reactive] - [athens.router :as router] - [clojure.string :as str] - [instaparse.core :as insta] - [re-frame.core :as rf])) + ["katex" :as katex] + ["katex/dist/contrib/mhchem"] + [athens.config :as config] + [athens.parser.impl :as parser-impl] + [athens.reactive :as reactive] + [athens.router :as router] + [athens.style :refer [color OPACITIES]] + [clojure.string :as str] + [instaparse.core :as insta] + [re-frame.core :as rf] + [stylefy.core :as stylefy :refer [use-style]])) (declare parse-and-render) -(def fm-props - {:as "b" - :class "formatting" - :whiteSpace "nowrap" - :fontWeight "normal" - :opacity "0.3"}) +;; Styles +(def page-link + {:cursor "pointer" + :text-decoration "none" + :color (color :link-color) + :display "inline" + :border-radius "0.25rem" + ::stylefy/manual [[:.formatting {:color (color :body-text-color) + :opacity (:opacity-low OPACITIES)}] + [:&:hover {:z-index 1 + :background (color :link-color :opacity-lower) + :box-shadow (str "0px 0px 0px 1px " (color :link-color :opacity-lower))}]]}) -(def link-props - {:color "link" - :borderRadius "1px" - :variant "link" - :minWidth "0" - :whiteSpace "inherit" - :wordBreak "inherit" - :alignItems "flex-start" - :justifyContent "flex-start" - :lineHeight "unset" - :textAlign "inherit" - :fontSize "inherit" - :fontWeight "inherit" - :textDecoration "none"}) + +(def hashtag + {::stylefy/mode [[:hover {:text-decoration "underline" :cursor "pointer"}]] + ::stylefy/manual [[:.formatting {:opacity (:opacity-low OPACITIES)}]]}) + + +(def image {:border-radius "0.125rem"}) + + +(def url-link + {:cursor "pointer" + :text-decoration "none" + :color (color :link-color) + ::stylefy/manual [[:.formatting {:color (color :body-text-color :opacity-low)}] + [:&:hover {:text-decoration "underline"}]]}) + + +(def autolink + {:cursor "pointer" + :text-decoration "none" + ::stylefy/manual [[:.formatting {:color (color :body-text-color :opacity-low)}] + [:.contents {:color (color :link-color) + :text-decoration "none"}] + [:&:hover [:.contents {:text-decoration "underline"}]]]}) + + +(def block-ref + {:font-size "0.9em" + :transition "background 0.05s ease" + :border-bottom [["1px" "solid" (color :highlight-color)]] + ::stylefy/mode [[:hover {:background-color (color :highlight-color :opacity-lower) + :cursor "alias"}]]}) (defn parse-title @@ -54,16 +79,12 @@ (defn render-page-link "Renders a page link given the title of the page." [{:keys [from title]} title-coll] - [:<> - [:> Text fm-props "[["] + [:span (assoc (use-style page-link {:class "page-link"}) + :title from) + [:span {:class "formatting"} "[["] (cond (not (str/blank? title)) - [:> Button - (merge link-props - {:class "page-link" - :fontWeight "normal" - :title from - :onClick (fn [e] + [:span {:on-click (fn [e] (let [parsed-title (parse-title title-coll) shift? (.-shiftKey e)] (.. e stopPropagation) ; prevent bubbling up click handler for nested links @@ -72,27 +93,22 @@ :pane (if shift? :right-pane :main-pane)}]) - (router/navigate-page parsed-title e)))}) + (router/navigate-page parsed-title e)))} title] :else - (into - [:> Button - (merge link-props - {:class "page-link" - :title from - :onClick (fn [e] - (let [parsed-title (parse-title title-coll) - shift? (.-shiftKey e)] - (.. e stopPropagation) ; prevent bubbling up click handler for nested links - (rf/dispatch [:reporting/navigation {:source :pr-page-link - :target :page - :pane (if shift? - :right-pane - :main-pane)}]) - (router/navigate-page parsed-title e)))})] - title-coll)) - [:> Text fm-props "]]"]]) + (into [:span {:on-click (fn [e] + (let [parsed-title (parse-title title-coll) + shift? (.-shiftKey e)] + (.. e stopPropagation) ; prevent bubbling up click handler for nested links + (rf/dispatch [:reporting/navigation {:source :pr-page-link + :target :page + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-page parsed-title e)))}] + title-coll)) + [:span {:class "formatting"} "]]"]]) (defn- block-breadcrumb-string @@ -109,50 +125,30 @@ parents (reactive/get-reactive-parents-recursively [:block/uid ref-uid]) bc-string (block-breadcrumb-string parents)] (if block - [:> Button {:variant "link" - :as "a" - :title (-> from - (str/replace "](" - "]\n---\n(") - (str/replace (str "((" ref-uid "))") - bc-string)) - :class "block-ref" - :display "inline" - :color "unset" - :whiteSpace "unset" - :textAlign "unset" - :minWidth "0" - :fontSize "inherit" - :fontWeight "inherit" - :lineHeight "inherit" - :marginInline "-2px" - :paddingInline "2px" - :borderBottomWidth "1px" - :borderBottomStyle "solid" - :borderBottomColor "ref.foreground" - :cursor "alias" - :sx {"WebkitBoxDecorationBreak" "clone"} - :_hover {:textDecoration "none" - :borderBottomColor "transparent" - :bg "ref.background"} - :onClick (fn [e] - (.. e stopPropagation) - (let [shift? (.-shiftKey e)] - (rf/dispatch [:reporting/navigation {:source :pr-block-ref - :target :block - :pane (if shift? - :right-pane - :main-pane)}]) - (router/navigate-uid ref-uid e)))} - (cond - (= uid ref-uid) - [parse-and-render "{{SELF}}"] - - (not (str/blank? title)) - [parse-and-render title ref-uid] - - :else - [parse-and-render (:block/string block) ref-uid])] + [:span (assoc (use-style block-ref {:class "block-ref"}) + :title (-> from + (str/replace "](" + "]\n---\n(") + (str/replace (str "((" ref-uid "))") + bc-string))) + [:span {:class "contents" + :on-click (fn [e] + (let [shift? (.-shiftKey e)] + (rf/dispatch [:reporting/navigation {:source :pr-block-ref + :target :block + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-uid ref-uid e)))} + (cond + (= uid ref-uid) + [parse-and-render "{{SELF}}"] + + (not (str/blank? title)) + [parse-and-render title ref-uid] + + :else + [parse-and-render (:block/string block) ref-uid])]] from))) @@ -218,56 +214,44 @@ :page-link (fn [{_from :from :as attr} & title-coll] (render-page-link attr title-coll)) :hashtag (fn [{_from :from} & title-coll] - [:> Button (merge link-props - {:variant "link" - :class "hashtag" - :color "inherit" - :fontWeight "inherit" - :_hover {:textDecoration "none"} - :onClick (fn [e] - (let [parsed-title (parse-title title-coll) - shift? (.-shiftKey e)] - (rf/dispatch [:reporting/navigation {:source :pr-hashtag - :target :hashtag - :pane (if shift? - :right-pane - :main-pane)}]) - (router/navigate-page parsed-title e)))}) - [:> Text fm-props "#"] + [:span (use-style hashtag {:class "hashtag" + :on-click (fn [e] + (let [parsed-title (parse-title title-coll) + shift? (.-shiftKey e)] + (rf/dispatch [:reporting/navigation {:source :pr-hashtag + :target :hashtag + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-page parsed-title e)))}) + [:span {:class "formatting"} "#"] [:span {:class "contents"} title-coll]]) :block-ref (fn [{_from :from :as attr} ref-uid] (render-block-ref attr ref-uid uid)) :url-image (fn [{url :src alt :alt}] - [:> Box {:class "url-image" - :as "img" - :borderRadius "md" - :alt alt - :src url}]) + [:img (use-style image {:class "url-image" + :alt alt + :src url})]) :url-link (fn [{url :url} text] - [:> Button - (merge link-props {:class "url-link" - :href url - :target "_blank"}) + [:a (use-style url-link {:class "url-link" + :href url + :target "_blank"}) text]) :link (fn [{:keys [text target title]}] - [:> Button (cond-> (merge link-props - {:class "url-link contents" - :as "a" - :href target - :target "_blank"}) - (string? title) - (assoc :title title)) + [:a (cond-> (use-style url-link {:class "url-link contents" + :href target + :target "_blank"}) + (string? title) + (assoc :title title)) text]) :autolink (fn [{:keys [text target]}] - [:<> - [:> Text fm-props "<"] - [:> Link (merge - link-props - {:class "autolink contents" - :href target - :target "_blank"}) + [:span (use-style autolink) + [:span {:class "formatting"} "<"] + [:a {:class "autolink contents" + :href target + :target "_blank"} text] - [:> Text fm-props ">"]]) + [:span {:class "formatting"} ">"]]) :text-run (fn [& contents] (apply conj [:span {:class "text-run"}] contents)) :paragraph (fn [& contents] diff --git a/src/cljs/athens/self_hosted/presence/views.cljs b/src/cljs/athens/self_hosted/presence/views.cljs index 43477a0f5c..788b06b1ed 100644 --- a/src/cljs/athens/self_hosted/presence/views.cljs +++ b/src/cljs/athens/self_hosted/presence/views.cljs @@ -1,7 +1,7 @@ (ns athens.self-hosted.presence.views (:require + ["/components/Avatar/Avatar" :refer [Avatar]] ["/components/PresenceDetails/PresenceDetails" :refer [PresenceDetails]] - ["@chakra-ui/react" :refer [Avatar AvatarGroup]] [athens.self-hosted.presence.events] [athens.self-hosted.presence.fx] [athens.self-hosted.presence.subs] @@ -10,6 +10,8 @@ [reagent.core :as r])) +;; Avatar + (defn user->person [{:keys [session-id username color] :page/keys [title]}] @@ -23,8 +25,8 @@ (defn copy-host-address-to-clipboard [host-address] (.. js/navigator -clipboard (writeText host-address)) - (util/toast (clj->js {:status "info" - :title "Host address copied to clipboard"}))) + (rf/dispatch [:show-snack-msg {:msg "Host address copied to clipboard" + :type :success}])) (defn go-to-user-block @@ -34,11 +36,11 @@ (->> (js->clj js-person :keywordize-keys true) :personId (get all-users))] - (if page-uid - ;; TODO: if we support navigating to a block, it should be added here. - (rf/dispatch [:navigate :page {:id page-uid}]) - (util/toast (clj->js {:title "User is not on any page" - :status "warning"}))))) + (rf/dispatch (if page-uid + ;; TODO: if we support navigating to a block, it should be added here. + [:navigate :page {:id page-uid}] + [:show-snack-msg {:msg "User is not on any page" + :type :success}])))) (defn edit-current-user @@ -85,17 +87,20 @@ (let [users (rf/subscribe [:presence/has-presence (util/embed-uid->original-uid uid)])] (when (seq @users) (into - [:> AvatarGroup {:max 3 - :zIndex 2 - :size "xs" - :position "absolute" - :right "-1.5rem" - :top "0.25rem"} - (->> @users - (map user->person) - (remove nil?) - (map (fn [{:keys [personId] :as person}] - [:> Avatar {:key personId - :bg (:color person) - :name (:username person)}])))])))) + [:> (.-Stack Avatar) + {:size "1.25rem" + :maskSize "1.5px" + :stackOrder "from-left" + :limit 3 + :style {:zIndex 100 + :position "absolute" + :right "-1.5rem" + :top "0.25rem" + :padding "0.125rem" + :background "var(--background-color)"}}] + (->> @users + (map user->person) + (remove nil?) + (map (fn [{:keys [personId] :as person}] + [:> Avatar (merge {:showTooltip false :key personId} person)]))))))) diff --git a/src/cljs/athens/style.cljs b/src/cljs/athens/style.cljs index a1bf274ee1..a00e2f3969 100644 --- a/src/cljs/athens/style.cljs +++ b/src/cljs/athens/style.cljs @@ -7,6 +7,97 @@ [stylefy.reagent :as stylefy-reagent])) +(def THEME-DARK + {:link-color "#2399E7" + :highlight-color "#FBBE63" + :text-highlight-color "#FBBE63" + :warning-color "#DE3C21" + :confirmation-color "#189E36" + :header-text-color "#BABABA" + :body-text-color "#AAA" + :border-color "hsla(32, 81%, 90%, 0.08)" + :background-minus-1 "#151515" + :background-minus-2 "#111" + :background-color "#1A1A1A" + :background-plus-1 "#222" + :background-plus-2 "#333" + + :graph-control-bg "#272727" + :graph-control-color "white" + :graph-node-normal "#909090" + :graph-node-hlt "#FBBE63" + :graph-link-normal "#323232" + + :error-color "#fd5243"}) + + +(def THEME-LIGHT + {:link-color "#0075E1" + :highlight-color "#F9A132" + :text-highlight-color "#ffdb8a" + :warning-color "#D20000" + :confirmation-color "#009E23" + :header-text-color "#322F38" + :body-text-color "#433F38" + :border-color "hsla(32, 81%, 10%, 0.08)" + :background-plus-2 "#fff" + :background-plus-1 "#fbfbfb" + :background-color "#F6F6F6" + :background-minus-1 "#FAF8F6" + :background-minus-2 "#EFEDEB" + :graph-control-bg "#f9f9f9" + :graph-control-color "black" + :graph-node-normal "#909090" + :graph-node-hlt "#0075E1" + :graph-link-normal "#cfcfcf" + + :error-color "#fd5243"}) + + +(def DEPTH-SHADOWS + {:4 "0 2px 4px rgba(0, 0, 0, 0.2)" + :8 "0 4px 8px rgba(0, 0, 0, 0.2)" + :16 "0 4px 16px rgba(0, 0, 0, 0.2)" + :64 "0 24px 60px rgba(0, 0, 0, 0.2)"}) + + +(def OPACITIES + {:opacity-lower 0.10 + :opacity-low 0.25 + :opacity-med 0.50 + :opacity-high 0.75 + :opacity-higher 0.85}) + + +;; Based on Bootstrap's excellent Z-index set +(def ZINDICES + {:zindex-dropdown 1000 + :zindex-sticky 1020 + :zindex-fixed 1030 + :zindex-modal-backdrop 1040 + :zindex-modal 1050 + :zindex-popover 1060 + :zindex-tooltip 1070}) + + +;; Color +(defn color + "Turns a color and optional opacity into a CSS variable. + Only accepts keywords." + ([variable] + (when (keyword? variable) + (str "var(--" + (symbol variable) + ")"))) + ([variable alpha] + (when (and (keyword? variable) (keyword? alpha)) + (str "var(--" + (symbol variable) + "---" + (symbol alpha) + ")")))) + + (reg-sub :zoom-level (fn [db _] diff --git a/src/cljs/athens/util.cljs b/src/cljs/athens/util.cljs index 67ef5f4857..89ab41b727 100644 --- a/src/cljs/athens/util.cljs +++ b/src/cljs/athens/util.cljs @@ -1,8 +1,6 @@ (ns athens.util (:require ["/textarea" :as getCaretCoordinates] - ["/theme/theme" :refer [theme]] - ["@chakra-ui/react" :refer [createStandaloneToast]] [athens.config :as config] [athens.electron.utils :as electron.utils] [clojure.string :as string] @@ -16,9 +14,6 @@ KeyCodes))) -(def toast (createStandaloneToast (clj->js {:theme theme}))) - - ;; Electron ipcMain Channels (def ipcMainChannels @@ -222,8 +217,8 @@ (let [os (.. js/window -navigator -appVersion)] (cond (re-find #"Windows" os) :windows - (re-find #"Mac" os) :mac - :else :linux))) + (re-find #"Linux" os) :linux + (re-find #"Mac" os) :mac))) (defn is-mac? diff --git a/src/cljs/athens/views.cljs b/src/cljs/athens/views.cljs index 55fdc4b32d..ec86fd1220 100644 --- a/src/cljs/athens/views.cljs +++ b/src/cljs/athens/views.cljs @@ -1,11 +1,15 @@ (ns athens.views (:require - ["/theme/theme" :refer [theme]] - ["@chakra-ui/react" :refer [ChakraProvider Flex Grid Spinner Center]] + ["/components/Spinner/Spinner" :refer [Spinner]] + ["/components/utils/style/style" :refer [GlobalStyles]] + ["@material-ui/core/Snackbar" :as Snackbar] + ["@react-aria/overlays" :refer [OverlayProvider]] [athens.config] [athens.electron.db-modal :as db-modal] + [athens.electron.utils :as electron.utils] [athens.style :refer [zoom]] [athens.subs] + [athens.util :refer [get-os]] [athens.views.app-toolbar :as app-toolbar] [athens.views.athena :refer [athena-component]] [athens.views.devtool :refer [devtool-component]] @@ -13,11 +17,28 @@ [athens.views.left-sidebar :as left-sidebar] [athens.views.pages.core :as pages] [athens.views.right-sidebar :as right-sidebar] - [re-frame.core :as rf])) + [re-frame.core :as rf] + [reagent.core :as r] + [stylefy.core :as stylefy :refer [use-style]])) + + +;; Styles + + +(def app-wrapper-style + {:display "grid" + :grid-template-areas + "'app-header app-header app-header' + 'left-sidebar main-content secondary-content' + 'devtool devtool devtool'" + :grid-template-columns "auto 1fr auto" + :grid-template-rows "auto 1fr auto" + :height "100vh"}) ;; Components + (defn alert [] (let [alert- (rf/subscribe [:alert])] @@ -26,49 +47,62 @@ (rf/dispatch [:alert/unset])))) +;; Snackbar + +(def m-snackbar (r/adapt-react-class (.-default Snackbar))) + + +(rf/reg-sub + :db/snack-msg + (fn [db] + (:db/snack-msg db))) + + +(rf/reg-event-db + :show-snack-msg + (fn [db [_ msg-opts]] + (js/setTimeout #(rf/dispatch [:show-snack-msg {}]) 4000) + (assoc db :db/snack-msg msg-opts))) + + (defn main [] (let [loading (rf/subscribe [:loading?]) + os (get-os) + electron? electron.utils/electron? modal (rf/subscribe [:modal])] (fn [] - [:div (merge {:style {:display "contents"}} - (zoom)) - [:> ChakraProvider {:theme theme, - :bg "background.basement"} + [:> OverlayProvider + [:div (merge {:style {:display "contents"}} + (zoom)) + [:> GlobalStyles] [help-popup] [alert] + (let [{:keys [msg type]} @(rf/subscribe [:db/snack-msg])] + [m-snackbar + {:message msg + :open (boolean msg)} + [:span + {:style {:background-color (case type + :success "green" + "red") + :padding "10px 20px" + :color "white"}} + msg]]) [athena-component] (cond (and @loading @modal) [db-modal/window] - @loading - [:> Center {:height "100vh"} - [:> Flex {:width 28 - :flexDirection "column" - :gap 2 - :color "foreground.secondary" - :borderRadius "lg" - :placeItems "center" - :placeContent "center" - :height 28} - [:> Spinner {:size "xl"}]]] + @loading [:> Spinner] :else [:<> (when @modal [db-modal/window]) - [:> Grid - {:gridTemplateColumns "auto 1fr auto" - :gridTemplateRows "auto 1fr auto" - :grid-template-areas - "'app-header app-header app-header' - 'left-sidebar main-content secondary-content' - 'devtool devtool devtool'" - :height "100vh" - :overflow "hidden" - :sx {"WebkitAppRegion" "drag" - "--app-toolbar-height" "3.25rem" - ".os-mac &" {"--app-header-height" "52px"} - ".os-windows &" {"--toolbar-height" "44px"} - ".os-linux &" {"--toolbar-height" "44px"}}} + [:div (use-style app-wrapper-style + {:class [(case os + :windows "os-windows" + :mac "os-mac" + :linux "os-linux") + (when electron? "is-electron")]}) [app-toolbar/app-toolbar] [left-sidebar/left-sidebar] [pages/view] diff --git a/src/cljs/athens/views/app_toolbar.cljs b/src/cljs/athens/views/app_toolbar.cljs index cd76c34805..4aca0f1b1a 100644 --- a/src/cljs/athens/views/app_toolbar.cljs +++ b/src/cljs/athens/views/app_toolbar.cljs @@ -2,6 +2,7 @@ (:require ["/components/AppToolbar/AppToolbar" :refer [AppToolbar]] [athens.electron.db-menu.core :refer [db-menu]] + [athens.electron.db-modal :as db-modal] [athens.electron.utils :as electron.utils] [athens.router :as router] [athens.self-hosted.presence.views :refer [toolbar-presence-el]] @@ -31,6 +32,7 @@ win-fullscreen? (if electron? (rf/subscribe [:win-fullscreen?]) (r/atom false)) + merge-open? (r/atom false) os (util/get-os) on-left-sidebar-toggle #(rf/dispatch [:left-sidebar/toggle]) on-back #(.back js/window.history) @@ -58,36 +60,42 @@ on-athena #(rf/dispatch [:athena/toggle]) on-help #(rf/dispatch [:help/toggle]) on-theme #(rf/dispatch [:theme/toggle]) + on-merge #(swap! merge-open? not) on-right-sidebar #(rf/dispatch [:right-sidebar/toggle]) on-maximize #(rf/dispatch [:toggle-max-min-win]) on-minimize #(rf/dispatch [:minimize-win]) on-close #(rf/dispatch [:close-win])] - [:> AppToolbar {:style (unzoom) - :os os - :isElectron electron? - :route @route-name - :isWinFullscreen @win-fullscreen? - :isWinMaximized @win-maximized? - :isWinFocused @win-focused? - :isHelpOpen @help-open? - :isThemeDark @theme-dark - :isLeftSidebarOpen @left-open? - :isRightSidebarOpen @right-open? - :isCommandBarOpen @athena-open? - :onPressLeftSidebarToggle on-left-sidebar-toggle - :onPressHistoryBack on-back - :onPressHistoryForward on-forward - :onPressDailyNotes on-daily-pages - :onPressAllPages on-all-pages - :onPressGraph on-graph - :onPressCommandBar on-athena - :onPressHelp on-help - :onPressThemeToggle on-theme - :onPressSettings on-settings - :onPressRightSidebarToggle on-right-sidebar - :onPressMaximizeRestore on-maximize - :onPressMinimize on-minimize - :onPressClose on-close - :databaseMenu (r/as-element [db-menu]) - :presenceDetails (when (electron.utils/remote-db? @selected-db) - (r/as-element [toolbar-presence-el]))}])) + (fn [] + [:<> + (when @merge-open? + [db-modal/merge-modal merge-open?]) + [:> AppToolbar {:style (unzoom) + :os os + :isElectron electron? + :route @route-name + :isWinFullscreen @win-fullscreen? + :isWinMaximized @win-maximized? + :isWinFocused @win-focused? + :isHelpOpen @help-open? + :isThemeDark @theme-dark + :isLeftSidebarOpen @left-open? + :isRightSidebarOpen @right-open? + :isCommandBarOpen @athena-open? + :onPressLeftSidebarToggle on-left-sidebar-toggle + :onPressHistoryBack on-back + :onPressHistoryForward on-forward + :onPressDailyNotes on-daily-pages + :onPressAllPages on-all-pages + :onPressGraph on-graph + :onPressCommandBar on-athena + :onPressHelp on-help + :onPressThemeToggle on-theme + :onPressSettings on-settings + :onPressMerge on-merge + :onPressRightSidebarToggle on-right-sidebar + :onPressMaximizeRestore on-maximize + :onPressMinimize on-minimize + :onPressClose on-close + :databaseMenu (r/as-element [db-menu]) + :presenceDetails (when (electron.utils/remote-db? @selected-db) + (r/as-element [toolbar-presence-el]))}]]))) diff --git a/src/cljs/athens/views/athena.cljs b/src/cljs/athens/views/athena.cljs index d3b60b2a68..29b9f1193f 100644 --- a/src/cljs/athens/views/athena.cljs +++ b/src/cljs/athens/views/athena.cljs @@ -1,22 +1,150 @@ (ns athens.views.athena (:require - ["/components/Icons/Icons" :refer [XmarkIcon]] - ["@chakra-ui/react" :refer [Modal ModalContent ModalOverlay VStack Button IconButton Input HStack Heading Text]] + ["@material-ui/icons/ArrowForward" :default ArrowForward] + ["@material-ui/icons/Close" :default Close] + ["@material-ui/icons/Create" :default Create] [athens.common.utils :as utils] [athens.db :as db :refer [search-in-block-content search-exact-node-title search-in-node-title re-case-insensitive]] [athens.router :as router] + [athens.style :refer [color DEPTH-SHADOWS OPACITIES ZINDICES]] [athens.subs] [athens.util :refer [scroll-into-view]] [clojure.string :as str] + [garden.selectors :as selectors] [goog.dom :refer [getElement]] [goog.events :as events] [re-frame.core :as rf :refer [subscribe dispatch]] - [reagent.core :as r]) + [reagent.core :as r] + [stylefy.core :as stylefy :refer [use-style use-sub-style]]) (:import (goog.events KeyCodes))) +;; Styles + + +(def container-style + {:width "49rem" + :max-width "calc(100vw - 1rem)" + :border-radius "0.25rem" + :box-shadow [[(:64 DEPTH-SHADOWS) ", 0 0 0 1px " (color :body-text-color :opacity-lower)]] + :display "flex" + :flex-direction "column" + :background (color :background-plus-1) + :position "fixed" + :overflow "hidden" + :max-height "60vh" + :z-index (:zindex-modal ZINDICES) + :top "40%" + :left "50%" + :transform "translate(-50%, -50%)" + ;; Styling for the states of the custom search-cancel button, which depend on the input contents + ::stylefy/manual [[(selectors/+ :input :button) {:opacity 0}] + ;; Using ':valid' here as a proxy for "has contents", i.e. "button should appear" + [(selectors/+ :input:valid :button) {:opacity 1}]]}) + + +(def athena-input-style + {:width "100%" + :border 0 + :font-size "2.375rem" + :font-weight "300" + :line-height "1.3" + :letter-spacing "-0.03em" + :border-radius "0.25rem 0.25rem 0 0" + :background (color :background-plus-2) + :color (color :body-text-color) + :caret-color (color :link-color) + :padding "1.5rem 4rem 1.5rem 1.5rem" + :cursor "text" + ::stylefy/mode {:focus {:outline "none"} + "::placeholder" {:color (color :body-text-color :opacity-low)} + "::-webkit-search-cancel-button" {:display "none"}}}) ; We replace the button elsewhere + + + +(def search-cancel-button-style + {:background "none" + :color "inherit" + :position "absolute" + :transition "opacity 0.1s ease, background 0.1s ease" + :cursor "pointer" + :border 0 + :right "2rem" + :place-items "center" + :place-content "center" + :height "2.5rem" + :width "2.5rem" + :border-radius "1000px" + :display "flex" + :transform "translate(0%, -50%)" + :top "50%" + ::stylefy/manual [[:&:hover :&:focus {:background (color :background-plus-1)}]]}) + + +(def results-list-style + {:background (color :background-color) + :overflow-y "auto" + :max-height "100%"}) + + +(def results-heading-style + {:padding "0.25rem 1.125rem" + :background (color :background-plus-2) + :display "flex" + :position "sticky" + :flex-wrap "wrap" + :gap "0.5rem" + :align-items "center" + :top "0" + :justify-content "space-between" + :box-shadow [["0 1px 0 0 " (color :border-color)]] + :border-top [["1px solid" (color :border-color)]]}) + + +(def result-style + {:display "flex" + :padding "0.75rem 2rem" + :background (color :background-plus-1) + :color (color :body-text-color) + :transition "all .05s ease" + :border-top [["1px solid " (color :border-color)]] + ::stylefy/sub-styles {:title {:font-size "1rem" + :margin "0" + :color (color :header-text-color) + :font-weight "500"} + :preview {:white-space "wrap" + :word-break "break-word" + :color (color :body-text-color :opacity-med)} + :link-leader {:color "transparent" + :margin "auto auto"}} + ::stylefy/manual [[:b {:font-weight "500" + :opacity (:opacity-high OPACITIES)}] + [:&.selected :&:hover {:background (color :link-color) + :color "#fff"} ; Intentionally not a theme value, because we don't have a semantic way to contrast with :link-color + [:.title :.preview :.link-leader :.result-highlight {:color "inherit"}]]]}) + + +(def result-body-style + {:flex "1 1 100%" + :display "flex" + :flex-direction "column" + :justify-content "center" + :align-items "flex-start"}) + + +(def result-highlight-style + {:color (color :body-text-color) + :font-weight "500"}) + + +(def hint-style + {:color "inherit" + :opacity (:opacity-med OPACITIES) + :font-size "14px"}) + + ;; Utilities @@ -26,8 +154,7 @@ (doall (map-indexed (fn [i part] (if (re-find query-pattern part) - [:> Text {:class "result-highlight" - :key i} part] + [:span.result-highlight (use-style result-highlight-style {:key i}) part] part)) (str/split txt query-pattern))))) @@ -55,6 +182,9 @@ {:keys [index query results]} @state item (get results index)] (cond + (= key KeyCodes.ESC) + (dispatch [:athena/toggle]) + (= KeyCodes.ENTER key) (cond ;; if page doesn't exist, create and open (and (zero? index) (nil? item)) @@ -102,7 +232,7 @@ input-el (.. e -target) ;; Get the result list container which is the last element child ;; of the whole athena component - result-el (.. input-el (closest "section.athena-modal") -lastElementChild) + result-el (.. input-el (closest "div.athena") -lastElementChild) ;; Get next element in the result list next-el (nth (array-seq (.. result-el -children)) cur-index)] ;; Check if next el is beyond the bounds of the result list and scroll if so @@ -114,7 +244,7 @@ (swap! state update :index #(if (= % (dec (count results))) 0 (inc %))) (let [cur-index (:index @state) input-el (.. e -target) - result-el (.. input-el (closest "section.athena-modal") -lastElementChild) + result-el (.. input-el (closest "div.athena") -lastElementChild) next-el (nth (array-seq (.. result-el -children)) cur-index)] (scroll-into-view next-el result-el (zero? cur-index)))) @@ -123,86 +253,38 @@ ;; Components -(defn result-el - [{:keys [title preview prefix icon query on-click active?]}] - [:> Button {:justifyContent "flex-start" - :fontWeight "normal" - :display "flex" - :height "auto" - :textAlign "start" - :flexDirection "row" - :bg "transparent" - :px 3 - :py 3 - :isActive active? - :onClick on-click} - [:> VStack {:align "stretch" - :spacing 1 - :overflow "hidden"} - [:> Heading {:as "h4" - :size "sm"} prefix (highlight-match query title)] - (when preview - [:> Text {:color "foreground.secondary" - :textOverflow "ellipsis" - :overflow "hidden"} (highlight-match query preview)])] - icon]) - (defn results-el [state] (let [no-query? (str/blank? (:query @state)) recent-items @(subscribe [:athena/get-recent])] - [:<> [:> HStack {:fontSize "sm" - :px 6 - :py 2 - :color "foreground.secondary" - :borderTop "1px solid" - :borderColor "separator.divider" - :justifyContent "space-between"} - [:> Heading {:size "xs"} - (if no-query? "Recent" "Results")] - [:> Text + [:<> [:div (use-style results-heading-style) + [:h5 (if no-query? "Recent" "Results")] + [:span (use-style hint-style) "Press " [:kbd "shift + enter"] " to open in right sidebar."]] (when no-query? - [:> VStack {:align "stretch" - :spacing 1 - :borderTopWidth "1px" - :borderTopStyle "solid" - :borderColor "separator.divider" - :pt 4 - :mb 4 - :px 4 - :overflowY "overlay" - :_empty {:display "none"}} + [:div (use-style results-list-style) (doall (for [[i x] (map-indexed list recent-items)] (when x (let [{:keys [query :node/title :block/string]} x] - [result-el {:key i - :title title - :query query - :preview string - :on-click (fn [e] - (rf/dispatch [:reporting/navigation {:source :athena - :target :page - :pane :main-pane}]) - (router/navigate-page title e))}]))))])])) + [:div (use-style result-style {:key i + :on-click (fn [e] + (rf/dispatch [:reporting/navigation {:source :athena + :target :page + :pane :main-pane}]) + (router/navigate-page title e))}) + [:h4.title (use-sub-style result-style :title) (highlight-match query title)] + (when string + [:span.preview (use-sub-style result-style :preview) (highlight-match query string)]) + [:span.link-leader (use-sub-style result-style :link-leader) [(r/adapt-react-class ArrowForward)]]]))))])])) (defn search-results-el [{:keys [results query index]}] - [:> VStack {:align "stretch" - :borderTopWidth "1px" - :borderTopStyle "solid" - :borderColor "separator.divider" - :spacing 1 - :pt 4 - :mb 4 - :px 4 - :overflowY "overlay" - :_empty {:display "none"}} + [:div (use-style results-list-style) (doall (for [[i x] (map-indexed list results) :let [block-uid (:block/uid x) @@ -212,113 +294,92 @@ string (:block/string x)]] (if (nil? x) ^{:key i} - [result-el {:key i - :title query - :prefix "Create page: " - :preview nil - :query query - :active? (= i index) - :on-click (fn [e] - (let [block-uid (utils/gen-block-uid) - shift? (.-shiftKey e)] - (dispatch [:athena/toggle]) - (dispatch [:page/new {:title query - :block-uid block-uid - :source :athena}]) - (dispatch [:reporting/navigation {:source :athena - :target (if parent - (str "block/" block-uid) - (str "page/" title)) - :pane (if shift? - :right-pane - :main-pane)}])))}] - [result-el {:key i - :title title - :query query - :preview string - :active? (= i index) - :on-click (fn [e] - (let [selected-page {:node/title title - :block/uid uid - :block/string string - :query query} - shift? (.-shiftKey e)] - (dispatch [:athena/toggle]) - (dispatch [:athena/update-recent-items selected-page]) - (dispatch [:reporting/navigation {:source :athena - :target (if parent - :block - :page) - :pane (if shift? - :right-pane - :main-pane)}]) - (if parent - (router/navigate-uid block-uid) - (router/navigate-page title e))))}])))]) + [:div (use-style result-style + {:on-click (fn [e] + (let [block-uid (utils/gen-block-uid) + shift? (.-shiftKey e)] + (dispatch [:athena/toggle]) + (dispatch [:page/new {:title query + :block-uid block-uid + :source :athena}]) + (dispatch [:reporting/navigation {:source :athena + :target (if parent + (str "block/" block-uid) + (str "page/" title)) + :pane (if shift? + :right-pane + :main-pane)}]))) + :class (when (= i index) "selected")}) + + [:div (use-style result-body-style) + [:h4.title (use-sub-style result-style :title) + [:b "Create Page: "] + query]] + [:span.link-leader (use-sub-style result-style :link-leader) [(r/adapt-react-class Create)]]] + + [:div (use-style result-style + {:key i + :on-click (fn [e] + (let [selected-page {:node/title title + :block/uid uid + :block/string string + :query query} + shift? (.-shiftKey e)] + (dispatch [:athena/toggle]) + (dispatch [:athena/update-recent-items selected-page]) + (dispatch [:reporting/navigation {:source :athena + :target (if parent + :block + :page) + :pane (if shift? + :right-pane + :main-pane)}]) + (if parent + (router/navigate-uid block-uid) + (router/navigate-page title e)))) + + :class (when (= i index) "selected")}) + [:div (use-style result-body-style) + + [:h4.title (use-sub-style result-style :title) (highlight-match query title)] + (when string + [:span.preview (use-sub-style result-style :preview) (highlight-match query string)])] + [:span.link-leader (use-sub-style result-style :link-leader) [(r/adapt-react-class ArrowForward)]]])))]) (defn athena-component [] - (let [athena-open? (rf/subscribe [:athena/open]) + (let [ref (atom nil) + athena-open? (rf/subscribe [:athena/open]) + handle-click-outside (fn [e] + (when (and @athena-open? + (not (.. @ref (contains (.. e -target))))) + (dispatch [:athena/toggle]))) state (r/atom {:index 0 :query nil :results []}) search-handler (create-search-handler state)] - (fn [] - [:> Modal {:maxHeight "60vh" - :display "flex" - :scrollBehavior "inside" - :outline "none" - :motionPreset "none" - :closeOnEsc true - :isOpen @athena-open? - :onClose #(dispatch [:athena/toggle])} - [:> ModalOverlay] - [:> ModalContent {:width "49rem" - :class "athena-modal" - :overflow "hidden" - :backdropFilter "blur(20px)" - :bg "background.vibrancy" - :maxWidth "calc(100vw - 4rem)"} - [:> Input - {:type "search" - :width "100%" - :border 0 - :fontSize "2.375rem" - :fontWeight "300" - :lineHeight "1.3" - :letterSpacing "-0.03em" - :color "inherit" - :background "none" - :borderRadius 0 - :height "auto" - :padding "1.5rem 4rem 1.5rem 1.5rem" - :cursor "text" - :id "athena-input" - :auto-focus true - :required true - :_focus {:outline "none"} - :sx {"::placeholder" {:color "foreground.secondary"} - "::-webkit-search-cancel-button" {:display "none"}} - :placeholder "Find or Create Page" - :on-change (fn [e] (search-handler (.. e -target -value))) - :on-key-down (fn [e] (key-down-handler e state))}] - (when (:query @state) - [:> IconButton {:background "none" - :color "foreground.secondary" - :position "absolute" - :transition "opacity 0.1s ease, background 0.1s ease" - :cursor "pointer" - :border 0 - :right "2rem" - :placeItems "center" - :placeContent "center" - :height "2.5rem" - :width "2.5rem" - :borderRadius "1000px" - :display "flex" - :top "2rem" - :onClick #(set! (.-value (getElement "athena-input")) nil)} - [:> XmarkIcon {:boxSize 6}]]) - [results-el state] - [search-results-el @state]]]))) + (r/create-class + {:display-name "athena" + ;; NOTE: this mouse listener stuff can go away with mechanism combining overlay and react portals + :component-did-mount (fn [_this] (events/listen js/document "mousedown" handle-click-outside)) + :component-will-unmount (fn [_this] (events/unlisten js/document "mousedown" handle-click-outside)) + :reagent-render + (fn [] + (when @athena-open? + [:div.athena (use-style container-style + {:ref #(reset! ref %)}) + [:header {:style {:position "relative"}} + [:input (use-style athena-input-style + {:type "search" + :id "athena-input" + :auto-focus true + :required true + :placeholder "Find or Create Page" + :on-change (fn [e] (search-handler (.. e -target -value))) + :on-key-down (fn [e] (key-down-handler e state))})] + [:button (use-style search-cancel-button-style + {:on-click #(set! (.-value (getElement "athena-input")) %)}) + [:> Close]]] + [results-el state] + [search-results-el @state]]))}))) diff --git a/src/cljs/athens/views/blocks/autocomplete_search.cljs b/src/cljs/athens/views/blocks/autocomplete_search.cljs index 279d98c48d..f9f1332d9e 100644 --- a/src/cljs/athens/views/blocks/autocomplete_search.cljs +++ b/src/cljs/athens/views/blocks/autocomplete_search.cljs @@ -1,8 +1,13 @@ (ns athens.views.blocks.autocomplete-search (:require - ["@chakra-ui/react" :refer [Portal Popover PopoverTrigger PopoverBody Button PopoverContent Text Box]] + ["/components/Button/Button" :refer [Button]] + [athens.style :as style] [athens.views.blocks.textarea-keydown :as textarea-keydown] - [clojure.string :as string])) + [athens.views.dropdown :as dropdown] + [clojure.string :as string] + [goog.events :as events] + [reagent.core :as r] + [stylefy.core :as stylefy])) (defn inline-item-click @@ -17,50 +22,42 @@ (defn inline-search-el - [_block] - (fn [block state] - (let [{:search/keys [index results type query] caret-position :caret-position} @state - can-open (some #(= % type) [:page :block :hashtag :template]) - {:keys [left top]} caret-position] - [:> Popover {:isOpen can-open - :placement "bottom-start" - :isLazy true - :returnFocusOnClose false - :closeOnBlur true - :closeOnEsc true - :onClose #(swap! state assoc :search/type false) - :autoFocus false - :onMouseDown (fn [e] (.. e preventDefault))} - [:> PopoverTrigger - [:> Box {:position "fixed" - :overflow "auto" - :width "0" - :height "0" - :top (str (+ 24 top) "px") - :left (str (+ 24 left) "px")}]] - [:> Portal - [:> PopoverContent - [:> PopoverBody {:p 0 - :overflow "hidden" - :borderRadius "inherit"} - (when can-open - (if (or (string/blank? query) - (empty? results)) - [:> Text {:py "0.4rem" - :px "0.8rem" - :fontStyle "italics"} - (str "Search for a " (symbol type))] - (doall - (for [[i {:keys [node/title block/string block/uid]}] (map-indexed list results)] - [:> Button {:key (str "inline-search-item" uid) - :id (str "dropdown-item-" i) - :borderRadius "0" - :justifyContent "flex-start" - :width "100%" - :_first {:borderTopRadius "inherit"} - :_last {:borderBottomRadius "inherit"} - :isActive (= index i) - ;; if page link, expand to title. otherwise expand to uid for a block ref - :onClick (fn [_] (inline-item-click state (:block/uid block) (or title uid)))} - (or title string)]))))]]]]))) + [_block state] + (let [ref (atom nil) + handle-click-outside (fn [e] + (let [{:search/keys [type]} @state] + (when (and (#{:page :block :hashtag :template} type) + (not (.. @ref (contains (.. e -target))))) + (swap! state assoc :search/type false))))] + (r/create-class + {:display-name "inline-search" + :component-did-mount (fn [_this] (events/listen js/document "mousedown" handle-click-outside)) + :component-will-unmount (fn [_this] (events/unlisten js/document "mousedown" handle-click-outside)) + :reagent-render (fn [block state] + (let [{:search/keys [query results index type] caret-position :caret-position} @state + {:keys [left top]} caret-position] + (when (some #(= % type) [:page :block :hashtag :template]) + [:div (merge (stylefy/use-style dropdown/dropdown-style + {:ref #(reset! ref %) + ;; don't blur textarea when clicking to auto-complete + :on-mouse-down (fn [e] (.. e preventDefault))}) + {:style {:position "absolute" + :max-height "20rem" + :z-index (:zindex-popover style/ZINDICES) + :top (+ 24 top) + :left (+ 24 left)}}) + [:div#dropdown-menu (stylefy/use-style dropdown/menu-style) + (if (or (string/blank? query) + (empty? results)) + ;; Just using button for styling + [:> Button (stylefy/use-style {:opacity (style/OPACITIES :opacity-low)}) (str "Search for a " (symbol type))] + (doall + (for [[i {:keys [node/title block/string block/uid]}] (map-indexed list results)] + [:> Button {:key (str "inline-search-item" uid) + :id (str "dropdown-item-" i) + :is-pressed (= index i) + ;; if page link, expand to title. otherwise expand to uid for a block ref + :on-click (fn [_] (inline-item-click state (:block/uid block) (or title uid))) + :style {:text-align "left"}} + (or title string)])))]])))}))) diff --git a/src/cljs/athens/views/blocks/autocomplete_slash.cljs b/src/cljs/athens/views/blocks/autocomplete_slash.cljs index f89c470ea2..ce80f6be1d 100644 --- a/src/cljs/athens/views/blocks/autocomplete_slash.cljs +++ b/src/cljs/athens/views/blocks/autocomplete_slash.cljs @@ -1,8 +1,11 @@ (ns athens.views.blocks.autocomplete-slash (:require - ["@chakra-ui/react" :refer [Portal Menu MenuList MenuItem]] + ["/components/Button/Button" :refer [Button]] [athens.views.blocks.textarea-keydown :as textarea-keydown] - [reagent.core :as r])) + [athens.views.dropdown :as dropdown] + [goog.events :as events] + [reagent.core :as r] + [stylefy.core :as stylefy])) (defn slash-item-click @@ -13,26 +16,33 @@ (defn slash-menu-el - [_block] - (fn [block state] - (let [{:search/keys [index results type] caret-position :caret-position} @state - {:keys [left top]} caret-position] - [:> Menu {:isOpen (= type :slash) - :onClose #(swap! state assoc :search/type false) - :isLazy true} - [:> Portal - (when (= type :slash) - [:> MenuList {:position "absolute" - :left (str left "px") - :top (str (+ top 24) "px")} - (doall - (for [[i [text icon _expansion kbd _pos :as item]] (map-indexed list results)] - [:> MenuItem {:key text - :isFocusable false - :id (str "dropdown-item-" i) - :command kbd - :class (when (= i index) "isActive") - :onClick (fn [_] (slash-item-click state block item))} - [:<> - [(r/adapt-react-class icon)] - text]]))])]]))) + [_block state] + (let [ref (atom nil) + handle-click-outside (fn [e] + (let [{:search/keys [type]} @state] + (when (and (= type :slash) + (not (.. @ref (contains (.. e -target))))) + (swap! state assoc :search/type false))))] + (r/create-class + {:display-name "slash-menu" + :component-did-mount (fn [_this] (events/listen js/document "mousedown" handle-click-outside)) + :component-will-unmount (fn [_this] (events/unlisten js/document "mousedown" handle-click-outside)) + :reagent-render (fn [block state] + (let [{:search/keys [index results type] caret-position :caret-position} @state + {:keys [left top]} caret-position] + (when (= type :slash) + [:div (merge (stylefy/use-style dropdown/dropdown-style + {:ref #(reset! ref %) + ;; don't blur textarea when clicking to auto-complete + :on-mouse-down (fn [e] (.. e preventDefault))}) + {:style {:position "absolute" :left (+ left 24) :top (+ top 24)}}) + [:div#dropdown-menu (merge (stylefy/use-style dropdown/menu-style) {:style {:max-height "8em"}}) + (doall + (for [[i [text icon _expansion kbd _pos :as item]] (map-indexed list results)] + [:> Button {:key text + :id (str "dropdown-item-" i) + :is-pressed (= i index) + :on-click (fn [_] (slash-item-click state block item))} + [:<> [(r/adapt-react-class icon)] [:span text] (when kbd [:kbd kbd])]]))]])))}))) + + diff --git a/src/cljs/athens/views/blocks/content.cljs b/src/cljs/athens/views/blocks/content.cljs index 2b0a43a8d5..3d3a6977fb 100644 --- a/src/cljs/athens/views/blocks/content.cljs +++ b/src/cljs/athens/views/blocks/content.cljs @@ -1,10 +1,10 @@ (ns athens.views.blocks.content (:require - ["/components/Block/components/Content" :refer [Content]] [athens.config :as config] [athens.db :as db] [athens.events.selection :as select-events] [athens.parse-renderer :refer [parse-and-render]] + [athens.style :as style] [athens.subs.selection :as select-subs] [athens.util :as util] [athens.views.blocks.internal-representation :as internal-representation] @@ -12,14 +12,175 @@ [clojure.edn :as edn] [clojure.set :as set] [clojure.string :as str] + [garden.selectors :as selectors] [goog.events :as goog-events] [komponentit.autosize :as autosize] - [re-frame.core :as rf]) + [re-frame.core :as rf] + [stylefy.core :as stylefy]) (:import (goog.events EventType))) +;; Styles + +(def block-content-style + {:display "grid" + :grid-template-areas "'main'" + :align-items "stretch" + :justify-content "stretch" + :position "relative" + :overflow "visible" + :z-index 2 + :flex-grow "1" + :word-break "break-word" + ::stylefy/manual [[:textarea {:display "block" + :line-height 0 + :-webkit-appearance "none" + :cursor "text" + :resize "none" + :transform "translate3d(0,0,0)" + :color "inherit" + :outline "none" + :overflow "hidden" + :padding "0" + :background (style/color :background-minus-1) + :grid-area "main" + :min-height "100%" + :caret-color (style/color :link-color) + :margin "0" + :font-size "inherit" + :border-radius "0.25rem" + :box-shadow (str "-0.25rem 0 0 0" (style/color :background-minus-1)) + :border "0" + :opacity "0" + :font-family "inherit"}] + [:&:hover [:textarea [(selectors/& (selectors/not :.is-editing)) {:line-height 2}]]] + [:.is-editing {:z-index 3 + :line-height "inherit" + :opacity "1"}] + [:span.text-run + {:pointer-events "None"} + [:>a {:position "relative" + :z-index 2 + :pointer-events "all"}]] + [:span + {:grid-area "main"} + [:>span + :>a {:position "relative" + :z-index 2}]] + [:abbr + {:grid-area "main" + :z-index 4} + [:>span + :>a {:position "relative" + :z-index 2}]] + ;; May want to refactor specific component styles to somewhere else. + ;; Closer to the component perhaps? + ;; Code + [:code :pre {:font-family "IBM Plex Mono"}] + ;; Media Containers + ;; Using a CSS hack/convention here to create a responsive container + ;; of a specific aspect ratio. + ;; TODO: Replace this with the CSS aspect-ratio property once available. + [:.media-16-9 {:height 0 + :width "calc(100% - 0.25rem)" + :z-index 1 + :transform-origin "right center" + :transition "all 0.2s ease" + :padding-bottom (str (* (/ 9 16) 100) "%") + :margin-block "0.25rem" + :margin-inline-end "0.25rem" + :position "relative"}] + ;; Media (YouTube embeds, map embeds, etc.) + [:iframe {:border 0 + :box-shadow [["inset 0 0 0 0.125rem" (style/color :background-minus-1)]] + :position "absolute" + :height "100%" + :width "100%" + :cursor "default" + :top 0 + :right 0 + :left 0 + :bottom 0 + :border-radius "0.25rem"}] + ;; Images + [:img {:border-radius "0.25rem" + :max-width "calc(100% - 0.25rem)"}] + ;; Checkboxes + ;; TODO: Refactor these complicated styles into clip paths or SVGs + ;; or something nicer than this + [:input [:& (selectors/attr= :type :checkbox) {:appearance "none" + :border-radius "0.25rem" + :cursor "pointer" + :color (style/color :link-color) + :margin-inline-end "0.25rem" + :position "relative" + :top "0.13em" + :width "1rem" + :height "1rem" + :transition "color 0.05s ease, transform 0.05s ease, box-shadow 0.05s ease" + :transform "scale(1)" + :box-shadow "inset 0 0 0 1px"} + [:&:after {:content "''" + :position "absolute" + :top "45%" + :left "20%" + :width "30%" + :height "50%" + :border-width "0 2px 2px 0" + :border-style "solid" + :opacity 0 + :transform "rotate(45deg) translate(-40%, -50%)"}] + [:&:checked {:background (style/color :link-color)} + [:&:after {:opacity 1 + :color (style/color :background-color)}]] + [:&:active {:transform "scale(0.9)"}]]] + + [:h1 :h2 :h3 :h4 :h5 :h6 {:margin "0" + :color (style/color :body-text-color :opacity-higher) + :font-weight "500"}] + [:h1 {:padding "0" + :margin-block-start "-0.1em"}] + [:h2 {:padding "0"}] + [:h3 {:padding "0"}] + [:h4 {:padding "0.25em 0"}] + [:h5 {:padding "1em 0"}] + [:h6 {:text-transform "uppercase" + :letter-spacing "0.06em" + :padding "1em 0"}] + [:p {:margin "0" + :padding-bottom "1em"}] + [:blockquote {:margin-inline "0.5em" + :margin-block "0.125rem" + :padding-block "calc(0.5em - 0.125rem - 0.125rem)" + :padding-inline "1.5em" + :border-radius "0.25em" + :background (style/color :background-minus-1) + :border-inline-start [["0.25em solid" (style/color :body-text-color :opacity-lower)]] + :color (style/color :body-text-color :opacity-high)} + [:p {:padding-bottom "1em"}] + [:p:last-child {:padding-bottom "0"}]] + [:.CodeMirror {:background (style/color :background-minus-1) + :margin "0.125rem 0.5rem" + :border-radius "0.25rem" + :font-size "85%" + :color (style/color :body-text-color) + :font-family "IBM Plex Mono"}] + [:.CodeMirror-gutters {:border-right "1px solid transparent" + :background (style/color :background-minus-1)}] + [:.CodeMirror-cursor {:border-left-color (style/color :link-color)}] + [:.CodeMirror-lines {:padding 0}] + [:.CodeMirror-linenumber {:color (style/color :body-text-color :opacity-med)}] + + [:mark.contents.highlight {:padding "0 0.2em" + :border-radius "0.125rem" + :background-color (style/color :text-highlight-color)}]]}) + + +(stylefy/class "block-content" block-content-style) + + (defn find-selected-items "Used by both shift-click and click-drag for multi-block-selection. Given a mouse event, a source block, and a target block, highlight blocks. @@ -238,14 +399,14 @@ 2 "1.7em" 3 "1.3em" "1em")] - [:> Content {:fontSize font-size - :on-click (fn [e] (.. e stopPropagation) (rf/dispatch [:editing/uid uid]))} + [:div {:class ["block-content"] + :style {:font-size font-size} + :on-click (fn [e] (.. e stopPropagation) (rf/dispatch [:editing/uid uid]))} ;; NOTE: komponentit forces reflow, likely a performance bottle neck ;; When block is in editing mode or the editing DOM elements are rendered - ;; (when (or (:show-editable-dom @state) @editing?) - (when true + (when (or (:show-editable-dom @state) @editing?) [autosize/textarea {:value (:string/local @state) - :class ["block-input-textarea" (when (and (empty? @selected-items) @editing?) "is-editing")] + :class ["textarea" (when (and (empty? @selected-items) @editing?) "is-editing")] ;; :auto-focus true :id (str "editable-uid-" uid) :on-change (fn [e] (textarea-change e uid state)) diff --git a/src/cljs/athens/views/blocks/context_menu.cljs b/src/cljs/athens/views/blocks/context_menu.cljs index 8f8135f4cf..fbc3dfbae1 100644 --- a/src/cljs/athens/views/blocks/context_menu.cljs +++ b/src/cljs/athens/views/blocks/context_menu.cljs @@ -1,15 +1,19 @@ (ns athens.views.blocks.context-menu (:require + ["/components/Button/Button" :refer [Button]] [athens.db :as db] [athens.listeners :as listeners] [athens.subs.selection :as select-subs] - [athens.util :refer [toast]] + [athens.views.dropdown :refer [menu-style dropdown-style]] [clojure.string :as string] - [re-frame.core :as rf])) + [goog.events :as events] + [re-frame.core :as rf] + [reagent.core :as r] + [stylefy.core :as stylefy])) -(defn handle-copy-refs - [_ uid] +(defn copy-refs-mouse-down + [_ uid state] (let [selected-items @(rf/subscribe [::select-subs/items]) ;; use this when using datascript-transit ;; uids (map (fn [x] [:block/uid x]) selected-items) @@ -19,12 +23,23 @@ (->> (map (fn [uid] (str "((" uid "))\n")) selected-items) (string/join "")))] (.. js/navigator -clipboard (writeText data)) - (toast (clj->js {:title "Copied ref to clipboard"})))) + (swap! state assoc :context-menu/show false))) + + +(defn bullet-context-menu + "Handle right click. If no blocks are selected, just give option for copying current block's uid." + [e _uid state] + (.. e preventDefault) + (let [rect (.. e -target getBoundingClientRect)] + (swap! state assoc + :context-menu/x (.. rect -left) + :context-menu/y (.. rect -bottom) + :context-menu/show true))) (defn handle-copy-unformatted "If copying only a single block, dissoc children to not copy subtree." - [^js uid] + [^js e uid state] (let [uids @(rf/subscribe [::select-subs/items])] (if (empty? uids) (let [block (dissoc (db/get-block [:block/uid uid]) :block/children) @@ -34,4 +49,36 @@ (map #(listeners/blocks-to-clipboard-data 0 % true)) (apply str))] (.. js/navigator -clipboard (writeText data))))) - (toast (clj->js {:title "Copied content to clipboard" :status "success"}))) + (.. e preventDefault) + (swap! state assoc :context-menu/show false)) + + +(defn context-menu-el + "Only option in context menu right now is copy block ref(s)." + [_block state] + (let [ref (atom nil) + handle-click-outside (fn [e] + (when (and (:context-menu/show @state) + (not (.. @ref (contains (.. e -target))))) + (swap! state assoc :context-menu/show false)))] + (r/create-class + {:display-name "context-menu" + :component-did-mount (fn [_this] (events/listen js/document "mousedown" handle-click-outside)) + :component-will-unmount (fn [_this] (events/unlisten js/document "mousedown" handle-click-outside)) + :reagent-render (fn [block state] + (let [{:block/keys [uid]} block + {:context-menu/keys [x y show]} @state + selected-items @(rf/subscribe [::select-subs/items])] + (when show + [:div (merge (stylefy/use-style dropdown-style + {:ref #(reset! ref %)}) + {:style {:position "fixed" + :left (str x "px") + :top (str y "px")}}) + [:div (stylefy/use-style menu-style) + [:> Button {:on-mouse-down (fn [e] (copy-refs-mouse-down e uid state))} + (if (empty? selected-items) + "Copy block ref" + "Copy block refs")] + [:> Button {:on-mouse-down (fn [e] (handle-copy-unformatted e uid state))} + "Copy unformatted"]]])))}))) diff --git a/src/cljs/athens/views/blocks/core.cljs b/src/cljs/athens/views/blocks/core.cljs index d32b8df721..ebe6ca4b45 100644 --- a/src/cljs/athens/views/blocks/core.cljs +++ b/src/cljs/athens/views/blocks/core.cljs @@ -1,9 +1,8 @@ (ns athens.views.blocks.core (:require ["/components/Block/components/Anchor" :refer [Anchor]] - ["/components/Block/components/Container" :refer [Container]] ["/components/Block/components/Toggle" :refer [Toggle]] - ["@chakra-ui/react" :refer [VStack Button Breadcrumb BreadcrumbItem BreadcrumbLink HStack]] + ["/components/Button/Button" :refer [Button]] [athens.common.logging :as log] [athens.db :as db] [athens.electron.images :as images] @@ -13,19 +12,21 @@ [athens.reactive :as reactive] [athens.router :as router] [athens.self-hosted.presence.views :as presence] + [athens.style :as style] [athens.subs.selection :as select-subs] [athens.util :as util :refer [mouse-offset vertical-center specter-recursive-path]] [athens.views.blocks.autocomplete-search :as autocomplete-search] [athens.views.blocks.autocomplete-slash :as autocomplete-slash] [athens.views.blocks.bullet :refer [bullet-drag-start bullet-drag-end]] [athens.views.blocks.content :as content] - [athens.views.blocks.context-menu :refer [handle-copy-unformatted handle-copy-refs]] + [athens.views.blocks.context-menu :as context-menu] [athens.views.blocks.drop-area-indicator :as drop-area-indicator] - [athens.views.references :refer [reference-group reference-block]] + [athens.views.breadcrumbs :as breadcrumbs] [com.rpl.specter :as s] [goog.functions :as gfns] [re-frame.core :as rf] - [reagent.core :as r])) + [reagent.core :as r] + [stylefy.core :as stylefy])) ;; Styles @@ -35,63 +36,81 @@ ;; smaller than main content blocks, for instance. -(def block-container-inner-style - {"&.show-tree-indicator:before" {:content "''" - :position "absolute" - :width "1px" - :left "calc(1.375em + 1px)" - :top "2em" - :bottom "0" - :opacity "0" - :transform "translateX(50%)" - :transition "background-color 0.2s ease-in-out, opacity 0.2s ease-in-out" - :background "separator.divider"} - "&:hover.show-tree-indicator:before, &:focus-within.show-tree-indicator:before" {:opacity 1} - "&:after" {:content "''" - :zIndex 0 - :position "absolute" - :inset "1px 0" - :opacity 0 - :pointerEvents "none" - :borderRadius "sm" - :transition "opacity 0.075s ease-in-out" - :background "link"} - "&.is-selected:after" {:opacity 0.2} - "&.is-presence .block-content" {:padding-right "1rem"} - ".user-avatar" {:position "absolute" - :left "4px" - :top "4px"} - ".block-body" {:display "grid" - :gridTemplateColumns "1em 1em 1fr auto" - :gridTemplateRows "0 1fr 0" - :gridTemplateAreas " +(def block-container-style + {:display "flex" + :line-height "2em" + :position "relative" + :border-radius "0.125rem" + :justify-content "flex-start" + :flex-direction "column" + ::stylefy/manual [[:&.show-tree-indicator:before {:content "''" + :position "absolute" + :width "1px" + :left "calc(1.375em + 1px)" + :top "2em" + :bottom "0" + :opacity "0" + :transform "translateX(50%)" + :transition "background-color 0.2s ease-in-out, opacity 0.2s ease-in-out" + :background (style/color :border-color)}] + [:&:hover + :&:focus-within [:&.show-tree-indicator:before {:opacity "1"}]] + [:&:after {:content "''" + :z-index -1 + :position "absolute" + :top "0.75px" + :right 0 + :bottom "0.75px" + :left 0 + :opacity 0 + :pointer-events "none" + :border-radius "0.25rem" + :transition "opacity 0.075s ease" + :background (style/color :link-color :opacity-lower)}] + [:&.is-selected:after {:opacity 1}] + [:&.is-presence [:.block-content {:padding-right "1rem"}]] + [:.user-avatar {:position "absolute" + :left "4px" + :top "4px"}] + [:.block-body {:display "grid" + :grid-template-columns "1em 1em 1fr auto" + :grid-template-rows "0 1fr 0" + :grid-template-areas " 'above above above above' 'toggle bullet content refs' 'below below below below'" - :borderRadius "0.5rem" - :position "relative"} - "&:hover > .block-toggle, &:focus-within > .block-toggle" {:opacity "1"} - "button.block-edit-toggle" {:position "absolute" - :appearance "none" - :width "100%" - :background "none" - :border 0 - :cursor "text" - :display "block" - :z-index 1 - :top 0 - :right 0 - :bottom 0 - :left 0} - ".block-embed" {:borderRadius "sm" - :sx {"--block-surface-color" "background.basement"} - :bg "background.basement" - ".block-container" {:marginLeft 0.5}} - ".block-content" {:gridArea "content" - :minHeight "1.5em"} - "&.is-linked-ref" {:bg "background-attic"} - ".block-container" {:marginLeft "2rem" - :gridArea "body"}}) + :border-radius "0.5rem" + :position "relative"} + [:&:hover + :&:focus-within ["> .block-toggle" {:opacity "1"}]] + [:button.block-edit-toggle {:position "absolute" + :appearance "none" + :width "100%" + :background "none" + :border 0 + :cursor "text" + :display "block" + :z-index 1 + :top 0 + :right 0 + :bottom 0 + :left 0}]] + [:.block-content {:grid-area "content" + :min-height "1.5em"}] + [:&.is-linked-ref {:background-color (style/color :background-plus-2)}] + ;; Inset child blocks + [:.block-container {:margin-left "2rem" + :grid-area "body"}]]}) + + +(stylefy/class "block-container" block-container-style) + + +(def dragging-style + {:opacity "0.25"}) + + +(stylefy/class "dragging" dragging-style) ;; Inline refs @@ -102,6 +121,21 @@ (declare block-el) +(def reference-breadcrumbs-style + {:font-size "12px" + :padding "0 0.25em"}) + + +(def reference-breadcrumbs-container-style + {:padding-left "0.5em" + :display "grid" + :grid-template-columns "1em 1fr" + :grid-template-rows "1fr" + :grid-template-areas "'toggle breadcrumbs'" + :border-radius "0.5rem" + :position "relative"}) + + (defn ref-comp [block parent-state] (let [orig-uid (:block/uid block) @@ -125,33 +159,33 @@ (let [{:keys [block parents embed-id]} @state block (reactive/get-reactive-block-document (:db/id block))] [:<> - [:> HStack + [:div (stylefy/use-style reference-breadcrumbs-container-style) [:> Toggle {:isOpen (:open? @state) :on-click (fn [e] (.. e stopPropagation) (swap! state update :open? not))}] - [:> Breadcrumb {:fontSize "0.7em"} + [breadcrumbs/breadcrumbs-list {:style reference-breadcrumbs-style} (doall (for [{:keys [node/title block/string block/uid] :as breadcrumb-block} (if (or (:open? @state) (not (:focus? @state))) parents (conj parents block))] - [:> BreadcrumbItem {:key (str "breadcrumb-" uid)} - [:> BreadcrumbLink {:onClick #(let [new-B (db/get-block [:block/uid uid]) - new-P (concat - (take-while (fn [b] (not= (:block/uid b) uid)) parents) - [breadcrumb-block])] - (.. % stopPropagation) - (swap! state assoc :block new-B :parents new-P :focus? false))} - [parse-renderer/parse-and-render (or title string) uid]]]))]] + [breadcrumbs/breadcrumb {:key (str "breadcrumb-" uid) + :on-click #(let [new-B (db/get-block [:block/uid uid]) + new-P (concat + (take-while (fn [b] (not= (:block/uid b) uid)) parents) + [breadcrumb-block])] + (.. % stopPropagation) + (swap! state assoc :block new-B :parents new-P :focus? false))} + [parse-renderer/parse-and-render (or title string) uid]]))]] (when (:open? @state) (if (:focus? @state) ;; Display the single child block only when focusing. ;; This is the default behaviour for a ref without children, for brevity. - [:div.block-embed {:fontSize "0.7em"} + [:div.block-embed [block-el (util/recursively-modify-block-for-embed block embed-id) linked-ref-data @@ -167,47 +201,55 @@ {:block-embed? true}]])))])))) +(def references-style + {:padding-left "2em"}) + + +(def references-list-style + {:font-size "14px"}) + + +(def references-group-style + {:background (style/color :background-minus-2 :opacity-med) + :padding "0rem 0.5rem" + :border-radius "0.25rem" + :margin "0.5em 0"}) + + +(def references-group-block-style + {:width "100%" + ::stylefy/manual [[:&:first-of-type {:border-top "0" + :margin-block-start "0"}]]}) + + (defn inline-linked-refs-el [state uid] (let [refs (reactive/get-reactive-linked-references [:block/uid uid])] (when (not-empty refs) - [:> VStack {:as "aside" - :align "stretch" - :key "Inline Linked References" - :zIndex 2 - :mt 2 - :mb 4 - :ml 6 - :py 2 - :px 4 - :borderRadius "sm" - :background "background.basement"} - (doall - (for [[group-title group] refs] - [reference-group {:title group-title - :key (str "group-" group-title)} - (doall - (for [block' group] - [reference-block {:key (str "ref-" (:block/uid block'))} - [ref-comp block' state]]))]))]))) + [:div (stylefy/use-style references-style {:key "Inline Linked References"}) + [:section + [:div (stylefy/use-style references-list-style) + (doall + (for [[group-title group] refs] + [:div (stylefy/use-style references-group-style {:key (str "group-" group-title)}) + (doall + (for [block' group] + [:div (stylefy/use-style references-group-block-style {:key (str "ref-" (:block/uid block'))}) + [ref-comp block' state]]))]))]]]))) ;; Components (defn block-refs-count-el - [count click-fn active?] - [:> Button {:gridArea "refs" - :size "xs" - :ml "1em" - :mt 1 - :mr 1 - :zIndex 10 - :visibility (if (pos? count) "visible" "hidden") - :isActive active? - :onClick (fn [e] - (.. e stopPropagation) - (click-fn e))} - count]) + [count click-fn] + [:div (stylefy/use-style {:margin-left "1em" + :grid-area "refs" + :z-index (:zindex-dropdown style/ZINDICES) + :visibility (when-not (pos? count) "hidden")}) + [:> Button {:on-click (fn [e] + (.. e stopPropagation) + (click-fn e))} + count]]) (defn block-drag-over @@ -387,6 +429,7 @@ (assoc block :block/uid (or original-uid uid))) block) {:keys [dragging]} @state + is-editing @(rf/subscribe [:editing/is-editing uid]) is-selected @(rf/subscribe [::select-subs/selected? uid]) present-user @(rf/subscribe [:presence/has-presence uid]) is-presence (seq present-user)] @@ -399,29 +442,28 @@ (when (not= string (:string/previous @state)) (swap! state assoc :string/previous string :string/local string)) - [:> Container {:sx (merge block-container-inner-style - {"--block-surface-color" "background.floor"}) - :isDragging (and dragging (not is-selected)) - ;; :isEditing is-editing - :isSelected is-selected - :hasChildren (seq children) - :isOpen open - :isLinkedRef (and (false? initial-open) (= uid linked-ref-uid)) - :hasPresence is-presence - :uid uid - ;; need to know children for selection resolution - :childrenUids children-uids - ;; :show-editable-dom allows us to render the editing elements (like the textarea) - ;; even when not editing this block. When true, clicking the block content will pass - ;; the clicks down to the underlying textarea. The textarea is expensive to render, - ;; so we avoid rendering it when it's not needed. - :onMouseEnter #(swap! state assoc :show-editable-dom true) - :onMouseLeave #(swap! state assoc :show-editable-dom false) - :onDragOver (fn [e] (block-drag-over e block state)) - :onDragLeave (fn [e] (block-drag-leave e block state)) - :onDrop (fn [e] (block-drop e block state))} - - (when (= (:drag-target @state) :before) [drop-area-indicator/drop-area-indicator {:placement "above"}]) + [:div + {:class ["block-container" + (when (and dragging (not is-selected)) "dragging") + (when is-editing "is-editing") + (when is-selected "is-selected") + (when (and (seq children) open) "show-tree-indicator") + (when (and (false? initial-open) (= uid linked-ref-uid)) "is-linked-ref") + (when is-presence "is-presence")] + :data-uid uid + ;; need to know children for selection resolution + :data-childrenuids children-uids + ;; :show-editable-dom allows us to render the editing elements (like the textarea) + ;; even when not editing this block. When true, clicking the block content will pass + ;; the clicks down to the underlying textarea. The textarea is expensive to render, + ;; so we avoid rendering it when it's not needed. + :on-mouse-enter #(swap! state assoc :show-editable-dom true) + :on-mouse-leave #(swap! state assoc :show-editable-dom false) + :on-drag-over (fn [e] (block-drag-over e block state)) + :on-drag-leave (fn [e] (block-drag-leave e block state)) + :on-drop (fn [e] (block-drop e block state))} + + (when (= (:drag-target @state) :before) [drop-area-indicator/drop-area-indicator {:grid-area "above"}]) [:div.block-body (when (seq children) @@ -429,28 +471,28 @@ (and (false? linked-ref) open)) true false) - :onClick (fn [e] - (.. e stopPropagation) - (if (true? linked-ref) - (swap! state update :linked-ref/open not) - (toggle uid (not open))))}]) + :on-click (fn [e] + (.. e stopPropagation) + (if (true? linked-ref) + (swap! state update :linked-ref/open not) + (toggle uid (not open))))}]) + (when (:context-menu/show @state) + [context-menu/context-menu-el uid-sanitized-block state]) [:> Anchor {:isClosedWithChildren (when (and (seq children) (or (and (true? linked-ref) (not (:linked-ref/open @state))) (and (false? linked-ref) (not open)))) "closed-with-children") :block block - :uidSanitizedBlock uid-sanitized-block :shouldShowDebugDetails (util/re-frame-10x-open?) - :onCopyRef #(handle-copy-refs nil uid) - :onCopyUnformatted #(handle-copy-unformatted uid) - :onClick (fn [e] - (let [shift? (.-shiftKey e)] - (rf/dispatch [:reporting/navigation {:source :block-bullet - :target :block - :pane (if shift? - :right-pane - :main-pane)}]) - (router/navigate-uid uid e))) + :on-click (fn [e] + (let [shift? (.-shiftKey e)] + (rf/dispatch [:reporting/navigation {:source :block-bullet + :target :block + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-uid uid e))) + :on-context-menu (fn [e] (context-menu/bullet-context-menu e uid state)) :on-drag-start (fn [e] (bullet-drag-start e uid state)) :on-drag-end (fn [e] (bullet-drag-end e uid state))}] [content/block-content-el block state] @@ -458,13 +500,10 @@ [presence/inline-presence-el uid] (when (and (> (count _refs) 0) (not= :block-embed? opts)) - [block-refs-count-el - (count _refs) - (fn [e] - (if (.. e -shiftKey) - (rf/dispatch [:right-sidebar/open-item uid]) - (swap! state update :inline-refs/open not))) - (:inline-refs/open @state)])] + [block-refs-count-el (count _refs) (fn [e] + (if (.. e -shiftKey) + (rf/dispatch [:right-sidebar/open-item uid]) + (swap! state update :inline-refs/open not)))])] [autocomplete-search/inline-search-el block state] [autocomplete-slash/slash-menu-el block state] @@ -485,6 +524,6 @@ (assoc linked-ref-data :initial-open (contains? parent-uids (:block/uid child))) opts]])) - (when (= (:drag-target @state) :first) [drop-area-indicator/drop-area-indicator {:placement "below" :child? true}]) - (when (= (:drag-target @state) :after) [drop-area-indicator/drop-area-indicator {:placement "below"}])]))))) + (when (= (:drag-target @state) :first) [drop-area-indicator/drop-area-indicator {:style {:grid-area "below"} :child true}]) + (when (= (:drag-target @state) :after) [drop-area-indicator/drop-area-indicator {:style {:grid-area "below"}}])]))))) diff --git a/src/cljs/athens/views/blocks/drop_area_indicator.cljs b/src/cljs/athens/views/blocks/drop_area_indicator.cljs index 2d9c532a96..93705f7264 100644 --- a/src/cljs/athens/views/blocks/drop_area_indicator.cljs +++ b/src/cljs/athens/views/blocks/drop_area_indicator.cljs @@ -1,17 +1,47 @@ (ns athens.views.blocks.drop-area-indicator (:require - ["@chakra-ui/react" :refer [Box]])) + [athens.style :as style] + [stylefy.core :as stylefy])) + + +(def drop-area-indicator-style + {:display "block" + :height "1px" + :pointer-events "none" + :margin-bottom "-1px" + :opacity (:opacity-high style/OPACITIES) + :color (style/color :link-color) + :position "relative" + :transform-origin "left" + :z-index 3 + :width "100%" + ::stylefy/manual [["&:after" {:position "absolute" + :content "''" + :top "-0.5px" + :right "0" + :bottom "-0.5px" + :left "calc(2em - 4px)" + :border-radius "100px" + :background "currentColor"}] + ["&.child" {:--indent "1.95em" + :width "calc(100% - var(--indent))" + :margin-left "var(--indent)"}] + ["&.child:after" {:border-top-left-radius 0 + :border-bottom-left-radius 0}] + ["&.child:before" {:position "absolute" + :content "''" + :border-radius "10em" + :border "2px solid " + :--size "4px" + :width "var(--size)" + :height "var(--size)" + :left "var(--indent)" + :top "50%" + :transform "translateY(-50%) translateX(-100%) translateX(-2px)"}]]}) (defn drop-area-indicator - ([{:keys [placement child?]}] - [:> Box {:display "block" - :height "1px" - :pointerEvents "none" - :background "link" - :gridArea (if (= placement "above") "above" "below") - :marginLeft (if child? "4rem" "2rem") - :marginBottom "-1px" - :position "relative" - :transformOrigin "left" - :zIndex 3}])) + ([{:keys [style child]}] + [:div (stylefy/use-style + (merge drop-area-indicator-style style) + {:class (when child "child")})])) diff --git a/src/cljs/athens/views/blocks/textarea_keydown.cljs b/src/cljs/athens/views/blocks/textarea_keydown.cljs index 8ef494790a..308252f8f5 100644 --- a/src/cljs/athens/views/blocks/textarea_keydown.cljs +++ b/src/cljs/athens/views/blocks/textarea_keydown.cljs @@ -21,7 +21,6 @@ [goog.dom.selection :refer [setStart setEnd getText setCursorPosition getEndPoints]] [goog.events.KeyCodes :refer [isCharacterKey]] [goog.functions :refer [throttle #_debounce]] - [goog.style :refer [getClientPosition]] [re-frame.core :as rf :refer [dispatch dispatch-sync subscribe]]) (:import (goog.events @@ -818,12 +817,8 @@ ;; update caret position for search dropdowns and for up/down (when (nil? (:search/type @state)) - - (let [caret-position (get-caret-position (.. e -target)) - textarea-position (js->clj (getClientPosition (.. e -target)) :keywordize-keys true) - position {:left (+ (:left caret-position) (.. textarea-position -x)) - :top (+ (:top caret-position) (.. textarea-position -y))}] - (swap! state assoc :caret-position position))) + (let [caret-position (get-caret-position (.. e -target))] + (swap! state assoc :caret-position caret-position))) ;; dispatch center ;; only when nothing is selected or duplicate/events dispatched diff --git a/src/cljs/athens/views/breadcrumbs.cljs b/src/cljs/athens/views/breadcrumbs.cljs new file mode 100644 index 0000000000..ddd1237b96 --- /dev/null +++ b/src/cljs/athens/views/breadcrumbs.cljs @@ -0,0 +1,68 @@ +(ns athens.views.breadcrumbs + (:require + [athens.db] + [athens.style :refer [color OPACITIES]] + [stylefy.core :as stylefy :refer [use-style]])) + + +;; Styles + + +(def breadcrumbs-list-style + {:list-style "none" + :display "flex" + :flex "1 1 auto" + :margin "0" + :padding "0" + :flex-direction "row" + :overflow "hidden" + :height "inherit" + :align-items "stretch" + :flex-wrap "nowrap" + :color (color :body-text-color :opacity-high) + ::stylefy/manual [[:svg {:font-size "inherit" + :color "inherit" + :margin "auto 0"}]]}) + + +(def breadcrumb-style + {:flex "0 1 auto" + :overflow "hidden" + :max-width "100%" + :min-width "2.5em" + :white-space "nowrap" + :text-overflow "ellipsis" + :transition "color 0.3s ease" + ::stylefy/manual [[:a {:text-decoration "none" + :cursor "pointer" + :position "relative" + :color "inherit"}] + [:* {:display "inline" + :margin 0 + :padding 0 + :font-size "inherit"}] + [:&:last-child {:color (color :body-text-color)}] + [:&:hover {:flex-shrink "0" + :color (color :link-color)}] + [:&:before {:display "inline-block" + :padding "0 0.15em" + :content "'>'" + :opacity (:opacity-low OPACITIES) + :transform "scaleX(0.5)"}] + [:&:first-child:before {:content "none"}]]}) + + +;; Components + + +(defn breadcrumbs-list + [{:keys [style]} & children] + (into [:ol (use-style (merge breadcrumbs-list-style style))] children)) + + +(defn breadcrumb + ([children] [breadcrumb {} children]) + ([{:keys [style] :as props} children] + [:li (use-style (merge breadcrumb-style style)) + [:a (merge props) + children]])) diff --git a/src/cljs/athens/views/devtool.cljs b/src/cljs/athens/views/devtool.cljs index 41ee5291b3..51011924ea 100644 --- a/src/cljs/athens/views/devtool.cljs +++ b/src/cljs/athens/views/devtool.cljs @@ -1,25 +1,126 @@ (ns athens.views.devtool (:require - ["@chakra-ui/react" :refer [Box Button Table Thead Tbody Th Tr Td Input ButtonGroup]] + ["/components/Button/Button" :refer [Button]] ["@material-ui/icons/ChevronLeft" :default ChevronLeft] ["@material-ui/icons/Clear" :default Clear] + ["@material-ui/icons/History" :default History] + ["@material-ui/icons/ShortText" :default ShortText] [athens.config :as config] [athens.db :as db :refer [dsdb]] + [athens.style :refer [color]] + [athens.views.textinput :refer [textinput-style]] [cljs.pprint :as pp] [clojure.core.protocols :as core-p] [clojure.datafy :refer [nav datafy]] [datascript.core :as d] [datascript.db] + [komponentit.autosize :as autosize] [me.tonsky.persistent-sorted-set] [re-frame.core :refer [subscribe dispatch]] [reagent.core :as r] [reagent.ratom] - [sci.core :as sci]) + [sci.core :as sci] + [stylefy.core :as stylefy :refer [use-style]]) (:import (goog.events KeyCodes))) +;; Styles + + +(def container-style + {:grid-area "devtool" + :flex-direction "column" + :background (color :background-minus-1) + :position "relative" + :width "100vw" + :height "33vh" + :display "flex" + :overflow-y "auto" + :right 0 + :z-index 2}) + + +(def tabs-style + {:padding "0 0.5rem" + :flex "0 0 auto" + :background (color :background-minus-1) + :display "flex" + :align-items "stretch" + :justify-content "space-between" + ::stylefy/manual [[:button {:border-radius "0"}]]}) + + +(def tabs-section-style + {:display "flex" + :align-items "stretch"}) + + +(def panels-style + {:overflow-y "auto" + :padding "0.5rem"}) + + +(def current-location-style + {:display "flex" + :align-items "center" + :flex "1 1 100%" + :font-size "14px" + :border-bottom [["1px solid" (color :background-minus-1) 10]]}) + + +(def current-location-name-style + {:font-weight "bold" + :font-size "inherit" + :margin-block "0" + :margin-inline-start "1em" + :margin-inline-end "1em"}) + + +(def current-location-controls-style {:margin-inline-start "1em"}) + + +(def devtool-table-style + {:border-collapse "collapse" + :font-size "12px" + :font-family "IBM Plex Sans Condensed" + :letter-spacing "-0.01em" + :margin "0.5rem 0 0" + :border-spacing "0" + :min-width "100%" + ::stylefy/manual [[:td {:border-top [["1px solid " (color :border-color)]] + :padding "0.125rem"}] + [:tbody {:vertical-align "top"}] + [:th {:text-align "left" :padding "0.125rem 0.125rem" :white-space "nowrap"}] + [:tr {:transition "all 0.05s ease"}] + [:td:first-child :th:first-child {:padding-left "0.5rem"}] + [:td:last-child :th-last-child {:padding-right "0.5rem"}] + [:tbody [:tr:hover {:cursor "pointer" + :background (color :background-minus-1) + :color (color :header-text-color)}]] + [:td>ul {:padding "0" + :margin "0" + :list-style "none"}] + [:td [:li {:margin "0 0 0.25rem" + :padding-top "0.25rem" ; + :border-top (str "1px solid " (color :border-color))}]] + [:td [:li:first-child {:border-top "none" :margin-top "0" :padding-top "0"}]] + [:a {:color (color :link-color)}] + [:a:hover {:text-decoration "underline"}]]}) + + +(def edn-viewer-style {:font-size "12px"}) + + +(def query-input-style + (merge textinput-style {:width "100%" + :min-height "2.5rem" + :font-size "12px" + :background (color :background-color) + :font-family "IBM Plex Mono"})) + + ;; Components @@ -72,34 +173,36 @@ [_ _ _] (let [limit (r/atom 20)] (fn [headers rows add-nav!] - [:> Box {:width "100%"} - [:> Table {:width "100%"} - [:> Thead - [:> Tr (for [h headers] - ^{:key h} [:> Th h])]] - [:> Tbody + [:div + [:table (use-style devtool-table-style) + [:thead + [:tr (for [h headers] + ^{:key h} [:th h])]] + [:tbody (doall (for [row (take @limit rows)] ^{:key row} - [:> Tr {:on-click #(add-nav! [(first row) - (-> row meta :row-value)])} + [:tr {:on-click #(add-nav! [(first row) + (-> row meta :row-value)])} (for [i (range (count row))] (let [cell (get row i)] ^{:key (str row i cell)} - [:> Td (if (nil? cell) - "" - (pr-str cell))]))]))]] ; use the edn-viewer here as well? + [:td (if (nil? cell) + "" + (pr-str cell))]))]))]] ; use the edn-viewer here as well? (when (< @limit (count rows)) - [:> Button {:onClick #(swap! limit + 10) - :width "100%"} + [:> Button {:on-click #(swap! limit + 10) + :style {:width "100%" + :justify-content "center" + :margin "0.25rem 0"}} "Load More"])]))) ;; TODO add truncation of long strings here (defn edn-viewer [data _] - [:> Box {:as "pre" :fontSize "12px"} [:code (with-out-str (pp/pprint data))]]) + [:pre (use-style edn-viewer-style) [:code (with-out-str (pp/pprint data))]]) (defn coll-viewer @@ -215,13 +318,13 @@ applicable-vs (applicable-viewers datafied-data) viewer-name (or (:viewer @state) (first applicable-vs)) viewer (get-in indexed-viewers [viewer-name :athens.viewer/fn])] - [:> Box - [:> Box {:display "flex" - :flexDirection "row" - :flexWrap "no-wrap" - :alignItems "stretch" - :justifyContent "space-between"} - [:> Box + [:div + [:div {:style {:display "flex" + :flex-direction "row" + :flex-wrap "no-wrap" + :align-items "stretch" + :justify-content "space-between"}} + [:div (use-style current-location-style) (doall (for [i (-> navs count range)] (let [nav (get navs i)] @@ -232,8 +335,8 @@ (update :navs subvec 0 i) (dissoc :viewer))))} [:<> [:> ChevronLeft] [:span (first nav)]]]))) - [:h3 (pr-str (type navved-data))] - [:div + [:h3 (use-style current-location-name-style) (pr-str (type navved-data))] + [:div (use-style current-location-controls-style) [:span "View as "] (for [v applicable-vs] (let [click-fn #(swap! state assoc :viewer v)] @@ -331,16 +434,12 @@ (defn query-component [{:keys [eval-str result error]}] - [:div {:style {:height "100%"}} - [:> Input {:value eval-str - :width "100%" - :minHeight "2.5rem" - :fontSize "12px" - :background "background.basement" - :fontFamily "code" - :resize "none" - :on-change handle-box-change! - :on-key-down handle-box-key-down!}] + [:div (use-style {:height "100%"}) + [autosize/textarea (use-style query-input-style + {:value eval-str + :resize "none" + :on-change handle-box-change! + :on-key-down handle-box-key-down!})] (if-not error [data-browser result] [error-component result])]) @@ -353,7 +452,7 @@ (defn devtool-close-el [] - [:> Button {:onClick #(dispatch [:devtool/toggle])} + [:> Button {:on-click #(dispatch [:devtool/toggle])} [:> Clear]]) @@ -362,28 +461,17 @@ (when devtool? (let [{:keys [active-panel]} @state switch-panel (fn [panel] (swap! state assoc :active-panel panel))] - [:> Box {:gridArea "devtool" - :flexDirection "column" - :background "background.basement" - :position "relative" - :width "100vw" - :height "33vh" - :display "flex" - :overflowY "auto" - :right 0 - :zIndex 2} - [:> ButtonGroup - [:> Button {:onClick #(switch-panel :query) - :isActive (= active-panel :query)} - "Query"] - [:> Button {:onClick #(switch-panel :txes) - :mr "auto" - :isActive (= active-panel :txes)} - "Transactions"] - + [:div (use-style container-style) + [:nav (use-style tabs-style) + [:div (use-style tabs-section-style) + [:> Button {:on-click #(switch-panel :query) + :is-pressed (= active-panel :query)} + [:<> [:> ShortText] [:span "Query"]]] + [:> Button {:on-click #(switch-panel :txes) + :is-pressed (= active-panel :txes)}] + [:<> [:> History] [:span "Transactions"]]] [devtool-close-el]] - [:> Box {:overflowY "auto" - :padding "0.5rem"} + [:div (use-style panels-style) (case active-panel :query [query-component @state] :txes [txes-component @state])]]))) diff --git a/src/cljs/athens/views/dropdown.cljs b/src/cljs/athens/views/dropdown.cljs new file mode 100644 index 0000000000..a49d9e6ddb --- /dev/null +++ b/src/cljs/athens/views/dropdown.cljs @@ -0,0 +1,129 @@ +(ns athens.views.dropdown + (:require + [athens.db] + [athens.style :refer [color DEPTH-SHADOWS ZINDICES]] + [garden.selectors :as selectors] + [stylefy.core :as stylefy])) + + +;; Styles + + +(stylefy/keyframes "dropdown-appear" + [:from {:opacity 0 + :transform "translateY(-10%)"}] + [:to {:opacity 1 + :transform "translateY(0)"}]) + + +(def dropdown-style + {:display "inline-flex" + :color (color :body-text-color) + :z-index (:zindex-dropdown ZINDICES) + :padding "0.25rem" + :border-radius "calc(0.25rem + 0.25rem)" ; Button corner radius + container padding makes "concentric" container radius + :min-height "2em" + :min-width "2em" + :animation "dropdown-appear 0.125s" + :animation-fill-mode "both" + :background (color :background-plus-2) + :box-shadow [[(:64 DEPTH-SHADOWS) ", 0 0 0 1px rgba(0, 0, 0, 0.05)"]] + :flex-direction "column"}) + + +(def menu-style + {:display "grid" + :grid-gap "0.125rem" + :min-width "9em" + :align-items "stretch" + :grid-auto-flow "row" + :overflow "auto" + ::stylefy/manual [[(selectors/& (selectors/not (selectors/first-child))) {:margin-block-start "0.25rem"}] + [(selectors/& (selectors/not (selectors/last-child))) {:margin-block-end "0.25rem"}] + [:button {:min-height "1.5rem"}]]}) + + +#_(def menu-heading-style + {:min-height "2rem" + :text-align "center" + :padding "0.375rem 0.5rem" + :display "flex" + :align-content "flex-end" + :justify-content "center" + :align-items "center" + :font-size "12px" + :max-width "100%" + :overflow "hidden" + :text-overflow "ellipsis"}) + + +(def menu-separator-style + {:border "0" + :background (color :border-color) + :align-self "stretch" + :justify-self "stretch" + :height "1px" + :margin "0.25rem 0"}) + + +#_(def submenu-indicator-style + {:margin-left "auto" + :opacity "0.5" + :display "flex" + :order 10 + :align-self "flex-end" + :font-family "inherit" + ::stylefy/manual [[:&:last-child {:padding-inline-end "0"}]]}) + + +;; Components +;; +;; +;; (defn block-context-menu-component +;; [style] +;; [dropdown {:style style :content +;; [menu {:content +;; [:<> +;; ;; [menu-heading "Modify Block 'Day of Datomic On-Prem 2016'"] +;; ;; [textinput {:icon [:> Face] :placeholder "Type to filter"}] +;; [:> Button [:<> [:> Link] [:span "Copy Page Reference"]]] +;; [:> Button [:<> [:> Star] [:span "Add to Shortcuts"]]] +;; [:> Button [:<> [:> Face] [:span "Add Reaction"] [submenu-indicator]]] +;; [menu-separator] +;; [:> Button [:<> [:> LastPage] [:span "Open in Sidebar"] [:kbd "shift-click"]]] +;; [:> Button [:<> [:> Launch] [:span "Open in New Window"] [:kbd "ctrl-o"]]] +;; [:> Button [:<> [:> UnfoldMore] [:span "Expand All"]]] +;; [:> Button [:<> [:> UnfoldLess] [:span "Collapse All"]]] +;; [:> Button [:<> [:> Slideshow] [:span "View As"] [submenu-indicator]]] +;; [menu-separator] +;; [:> Button [:<> [:> FileCopy] [:span "Duplicate and Break Links"]]] +;; [:> Button [:<> [:> LibraryAdd] [:span "Save as Template"]]] +;; [:> Button [:<> [:> History] [:span "Browse Versions"]]] +;; [:> Button [:<> [:> CloudDownload] [:span "Export As"]]]]}]}]) +;; +;; +;; (def items +;; {"Amet" {:count 6 :state :added} +;; "At" {:count 130 :state :excluded} +;; "Diam" {:count 6} +;; "Donec" {:count 6} +;; "Elit" {:count 30} +;; "Elitudomin mesucen defibocutruon" {:count 1} +;; "Erat" {:count 11} +;; "Est" {:count 2} +;; "Eu" {:count 2} +;; "Ipsum" {:count 2 :state :excluded} +;; "Magnis" {:count 10 :state :added} +;; "Metus" {:count 29} +;; "Mi" {:count 7 :state :added} +;; "Quam" {:count 1} +;; "Turpis" {:count 97} +;; "Vitae" {:count 1}}) +;; +;; +;; (defn filter-dropdown-component +;; [] +;; [dropdown {:style {:width "20em" :height "20em"} +;; :content [:<> +;; [menu-heading "Filters"] +;; [filters-el "((some-uid))" items]]}]) diff --git a/src/cljs/athens/views/help.cljs b/src/cljs/athens/views/help.cljs index f77f700da5..1d22b089fa 100644 --- a/src/cljs/athens/views/help.cljs +++ b/src/cljs/athens/views/help.cljs @@ -1,31 +1,34 @@ (ns athens.views.help (:require - ["@chakra-ui/react" :refer [Text Heading Box Modal ModalOverlay ModalContent ModalHeader ModalBody ModalCloseButton]] + ["@material-ui/core/Modal" :default Modal] + [athens.style :refer [color]] [athens.util :as util] [clojure.string :as str] [re-frame.core :refer [dispatch subscribe]] - [reagent.core :as r])) + [reagent.core :as r] + [stylefy.core :as stylefy :refer [use-style]]) + (:import + (goog.events + KeyCodes))) ;; Helpers to create the help content ;; ========================== -(defn faded-text +(defn opaque-text [text] - [:> Text {:as "span" - :color "foreground.secondary" - :fontWeight "normal"} + [:span (use-style {:color (color :body-text-color :opacity-med) + :font-weight "normal"}) text]) (defn space [] - [:> Box {:as "i" - :width "0.5em" - :display "inline-block" - :marginInline "0.125em" - :background "currentColor" - :height "1px" - :opacity "0.5"}]) + [:i (use-style {:width "0.25em" + :display "inline-block" + :margin-inline "0.25em" + :height "0.125em" + :border (str "1px solid " (color :body-text-color :opacity-low)) + :border-top 0})]) (defn- add-keys @@ -38,7 +41,7 @@ (defn example [template & args] - (let [faded-texts (map #(r/as-element [faded-text %]) args) + (let [opaque-texts (map #(r/as-element [opaque-text %]) args) space-component (r/as-element [space]) insert-spaces (fn [str-or-vec] (if (and (string? str-or-vec) @@ -49,13 +52,14 @@ (interleave (repeat space-component)) add-keys)) str-or-vec))] - [:> Text {:fontSize "85%" - :fontWeight "bold" - :userSelect "all" - :wordBreak "break-word"} + [:span (use-style + {:font-size "85%" + :font-weight "bold" + :user-select "all" + :word-break "break-word"}) (as-> template t (str/split t #"\$text") - (interleave t (concat faded-texts [nil])) + (interleave t (concat opaque-texts [nil])) (map insert-spaces t) (add-keys t) (into [:<>] t))])) @@ -177,15 +181,13 @@ ;; :example [:span (use-style {:text-decoration "underline"}) "Athens"] ;; :shortcut "mod+u"} {:description "Strikethrough" - :example [:> Text {:as "span" :textDecoration "line-through"} "Athens"] + :example [:span (use-style {:text-decoration "line-through"}) "Athens"] :shortcut "mod+y"} {:description "Highlight" - :example [:> Text {:as "span" - :background "highlight" - :color "highlightContrast" - :borderRadius "0.1rem" - :padding "0 0.125em"} - "Athens"] + :example [:span (use-style {:background (color :highlight-color) + :color (color :background-color) + :border-radius "0.1rem" + :padding "0 0.125em"}) "Athens"] :shortcut "mod+h"}]} {:name "Graph" :items [{:description "Open Node in Sidebar" @@ -225,36 +227,69 @@ keys (as-> shortcut-str s (str/split s #"\+") (map key-to-display s))] - [:> Box {:display "flex" - :alignItems "center" - :gap "0.3rem"} + [:div (use-style {:display "flex" + :align-items "center" + :gap "0.3rem"}) (doall (for [key keys] ^{:key key} - [:> Text {:fontFamily "inherit" - :display "inline-flex" - :gap "0.3em" - :textTransform "uppercase" - :fontSize "0.8em" - :paddingInline "0.35em" - :background "background.basement" - :borderRadius "0.25rem" - :fontWeight 600} + [:span (use-style {:font-family "inherit" + :display "inline-flex" + :gap "0.3em" + :text-transform "uppercase" + :font-size "0.8em" + :padding-inline "0.35em" + :background (color :background-plus-2) + :border-radius "0.25rem" + :font-weight 600}) key]))])) +(def modal-body-styles + {:width "max-content" + :margin "2rem auto" + :max-width "calc(100% - 1rem)" + :border (str "1px solid " (color :border-color)) + :border-radius "1rem" + :box-shadow (str "0 0.25rem 0.5rem -0.25rem " (color :shadow-color)) + :display "flex"}) + + +(def help-styles + {:background-color (color :background-color) + :border-radius "1rem" + :display "flex" + :flex-direction "column" + :min-width "500px"}) + + +(def help-header-styles + {:display "flex" + :justify-content "space-between" + :margin 0 + :align-items "center" + :border-bottom [["1px solid" (color :border-color)]]}) + + +(def help-title + {:padding "1rem 1.5rem" + :margin "0" + :font-size "2rem" + :color (color :header-text-color)}) + + (defn help-section [title & children] - [:> Box {:as "section"} - [:> Heading {:as "h2" - :color "foreground.primary" - :textTransform "uppercase" - :letterSpacing "0.06rem" - :margin 0 - :font-weight 600 - :font-size "100%" - :padding "1rem 1.5rem"} + [:section + [:h2 (use-style + {:color (color :body-text-color :opacity-med) + :text-transform "uppercase" + :letter-spacing "0.06rem" + :margin 0 + :font-weight 600 + :font-size "100%" + :padding "1rem 1.5rem"}) title] (doall (for [child children] @@ -264,16 +299,15 @@ (defn help-section-group [title & children] - [:> Box {:display "grid" - :padding "1.5rem" - :gridTemplateColumns "12rem 1fr" - :columnGap "1rem" - :borderTop "1px solid" - :borderColor "separator.divider"} - [:> Heading {:fontSize "1.5em" - :as "h3" - :margin 0 - :font-weight "bold"} + [:section (use-style + {:display "grid" + :padding "1.5rem" + :grid-template-columns "12rem 1fr" + :column-gap "1rem" + :border-top [["1px solid" (color :border-color)]]}) + [:h3 (use-style {:font-size "1.5em" + :margin 0 + :font-weight "bold"}) title] [:div (doall @@ -284,16 +318,18 @@ (defn help-item [item] - [:> Box {:borderRadius "0.5rem" - :alignItems "center" - :display "grid" - :gap "1rem" - :gridTemplateColumns "12rem 1fr" - :padding "0.25rem 0.5rem" - :sx {"&:nth-child(odd)" - {:bg "background.floor"}}} - [:> Text {:display "flex" - :justify-content "space-between"} + [:div (use-style + {:border-radius "0.5rem" + :align-items "center" + :display "grid" + :gap "1rem" + :grid-template-columns "12rem 1fr" + :padding "0.25rem 0.5rem" + ::stylefy/manual ["&:nth-child(odd)" + {:background (color :background-plus-2 :opacity-low)}]}) + [:span (use-style + {:display "flex" + :justify-content "space-between"}) ;; Position of the example changes if there is a shortcut or not. (:description item) (when (contains? item :shortcut) @@ -304,32 +340,56 @@ [shortcut (:shortcut item)])]) +;; Help popup UI +;; Why the escape handler? +;; Because when disabled the modal autofocus (which moves the modal to the top when +;; opened and causes other issues like moving into the top the modal when clicking outside +;; of it from the top), the escape handler from the modal itself doesn't work. +;; Because of that, our own escape handler is added. (defn help-popup [] (r/with-let [open? (subscribe [:help/open?]) - close #(dispatch [:help/toggle])] - [:> Modal {:isOpen @open? - :onClose close - :scrollBehavior "outside" - :size "full"} - [:> ModalOverlay] - [:> ModalContent {:maxWidth "calc(100% - 8rem)" - :width "max-content" - :my "4rem"} - [:> ModalHeader "Help" - [:> ModalCloseButton]] - [:> ModalBody {:flexDirection "column"} - (doall - (for [section content] - ^{:key section} - [help-section (:name section) - (doall - (for [group (:groups section)] - ^{:key group} - [help-section-group (:name group) - (doall - (for [item (:items group)] - ^{:key item} - [help-item item]))]))]))]]])) + close #(dispatch [:help/toggle]) + escape-handler (fn [event] + (when + (and @open? (= (.. event -keyCode) KeyCodes.ESC)) + (close))) + _ (js/addEventListener "keydown" escape-handler)] + [:> Modal {:open @open? + :style {:overflow-y "auto"} + :disableAutoFocus true + :onClose close} + [:div (use-style modal-body-styles) + [:div (use-style help-styles) + [:header (use-style help-header-styles) + [:h1 (use-style help-title) + "Help"] + [:nav (use-style {:display "flex" + :gap "1rem" + :padding "1rem"})]] + ;; Links at the top of the help. Uncomment when the correct links are obtained. + ;; [help-link + ;; [:> LiveHelp] + ;; "Get Help on Discord"] + ;; [help-link + ;; [:> Error] + ;; "Get Help on Discord"] + ;; [help-link + ;; [:> AddToPhotos] + ;; "Get Help on Discord"]]] + [:div (use-style {:overflow-y "auto"}) + (doall + (for [section content] + ^{:key section} + [help-section (:name section) + (doall + (for [group (:groups section)] + ^{:key group} + [help-section-group (:name group) + (doall + (for [item (:items group)] + ^{:key item} + [help-item item]))]))]))]]]] + (finally js/removeEventListener "keydown" escape-handler))) diff --git a/src/cljs/athens/views/left_sidebar.cljs b/src/cljs/athens/views/left_sidebar.cljs index 5ef33bcf9d..c5a522bb04 100644 --- a/src/cljs/athens/views/left_sidebar.cljs +++ b/src/cljs/athens/views/left_sidebar.cljs @@ -1,87 +1,155 @@ (ns athens.views.left-sidebar (:require - ["@chakra-ui/react" :refer [VStack Flex Heading Button Link Flex]] - ["framer-motion" :refer [AnimatePresence motion]] [athens.reactive :as reactive] [athens.router :as router] + [athens.style :refer [color OPACITIES]] [athens.util :as util] [re-frame.core :as rf] - [reagent.core :as r])) + [reagent.core :as r] + [stylefy.core :as stylefy :refer [use-style use-sub-style]])) -;; Components +;; Styles + + +(def left-sidebar-style + {:width 0 + :grid-area "left-sidebar" + :height "100%" + :display "flex" + :flex-direction "column" + :overflow-x "hidden" + :overflow-y "auto" + ::stylefy/supports {"overflow-y: overlay" + {:overflow-y "overlay"}} + :transition "width 0.5s ease" + ::stylefy/sub-styles {:top-line {:margin-bottom "2.5rem" + :display "flex" + :flex "0 0 auto" + :justify-content "space-between"} + :footer {:flex "0 0 auto" + :margin "auto 2rem 0" + :align-self "stretch" + :display "grid" + :grid-auto-flow "column" + :grid-template-columns "1fr auto auto" + :grid-gap "0.25rem"} + :small-icon {:font-size "16px"} + :large-icon {:font-size "22px"}} + ::stylefy/manual [[:&.is-open {:width "18rem"}] + [:&.is-closed {:width "0"}]]}) + + +(def left-sidebar-content-style + {:width "18rem" + :height "100%" + :display "flex" + :flex-direction "column" + :padding "7.5rem 0 1rem" + :transition "opacity 0.5s ease" + :opacity 0 + ::stylefy/manual [[:&.is-open {:opacity 1}] + [:&.is-closed {:opacity 0}]]}) + + +(def shortcuts-list-style + {:flex "1 1 100%" + :display "flex" + :list-style "none" + :flex-direction "column" + :padding "0 2rem" + :margin "0 0 2rem" + :overflow-y "auto" + ::stylefy/supports {"overflow-y: overlay" + {:overflow-y "overlay"}} + ::stylefy/sub-styles {:heading {:flex "0 0 auto" + :opacity (:opacity-med OPACITIES) + :line-height "1" + :margin "0 0 0.25rem" + :font-size "inherit"}}}) + -(def expanded-sidebar-width "clamp(12rem, 25vw, 18rem)") +(def shortcut-style + {:color (color :link-color) + :cursor "pointer" + :display "flex" + :flex "0 0 auto" + :padding "0.25rem 0" + :transition "opacity 0.05s ease" + ::stylefy/mode [[:hover {:opacity (:opacity-high OPACITIES)}]]}) + + +(def notional-logotype-style + {:font-family "IBM Plex Serif" + :font-size "18px" + :opacity (:opacity-med OPACITIES) + :letter-spacing "-0.05em" + :font-weight "bold" + :text-decoration "none" + :justify-self "flex-start" + :color (color :header-text-color) + :transition "opacity 0.05s ease" + ::stylefy/mode [[:hover {:opacity (:opacity-high OPACITIES)}]]}) + + +(def version-style + {:color "inherit" + :text-decoration "none" + :opacity 0.3 + :font-size "clamp(12px, 100%, 14px)" + ::stylefy/mode [[:hover {:opacity (:opacity-high OPACITIES)}]]}) + + +;; Components (defn shortcut-component [_] (let [drag (r/atom nil)] (fn [[order title]] - [:> Flex {:as "li" - :align "stretch" - :border "1px solid transparent" - :borderTopColor (when (:above @drag) "brand") - :borderBottomColor (when (:below @drag) "brand")} - [:> Button {:variant "link" - :borderWidth "1px" - :color "foreground.primary" - :p "1rem" - :display "block" - :py "0.5rem" - :mx "1rem" - :textAlign "left" - :justifyContent "flex-start" - :overflow "hidden" - :fontWeight "medium" - :whiteSpace "nowrap" - :textOverflow "ellipsis" - :flex "1" - :border "none" - :bg "transparent" - :borderRadius "md" - :boxShadow "0 0 0 0.25rem transparent" - :_focus {:outline "none"} - :_hover {:bg "background.upper"} - :_active {:bg "background.attic" - :transitionDuration "0s"} - :on-click (fn [e] - (let [shift? (.-shiftKey e)] - (rf/dispatch [:reporting/navigation {:source :left-sidebar - :target :page - :pane (if shift? - :right-pane - :main-pane)}]) - (router/navigate-page title e))) - :draggable true - :on-drag-over (fn [e] - (.. e preventDefault) - (let [offset (util/mouse-offset e) - middle-y (util/vertical-center (.. e -target)) - ;; find closest li because sometimes event.target is anchor tag - ;; if nextSibling is null, then target is last li and therefore end of list - closest-li (.. e -target (closest "li")) - next-sibling (.. closest-li -nextElementSibling) - last-child? (nil? next-sibling)] - (cond - (> middle-y (:y offset)) (reset! drag :above) - (and (< middle-y (:y offset)) last-child?) (reset! drag :below)))) - :on-drag-start (fn [e] - (set! (.. e -dataTransfer -dropEffect) "move") - (.. e -dataTransfer (setData "text/plain" order))) - :on-drag-end (fn [_]) - :on-drag-leave (fn [_] (reset! drag nil)) - :on-drop (fn [e] - (let [source-order (js/parseInt (.. e -dataTransfer (getData "text/plain")))] - (prn source-order order) - (cond - (= source-order order) nil - (and (= source-order - (dec order)) - (= @drag :above)) nil - (= @drag :below) (rf/dispatch [:left-sidebar/drop source-order order :after]) - :else (rf/dispatch [:left-sidebar/drop source-order order :before]))) - (reset! drag nil))} + [:li + [:a (use-style (merge shortcut-style + (case @drag + :above {:border-top [["1px" "solid" (color :link-color)]]} + :below {:border-bottom [["1px" "solid" (color :link-color)]]} + {})) + {:on-click (fn [e] + (let [shift? (.-shiftKey e)] + (rf/dispatch [:reporting/navigation {:source :left-sidebar + :target :page + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-page title e))) + :draggable true + :on-drag-over (fn [e] + (.. e preventDefault) + (let [offset (util/mouse-offset e) + middle-y (util/vertical-center (.. e -target)) + ;; find closest li because sometimes event.target is anchor tag + ;; if nextSibling is null, then target is last li and therefore end of list + closest-li (.. e -target (closest "li")) + next-sibling (.. closest-li -nextElementSibling) + last-child? (nil? next-sibling)] + (cond + (> middle-y (:y offset)) (reset! drag :above) + (and (< middle-y (:y offset)) last-child?) (reset! drag :below)))) + :on-drag-start (fn [e] + (set! (.. e -dataTransfer -dropEffect) "move") + (.. e -dataTransfer (setData "text/plain" order))) + :on-drag-end (fn [_]) + :on-drag-leave (fn [_] (reset! drag nil)) + :on-drop (fn [e] + (let [source-order (js/parseInt (.. e -dataTransfer (getData "text/plain")))] + (prn source-order order) + (cond + (= source-order order) nil + (and (= source-order + (dec order)) + (= @drag :above)) nil + (= @drag :below) (rf/dispatch [:left-sidebar/drop source-order order :after]) + :else (rf/dispatch [:left-sidebar/drop source-order order :before]))) + (reset! drag nil))}) title]]))) @@ -89,38 +157,19 @@ [] (let [open? (rf/subscribe [:left-sidebar/open]) shortcuts (reactive/get-reactive-shortcuts)] - [:> AnimatePresence {:initial false} - (when @open? - [:> (.-div motion) - {:style {:display "flex" - :flex-direction "column" - :height "100%" - :alignItems "stretch" - :gridArea "left-sidebar" - :position "relative" - :overflow "hidden"} - :initial {:width 0 - :opacity 0} - :animate {:width expanded-sidebar-width - :opacity 1} - :exit {:width 0 - :opacity 0}} + (fn [] + [:div (use-style left-sidebar-style + {:class (if @open? + "is-open" + "is-closed")}) + [:div (use-style left-sidebar-content-style + {:class (if @open? + "is-open" + "is-closed")}) ;; SHORTCUTS - [:> VStack {:as "ol" - :align "stretch" - :width expanded-sidebar-width - :py "1rem" - :paddingTop "7rem" - :spacing "0.25rem" - :overflowY "overlay" - :sx {:listStyle "none" - :WebkitAppRegion "no-drag"}} - [:> Heading {:as "h2" - :px "2rem" - :pb "0.5rem" - :size "sm" - :color "foreground.secondary"} + [:ol (use-style shortcuts-list-style) + [:h2 (use-sub-style shortcuts-list-style :heading) "Shortcuts"] (doall (for [sh shortcuts] @@ -128,20 +177,10 @@ [shortcut-component sh]))] ;; LOGO + BOTTOM BUTTONS - [:> Flex {:as "footer" - :width expanded-sidebar-width - :flexWrap "wrap" - :gap "0.25em 0.5em" - :fontSize "sm" - :p "2rem" - :mt "auto"} - [:> Link {:fontWeight "bold" - :display "inline-block" - :href "https://github.com/athensresearch/athens/issues/new/choose" - :target "_blank"} - "Athens"] - [:> Link {:color "foreground.secondary" - :display "inline-block" - :href "https://github.com/athensresearch/athens/blob/master/CHANGELOG.md" - :target "_blank"} - (athens.util/athens-version)]]])])) + [:footer (use-sub-style left-sidebar-style :footer) + [:a (use-style notional-logotype-style {:href "https://github.com/athensresearch/athens/issues/new/choose" :target "_blank"}) "Athens"] + [:h5 (use-style {:align-self "center"}) + [:a (use-style version-style {:href "https://github.com/athensresearch/athens/blob/master/CHANGELOG.md" + :target "_blank"}) + (athens.util/athens-version)]]]]]))) + diff --git a/src/cljs/athens/views/modal.cljs b/src/cljs/athens/views/modal.cljs new file mode 100644 index 0000000000..ded8914e85 --- /dev/null +++ b/src/cljs/athens/views/modal.cljs @@ -0,0 +1,46 @@ +(ns athens.views.modal + (:require + [athens.db] + [athens.style :refer [color ZINDICES DEPTH-SHADOWS]] + [garden.selectors :as selectors] + [stylefy.core :as stylefy])) + + +;; Styles + +(def modal-style + {:z-index (:zindex-modal ZINDICES) + :animation "fade-in 0.2s" + :position "relative" + ::stylefy/manual [[:.modal {:position "fixed" + :top "50vh" + :left "50vw" + :transform "translate(-50%, -50%)" + :border-radius "0.5rem" + :display "flex" + :flex-direction "column" + :background-clip "padding-box" + :background (color :background-plus-1) + :box-shadow [[(:64 DEPTH-SHADOWS) ", 0 0 0 1px " (color :body-text-color :opacity-low)]]}] + [:modal__header {:display "contents"}] ; Deactivate layout on the default header + [(selectors/> :.modal__header :button) {:display "none"}] ; Hide default close button + [:.modal__title :.modal__footer {:flex "0 0 auto" + :padding "0.25rem 1rem" + :display "flex" + :align-items "center"} + [:&:empty {:display "none"}]] + [:.modal__title {:padding-right "0.75rem"} + [(selectors/+ :svg :h4) {:margin-inline-start "0.5rem"}] + [:button {:margin-inline-start "auto" + :align-self "flex-start" + :margin-block "0.5rem"}]] + [:.modal__content {:flex "1 1 100%" + :overflow-y "auto"}] + [:.modal__footer {:display "flex"}] + [:.modal__backdrop {:position "fixed" + :top 0 + :left 0 + :background "rgba(0,0,0,0.1)" + :z-index -1 + :width "100vw" + :height "100vh"}]]}) diff --git a/src/cljs/athens/views/pages/all_pages.cljs b/src/cljs/athens/views/pages/all_pages.cljs index a2fc62df0c..4e524f9bfe 100644 --- a/src/cljs/athens/views/pages/all_pages.cljs +++ b/src/cljs/athens/views/pages/all_pages.cljs @@ -1,14 +1,80 @@ (ns athens.views.pages.all-pages (:require - ["@chakra-ui/react" :refer [Table Thead Tr Th Tbody Td Button Box]] ["@material-ui/icons/ArrowDropDown" :default ArrowDropDown] ["@material-ui/icons/ArrowDropUp" :default ArrowDropUp] [athens.common-db :as common-db] [athens.dates :as dates] [athens.db :as db] [athens.router :as router] + [athens.style :as style :refer [color OPACITIES]] [clojure.string :refer [lower-case]] - [re-frame.core :as rf])) + [garden.selectors :as selectors] + [re-frame.core :as rf] + [stylefy.core :as stylefy :refer [use-style]])) + + +;; Styles + + +(def page-style + {:display "flex" + :margin "5rem auto" + :flex-basis "100%" + :max-width "70rem"}) + + +(def table-style + {:flex "1 1 100%" + :margin "0 2rem" + :text-align "left" + :border-collapse "collapse" + ::stylefy/manual [[:tbody {:vertical-align "top"} + [:tr {:transition "background 0.1s ease"} + [:td {:border-top (str "1px solid " (color :border-color)) + :transition "box-shadow 0.1s ease"} + [:&.title {:color (color :link-color) + :width "15vw" + :cursor "pointer" + :min-width "10em" + :word-break "break-word" + :font-weight "500" + :font-size "1.3125em" + :line-height "1.28"}] + [:&.links {:font-size "1em" + :text-align "center"}] + [:&.body-preview {:word-break "break-word" + :overflow "hidden" + :text-overflow "ellipsis" + :display "-webkit-box" + :-webkit-mask "linear-gradient(to bottom, #fff calc(100% - 1em), transparent)" + :-webkit-line-clamp "3" + :-webkit-box-orient "vertical"} + [:span:empty {:display "none"}] + [(selectors/+ :span :span) + [:&:before {:content "'•'" + :margin-inline "0.5em" + :opacity (:opacity-low OPACITIES)}]]] + [:&.date {:text-align "right" + :opacity (:opacity-high OPACITIES) + :font-size "0.75em" + :min-width "9em"}] + [:&:first-child {:border-radius "0.5rem 0 0 0.5rem" + :box-shadow "-1rem 0 transparent"}] + [:&:last-child {:border-radius "0 0.5rem 0.5rem 0" + :box-shadow "1rem 0 transparent"}]] + [:&:hover {:background-color (color :background-minus-1 :opacity-med) + :border-radius "0.5rem"} + [:td [:&:first-child {:box-shadow [["-1rem 0 " (color :background-minus-1 :opacity-med)]]}]] + [:td [:&:last-child {:box-shadow [["1rem 0 " (color :background-minus-1 :opacity-med)]]}]]]]] + [:td :th {:padding "0.5rem"}] + [:th {:opacity (:opacity-med OPACITIES) + :user-select "none"} + [:&.sortable {:cursor "pointer"} + [:.wrap-label {:display "flex" + :align-items "center"}] + [:&.date + [:.wrap-label {:flex-direction "row-reverse"}]] + [:&:hover {:opacity 1}]]]]}) ;; Sort state and logic @@ -63,19 +129,17 @@ ;; Components (defn- sortable-header - ([column-id label width isNumeric] + ([column-id label] + (sortable-header column-id label {:date? false})) + ([column-id label {:keys [date?]}] (let [sorted-by @(rf/subscribe [:all-pages/sorted-by]) growing? @(rf/subscribe [:all-pages/sort-order-ascending?])] - [:> Th {:width width :isNumeric isNumeric} - [:> Button {:onClick #(rf/dispatch [:all-pages/sort-by column-id]) - :size "sm" - :variant "link"} - (when-not isNumeric label) + [:th {:on-click #(rf/dispatch [:all-pages/sort-by column-id]) + :class ["sortable" (when date? "date")]} + [:div.wrap-label + [:h5 label] (when (= sorted-by column-id) - (if growing? - [:> ArrowDropUp] - [:> ArrowDropDown])) - (when isNumeric label)]]))) + (if growing? [:> ArrowDropUp] [:> ArrowDropDown]))]]))) (defn page @@ -83,39 +147,30 @@ (let [all-pages (common-db/get-all-pages @db/dsdb)] (fn [] (let [sorted-pages @(rf/subscribe [:all-pages/sorted all-pages])] - [:> Box {:px 4 - :margin "calc(var(--app-header-height) + 2rem) auto 5rem"} - [:> Table {:variant "striped"} - [:> Thead - [:> Tr + [:div (use-style page-style) + [:table (use-style table-style) + [:thead + [:tr [sortable-header :title "Title"] - [sortable-header :links-count "Links" "12rem" true] - [sortable-header :modified "Modified" "16rem" false {:date? true}] - [sortable-header :created "Created" "16rem" false {:date? true}]]] - [:> Tbody + [sortable-header :links-count "Links"] + [sortable-header :modified "Modified" {:date? true}] + [sortable-header :created "Created" {:date? true}]]] + [:tbody (doall (for [{:keys [block/uid node/title block/_refs] modified :edit/time created :create/time} sorted-pages] - [:> Tr {:key uid} - [:> Td {:overflow "hidden"} - [:> Button {:variant "link" - :justifyContent "flex-start" - :textAlign "left" - :padding "0" - :color "link" - :display "block" - :maxWidth "100%" - :whiteSpace "nowrap" - :onClick (fn [e] - (let [shift? (.-shiftKey e)] - (rf/dispatch [:reporting/navigation {:source :all-pages - :target :page - :pane (if shift? - :right-pane - :main-pane)}]) - (router/navigate-page title e)))} - title]] - [:> Td {:width "12rem" :whiteSpace "nowrap" :color "foreground.secondary" :isNumeric true} (count _refs)] - [:> Td {:width "16rem" :whiteSpace "nowrap" :color "foreground.secondary"} (dates/date-string modified)] - [:> Td {:width "16rem" :whiteSpace "nowrap" :color "foreground.secondary"} (dates/date-string created)]]))]]])))) + [:tr {:key uid} + [:td {:class "title" + :on-click (fn [e] + (let [shift? (.-shiftKey e)] + (rf/dispatch [:reporting/navigation {:source :all-pages + :target :page + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-page title e)))} + title] + [:td {:class "links"} (count _refs)] + [:td {:class "date"} (dates/date-string modified)] + [:td {:class "date"} (dates/date-string created)]]))]]])))) diff --git a/src/cljs/athens/views/pages/block_page.cljs b/src/cljs/athens/views/pages/block_page.cljs index 42db80a176..a1ef430442 100644 --- a/src/cljs/athens/views/pages/block_page.cljs +++ b/src/cljs/athens/views/pages/block_page.cljs @@ -1,16 +1,59 @@ (ns athens.views.pages.block-page (:require - ["/components/Page/Page" :refer [PageHeader PageBody PageFooter EditableTitleContainer]] - ["@chakra-ui/react" :refer [Breadcrumb BreadcrumbItem BreadcrumbLink VStack AccordionIcon Accordion AccordionItem AccordionButton AccordionPanel]] + ["@material-ui/icons/Link" :default Link] [athens.parse-renderer :as parse-renderer] [athens.reactive :as reactive] [athens.router :as router] + [athens.style :refer [color]] [athens.views.blocks.core :as blocks] + [athens.views.breadcrumbs :refer [breadcrumbs-list breadcrumb]] [athens.views.pages.node-page :as node-page] - [athens.views.references :refer [reference-group reference-block]] + [garden.selectors :as selectors] [komponentit.autosize :as autosize] [re-frame.core :as rf :refer [dispatch subscribe]] - [reagent.core :as r])) + [reagent.core :as r] + [stylefy.core :as stylefy :refer [use-style]])) + + +;; Styles + + +(def title-style + {:position "relative" + :overflow "visible" + :flex-grow "1" + :margin "0.1em 0" + :letter-spacing "-0.03em" + :word-break "break-word" + :line-height "1.4em" + ::stylefy/manual [[:textarea {:-webkit-appearance "none" + :cursor "text" + :resize "none" + :transform "translate3d(0,0,0)" + :color "inherit" + :font-weight "inherit" + :padding "0" + :letter-spacing "inherit" + :width "100%" + :min-height "100%" + :caret-color (color :link-color) + :background "transparent" + :margin "0" + :font-size "inherit" + :line-height "inherit" + :border-radius "0.25rem" + :transition "opacity 0.15s ease" + :border "0" + :font-family "inherit" + :visibility "hidden" + :position "absolute"}] + [:textarea ["::-webkit-scrollbar" {:display "none"}]] + [:textarea:focus + :.is-editing {:outline "none" + :visibility "visible" + :position "relative"}] + [(selectors/+ :.is-editing :span) {:visibility "hidden" + :position "absolute"}]]}) ;; Helpers @@ -52,45 +95,46 @@ [id] (let [linked-refs (reactive/get-reactive-linked-references id)] (when (seq linked-refs) - [:> Accordion - [:> AccordionItem - [:h2 - [:> AccordionButton - [:> AccordionIcon "LinkedReferences"]]] - [:> AccordionPanel {:px 0} - [:> VStack {:spacing 6 - :pl 6 - :align "stretch"} - (doall - (for [[group-title group] linked-refs] - [reference-group {:key (str "group-" group-title) - :title group-title - :on-click-title (fn [e] - (let [shift? (.-shiftKey e) - parsed-title (parse-renderer/parse-title group-title)] - (rf/dispatch [:reporting/navigation {:source :block-page-linked-refs - :target :page - :pane (if shift? - :right-pane - :main-pane)}]) - (router/navigate-page parsed-title)))} - (doall - (for [block group] - [reference-block {:key (str "ref-" (:block/uid block))} - [node-page/ref-comp block]]))]))]]]]))) + [:div (use-style node-page/references-style {:key "Linked References"}) + [:section + [:h4 (use-style node-page/references-heading-style) + [(r/adapt-react-class Link)] + [:span "Linked References"]] + ;; Hide button until feature is implemented + ;; [:> Button {:disabled true} [(r/adapt-react-class FilterList)]]] + [:div (use-style node-page/references-list-style) + (doall + (for [[group-title group] linked-refs] + [:div (use-style node-page/references-group-style {:key (str "group-" group-title)}) + [:h4 (use-style node-page/references-group-title-style) + [:a {:on-click (fn [e] + (let [shift? (.-shiftKey e) + parsed-title (parse-renderer/parse-title group-title)] + (rf/dispatch [:reporting/navigation {:source :block-page-linked-refs + :target :page + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-page parsed-title)))} + group-title]] + (doall + (for [block group] + [:div (use-style node-page/references-group-block-style {:key (str "ref-" (:block/uid block))}) + [node-page/ref-comp block]]))]))]]]))) (defn parents-el [uid id] (let [parents (reactive/get-reactive-parents-recursively id)] - [:> Breadcrumb {:gridArea "breadcrumb" :opacity 0.75} - (doall - (for [{:keys [node/title block/string] breadcrumb-uid :block/uid} parents] - ^{:key breadcrumb-uid} - [:> BreadcrumbItem {:key (str "breadcrumb-" breadcrumb-uid)} - [:> BreadcrumbLink {:onClick #(breadcrumb-handle-click % uid breadcrumb-uid)} + [:span {:style {:color "gray"}} + [breadcrumbs-list {:style {:font-size "1.2rem"}} + (doall + (for [{:keys [node/title block/string] breadcrumb-uid :block/uid} parents] + ^{:key breadcrumb-uid} + [breadcrumb {:key (str "breadcrumb-" breadcrumb-uid) + :on-click #(breadcrumb-handle-click % uid breadcrumb-uid)} [:span {:style {:pointer-events "none"}} - [parse-renderer/parse-and-render (or title string)]]]]))])) + [parse-renderer/parse-and-render (or title string)]]]))]])) (defn block-page-el @@ -102,48 +146,41 @@ (when (not= string (:string/previous @state)) (swap! state assoc :string/previous string :string/local string)) - [:<> + [:div.block-page (use-style node-page/page-style {:data-uid uid}) + ;; Parent Context + [parents-el uid id] ;; Header - [:> PageHeader - - ;; Parent Context - [parents-el uid id] - [:> EditableTitleContainer {:isEditing @(subscribe [:editing/is-editing uid]) - :onClick (fn [e] - (.. e preventDefault) - (if (.. e -shiftKey) - (do - (dispatch [:reporting/navigation {:source :block-page - :target :block - :pane :right-pane}]) - (router/navigate-uid uid e)) - - (dispatch [:editing/uid uid])))} - [autosize/textarea - {:value (:string/local @state) - :class (when @(subscribe [:editing/is-editing uid]) "is-editing") - :id (str "editable-uid-" uid) - ;; :auto-focus true - :on-blur (fn [_] - (persist-textarea-string @state uid) - (dispatch [:editing/uid nil])) - :on-click #(dispatch [:editing/uid uid]) - :on-key-down (fn [e] (node-page/handle-key-down e uid state nil)) - :on-change (fn [e] (block-page-change e uid state))}] - (if (clojure.string/blank? (:string/local @state)) - [:span [:wbr]] - [parse-renderer/parse-and-render (:string/local @state) uid])]] + [:h1 (merge + (use-style title-style {:data-uid uid :class "block-header"}) + {:on-click (fn [e] + (.. e preventDefault) + (if (.. e -shiftKey) + (do + (rf/dispatch [:reporting/navigation {:source :block-page + :target :block + :pane :right-pane}]) + (router/navigate-uid uid e)) + (dispatch [:editing/uid uid])))}) + [autosize/textarea + {:id (str "editable-uid-" uid) + :value (:string/local @state) + :class (when @(subscribe [:editing/is-editing uid]) "is-editing") + :auto-focus true + :on-blur (fn [_] (persist-textarea-string @state uid)) + :on-key-down (fn [e] (node-page/handle-key-down e uid state nil)) + :on-change (fn [e] (block-page-change e uid state))}] + (if (clojure.string/blank? (:string/local @state)) + [:wbr] + [:span [parse-renderer/parse-and-render (:string/local @state) uid]])] ;; Children - [:> PageBody - (for [child children] - (let [{:keys [db/id]} child] - ^{:key id} [blocks/block-el child]))] + [:div (for [child children] + (let [{:keys [db/id]} child] + ^{:key id} [blocks/block-el child]))] ;; Refs - [:> PageFooter - [linked-refs-el id]]])))) + [linked-refs-el id]])))) (defn page diff --git a/src/cljs/athens/views/pages/core.cljs b/src/cljs/athens/views/pages/core.cljs index 9d0f5b1801..16c794553a 100644 --- a/src/cljs/athens/views/pages/core.cljs +++ b/src/cljs/athens/views/pages/core.cljs @@ -1,14 +1,34 @@ (ns athens.views.pages.core (:require - ["@chakra-ui/react" :refer [Box]] - [athens.util :refer [toast]] + [athens.style :as style] [athens.views.hoc.perf-mon :as perf-mon] [athens.views.pages.all-pages :as all-pages] [athens.views.pages.daily-notes :as daily-notes] [athens.views.pages.graph :as graph] [athens.views.pages.page :as page] [athens.views.pages.settings :as settings] - [re-frame.core :as rf])) + [re-frame.core :as rf] + [stylefy.core :as stylefy])) + + +;; Styles + +(def main-content-style + {:flex "1 1 100%" + :grid-area "main-content" + :align-items "flex-start" + :justify-content "stretch" + :padding-top "2.5rem" + :display "flex" + :overflow-y "auto" + ::stylefy/supports {"overflow-y: overlay" + {:overflow-y "overlay"}} + ::stylefy/mode {"::-webkit-scrollbar" {:background (style/color :background-minus-1) + :width "0.5rem" + :height "0.5rem"} + "::-webkit-scrollbar-corner" {:background (style/color :background-minus-1)} + "::-webkit-scrollbar-thumb" {:background (style/color :background-minus-2) + :border-radius "0.5rem"}}}) ;; View @@ -18,43 +38,10 @@ (let [route-name (rf/subscribe [:current-route/name])] ;; TODO: create a UI to inform the player of the connection status (when (= @(rf/subscribe [:connection-status]) :reconnecting) - (toast (clj->js {:status "info" - :title "Reconnecting to server..."}))) - [:> Box {:flex "1 1 100%" - :position "relative" - :gridArea "main-content" - :alignItems "flex-start" - :justifyContent "stretch" - :display "flex" - :overflowY "overlay" - :sx {:maskImage "linear-gradient(to bottom, - transparent, - #000000cc 1rem, - black 1.5rem, - black calc(100vh - 5rem), - #000000f0 calc(100vh - 4rem), - #00000088 100vh)" - ".os-mac &" {:maskImage "linear-gradient(to bottom, - transparent var(--app-header-height), - black calc(var(--app-header-height) + 2rem), - black 5rem, - black calc(100vh - 5rem), - #000000f0 calc(100vh - 4rem), - #00000088 100vh)"} - "&:before" {:content "''" - :position "fixed" - :zIndex "-1" - :inset 0 - :top "3.25rem" - :WebkitAppRegion "no-drag"} - "::WebkitScrollbar" {:background "background.basement" - :width "0.5rem" - :height "0.5rem"} - "::WebkitScrollbar-corner" {:bg "background.basement"} - "::WebkitScrollbar-thumb" {:bg "background.upper" - :borderRadius "full"}} - :on-scroll (when (= @route-name :home) - #(rf/dispatch [:daily-note/scroll]))} + (rf/dispatch [:alert/js "Oops! Connection Lost. Reconnecting..."])) + [:div (stylefy/use-style main-content-style + {:on-scroll (when (= @route-name :home) + #(rf/dispatch [:daily-note/scroll]))}) (case @route-name :settings [perf-mon/hoc-perfmon-no-new-tx {:span-name "pages/settings"} [settings/page]] diff --git a/src/cljs/athens/views/pages/daily_notes.cljs b/src/cljs/athens/views/pages/daily_notes.cljs index 87222c3373..6b162b9c8d 100644 --- a/src/cljs/athens/views/pages/daily_notes.cljs +++ b/src/cljs/athens/views/pages/daily_notes.cljs @@ -1,11 +1,39 @@ (ns athens.views.pages.daily-notes (:require - ["/components/Page/Page" :refer [PageHeader TitleContainer DailyNotesPage]] - ["@chakra-ui/react" :refer [VStack]] [athens.dates :as dates] [athens.reactive :as reactive] + [athens.style :refer [DEPTH-SHADOWS]] [athens.views.pages.node-page :as node-page] - [re-frame.core :refer [dispatch subscribe]])) + [re-frame.core :refer [dispatch subscribe]] + [stylefy.core :refer [use-style]])) + + +;; Styles + + +(def daily-notes-scroll-area-style + {:min-height "calc(100vh + 1px)" + :display "flex" + :padding "1.25rem 0" + :align-items "stretch" + :flex "1 1 100%" + :flex-direction "column"}) + + +(def daily-notes-page-style + {:box-shadow (:16 DEPTH-SHADOWS) + :align-self "stretch" + :justify-self "stretch" + :margin "1.25rem 2.5rem" + :padding "1rem 2rem" + :transition-duration "0s" + :border-radius "0.5rem" + :min-height "calc(100vh - 10rem)"}) + + +(def daily-notes-notional-page-style + (merge daily-notes-page-style {:box-shadow (:4 DEPTH-SHADOWS) + :opacity "0.5"})) (defn reactive-pull-many @@ -27,20 +55,12 @@ (if (empty? @note-refs) (dispatch [:daily-note/next (dates/get-day)]) (let [notes (reactive-pull-many @note-refs)] - [:> VStack {:id "daily-notes" - :minHeight "calc(100vh + 1px)" - :display "flex" - :gap "1.5rem" - :py "6rem" - :px "2rem" - :alignItems "stretch" - :flex "1 1 100%" - :flexDirection "column"} + [:div#daily-notes (use-style daily-notes-scroll-area-style) (doall (for [{:keys [block/uid]} notes] - [:> DailyNotesPage {:key uid - :isReal true} - [node-page/page [:block/uid uid]]])) - [:> DailyNotesPage {:isReal false} - [:> PageHeader - [:> TitleContainer "Earlier"]]]]))))) + ^{:key uid} + [:<> + [:div (use-style daily-notes-page-style) + [node-page/page [:block/uid uid]]]])) + [:div (use-style daily-notes-notional-page-style) + [:h1 "Earlier"]]]))))) diff --git a/src/cljs/athens/views/pages/graph.cljs b/src/cljs/athens/views/pages/graph.cljs index f76ff725ff..0b872ff53b 100644 --- a/src/cljs/athens/views/pages/graph.cljs +++ b/src/cljs/athens/views/pages/graph.cljs @@ -6,32 +6,26 @@ and customizations are based on that where as global doesn't have an explicit root Relies on material ui comps for user inputs."} - athens.views.pages.graph + athens.views.pages.graph (:require - ["@chakra-ui/react" :refer [Box Accordion AccordionButton AccordionItem AccordionPanel AccordionIcon]] - ["@material-ui/core/Slider" :as OldSlider] - ["@material-ui/core/Switch" :as OldSwitch] - ["react-force-graph-2d" :as ForceGraph2D] - [athens.dates :as dates] - [athens.db :as db] - [athens.router :as router] - [clojure.set :as set] - [datascript.core :as d] - [re-frame.core :as rf :refer [subscribe]] - [reagent.core :as r] - [reagent.dom :as dom])) - - -(def THEME-DARK - {:graph-node-normal "hsla(0, 0%, 100%, 0.57)" - :graph-node-hlt "#498eda" - :graph-link-normal "#ffffff11"}) - - -(def THEME-LIGHT - {:graph-node-normal "#909090" - :graph-node-hlt "#0075E1" - :graph-link-normal "#cfcfcf"}) + ["@material-ui/core/ExpansionPanel" :as ExpansionPanel] + ["@material-ui/core/ExpansionPanelDetails" :as ExpansionPanelDetails] + ["@material-ui/core/ExpansionPanelSummary" :as ExpansionPanelSummary] + ["@material-ui/core/Slider" :as Slider] + ["@material-ui/core/Switch" :as Switch] + ["@material-ui/icons/KeyboardArrowRight" :default KeyboardArrowRight] + ["@material-ui/icons/KeyboardArrowUp" :default KeyboardArrowUp] + ["react-force-graph-2d" :as ForceGraph2D] + [athens.dates :as dates] + [athens.db :as db] + [athens.router :as router] + [athens.style :as styles] + [clojure.set :as set] + [datascript.core :as d] + [re-frame.core :as rf :refer [subscribe]] + [reagent.core :as r] + [reagent.dom :as dom] + [stylefy.core :as stylefy :refer [use-style]])) ;; all graph refs(react refs) reside in this atom @@ -44,9 +38,19 @@ ;; --- material ui --- -(def m-slider (r/adapt-react-class (.-default OldSlider))) +(def m-slider (r/adapt-react-class (.-default Slider))) + + +(def m-expansion-panel (r/adapt-react-class (.-default ExpansionPanel))) + -(def m-switch (r/adapt-react-class (.-default OldSwitch))) +(def m-expansion-panel-details (r/adapt-react-class (.-default ExpansionPanelDetails))) + + +(def m-expansion-panel-summary (r/adapt-react-class (.-default ExpansionPanelSummary))) + + +(def m-switch (r/adapt-react-class (.-default Switch))) ;; ------------------------------------------------------------------- @@ -169,29 +173,54 @@ ;; ------------------------------------------------------------------- ;; --- comps --- + +(defn graph-control-style + [theme] + {:position "absolute" + :right "10px" + :font-size "14px" + :z-index 2 + ::stylefy/manual [[:.MuiExpansionPanelDetails-root {:flex-flow "column" + :color "grey"} + [:.switch {:display "flex" + :justify-content "space-between" + :align-items "center"}]] + [:.MuiSvgIcon-root {:font-size "1.2rem"}] + [:.MuiExpansionPanelSummary-content {:justify-content "space-between"} + [:&.Mui-expanded {:margin "24px 0" + :min-height "unset"}]] + [:.MuiExpansionPanelSummary-root + [:&.Mui-expanded {:min-height "unset"}]] + [:.MuiPaper-root {:background (:graph-control-bg theme) + :color (:graph-control-color theme) + :margin "10px 0 2px 0"} + [:&.Mui-expanded {:margin "0 0 5px 0"}]]]}) + + (defn expansion-panel [{:keys [heading controls]} local-node-eid] - (let [graph-conf @(subscribe [:graph/conf]) - graph-ref (get @graph-ref-map (or local-node-eid :global))] - [:> AccordionItem - [:> AccordionButton - [:> AccordionIcon] - heading] - [:> AccordionPanel - (doall - (for [{:keys [key comp label onChange no-simulation-reheat? props class]} controls] - ^{:key key} - [:div {:class class} label - [comp - (merge - props - {:value (key graph-conf) - :color "primary" - :onChange (fn [_ n-val] - (and onChange (onChange n-val)) - (rf/dispatch [:graph/set-conf key n-val]) - (when-not no-simulation-reheat? - (.d3ReheatSimulation graph-ref)))})]]))]])) + (r/with-let [is-open? (r/atom false)] + (let [graph-conf @(subscribe [:graph/conf]) + graph-ref (get @graph-ref-map (or local-node-eid :global))] + [m-expansion-panel + [m-expansion-panel-summary + {:onClick #(swap! is-open? not)} + [:<> [:span heading] (if @is-open? [:> KeyboardArrowUp] [:> KeyboardArrowRight])]] + [m-expansion-panel-details + (doall + (for [{:keys [key comp label onChange no-simulation-reheat? props class]} controls] + ^{:key key} + [:div {:class class} label + [comp + (merge + props + {:value (key graph-conf) + :color "primary" + :onChange (fn [_ n-val] + (and onChange (onChange n-val)) + (rf/dispatch [:graph/set-conf key n-val]) + (when-not no-simulation-reheat? + (.d3ReheatSimulation graph-ref)))})]]))]]))) (defn graph-controls @@ -203,6 +232,7 @@ (fn [] (let [graph-conf @(subscribe [:graph/conf]) graph-ref (get @graph-ref-map (or local-node-eid :global)) + theme (if @(rf/subscribe [:theme/dark]) styles/THEME-DARK styles/THEME-LIGHT) ;; code theme ;; category -- for eg node-section and section related data @@ -267,11 +297,7 @@ :no-simulation-reheat? true}] local-section {:heading "Local options" :controls local-controls}] - [:> Accordion {:width "14em" - :position "fixed" - :allowMultiple true - :top "4rem" - :right 0} + [:div (use-style (graph-control-style theme)) (doall (for [{:keys [heading] :as section} (remove nil? [(when-not local-node-eid node-section) @@ -299,9 +325,9 @@ graph-conf @(subscribe [:graph/conf]) graph-ref (get @graph-ref-map (or local-node-eid :global))] ;; set canvas dimensions - (swap! dimensions assoc :width (-> dom-node (.. (closest "#app")) + (swap! dimensions assoc :width (-> dom-node (.. (closest ".graph-page")) .-parentNode .-clientWidth)) - (swap! dimensions assoc :height (-> dom-node (.. (closest "#app")) + (swap! dimensions assoc :height (-> dom-node (.. (closest ".graph-page")) .-parentNode .-clientHeight)) ;; set init forces for graph (when graph-ref @@ -388,15 +414,15 @@ (contains? filtered-nodes-set (get link-obj "target")))))) theme (if dark? - THEME-DARK - THEME-LIGHT)] + styles/THEME-DARK + styles/THEME-LIGHT)] [:> ForceGraph2D {:graphData {:nodes nodes :links links} ;; example data #_{:nodes [{"id" "foo", "name" "name1", "val" 1} - {"id" "bar", "name" "name2", "val" 10}] - :links [{"source" "foo", "target" "bar"}]} + {"id" "bar", "name" "name2", "val" 10}] + :links [{"source" "foo", "target" "bar"}]} :width (:width @dimensions) :height (:height @dimensions) :ref #(swap! graph-ref-map assoc (or local-node-eid :global) %) @@ -463,12 +489,8 @@ (let [local-node-eid (when block-uid (->> [:block/uid block-uid] (d/pull @db/dsdb '[:db/id]) :db/id))] - [:> Box {:class "graph-page" - :gridColumn "1 / -1" - :position "fixed" - :top 0 - :left 0 - :width "100vw" - :height "100vh"} - [graph-root local-node-eid] - [graph-controls local-node-eid]]))) + [:div.graph-page + {:style (merge (when local-node-eid {:min-height "500px"}) + {:position "relative"})} + [graph-controls local-node-eid] + [graph-root local-node-eid]]))) diff --git a/src/cljs/athens/views/pages/node_page.cljs b/src/cljs/athens/views/pages/node_page.cljs index 973d6d460b..e2fa358cb1 100644 --- a/src/cljs/athens/views/pages/node_page.cljs +++ b/src/cljs/athens/views/pages/node_page.cljs @@ -1,13 +1,16 @@ (ns athens.views.pages.node-page (:require ["/components/Block/components/Anchor" :refer [Anchor]] - ["/components/Confirmation/Confirmation" :refer [Confirmation]] - ["/components/Page/Page" :refer [PageHeader PageBody PageFooter EditableTitleContainer]] - ["@chakra-ui/react" :refer [Text Box Button Portal IconButton AccordionIcon AccordionItem AccordionPanel MenuDivider MenuButton Menu MenuList MenuItem Accordion AccordionButton Breadcrumb BreadcrumbItem BreadcrumbLink VStack]] + ["/components/Button/Button" :refer [Button]] + ["/components/Dialog/Dialog" :refer [Dialog]] + ["@material-ui/core/Popover" :as Popover] ["@material-ui/icons/Bookmark" :default Bookmark] ["@material-ui/icons/BookmarkBorder" :default BookmarkBorder] ["@material-ui/icons/BubbleChart" :default BubbleChart] + ["@material-ui/icons/ChevronRight" :default ChevronRight] ["@material-ui/icons/Delete" :default Delete] + ["@material-ui/icons/KeyboardArrowDown" :default KeyboardArrowDown] + ["@material-ui/icons/Link" :default Link] ["@material-ui/icons/MoreHoriz" :default MoreHoriz] [athens.common-db :as common-db] [athens.common.sentry :refer-macros [wrap-span-no-new-tx]] @@ -17,21 +20,150 @@ [athens.parse-renderer :as parse-renderer :refer [parse-and-render]] [athens.reactive :as reactive] [athens.router :as router] + [athens.style :refer [color DEPTH-SHADOWS]] [athens.util :refer [escape-str get-caret-position recursively-modify-block-for-embed]] [athens.views.blocks.core :as blocks] [athens.views.blocks.textarea-keydown :as textarea-keydown] - [athens.views.hoc.perf-mon :as perf-mon] - [athens.views.references :refer [reference-group reference-block]] + [athens.views.breadcrumbs :refer [breadcrumbs-list breadcrumb]] + [athens.views.dropdown :refer [menu-style menu-separator-style]] + [athens.views.hoc.perf-mon :as perf-mon] [clojure.string :as str] [datascript.core :as d] + [garden.selectors :as selectors] [komponentit.autosize :as autosize] [re-frame.core :as rf :refer [dispatch subscribe]] - [reagent.core :as r]) + [reagent.core :as r] + [stylefy.core :as stylefy :refer [use-style]]) (:import (goog.events KeyCodes))) +;; ------------------------------------------------------------------- +;; --- material ui --- + + +(def m-popover (r/adapt-react-class (.-default Popover))) + + +;; Styles + + +(def page-style + {:margin "2rem auto" + :padding "1rem 2rem 10rem 2rem" + :flex-basis "100%" + :max-width "55rem"}) + + +(def dropdown-style + {::stylefy/manual [[:.menu {:background (color :background-plus-2) + :color (color :body-text-color) + :border-radius "calc(0.25rem + 0.25rem)" ; Button corner radius + container padding makes "concentric" container radius + :padding "0.25rem" + :display "inline-flex" + :box-shadow [[(:64 DEPTH-SHADOWS) ", 0 0 0 1px rgba(0, 0, 0, 0.05)"]]}]]}) + + +(def page-header-style + {:position "relative"}) + + +(def title-style + {:position "relative" + :overflow "visible" + :flex-grow "1" + :margin "0.10em 0 0.10em 1rem" + :letter-spacing "-0.03em" + :white-space "pre-line" + :word-break "break-word" + :line-height "1.40em" + ::stylefy/manual [[:textarea {:-webkit-appearance "none" + :cursor "text" + :resize "none" + :transform "translate3d(0,0,0)" + :color "inherit" + :font-weight "inherit" + :padding "0" + :letter-spacing "inherit" + :width "100%" + :min-height "100%" + :caret-color (color :link-color) + :background "transparent" + :margin "0" + :font-size "inherit" + :line-height "inherit" + :border-radius "0.25rem" + :transition "opacity 0.15s ease" + :border "0" + :font-family "inherit" + :visibility "hidden" + :position "absolute"}] + [:textarea ["::-webkit-scrollbar" {:display "none"}]] + [:textarea:focus + :.is-editing {:outline "none" + :visibility "visible" + :position "relative"}] + [:abbr {:z-index 4}] + [(selectors/+ :.is-editing :span) {:visibility "hidden" + :position "absolute"}]]}) + + +(def references-style {:margin-top "3em"}) + + +(def references-heading-style + {:font-weight "normal" + :display "flex" + :padding "0 0.5rem 0 0" + :align-items "center" + ::stylefy/manual [[:svg {:margin-right "0.25em" + :font-size "1rem"}]]}) + + +(def references-list-style + {:font-size "14px"}) + + +(def references-group-title-style + {:color (color :link-color) + :margin "0 1.5rem" + :font-weight "500" + ::stylefy/manual [[:a:hover {:cursor "pointer" + :text-decoration "underline"}]]}) + + +(def references-group-style + {:background (color :background-minus-2 :opacity-med) + :padding "1rem 0.5rem" + :border-radius "0.25rem" + :margin "0.5em 0"}) + + +(def reference-breadcrumbs-style + {:font-size "12px" + :padding "0.25rem calc(2rem - 0.5em)"}) + + +(def references-group-block-style + {:border-top [["1px solid " (color :border-color)]] + :width "100%" + :padding-block-start "1em" + :margin-block-start "1em" + ::stylefy/manual [[:&:first-of-type {:border-top "0" + :margin-block-start "0"}]]}) + + +(def page-menu-toggle-style + {:position "absolute" + :left "-1.5rem" + :border-radius "1000px" + :padding "0.375rem 0.5rem" + :color (color :body-text-color :opacity-high) + :transform "translateY(-50%)" + :top "50%"}) + + ;; Helpers @@ -171,17 +303,10 @@ (defn placeholder-block-el [parent-uid] - [:> Box {:class "block-container" - :pl "1em" - :display "flex"} - [:> Anchor] - [:> Button {:variant "link" - :flex "1 1 100%" - :pl 1 - :textAlign "left" - :justifyContent "flex-start" - :onClick #(handle-new-first-child-block-click parent-uid)} - "Click here to add content..."]]) + [:div {:class "block-container"} + [:div {:style {:display "flex"}} + [:> Anchor] + [:span {:on-click #(handle-new-first-child-block-click parent-uid)} "Click here to add content..."]]]) (defn sync-title @@ -209,52 +334,55 @@ (defn menu-dropdown [node daily-note?] - (let [{:block/keys [uid] sidebar - :page/sidebar title - :node/title} node] - [:> Menu - [:> MenuButton {:as IconButton - :gridArea "menu" - :justifySelf "flex-end" - :alignSelf "center" - :bg "transparent" - :height "2.25em" - :width "2.25em" - :mr "0.5em" - :borderRadius "full" - :sx {"span" {:display "contents"} - "button svg:first-of-type" {:marginRight "0.25rem"}}} - [:> MoreHoriz]] - [:> Portal - [:> MenuList {:sx {"button svg:first-of-type" {:marginRight "0.25rem"}}} - [:<> - (if sidebar - [:> MenuItem {:onClick #(dispatch [:left-sidebar/remove-shortcut title])} - [:> BookmarkBorder] - "Remove Shortcut"] - [:> MenuItem {:onClick #(dispatch [:left-sidebar/add-shortcut title])} - [:> Bookmark] - [:span "Add Shortcut"]]) - [:> MenuItem {:onClick #(dispatch [:right-sidebar/open-item uid true])} - [:> BubbleChart] - "Show Local Graph"]] - [:> MenuDivider] - [:> MenuItem {:onClick (fn [] - ;; if page being deleted is in right sidebar, remove from right sidebar - (when (contains? @(subscribe [:right-sidebar/items]) uid) - (dispatch [:right-sidebar/close-item uid])) - ;; if page being deleted is open, navigate to all pages - (when (or (= @(subscribe [:current-route/page-title]) title) - (= @(subscribe [:current-route/uid]) uid)) - (rf/dispatch [:reporting/navigation {:source :page-title-delete - :target :all-pages - :pane :main-pane}]) - (router/navigate :pages)) - ;; if daily note, delete page and remove from daily notes, otherwise just delete page - (if daily-note? - (dispatch [:daily-note/delete uid title]) - (dispatch [:page/delete title])))} - [:> Delete] "Delete Page"]]]])) + (let [{:block/keys [uid] sidebar :page/sidebar title :node/title} node] + (r/with-let [ele (r/atom nil)] + [:<> + [:> Button {:class [(when @ele "is-active")] + :on-click #(reset! ele (.-currentTarget %)) + :style page-menu-toggle-style} + [:> MoreHoriz]] + [m-popover + (merge (use-style dropdown-style) + {:style {:font-size "14px"} + :open (boolean @ele) + :anchorEl @ele + :onClose #(reset! ele nil) + :anchorOrigin #js{:vertical "bottom" + :horizontal "left"} + :marginThreshold 10 + :transformOrigin #js{:vertical "top" + :horizontal "left"} + :classes {:root "backdrop" + :paper "menu"}}) + [:div (use-style menu-style) + [:<> + (if sidebar + [:> Button {:on-click #(dispatch [:left-sidebar/remove-shortcut title])} + [:> BookmarkBorder] + [:span "Remove Shortcut"]] + [:> Button {:on-click #(dispatch [:left-sidebar/add-shortcut title])} + [:> Bookmark] + [:span "Add Shortcut"]]) + [:> Button {:on-click #(dispatch [:right-sidebar/open-item uid true])} + [:> BubbleChart] + [:span "Show Local Graph"]]] + [:hr (use-style menu-separator-style)] + [:> Button {:on-click (fn [] + ;; if page being deleted is in right sidebar, remove from right sidebar + (when (contains? @(subscribe [:right-sidebar/items]) uid) + (dispatch [:right-sidebar/close-item uid])) + ;; if page being deleted is open, navigate to all pages + (when (or (= @(subscribe [:current-route/page-title]) title) + (= @(subscribe [:current-route/uid]) uid)) + (rf/dispatch [:reporting/navigation {:source :page-title-delete + :target :all-pages + :pane :main-pane}]) + (router/navigate :pages)) + ;; if daily note, delete page and remove from daily notes, otherwise just delete page + (if daily-note? + (dispatch [:daily-note/delete uid title]) + (dispatch [:page/delete title])))} + [:> Delete] [:span "Delete Page"]]]]]))) (defn ref-comp @@ -270,16 +398,15 @@ (let [{:keys [block parents embed-id]} @state block (reactive/get-reactive-block-document (:db/id block))] [:<> - [:> Breadcrumb {:fontSize "0.7em" :pl 6 :opacity 0.75} + [breadcrumbs-list {:style reference-breadcrumbs-style} (doall (for [{:keys [node/title block/string block/uid]} parents] - [:> BreadcrumbItem {:key (str "breadcrumb-" uid)} - [:> BreadcrumbLink - {:onClick #(let [new-B (db/get-block [:block/uid uid]) - new-P (drop-last parents)] - (swap! state assoc :block new-B :parents new-P))} - [parse-and-render (or title string) uid]]]))] - [:> Box {:class "block-embed"} + [breadcrumb {:key (str "breadcrumb-" uid) + :on-click #(let [new-B (db/get-block [:block/uid uid]) + new-P (drop-last parents)] + (swap! state assoc :block new-B :parents new-P))} + [parse-and-render (or title string) uid]]))] + [:div.block-embed [blocks/block-el (recursively-modify-block-for-embed block embed-id) linked-ref-data @@ -293,113 +420,114 @@ (reactive/get-reactive-linked-references [:node/title title]))] (when (or (and daily-notes? (not-empty linked-refs)) (not daily-notes?)) - [:> Accordion {:index (if (get @state linked?) 0 nil)} - [:> AccordionItem - [:h2 - [:> AccordionButton {:onClick (fn [] (swap! state update linked? not))} - [:> AccordionIcon] - linked? - [:> Text {:ml "auto" - :fontWeight "medium" - :color "foreground.secondary" - :borderRadius "full"} - (count linked-refs)]]] - [:> AccordionPanel {:p 0} - [:> VStack {:spacing 6 - :pl 9 - :align "stretch"} + [:section (use-style references-style) + [:h4 (use-style references-heading-style) + [:> Button {:on-click (fn [] (swap! state update linked? not))} + (if (get @state linked?) + [:> KeyboardArrowDown] + [:> ChevronRight])] + [(r/adapt-react-class Link)] + [:div {:style {:display "flex" + :flex "1 1 100%" + :justify-content "space-between"}} + [:span linked?]]] + (when (get @state linked?) + [:div (use-style references-list-style) (doall (for [[group-title group] linked-refs] - [reference-group {:key (str "group-" group-title) - :title group-title - :on-click-title (fn [e] - (let [shift? (.-shiftKey e) - parsed-title (parse-renderer/parse-title group-title)] - (rf/dispatch [:reporting/navigation {:source :main-page-linked-refs ; NOTE: this might be also used in right-pane situation - :target :page - :pane (if shift? - :right-pane - :main-pane)}]) - (router/navigate-page parsed-title e)))} + [:div (use-style references-group-style {:key (str "group-" group-title)}) + [:h4 (use-style references-group-title-style) + [:a {:on-click (fn [e] + (let [shift? (.-shiftKey e) + parsed-title (parse-renderer/parse-title group-title)] + (rf/dispatch [:reporting/navigation {:source :main-page-linked-refs ; NOTE: this might be also used in right-pane situation + :target :page + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-page parsed-title e)))} + group-title]] (doall (for [block group] - [reference-block {:key (str "ref-" (:block/uid block))} - [ref-comp block]]))]))]]]]))) + ^{:key (str "ref-" (:block/uid block))} + [:div {:style {:display "flex" + :flex "1 1 100%" + :justify-content "space-between" + :align-items "flex-start"}} + [:div (use-style references-group-block-style) + [ref-comp block]]]))]))])]))) (defn unlinked-ref-el [state daily-notes? unlinked-refs title] (let [unlinked? "Unlinked References"] (when (not daily-notes?) - [:> Accordion {:index (if (get @state unlinked?) 0 nil)} - [:> AccordionItem {:isDisabled (empty @unlinked-refs)} - [:> Box {:as "h2" :position "relative"} - [:> AccordionButton {:onClick (fn [] - (if (get @state unlinked?) - (swap! state assoc unlinked? false) - (let [un-refs (get-unlinked-references (escape-str title))] - (swap! state assoc unlinked? true) - (reset! unlinked-refs un-refs))))} - [:> AccordionIcon] - unlinked? - [:> Text {:ml "auto" - :fontWeight "medium" - :color "foreground.secondary" - :borderRadius "full"} - (count @unlinked-refs)]] - (when (and unlinked? (not-empty @unlinked-refs)) - [:> Button {:position "absolute" - :size "xs" - :top 1 - :right 1 - :onClick (fn [] - (let [unlinked-str-ids (->> @unlinked-refs - (mapcat second) - (map #(select-keys % [:block/string :block/uid])))] ; to remove the unnecessary data before dispatching the event - (dispatch [:unlinked-references/link-all unlinked-str-ids title])) - + [:section (use-style references-style) + [:h4 (use-style references-heading-style) + [:> Button {:on-click (fn [] + (if (get @state unlinked?) (swap! state assoc unlinked? false) - - (reset! unlinked-refs []))} - "Link All"])] - [:> AccordionPanel {:p 0} - [:> VStack {:spacing 6 - :pl 1 - :align "stretch"} + (let [un-refs (get-unlinked-references (escape-str title))] + (swap! state assoc unlinked? true) + (reset! unlinked-refs un-refs))))} + (if (get @state unlinked?) + [:> KeyboardArrowDown] + [:> ChevronRight])] + [(r/adapt-react-class Link)] + [:div {:style {:display "flex" + :justify-content "space-between" + :width "100%"}} + [:span unlinked?] + (when (and unlinked? (not-empty @unlinked-refs)) + [:> Button {:style {:font-size "14px"} + :on-click (fn [] + (let [unlinked-str-ids (->> @unlinked-refs + (mapcat second) + (map #(select-keys % [:block/string :block/uid])))] ; to remove the unnecessary data before dispatching the event + (dispatch [:unlinked-references/link-all unlinked-str-ids title])) + + (swap! state assoc unlinked? false) + + (reset! unlinked-refs []))} + "Link All"])]] + (when (get @state unlinked?) + [:div (use-style references-list-style) (doall (for [[[group-title] group] @unlinked-refs] - [reference-group - {:title group-title - :on-click-title (fn [e] - (let [shift? (.-shiftKey e) - parsed-title (parse-renderer/parse-title group-title)] - (rf/dispatch [:reporting/navigation {:source :main-unlinked-refs ; NOTE: this isn't always `:main-unlinked-refs` it can also be `:right-pane-unlinked-refs` - :target :page - :pane (if shift? - :right-pane - :main-pane)}]) - (router/navigate-page parsed-title e)))} + [:div (use-style references-group-style {:key (str "group-" group-title)}) + [:h4 (use-style references-group-title-style) + [:a {:on-click (fn [e] + (let [shift? (.-shiftKey e) + parsed-title (parse-renderer/parse-title group-title)] + (rf/dispatch [:reporting/navigation {:source :main-unlinked-refs ; NOTE: this isn't always `:main-unlinked-refs` it can also be `:right-pane-unlinked-refs` + :target :page + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-page parsed-title e)))} + group-title]] (doall (for [block group] - [reference-block - {:key (str "ref-" (:block/uid block)) - :actions (when unlinked? - [:> Button {:marginTop "1.5em" - :size "xs" - :flex "0 0" - :float "right" - :variant "link" - :onClick (fn [] - (let [hm (into (hash-map) @unlinked-refs) - new-unlinked-refs (->> (update-in hm [group-title] #(filter (fn [{:keys [block/uid]}] - (= uid (:block/uid block))) - %)) - seq)] - ;; ctrl-z doesn't work though, because Unlinked Refs aren't reactive to datascript. - (reset! unlinked-refs new-unlinked-refs) - (dispatch [:unlinked-references/link block title])))} - "Link"])} - [ref-comp block]]))]))]]]]))) + ^{:key (str "ref-" (:block/uid block))} + [:div {:style {:display "flex" + :justify-content "space-between" + :align-items "flex-start"}} + [:div (merge + (use-style references-group-block-style) + {:style {:max-width "90%"}}) + [ref-comp block]] + (when unlinked? + [:> Button {:style {:margin-top "1.5em"} + :on-click (fn [] + (let [hm (into (hash-map) @unlinked-refs) + new-unlinked-refs (->> (update-in hm [group-title] #(filter (fn [{:keys [block/uid]}] + (= uid (:block/uid block))) + %)) + seq)] + ;; ctrl-z doesn't work though, because Unlinked Refs aren't reactive to datascript. + (reset! unlinked-refs new-unlinked-refs) + (dispatch [:unlinked-references/link block title])))} + "Link"])]))]))])]))) ;; TODO: where to put page-level link filters? @@ -422,26 +550,48 @@ daily-note? (dates/is-daily-note uid) on-daily-notes? (= :home @(subscribe [:current-route/name]))] + (sync-title title state) - [:<> + [:div (use-style page-style {:class ["node-page"] + :data-uid uid}) + + (when alert-show + [:> Dialog {:isOpen true + :title message + :onConfirm confirm-fn + :onDismiss cancel-fn}]) - [:> Confirmation {:isOpen alert-show - :title message - :onConfirm confirm-fn - :onClose cancel-fn}] ;; Header - [:> PageHeader + [:header (use-style page-header-style) ;; Dropdown [menu-dropdown node daily-note?] - [:> EditableTitleContainer - {:isEditing @(subscribe [:editing/is-editing uid])} + [:h1 (use-style title-style + {:data-uid uid + :class "page-header" + :on-click (fn [e] + (let [shift? (.-shiftKey e)] + (.. e preventDefault) + (if (or daily-note? shift?) + (do + (rf/dispatch [:reporting/navigation {:source :page-title ; NOTE: this might be also used in right-pane situation + :target (if title + :page + :block) + :pane (if shift? + :right-pane + :main-pane)}]) + (if title + (router/navigate-page title e) + (router/navigate-uid uid e))) + (dispatch [:editing/uid uid]))))}) ;; Prevent editable textarea if a node/title is a date ;; Don't allow title editing from daily notes, right sidebar, or node-page itself. + (when-not daily-note? [autosize/textarea {:value (:title/local @state) @@ -460,23 +610,20 @@ [perf-mon/hoc-perfmon {:span-name "parse-and-render"} [parse-renderer/parse-and-render (:title/local @state) uid]])]] - [:> PageBody - - ;; Children - (if (empty? children) - [placeholder-block-el uid] - [:div - (for [{:block/keys [uid] :as child} children] - ^{:key uid} - [perf-mon/hoc-perfmon {:span-name "block-el"} - [blocks/block-el child]])])] + ;; Children + (if (empty? children) + [placeholder-block-el uid] + [:div + (for [{:block/keys [uid] :as child} children] + ^{:key uid} + [perf-mon/hoc-perfmon {:span-name "block-el"} + [blocks/block-el child]])]) ;; References - [:> PageFooter - [perf-mon/hoc-perfmon-no-new-tx {:span-name "linked-ref-el"} - [linked-ref-el state on-daily-notes? title]] - [perf-mon/hoc-perfmon-no-new-tx {:span-name "unlinked-ref-el"} - [unlinked-ref-el state on-daily-notes? unlinked-refs title]]]])))) + [perf-mon/hoc-perfmon-no-new-tx {:span-name "linked-ref-el"} + [linked-ref-el state on-daily-notes? title]] + [perf-mon/hoc-perfmon-no-new-tx {:span-name "unlinked-ref-el"} + [unlinked-ref-el state on-daily-notes? unlinked-refs title]]])))) (defn page diff --git a/src/cljs/athens/views/pages/page.cljs b/src/cljs/athens/views/pages/page.cljs index 7146879b95..bcb5061db0 100644 --- a/src/cljs/athens/views/pages/page.cljs +++ b/src/cljs/athens/views/pages/page.cljs @@ -1,6 +1,5 @@ (ns athens.views.pages.page (:require - ["/components/Page/Page" :refer [PageContainer]] [athens.common-db :as common-db] [athens.db :as db] [athens.reactive :as reactive] @@ -14,8 +13,7 @@ (let [title (rf/subscribe [:current-route/page-title]) page-eid (common-db/e-by-av @db/dsdb :node/title @title)] (if (int? page-eid) - [:> PageContainer {:uid page-eid :type "node"} - [node-page/page page-eid]] + [node-page/page page-eid] [:h3 (str "404: Page with title '" @title "' doesn't exist")]))) @@ -24,8 +22,7 @@ [] (let [uid (rf/subscribe [:current-route/uid]) {:keys [node/title block/string db/id]} (reactive/get-reactive-block-or-page-by-uid @uid)] - [:> PageContainer {:uid @uid :type (if title "node" "block")} - (cond - title [node-page/page id] - string [block-page/page id] - :else [:h3 "404: This page doesn't exist"])])) + (cond + title [node-page/page id] + string [block-page/page id] + :else [:h3 "404: This page doesn't exist"]))) diff --git a/src/cljs/athens/views/pages/settings.cljs b/src/cljs/athens/views/pages/settings.cljs index 9d2d454587..a51cf38786 100644 --- a/src/cljs/athens/views/pages/settings.cljs +++ b/src/cljs/athens/views/pages/settings.cljs @@ -1,16 +1,58 @@ (ns athens.views.pages.settings (:require - ["@chakra-ui/react" :refer [Text Heading Box FormControl FormLabel ButtonGroup Grid Input Button Switch Modal ModalOverlay ModalContent ModalHeader ModalBody ModalCloseButton]] + ["/components/Button/Button" :refer [Button]] + ["/components/Toggle/Toggle" :refer [Toggle]] + ["@material-ui/icons/Check" :default Check] + ["@material-ui/icons/NotInterested" :default NotInterested] [athens.db :refer [default-athens-persist]] - [athens.util :refer [toast]] + [athens.views.textinput :as textinput] [cljs-http.client :as http] [cljs.core.async :refer [js {:title "Account connected" - :status "success"}))) + (update-fn @value) ;; Open Collective Lambda doesn't find email (and (:success resp) (false? (:email_exists (:body resp)))) (do (update-fn nil) - - (toast (clj->js {:title "Account not found" - :status "error" - :description "No OpenCollective account was found with this email address."}))) + (js/alert "No OpenCollective account was found with this email address.")) ;; Something else, e.g. networking error :else - (toast (clj->js {:title "Unknown error" - :status "error" - :description resp}))))))) + (js/alert (str "Unexpected error" resp))))))) (defn handle-reset-email @@ -75,49 +110,13 @@ ;; Components -(defn title - [children] - [:> Heading {:size "md"} - children]) - - -(defn header - [children] - [:> Box {:gridArea "header"} children]) - - -(defn glance - [children] - [:> Box children]) - - -(defn form - [children] - [:> Box {:gridArea "form"} children]) - - -(defn help - [children] - [:> Text {:color "foreground.secondary" - :gridArea "help"} children]) - - (defn setting-wrapper ([children] [setting-wrapper {} children]) ([config children] (let [{:keys [disabled] :as _props} config] - [:> Grid {:as "section" - :py 7 - :gap "1rem" - :gridTemplateColumns "12rem 1fr" - :gridTemplateAreas "'header form' - 'header help'" - :_first {:borderTop "none"} - :_notFirst {:borderTop "1px solid" - :borderColor "separator.divider"} - :sx {"*" {:opacity (if disabled 0.5 1)}}} - children]))) + [:div (stylefy/use-style settings-wrap-style + {:class [(when disabled "disabled")]}) children]))) (defn email-comp @@ -128,43 +127,48 @@ (fn [] [setting-wrapper [:<> - [header - [title "OpenCollective Address"] - [glance (if (clojure.string/blank? email) - "Not set" - email)]] - [form - [:<> [:> FormControl - [:> FormLabel "Email address"] - [:> Input {:type " email " - :width "25em" - :placeholder " Open Collective Email " - :onChange #(reset! value (.. % -target -value)) - :value @value}]] - [:> ButtonGroup {:pt 2} - [:> Button {:isDisabled (not (clojure.string/blank? email)) - :onClick #(handle-submit-email value update-fn)} - "Submit"] - [:> Button {:onClick #(handle-reset-email value update-fn)} - "Reset"]]]] - [help - [:p (if (clojure.string/blank? email) - "You are using the free version of Athens. You are hosting your own data. Please be careful!" - "Thank you for supporting Athens! Backups are coming soon.")]]]]))) + [:header + [:h3 "Email"] + [:span.glance (if (clojure.string/blank? email) + "Not set" + email)]] + [:main + [:div + [textinput/textinput {:type " email " + :placeholder " Open Collective Email " + :on-change #(reset! value (.. % -target -value)) + :value @value}] + [:> Button {:is-primary true + :disabled (not (clojure.string/blank? email)) + :on-click #(handle-submit-email value update-fn)} + "Submit"] + [:> Button {:on-click #(handle-reset-email value update-fn)} + "Reset"]] + [:aside + [:p (if (clojure.string/blank? email) + "You are using the free version of Athens. You are hosting your own data. Please be careful!" + "Thank you for supporting Athens! Backups are coming soon.")]]]]]))) (defn monitoring-comp [monitoring update-fn] [setting-wrapper [:<> - [header - [title "Usage and Diagnostics"]] - [form - [:> Switch {:defaultChecked monitoring - :onChange #(handle-monitoring-click monitoring update-fn)} - "Send usage data and diagnostics to Athens"]] - [help - [:<> [:p "Athens has never and will never look at the contents of your database."] + [:header + [:h3 "Usage and Diagnostics"] + [:span.glance (if (true? monitoring) + [:<> + [:> Check] + [:span "Sending usage data"]] + [:<> + [:> NotInterested] + [:span "Not sending usage data"]])]] + [:main + [:> Toggle {:defaultSelected monitoring + :on-change #(handle-monitoring-click monitoring update-fn)} + "Send usage data and diagnostics to Athens"] + [:aside + [:p "Athens has never and will never look at the contents of your database."] [:p "Athens will never ever sell your data."]]]]]) @@ -172,23 +176,21 @@ [backup-time update-fn] [setting-wrapper [:<> - [header - [title "On-disk Backups"]] - [form - [:> FormControl - [:> FormLabel "Idle time before saving new backup"] - [:> Input {:type "number" - :defaultValue backup-time - :width "6em" - :mr "0.5rem" - :min 0 - :step 15 - :max 100 - :onBlur #(update-fn (.. % -target -value))}] - " seconds"]] - [help - [:<> [:> Text "Changes are saved immediately."] - [:> Text (str "Athens will save a new backup " backup-time " seconds after your last edit.")]]]]]) + [:header + [:h3 "Backups"] + [:span.glance (str backup-time " seconds after last edit")]] + [:main + [:label + [textinput/textinput {:type "number" + :defaultValue backup-time + :min 0 + :step 15 + :max 100 + :on-blur #(update-fn (.. % -target -value))}] + " seconds"] + [:aside + [:p "Changes are saved immediately."] + [:p (str "Athens will save a new backup " backup-time " seconds after your last edit.")]]]]]) (defn remote-backups-comp @@ -196,30 +198,35 @@ [setting-wrapper {:disabled true} [:<> - [header - [title "Remote Backups"] - [glance "Coming soon to " + [:header + [:h3 "Remote Backups"] + [:span.glance "Coming soon to " [:a {:href "https://opencollective.com/athens" :target "_blank" :rel "noreferrer"} " paid users and sponsors"]]] - [form - [:> Button {:isDisabled true} "Backup my DB to the cloud"]]]]) + [:main + [:> Button {:disabled true} "Backup my DB to the cloud"]]]]) + + +(defn settings-container + [child] + [:div (stylefy/use-style settings-page-styles) child]) (defn reset-settings-comp [reset-fn] [setting-wrapper [:<> - [header - [title "Reset settings"]] - [form - [:> Button {:onClick reset-fn} - "Reset all settings to defaults"]] - [help - [:<> [:> Text "All settings saved between sessions will be restored to defaults."] - [:> Text "Databases on disk will not be deleted, but you will need to add them to Athens again."] - [:> Text "Athens will restart after reset and open the default database path."]]]]]) + [:header + [:h3 "Reset settings"]] + [:main + [:> Button {:on-click reset-fn} + "Reset all settings to defaults"] + [:aside + [:p "All settings saved between sessions will be restored to defaults."] + [:p "Databases on disk will not be deleted, but you will need to add them to Athens again."] + [:p "Athens will restart after reset and open the default database path."]]]]]) (reg-event-fx @@ -238,24 +245,13 @@ (defn page [] (let [{:keys [email monitoring backup-time]} @(subscribe [:settings])] - [:> Modal {:isOpen true - :scrollBehavior "inside" - :onClose #(.back js/window.history) - :size "xl"} - [:> ModalOverlay] - [:> ModalContent {:maxWidth "calc(100% - 8rem)" - :width "50rem" - :my "4rem"} - [:> ModalHeader - {:borderBottom "1px solid" :borderColor "separator.divider"} - "Settings" - [:> ModalCloseButton]] - [:> ModalBody {:flexDirection "column"} - [:<> - [email-comp email #(dispatch [:settings/update :email %])] - [monitoring-comp monitoring #(dispatch [:settings/update :monitoring %])] - [backup-comp backup-time (fn [x] - (dispatch [:settings/update :backup-time x]) - (dispatch [:fs/update-write-db]))] - [remote-backups-comp] - [reset-settings-comp #(dispatch [:settings/reset])]]]]])) + [settings-container + [:<> + [:h1 "Settings"] + [email-comp email #(dispatch [:settings/update :email %])] + [monitoring-comp monitoring #(dispatch [:settings/update :monitoring %])] + [backup-comp backup-time (fn [x] + (dispatch [:settings/update :backup-time x]) + (dispatch [:fs/update-write-db]))] + [remote-backups-comp] + [reset-settings-comp #(dispatch [:settings/reset])]]])) diff --git a/src/cljs/athens/views/references.cljs b/src/cljs/athens/views/references.cljs deleted file mode 100644 index 8dc8bc756e..0000000000 --- a/src/cljs/athens/views/references.cljs +++ /dev/null @@ -1,44 +0,0 @@ -(ns athens.views.references - (:require - ["@chakra-ui/react" :refer [Box Button Heading VStack]])) - - -(defn reference-header - ([props] - (let [{:keys [on-click title]} props] - [:> Heading {:as "h4" - :color "foreground.secondary" - :textTransform "uppercase" - :pt 4 - :borderTop "1px solid" - :borderTopColor "separator.divider" - :fontWeight "bold" - :fontSize "0.75rem" - :size "md"} - [:> Button {:onClick on-click - :color "inherit" - :textTransform "inherit" - :_hover {:textDecoration "none" - :opacity 0.5} - :fontWeight "inherit" - :fontSize "inherit" - :variant "link"} - title]]))) - - -(defn reference-group - ([props children] - (let [{:keys [on-click-title title]} props] - [:> VStack {:spacing 2 :align "stretch"} - [reference-header {:on-click on-click-title - :title title}] - children]))) - - -(defn reference-block - ([props children] - (let [{:keys [actions]} props] - [:> Box - children - actions]))) - diff --git a/src/cljs/athens/views/right_sidebar.cljs b/src/cljs/athens/views/right_sidebar.cljs index 9a73961d12..5cbea23ae0 100644 --- a/src/cljs/athens/views/right_sidebar.cljs +++ b/src/cljs/athens/views/right_sidebar.cljs @@ -1,14 +1,191 @@ (ns athens.views.right-sidebar (:require - ["/components/Icons/Icons" :refer [RightSidebarAddIcon]] - ["/components/Layout/Layout" :refer [RightSidebarContainer SidebarItem]] - ["@chakra-ui/react" :refer [Flex Text Box]] + ["/components/Button/Button" :refer [Button]] + ["@material-ui/icons/BubbleChart" :default BubbleChart] + ["@material-ui/icons/ChevronRight" :default ChevronRight] + ["@material-ui/icons/Close" :default Close] + ["@material-ui/icons/Description" :default Description] + ["@material-ui/icons/FiberManualRecord" :default FiberManualRecord] + ["@material-ui/icons/VerticalSplit" :default VerticalSplit] [athens.parse-renderer :as parse-renderer] + [athens.style :refer [color OPACITIES ZINDICES]] [athens.views.pages.block-page :as block-page] [athens.views.pages.graph :as graph] [athens.views.pages.node-page :as node-page] [re-frame.core :refer [dispatch subscribe]] - [reagent.core :as r])) + [reagent.core :as r] + [stylefy.core :as stylefy :refer [use-style]])) + + +;; Styles + + +(def sidebar-style + {:justify-self "stretch" + :overflow "hidden" + :width "0" + :grid-area "secondary-content" + :display "flex" + :justify-content "space-between" + :padding-top "2.75rem" + :transition-property "width, border, background" + :transition-duration "0.35s" + :transition-timing-function "ease-out" + :box-shadow [["0 -100px 0 " (color :background-minus-1) ", inset 1px 0 " (color :background-minus-1)]] + ::stylefy/manual [[:svg {:color (color :body-text-color :opacity-high)}] + [:&.is-closed {:width "0"}] + [:&.is-open {:width "32vw"}] + ["::-webkit-scrollbar" {:background (color :background-minus-1) + :width "0.5rem" + :height "0.5rem"}] + ["::-webkit-scrollbar-corner" {:background (color :background-minus-1)}] + ["::-webkit-scrollbar-thumb" {:background (color :background-plus-1) + :border-radius "0.5rem"}]]}) + + +(def sidebar-content-style + {:display "flex" + :flex "1 1 32vw" + :flex-direction "column" + :margin-left "0" + :overflow-y "auto" + ::stylefy/supports {"overflow-y: overlay" + {:overflow-y "overlay"}} + ::stylefy/manual [[:&.is-closed {:margin-left "-32vw" + :opacity 0}] + [:&.is-open {:opacity 1}]]}) + + +(def sidebar-section-heading-style + {:font-size "14px" + :display "flex" + :flex-direction "row" + :align-items "center" + :min-height "2.75rem" + :padding "0.5rem 1rem 0.25rem 1.5rem" + ::stylefy/manual [[:h1 {:font-size "inherit" + :margin "0 auto 0 0" + :line-height "1" + :color (color :body-text-color :opacity-med)}]]}) + + +(def sidebar-item-style + {:display "flex" + :flex "0 0 auto" + :flex-direction "column"}) + + +(def sidebar-item-toggle-style + {:margin "auto 0.5rem auto 0" + :flex "0 0 auto" + :width "1.75rem" + :height "1.75rem" + :padding "0" + :border-radius "1000px" + :cursor "pointer" + :place-content "center" + ::stylefy/manual [[:svg {:transition "transform 0.1s ease-out" + :margin "0"}] + [:&.is-open [:svg {:transform "rotate(90deg)"}]]]}) + + +(def sidebar-item-container-style + {:padding "0 0 1.25rem" + :line-height "1.5rem" + :font-size "95%" + :position "relative" + :background "inherit" + :z-index 1 + ::stylefy/manual [[:h1 {:font-size "1.5em" + :display "-webkit-box" + :-webkit-box-orient "vertical" + :-webkit-line-clamp 1 + :line-clamp 1 + :overflow "hidden" + :text-overflow "ellipsis"}] + [:.node-page :.block-page {:margin-top 0}]]}) + + +(def sidebar-item-heading-style + {:font-size "100%" + :display "flex" + :flex "0 0 auto" + :align-items "center" + :padding "0.25rem 1rem" + :position "sticky" + :z-index 2 + :background (color :background-color) + :box-shadow [["0 -1px 0 0" (color :border-color)]] + :top "0" + :bottom "0" + ::stylefy/manual [[:h2 {:font-size "inherit" + :flex "1 1 100%" + :line-height "1" + :margin "0" + :white-space "nowrap" + :text-overflow "ellipsis" + :font-weight "normal" + :max-width "100%" + :overflow "hidden" + :align-items "center" + :color (color :body-text-color)} + [:svg {:opacity (:opacity-med OPACITIES) + :display "inline" + :vertical-align "-4px" + :margin-right "0.2em"}]] + [:.controls {:display "flex" + :flex "0 0 auto" + :align-items "stretch" + :flex-direction "row" + :transition "opacity 0.3s ease-out" + :opacity "0.5"}] + [:&:hover [:.controls {:opacity "1"}]] + [:svg {:font-size "18px"}] + [:hr {:width "1px" + :background (color :background-minus-1) + :border "0" + :margin "0.25rem" + :flex "0 0 1px" + :height "1em" + :justify-self "stretch"}] + [:&.is-open [:h2 {:font-weight "500"}]]]}) + + +(def panel-drag-handle-style + {:cursor "col-resize" + :height "100%" + :position "absolute" + :top 0 + :width "1px" + :z-index (:zindex-fixed ZINDICES) + :background-color (color :border-color) + ::stylefy/manual [[:&:after {:content "''" + :position "absolute" + :background (color :link-color) + :transition "opacity 0.2s ease" + :top 0 + :bottom 0 + :left 0 + :right "-4px" + :opacity 0}] + [:&:hover:after {:opacity 0.5}] + [:&.is-dragging:after {:opacity 1}]]}) + + +(def empty-message-style + {:align-self "center" + :display "flex" + :flex-direction "column" + :margin "auto auto" + :align-items "center" + :text-align "center" + :color (color :body-text-color :opacity-med) + :font-size "80%" + :border-radius "0.5rem" + :line-height 1.3 + ::stylefy/manual [[:svg {:opacity (:opacity-low OPACITIES) + :font-size "1000%"}] + [:p {:max-width "13em"}]]}) ;; Components @@ -16,20 +193,9 @@ (defn empty-message [] - [:> Box {:alignSelf "center" - :display "flex" - :flexDirection "column" - :margin "auto" - :padding 5 - :gap "1rem" - :alignItems "center" - :textAlign "center" - :color "foreground.secondary" - :fontSize "80%" - :borderRadius "0.5rem" - :lineHeight 1.3} - [:> RightSidebarAddIcon {:boxSize "4rem"}] - [:> Text {:maxWidth "15em"} + [:div (use-style empty-message-style) + [:> VerticalSplit] + [:p "Hold " [:kbd "shift"] " when clicking a page link to view the page in the sidebar."]]) @@ -60,51 +226,45 @@ (js/document.removeEventListener "mousemove" move-handler) (js/document.removeEventListener "mouseup" mouse-up-handler)) :reagent-render (fn [open? items _] - [:> RightSidebarContainer - {:isOpen open? - :isDragging (:dragging @state) - :width (:width @state)} - [:> Box - {:role "separator" - :aria-orientation "vertical" - :cursor "col-resize" - :position "absolute" - :top 0 - :bottom 0 - :width "1px" - :zIndex 1 - :transitionDuration "0.2s" - :transitionTimingFunction "ease-in-out" - :transitionProperty "common" - :bg "separator.divider" - :sx {:WebkitAppRegion "no-drag"} - :_hover {:bg "link"} - :_active {:bg "link"} - :_after {:content "''" - :position "absolute" - :sx {:WebkitAppRegion "no-drag"} - :inset "-4px"} - :on-mouse-down #(swap! state assoc :dragging true) - :class (when (:dragging @state) "is-dragging")}] - [:> Flex - {:flexDirection "column" - :flex 1; - :maxHeight "calc(100vh - 3.25rem - 1px)" - :width (str (:width @state) "vw") - :overflowY "overlay"} + [:div (merge (use-style sidebar-style + {:class ["right-sidebar" (if open? "is-open" "is-closed")]}) + {:style (cond-> {} + (:dragging @state) (assoc :transition-duration "0s") + open? (assoc :width (str (:width @state) "vw")))}) + [:div (use-style panel-drag-handle-style + {:on-mouse-down #(swap! state assoc :dragging true) + :class (when (:dragging @state) "is-dragging")})] + [:div (use-style sidebar-content-style {:class [(if open? "is-open" "is-closed") "right-sidebar-content"]}) + ;; [:header (use-style sidebar-section-heading-style)] ;; Waiting on additional sidebar contents + ;; [:h1 "Pages and Blocks"]] + ;; [:> Button [:> FilterList]] (if (empty? items) [empty-message] (doall - (for [[uid {:keys [node/title block/string is-graph?]}] items] + (for [[uid {:keys [open node/title block/string is-graph?]}] items] ^{:key uid} - [:> SidebarItem {:defaultIsOpen true - :onRemove #(dispatch [:right-sidebar/close-item uid]) - ;; nth 1 to get just the title - :title (nth [parse-renderer/parse-and-render (or title string) uid] 1)} - (cond - is-graph? [graph/page uid] - title [node-page/page [:block/uid uid]] - :else [block-page/page [:block/uid uid]])])))]])}))) + [:article (use-style sidebar-item-style) + [:header (use-style sidebar-item-heading-style {:class (when open "is-open")}) + [:> Button (use-style sidebar-item-toggle-style + {:on-click #(dispatch [:right-sidebar/toggle-item uid]) + :class (when open "is-open")}) + [:> ChevronRight]] + [:h2 + (cond + is-graph? [:<> [:> BubbleChart] [parse-renderer/parse-and-render title uid]] + title [:<> [:> Description] [parse-renderer/parse-and-render title uid]] + :else [:<> [:> FiberManualRecord] [parse-renderer/parse-and-render string uid]])] + [:div {:class "controls"} + ;; [:> Button [:> DragIndicator]] + ;; [:hr] + [:> Button {:on-click #(dispatch [:right-sidebar/close-item uid])} + [:> Close]]]] + (when open + [:div (use-style sidebar-item-container-style) + (cond + is-graph? [graph/page uid] + title [node-page/page [:block/uid uid]] + :else [block-page/page [:block/uid uid]])])])))]])}))) (defn right-sidebar diff --git a/src/cljs/athens/views/textinput.cljs b/src/cljs/athens/views/textinput.cljs new file mode 100644 index 0000000000..1d0db4722e --- /dev/null +++ b/src/cljs/athens/views/textinput.cljs @@ -0,0 +1,61 @@ +(ns athens.views.textinput + (:require + [athens.db] + [athens.style :refer [color OPACITIES DEPTH-SHADOWS]] + [stylefy.core :as stylefy :refer [use-style]])) + + +;; Styles + + +(def textinput-style + {:min-height "2rem" + :color (color :body-text-color) + :caret-color (color :link-color) + :border-radius "0.25rem" + :background (color :background-minus-1) + :padding "0.125rem 0.5rem" + :border [["1px solid " (color :border-color)]] + :transition-property "box-shadow, border, background" + :transition-duration "0.1s" + :transition-timing-function "ease" + ::stylefy/manual [[:placeholder {:opacity (:opacity-med OPACITIES)}] + [:&:hover {:box-shadow (:4 DEPTH-SHADOWS)}] + [:&:focus :&:focus:hover {:outline "none" + :border "1px solid" + :box-shadow (:8 DEPTH-SHADOWS)}]]}) + + +(def input-wrap + {:position "relative" + :display "inline-flex" + :align-items "stretch" + :justify-content "stretch" + ::stylefy/manual [[:input {:padding-left "1.75rem"}]]}) + + +(def input-icon + {:position "absolute" + :top "50%" + :display "flex" + :pointer-events "none" + :transform "translateY(-50%)" + :left "0.375rem" + :color (color :body-text-color) + :opacity (:opacity-med OPACITIES) + ::stylefy/manual [[:svg {:font-size "20px"}]]}) + + +;; Components + + +(defn textinput + [{:keys [style icon class] :as props}] + (let [props- (dissoc props :style :icon :class)] + (if icon + [:div (use-style input-wrap) + [:input (use-style (merge textinput-style style) + (merge props- {:class (vec (flatten class))}))] + [:span (use-style input-icon) icon]] + [:input (use-style (merge textinput-style style) + (merge props- {:class (vec (flatten class))}))]))) diff --git a/src/js/components/AppToolbar/AppToolbar.stories.tsx b/src/js/components/AppToolbar/AppToolbar.stories.tsx index 540135621e..856725c6c8 100644 --- a/src/js/components/AppToolbar/AppToolbar.stories.tsx +++ b/src/js/components/AppToolbar/AppToolbar.stories.tsx @@ -1,20 +1,32 @@ import React from 'react'; +import styled from 'styled-components'; import { BADGE, Storybook } from '@/utils/storybook'; +import { mockDatabases } from '@/concept/DatabaseMenu/mockData'; import * as mockPresence from '@/PresenceDetails/mockData'; import { useAppState } from '@/utils/useAppState'; import { AppToolbar, AppToolbarProps } from './AppToolbar'; +import { DatabaseMenu } from '@/concept/DatabaseMenu'; import { PresenceDetails } from '@/PresenceDetails'; +const ToolbarStoryWrapper = styled(Storybook.Desktop)` + > * { + /* Make the macOS toolbar behave inside the story */ + position: static !important; + width: 100%; + } +`; + export default { title: 'Sections/AppToolbar', component: AppToolbar, - subcomponents: { PresenceDetails }, + subcomponents: { DatabaseMenu, PresenceDetails }, argTypes: {}, parameters: { badges: [BADGE.DEV] }, + decorators: [(Story) => {Story()}] }; const Template = (args: AppToolbarProps) => { @@ -41,6 +53,7 @@ const Template = (args: AppToolbarProps) => { } = useAppState(); return } route={route} isWinFullscreen={isWinFullscreen} isWinFocused={isWinFocused} diff --git a/src/js/components/AppToolbar/AppToolbar.tsx b/src/js/components/AppToolbar/AppToolbar.tsx index 5fb920ca0f..f708956718 100644 --- a/src/js/components/AppToolbar/AppToolbar.tsx +++ b/src/js/components/AppToolbar/AppToolbar.tsx @@ -1,150 +1,82 @@ import React from 'react'; +import styled from 'styled-components'; +import { BubbleChart, ChevronLeft, ChevronRight, FileCopy, Help, Menu as MenuIcon, MergeType, Search, Settings, Storage, Today, ToggleOff, ToggleOn, VerticalSplit } from '@material-ui/icons'; -import { - RightSidebarIcon, - SearchIcon, - MenuIcon, - HelpIcon, - ChevronLeftIcon, - ChevronRightIcon, - AllPagesIcon, - SettingsIcon, - ContrastIcon, - DailyNotesIcon -} from '@/Icons/Icons'; - -import { - BubbleChart, - MoreHoriz, -} from '@material-ui/icons'; - -import { - HTMLChakraProps, - Portal, - ThemingProps, - Menu, - MenuButton, - MenuItem, - MenuList, - Tooltip, - Flex, - Button, - ButtonOptions, - HStack, - IconButton, - ButtonGroup, - useColorMode, - useMediaQuery -} from '@chakra-ui/react'; - +import { Button } from '@/Button'; import { WindowButtons } from './components/WindowButtons'; -interface ToolbarButtonProps extends ButtonOptions, HTMLChakraProps<'button'>, ThemingProps<"Button"> { - children: React.ReactChild; -}; -interface ToolbarIconButtonProps extends ButtonOptions, HTMLChakraProps<'button'>, ThemingProps<"Button"> { - children: React.ReactChild; -} - -const toolbarButtonStyle = { - background: 'background.floor', - color: "foreground.secondary", - sx: { WebkitAppRegion: "no-drag" } -} +const AppToolbarWrapper = styled.header` + background: var(--color-background); + grid-area: app-header; + justify-content: flex-start; + background-clip: padding-box; + background: var(--background-plus-1); + color: var(--body-text-color---opacity-high); + border-bottom: 1px solid transparent; + align-items: center; + display: grid; + height: 48px; + padding-left: 10px; + grid-template-columns: auto 1fr auto; + transition: border-color 1s ease; + z-index: var(--zindex-sticky); + grid-auto-flow: column; + -webkit-app-region: drag; -const toolbarIconButtonStyle = { - background: 'background.floor', - color: "foreground.secondary", - sx: { - WebkitAppRegion: "no-drag", - "svg": { - fontSize: "1.5em" - } + .is-fullscreen & { + height: 44px; } -} -const ToolbarButton = React.forwardRef((props: ToolbarButtonProps, ref) => { - const { children } = props; - return + {isElectron && ( + <> + + + + ) + } + + + + Find or create a page + + + {presenceDetails} + + + + + + + + {isElectron && (os === 'windows' || os === 'linux') && ( + )} + ); +}; - return ( - - - - {databaseMenu} - - - - - - {isElectron && ( - - - - - - - - - - - - ) - } - - - - - - - - - - - - - - - - } - isActive={isCommandBarOpen} - onClick={handlePressCommandBar} - pl="0.5rem" - > - Find or create a page - - +AppToolbar.Separator = styled.hr` + border: 0; + margin-inline: 0.125rem; + margin-block: 0; + block-size: auto; +`; - {presenceDetails} +AppToolbar.MainControls = styled.div` + display: grid; + grid-auto-flow: column; + grid-gap: 0.25rem; + align-items: center; +`; - {canShowFullSecondaryMenu - ? SecondaryToolbarItems(secondaryTools) - : SecondaryToolbarOverflowMenu(secondaryTools)}0 +AppToolbar.SecondaryControls = styled(AppToolbar.MainControls)` + justify-self: flex-end; + margin-left: auto; - - {isElectron && (os === 'windows' || os === 'linux') && ( - )} - ); -}; + button { + color: inherit; + background: inherit; + } +`; diff --git a/src/js/components/AppToolbar/components/WindowButtons.tsx b/src/js/components/AppToolbar/components/WindowButtons.tsx index 5ea5b09528..e06be83c78 100644 --- a/src/js/components/AppToolbar/components/WindowButtons.tsx +++ b/src/js/components/AppToolbar/components/WindowButtons.tsx @@ -1,134 +1,142 @@ -import { Box } from '@chakra-ui/react'; +import styled from 'styled-components'; +import { classnames } from '@/utils/classnames'; import { SvgIcon } from '@material-ui/core'; -const Wrapper = ({ children }) => {children}; + } + +`; export interface WindowButtonsProps { - handlePressMinimize(): void, + + handlePressMinimize(): void; } export const WindowButtons = ({ + os, isWinFocused, isWinFullscreen, isWinMaximized, @@ -136,7 +144,14 @@ export const WindowButtons = ({ handlePressMaximizeRestore, handlePressClose }) => { - return ( + return ( {/* Minimize button */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ; + + +export const Icon = Template.bind({}); +Icon.args = { + children: + + + + , +}; diff --git a/src/js/components/Button/Button.tsx b/src/js/components/Button/Button.tsx new file mode 100644 index 0000000000..8fb7d13256 --- /dev/null +++ b/src/js/components/Button/Button.tsx @@ -0,0 +1,196 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import styled from 'styled-components'; +import { classnames } from '@/utils/classnames'; +import { useFocusRing } from '@react-aria/focus' +import { useFocusRingEl } from '@/utils/useFocusRingEl'; +import { mergeProps } from '@react-aria/utils'; +import { DOMRoot } from '@/utils/config'; + +export interface ButtonProps extends React.ButtonHTMLAttributes { + /** + * Whether this button should have a stronger style + */ + isPrimary?: boolean; + /** + * Whether the button should appear pressed + */ + isPressed?: boolean; + /** + * Button shape. Set to 'unset' to manually style padding and radius. + */ + shape?: 'rect' | 'round' | 'unset'; + /** + * Button shape style. Set to 'unset' to manually style color and interaction styles. + */ + variant?: 'plain' | 'gray' | 'tinted' | 'filled' | 'unset'; + /** + * Styles provided to the button's focus ring. + */ + focusRingStyle?: React.CSSProperties; +} + + +/** + * Primary UI component for user interaction + */ +export const ButtonWrap = styled.button.attrs(props => { + if (props.isPrimary) props.variant = 'tinted'; + return ({ + "aria-pressed": props.isPressed ? 'true' : undefined, + className: classnames( + 'button', + props.className, + 'shape-' + props.shape, + 'variant-' + props.variant) + }) +}) ` + margin: 0; + font-family: inherit; + font-size: inherit; + font-weight: 500; + border: none; + display: inline-flex; + place-items: center; + place-content: center; + color: var(--body-text-color); + background-color: transparent; + transition-property: background, color; + transition-duration: 0.075s; + transition-timing-function: ease; + gap: 1ch; + text-align: left; + + &:focus { + outline: none; + } + + &:enabled { + cursor: pointer; + } + + span { + flex: 1 0 auto; + } + + /* Shapes */ + &.shape-rect { + --padding-v: 0.375rem; + --padding-h: 0.625rem; + border-radius: 0.25rem; + padding: var(--padding-v) var(--padding-h); + } + + &.shape-round { + --padding-v: 0.375rem; + --padding-h: 0.625rem; + border-radius: 2rem; + padding: var(--padding-v) var(--padding-h); + } + + :where(:not(& * svg), :not(.unset-margin)) svg { + --icon-padding: 0.25rem; + margin: calc((var(--padding-v) * -1) + var(--icon-padding)) calc((var(--padding-h) * -1) + var(--icon-padding)); + + &:not(:first-child) { + margin-left: 0.251em; + } + &:not(:last-child) { + margin-right: 0.251em; + } + } + + /* Variants */ + &.variant-plain { + background: transparent; + + &:hover { + background: var(--body-text-color---opacity-05); + } + + &[aria-pressed="true"], + &:active { + background: var(--body-text-color---opacity-10); + } + } + + &.variant-gray { + color: var(--link-color); + background: var(--body-text-color---opacity-10); + + &:hover { + background: var(--body-text-color---opacity-15); + } + + &[aria-pressed="true"], + &:active { + background: var(--body-text-color---opacity-20); + } + } + + &.variant-tinted { + color: var(--link-color); + background: var(--link-color---opacity-15); + + &:hover { + background: var(--link-color---opacity-20); + } + + &[aria-pressed="true"], + &:active { + background: var(--link-color---opacity-25); + } + } + + &.variant-filled { + color: var(--link-color---contrast); + background: var(--link-color); + + &:hover { + background: var(--link-color---opacity-90); + } + + &[aria-pressed="true"], + &:active { + background: var(--link-color---opacity-80); + } + } + + &:disabled { + &, + &:hover, + &:active { + color: var(--body-text-color---opacity-med); + background: var(---body-text-color---opacity-10); + cursor: not-allowed; + } + } +`; + +interface Result extends React.ForwardRefExoticComponent { + Wrap?: typeof ButtonWrap; +} + +const _Button: Result = React.forwardRef((props: ButtonProps, ref): any => { + ref = ref || React.useRef(); + let { FocusRing, focusProps } = useFocusRingEl(ref); + + return <> + + {props.children} + + {FocusRing} + ; +}); + +_Button.defaultProps = { + shape: 'rect', + variant: 'plain', +} + +export { _Button as Button }; diff --git a/src/js/components/Button/index.ts b/src/js/components/Button/index.ts new file mode 100644 index 0000000000..3b186e1359 --- /dev/null +++ b/src/js/components/Button/index.ts @@ -0,0 +1,2 @@ +import { Button, ButtonWrap } from './Button'; +export { Button, ButtonWrap }; \ No newline at end of file diff --git a/src/js/components/Confirmation/Confirmation.tsx b/src/js/components/Confirmation/Confirmation.tsx deleted file mode 100644 index 8b67b7e64d..0000000000 --- a/src/js/components/Confirmation/Confirmation.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Modal, ModalOverlay, ModalContent, ModalBody, ModalHeader, ModalFooter, Button, ButtonGroup } from '@chakra-ui/react'; - -export const Confirmation = ({ - isOpen, - onClose, - onConfirm, - title, - description -}) => { - return ( - - - - {title} - {description && {description}} - - - - - - - - - ); -}; \ No newline at end of file diff --git a/src/js/components/Design.stories.tsx b/src/js/components/Design.stories.tsx new file mode 100644 index 0000000000..be3da01dab --- /dev/null +++ b/src/js/components/Design.stories.tsx @@ -0,0 +1,160 @@ +import styled, { css } from 'styled-components'; +import { readableColor } from 'polished'; +import { permuteColorOpacities, themeLight, themeDark } from '@/utils/style/style' + +export default { + title: 'Design', + argTypes: {}, + parameters: { + layout: 'fullscreen' + } +}; + +const Stack = styled.div` + width: 40em; + margin: 4em auto; + + h2 { + margin: 0; + text-align: center; + } +`; + +const Title = styled.div` + font-weight: bold; + font-size: var(--font-size--text-xl); +`; + +const Description = styled.div`` + +const Wrapper = styled.div` + padding: 2rem; + display: flex; + gap: 1rem; + flex-direction: column; + + p { + margin: 0; + } + + code { + color: var(--link-color); + padding: 0.25rem 0.5rem; + background: var(--background-minus-2); + border-radius: 0.25rem; + font-size: 0.75rem; + font-family: var(--font-family-code); + user-select: all; + } +`; + +const ColorInstance = styled.div` + width: 3rem; + height: 3rem; + flex: 0 0 3rem; + color: var(--background); + position: relative; + border-radius: 100em; + transition: all 0.12s ease-in-out; + + &:after, + &:before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: var(--background); + opacity: var(--opacity); + transition: all 0.12s ease-in-out; + } + + &:before { + opacity: 0; + background: var(--contrast-background, var(--background-color)); + inset: 0.25rem; + filter: blur(0.25rem); + transition: all 0.12s ease-in-out; + } + + &:hover { + transform: scale(1.1); + z-index: 10; + box-shadow: var(--depth-shadow-8); + + &:before { + opacity: 0.7; + } + } +`; + +const ColorStack = styled.div` + display: flex; + + ${props => props.hasContrast && css` + padding: 0.75rem 1.5rem 0.75rem 1rem; + border-radius: 100em; + background: var(--body-text-color); + --contrast-background: var(--body-text-color); + width: max-content; + `} + + > * { + margin-inline-end: -0.5rem; + } +`; + +const DepthStack = styled.div` + display: flex; + gap: 1rem; +`; + +const ColorDemo = ({ name, color, description, hasContrast = false }) => +
+ {name} + {description} + {color} +
+ + + + + + + + +
+ + +export const Design = () => <> + +

Intent colors

+ + + + +

Interface colors

+ + + + + + + + + +

Depth

+ +
+

Depth Shadows

+ Use shadows sparingly. Shadows may also be paired with a 1px shadow on the same element to better cut it out of its context. +
+ +
+
+
+
+ + +
+ + \ No newline at end of file diff --git a/src/js/components/Dialog/Dialog.stories.tsx b/src/js/components/Dialog/Dialog.stories.tsx new file mode 100644 index 0000000000..ea2dd0d12d --- /dev/null +++ b/src/js/components/Dialog/Dialog.stories.tsx @@ -0,0 +1,36 @@ +import { Dialog } from './Dialog'; +import { BADGE, Storybook } from '@/utils/storybook'; + +export default { + title: 'Components/Dialog', + component: Dialog, + argTypes: {}, + parameters: { + layout: 'centered', + badges: [BADGE.DEV, BADGE.IN_USE] + }, + decorators: [(Story) => ] +}; + +const Template = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + title: 'Lorem ipsum dolor sit amet.', + children: 'Lorem ipsum dolor sit amet', + isOpen: true, +}; + +export const Image = Template.bind({}); +Image.args = { + image: , + title: 'Lorem ipsum dolor sit amet.', + children: 'Lorem ipsum dolor sit amet', + isOpen: true, +}; + +export const Minimal = Template.bind({}); +Minimal.args = { + title: 'Lorem ipsum dolor sit amet.', + isOpen: true, +}; diff --git a/src/js/components/Dialog/Dialog.tsx b/src/js/components/Dialog/Dialog.tsx new file mode 100644 index 0000000000..f6b3f9ae53 --- /dev/null +++ b/src/js/components/Dialog/Dialog.tsx @@ -0,0 +1,189 @@ +import React from 'react'; +import styled from 'styled-components'; + +import { + useOverlay, + usePreventScroll, + useModal, + OverlayProps, + OverlayContainer +} from '@react-aria/overlays'; +import { useDialog } from '@react-aria/dialog'; +import { AriaDialogProps } from '@react-types/dialog'; +import { FocusScope } from '@react-aria/focus'; +import { mergeProps } from '@react-aria/utils'; + +import { Button } from '@/Button'; +import { Overlay } from '@/Overlay'; + +const Container = styled(Overlay)` + display: flex; + flex-direction: row; + gap: 1rem; + width: max-content; + padding: 1rem; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + min-width: 20rem; +`; + +const Image = styled.div``; + +const Title = styled.h1` + font-size: 1em; + margin: 0; +`; + +const Message = styled.p` + margin: 0; +`; + +const Body = styled.div` + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 15rem; + flex: 1 1 100%; +`; + +const Actions = styled.div` + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + gap: 0.25rem; + width: max-content; + margin-top: auto; + margin-left: auto; + padding-top: 1rem; + align-self: flex-end; +`; + +const Backdrop = styled.div` + position: fixed; + z-index: var(--zindex-modal); + top: 0; + left: 0; + bottom: 0; + right: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; +`; + +const DismissButton = styled(Button)` + font-weight: normal; +`; +const ConfirmButton = styled(Button)` + font-weight: normal; +`; + +interface DialogProps extends OverlayProps, AriaDialogProps { + isOpen: boolean, + title: string, + children?: React.ReactNode, + image?: JSX.Element; + defaultAction?: 'confirm' | 'dismiss'; + dismiss?: { + label?: string; + variant?: 'filled' | 'tinted' | 'gray' | 'plain'; + }, + confirm?: { + label?: string; + variant?: 'filled' | 'tinted' | 'gray' | 'plain'; + } + // onClose?: () => void; + onConfirm?: () => void; +} + +export const Dialog = (props: DialogProps): JSX.Element | null => { + const { + isOpen, + title, + children, + image, + onConfirm: handleConfirm, + onClose: handleClose, + defaultAction, + dismiss, + confirm + } = props; + + let ref = React.useRef(); + let { overlayProps, underlayProps } = useOverlay(props, ref); + let { modalProps } = useModal(); + let { dialogProps, titleProps } = useDialog(props, ref); + usePreventScroll(); + + const dismissProps = { + ...mergeProps({ + onClick: handleClose, + label: 'Cancel', + variant: 'plain', + autoFocus: defaultAction === 'dismiss', + ...dismiss, + }) + } + + const confirmProps = { + ...mergeProps({ + onClick: handleConfirm, + label: 'Cancel', + variant: 'filled', + autoFocus: defaultAction === 'confirm', + ...confirm, + }) + } + + return ( + isOpen ? + + + + + {children + ? children + : ( + <> + {image && {image}} + ( + {title} + {children} + + Cancel + Confirm + + ) + + )} + + + + + : null + ); +}; + +Dialog.defaultProps = { + defaultAction: 'confirm', +} + +Dialog.Container = Container; +Dialog.Image = Image; +Dialog.Title = Title; +Dialog.Message = Message; +Dialog.Body = Body; +Dialog.Actions = Actions; +Dialog.DismissButton = DismissButton; +Dialog.ConfirmButton = ConfirmButton; diff --git a/src/js/components/Dialog/index.ts b/src/js/components/Dialog/index.ts new file mode 100644 index 0000000000..96d19d0055 --- /dev/null +++ b/src/js/components/Dialog/index.ts @@ -0,0 +1,2 @@ +import { Dialog } from './Dialog'; +export { Dialog }; \ No newline at end of file diff --git a/src/js/components/Icons/ConnectedGraphConnection.tsx b/src/js/components/Icons/ConnectedGraphConnection.tsx new file mode 100644 index 0000000000..7fc4f5899b --- /dev/null +++ b/src/js/components/Icons/ConnectedGraphConnection.tsx @@ -0,0 +1,7 @@ +export const ConnectedGraphConnection = () => { + return ( + + + + ) +} \ No newline at end of file diff --git a/src/js/components/Icons/ConnectedGraphHost.tsx b/src/js/components/Icons/ConnectedGraphHost.tsx new file mode 100644 index 0000000000..995738cc0e --- /dev/null +++ b/src/js/components/Icons/ConnectedGraphHost.tsx @@ -0,0 +1,10 @@ +export const ConnectedGraphHost = () => { + return ( + <> + + + + + + ) +} \ No newline at end of file diff --git a/src/js/components/Icons/Icon.tsx b/src/js/components/Icons/Icon.tsx new file mode 100644 index 0000000000..4cf4054d4c --- /dev/null +++ b/src/js/components/Icons/Icon.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import styled from "styled-components"; + +export const Icon = React.memo(styled.svg.attrs({ + viewBox: "0 0 24 24", +})` + width: var(--size, 2em); + height: var(--size, 2em); + + &, + * { + vector-effect: non-scaling-stroke; + stroke-linecap: round; + stroke-linejoin: round; + } + + .fill { + fill: var(--fill, currentColor); + stroke: none; + } + + .stroke { + stroke: var(--stroke, currentColor); + stroke-width: var(--stroke-width, 1.5); + fill: none; + } + + .fill.stroke { + fill: var(--fill, currentColor); + } +`); diff --git a/src/js/components/Icons/Icons.tsx b/src/js/components/Icons/Icons.tsx deleted file mode 100644 index a11e314272..0000000000 --- a/src/js/components/Icons/Icons.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { createIcon } from '@chakra-ui/react' - - -export const RightSidebarIcon = createIcon({ - displayName: 'RightSidebarIcon', - viewBox: '0 0 24 24', - path: ( - - ), -}) - -export const AllPagesIcon = createIcon({ - displayName: 'AllPagesIcon', - viewBox: '0 0 24 24', - path: ( - - - ), -}) - -export const XmarkIcon = createIcon({ - displayName: 'XmarkIcon', - viewBox: '0 0 24 24', - path: ( - - ), -}) - -export const DailyNotesIcon = createIcon({ - displayName: 'DailyNotesIcon', - viewBox: '0 0 24 24', - path: ( - - ), -}) - -export const SearchIcon = createIcon({ - displayName: 'SearchIcon', - viewBox: '0 0 24 24', - path: ( - - ), -}) - -export const SettingsIcon = createIcon({ - displayName: 'SettingsIcon', - viewBox: '0 0 24 24', - path: ( - - ), -}) - -export const RightSidebarAddIcon = createIcon({ - displayName: 'RightSidebarAddIcon', - viewBox: '0 0 24 24', - path: ( - - ), -}) - -export const HelpIcon = createIcon({ - displayName: 'HelpIcon', - viewBox: '0 0 24 24', - path: ( - - ), -}) - -export const MenuIcon = createIcon({ - displayName: 'MenuIcon', - viewBox: '0 0 24 24', - path: ( - - ), -}) - -export const ChevronUpIcon = createIcon({ - displayName: 'ChevronUpIcon', - viewBox: '0 0 24 24', - path: ( - - ), -}) - -export const ChevronLeftIcon = createIcon({ - displayName: 'ChevronLeftIcon', - viewBox: '0 0 24 24', - path: ( - - ), -}) - -export const ChevronRightIcon = createIcon({ - displayName: 'ChevronRightIcon', - viewBox: '0 0 24 24', - path: ( - - ), -}) - -export const ChevronDownIcon = createIcon({ - displayName: 'ChevronDownIcon', - viewBox: '0 0 24 24', - path: ( - - ), -}) - -export const ContrastIcon = createIcon({ - displayName: 'ContrastIcon', - viewBox: '0 0 24 24', - path: ( - - ), -}) - diff --git a/src/js/components/Icons/X.tsx b/src/js/components/Icons/X.tsx new file mode 100644 index 0000000000..cfffd0c99c --- /dev/null +++ b/src/js/components/Icons/X.tsx @@ -0,0 +1 @@ +export const X = () => ; \ No newline at end of file diff --git a/src/js/components/Input/Input.stories.tsx b/src/js/components/Input/Input.stories.tsx new file mode 100644 index 0000000000..046e087e34 --- /dev/null +++ b/src/js/components/Input/Input.stories.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import { Input } from './Input'; +import { BADGE, Storybook } from '@/utils/storybook'; +import { Mail, Check } from '@material-ui/icons'; +import styled from 'styled-components'; + +const InputStoryWrapper = styled(Storybook.Wrapper)` + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 1rem 2rem; + + hr { + grid-column: 1 / -1; + width: 100%; + border: 0 0 1px; + opacity: var(--opacity-low); + } +`; + +export default { + title: 'Components/Input', + component: Input, + argTypes: {}, + parameters: { + layout: 'centered', + badges: [BADGE.DEV, BADGE.IN_USE] + }, + decorators: [(Story) => ] +}; + +const Template = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + defaultValue: 'Input', + type: 'text' +}; + +export const Password = () => <> + + + +
+ + + + + +export const WithLayout = () => { + return <> + + + + Label + + Help text + + +} + +export const WithValidation = () => { + const [value, setValue] = React.useState(null); + + const regex = /^[_a-zA-Z0-9-+_.]+@[a-zA-Z0-9-\.]+(\.[a-zA-Z]{2,3})$/; + const isValid = regex.exec(value); + + return <> + + + {isValid && } + Email Address + setValue(e.target.value)} /> + Provide a valid email + + +} \ No newline at end of file diff --git a/src/js/components/Input/Input.tsx b/src/js/components/Input/Input.tsx new file mode 100644 index 0000000000..96aae78e91 --- /dev/null +++ b/src/js/components/Input/Input.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Check, Warning } from '@material-ui/icons'; + +export interface InputProps extends React.InputHTMLAttributes { } + +export const Input = styled.input` + padding: 0.375rem 0.625rem; + margin: 0; + font-family: inherit; + font-size: inherit; + border-radius: 0.25rem; + font-weight: 500; + border: none; + display: inline-flex; + align-items: center; + color: var(--body-text-color); + caret-color: var(--link-color); + background: var(--body-text-color---opacity-lower); + transition-property: filter, background, color, opacity, border-color; + transition-duration: 0.1s; + transition-timing-function: ease-in-out; + gap: 0.5rem; + + &:enabled { + &:hover { + background: var(--body-text-color---opacity-low); + } + + &:active { + background: var(--body-text-color---opacity-low); + } + } + + &:disabled { + color: var(--body-text-color---opacity-low); + background: var(--body-text-color---opacity-lower); + cursor: default; + } + + &.is-invalid { + color: var(--warning-color); + background: var(--warning-color---opacity-lower); + + &:hover { + background: var(--warning-color---opacity-low); + } + + &:active { + background: var(--warning-color---opacity-low); + } + } + + &.is-valid { + color: var(--confirmation-color); + background: var(--confirmation-color---opacity-lower); + + &:hover { + background: var(--confirmation-color---opacity-low); + } + + &:active { + background: var(--confirmation-color---opacity-low); + } + } +`; + +Input.Label = styled.span` + font-weight: bold; +`; + +Input.Help = styled.small``; + +Input.LabelWrapper = styled.label` + display: grid; + grid-template-areas: "label" "input" "help"; + + .label, + ${Input.Label} { + grid-area: label; + } + + .input, + ${Input} { + grid-area: input; + } + + .help, + ${Input.Help} { + grid-area: help; + } + + .input-right { + grid-area: input; + margin: auto 0; + margin-left: auto; + z-index: 1; + } + + .icon-right, + .icon-left { + grid-area: input; + pointer-events: none; + margin: auto; + } + + .icon-right { + margin-right: 0.25rem; + + ~ ${Input} { + padding-right: 2rem; + } + } + + .icon-left { + margin-left: 0.25rem; + + ~ ${Input} { + padding-left: 2rem; + } + } +`; + +Input.Invalid = styled(Warning).attrs({ + className: 'icon-right', +})` +`; + +Input.Valid = styled(Check).attrs({ + className: 'icon-right', +})``; diff --git a/src/js/components/Input/index.ts b/src/js/components/Input/index.ts new file mode 100644 index 0000000000..f0fee8fc27 --- /dev/null +++ b/src/js/components/Input/index.ts @@ -0,0 +1,2 @@ +import { Input } from './Input'; +export { Input }; \ No newline at end of file diff --git a/src/js/components/Layout/Layout.tsx b/src/js/components/Layout/Layout.tsx deleted file mode 100644 index b12d3ef3a5..0000000000 --- a/src/js/components/Layout/Layout.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { Button, IconButton, Box, useDisclosure, Collapse, VStack } from '@chakra-ui/react'; -import { AnimatePresence, motion } from 'framer-motion'; -import { XmarkIcon, ChevronUpIcon } from '@/Icons/Icons'; - -const Container = motion(Box) - -export const RightSidebarContainer = ({ isOpen, width, isDragging, children }) => { - return - {isOpen && - - {children} - } - -} - -export const SidebarItem = ({ title, defaultIsOpen, onRemove, onClose, children }) => { - const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: defaultIsOpen, onClose: onClose }); - - return ( - - - - - - - - - {children} - - ) -} \ No newline at end of file diff --git a/src/js/components/Link/Link.stories.tsx b/src/js/components/Link/Link.stories.tsx new file mode 100644 index 0000000000..27d8a93f27 --- /dev/null +++ b/src/js/components/Link/Link.stories.tsx @@ -0,0 +1,18 @@ +import { Link } from './Link'; +import { BADGE, Storybook } from '@/utils/storybook'; + +export default { + title: 'components/Link', + component: Link, + argTypes: {}, + parameters: { + layout: 'centered', + badges: [BADGE.DEV] + }, + decorators: [(Story) => ] +}; + +export const BidirectionalLink = () => ( +

+ Abberton Reservoir is a pumped storage freshwater reservoir in England near the Essex coast, with an area of 700 hectares (1,700 acres). +

); diff --git a/src/js/components/Link/Link.ts b/src/js/components/Link/Link.ts new file mode 100644 index 0000000000..cfbca65e87 --- /dev/null +++ b/src/js/components/Link/Link.ts @@ -0,0 +1,37 @@ +import styled from 'styled-components'; + +export const Link = styled.a` + display: inline-flex; + color: var(--link-color); + margin-inline: calc(-0.25em + 0.1ch); + padding-inline: calc(0.25em); + border-radius: 0.25em; + text-decoration: none; + cursor: pointer; + transition: background 0.1s ease-in-out; + + &:hover { + opacity: var(--opacity-higher); + } + + &:active { + opacity: var(--opacity-high); + user-select: none; + } + + &:before, + &:after { + color: var(--link-color---opacity-low); + letter-spacing: -0.2ch; + } + + &:before { + content: '[['; + margin-inline-end: 0.1ch; + } + + &:after { + content: ']]'; + margin-inline-start: 0.1ch; + } +`; \ No newline at end of file diff --git a/src/js/components/Link/index.ts b/src/js/components/Link/index.ts new file mode 100644 index 0000000000..d60acc35d3 --- /dev/null +++ b/src/js/components/Link/index.ts @@ -0,0 +1,2 @@ +import { Link } from './Link'; +export { Link }; \ No newline at end of file diff --git a/src/js/components/Menu/Menu.stories.tsx b/src/js/components/Menu/Menu.stories.tsx new file mode 100644 index 0000000000..adcf36871c --- /dev/null +++ b/src/js/components/Menu/Menu.stories.tsx @@ -0,0 +1,54 @@ +import { BADGE, Storybook } from '@/utils/storybook'; + +import { Link } from '@material-ui/icons' + +import { Menu } from './Menu'; +import { Overlay } from '@/Overlay'; + +export default { + title: 'Components/Menu', + component: Menu, + argTypes: {}, + parameters: { + layout: 'centered', + badges: [BADGE.DEV] + }, + decorators: [(Story) => ] +}; + +const Template = (args) => ; + +export const Basic = Template.bind({}); +Basic.args = { + children: <> + Menu Item + Menu Item + Menu Item + + Menu Item + , +}; + +export const InAnOverlay = Template.bind({}); +InAnOverlay.args = { + children: <> + Menu Item + Menu Item + Menu Item + + Menu Item + , +}; +InAnOverlay.decorators = [(Story) => ] + +export const Typical = Template.bind({}); +Typical.args = { + children: <> + Menu Item + Menu Item + Menu Item + + Menu Item + +}; +Typical.decorators = [(Story) => ] diff --git a/src/js/components/Menu/Menu.ts b/src/js/components/Menu/Menu.ts new file mode 100644 index 0000000000..bc4f955993 --- /dev/null +++ b/src/js/components/Menu/Menu.ts @@ -0,0 +1,55 @@ +import styled from 'styled-components'; + +import { Button } from '@/Button'; + +/** + * Wraps buttons into a menu. + */ +export const Menu = styled.div` + display: flex; + gap: 0.125rem; + min-width: 9em; + align-items: stretch; + flex-direction: column; + &:focus { + outline: none; + } +`; + +/** + * Divider between sections of a menu. + */ +Menu.Separator = styled.hr` + border: 0; + background: var(--border-color); + align-self: stretch; + justify-self: stretch; + height: 1px; + margin: 0.25rem 0; + flex: 0 0 auto; +`; + +/** + * Wraps a menu item. + */ +Menu.Button = styled(Button).attrs({ + shape: 'unset' +})` + font-size: var(--font-size--text-sm); + flex: 0 0 auto; + justify-content: flex-start; + padding: 0.125rem 0.5rem; + border-radius: 0.25rem; +`; + +/** + * Heading for a section of a menu. + */ +Menu.Heading = styled.h3` + margin: 0; + font-weight: 500; + flex: 1 1 100%; + padding: 0.25rem 0.5rem; + font-size: var(--font-size--text-sm); + color: var(--body-text-color---opacity-med); +`; \ No newline at end of file diff --git a/src/js/components/Menu/hooks/useMenu.ts b/src/js/components/Menu/hooks/useMenu.ts new file mode 100644 index 0000000000..5fcc28287c --- /dev/null +++ b/src/js/components/Menu/hooks/useMenu.ts @@ -0,0 +1,79 @@ +import React from 'react'; + +const contextMenuAnchor = (e) => ({ + clientHeight: 0, + clientWidth: 0, + getBoundingClientRect: () => ({ + width: 0, + height: 0, + top: e.clientY, + right: e.clientX, + bottom: e.clientY, + left: e.clientX, + }) +}); + +type TriggerType = 'contextMenu' | 'click' | 'hover'; + +export const useMenu = () => { + const [isOpen, setIsOpen] = React.useState(false); + const [position, setPosition] = React.useState(null); + const [anchorEl, setAnchorEl] = React.useState(null); + const [placement, setPlacement] = React.useState('bottom-start'); + const [triggerType, setTriggerType] = React.useState(null); + + const closeMenu = () => { + setIsOpen(false); + setPosition(null); + setTriggerType(null); + }; + + const triggerProps = (type: TriggerType, placement?) => { + if (type === 'contextMenu') { + return ({ + isPressed: triggerType === 'contextMenu', + onContextMenu: (e) => { + setAnchorEl(contextMenuAnchor(e)); + e.preventDefault(); + e.stopPropagation(); + setIsOpen(true); + setPlacement(placement || 'bottom-start'); + setTriggerType('contextMenu'); + } + }); + } else if (type === 'click') { + return ({ + isPressed: triggerType === 'click', + onClick: (e) => { + setAnchorEl(e.currentTarget); + setIsOpen(true); + setPlacement(placement || 'bottom-end'); + setTriggerType('click'); + } + }); + } else if (type === 'hover') { + return ({ + isPressed: triggerType === 'hover', + onMouseEnter: (e) => { + setAnchorEl(e.currentTarget); + setIsOpen(true); + setPlacement(placement || 'bottom-end'); + setTriggerType('hover'); + } + }); + } + }; + + const menuProps = { + position, + anchorEl, + isOpen, + placement + }; + + return { + triggerProps, + menuProps, + closeMenu + }; +}; diff --git a/src/js/components/Menu/index.ts b/src/js/components/Menu/index.ts new file mode 100644 index 0000000000..52361e565f --- /dev/null +++ b/src/js/components/Menu/index.ts @@ -0,0 +1,2 @@ +import { Menu } from './Menu'; +export { Menu }; \ No newline at end of file diff --git a/src/js/components/Notifications/Notification.stories.tsx b/src/js/components/Notifications/Notification.stories.tsx new file mode 100644 index 0000000000..d977daaca4 --- /dev/null +++ b/src/js/components/Notifications/Notification.stories.tsx @@ -0,0 +1,99 @@ +import { Storybook } from "@/utils/storybook"; + +import { notify, Notification } from "@/Notifications/Notifications"; +import { Button } from "@/Button"; +import { Indeterminate } from "@/Spinner/components/Indeterminate"; + +export default { + title: "Components/Notification", + component: Notification, + argTypes: {}, + parameters: { + layout: "centered", + decorators: [ + (Story, args) => ( + + + + ), + ], + }, +}; + +export const Active = () => { + return ( +
+ + + + + + +
+ +
+ ); +}; diff --git a/src/js/components/Notifications/Notifications.tsx b/src/js/components/Notifications/Notifications.tsx new file mode 100644 index 0000000000..6fed3649ca --- /dev/null +++ b/src/js/components/Notifications/Notifications.tsx @@ -0,0 +1,18 @@ +import toast from "react-hot-toast"; + +import { NotificationContainer } from './components/NotificationContainer'; +import { NotificationItem } from './components/NotificationItem'; + +const notify = toast; + +// TODO: Properly extend Toast type with new options: +// id: string; +// isDismissable?: boolean; +// onUndo?: () => void; +// icon?: never; +// iconTheme?: never; +// undoMessage?: string; + +export type Notification = any; + +export { notify, NotificationContainer, NotificationItem }; diff --git a/src/js/components/Notifications/components/NotificationContainer.tsx b/src/js/components/Notifications/components/NotificationContainer.tsx new file mode 100644 index 0000000000..993421b141 --- /dev/null +++ b/src/js/components/Notifications/components/NotificationContainer.tsx @@ -0,0 +1,22 @@ +import { + Toaster, + ToastPosition, + resolveValue, +} from "react-hot-toast"; +import { NotificationItem } from '../components/NotificationItem'; + +const ToasterProps = { + position: "bottom-right" as ToastPosition, + containerStyle: { + filter: "drop-shadow(0 0.5rem 0.5rem var(--shadow-color---opacity-25)" + }, + gutter: 8, // 1rem +}; + +export const NotificationContainer = () => { + return ( + + {(t) => {resolveValue(t.message, t)}} + + ); +}; diff --git a/src/js/components/Notifications/components/NotificationItem.tsx b/src/js/components/Notifications/components/NotificationItem.tsx new file mode 100644 index 0000000000..851c054fa3 --- /dev/null +++ b/src/js/components/Notifications/components/NotificationItem.tsx @@ -0,0 +1,142 @@ +import React from "react"; +import styled, { keyframes } from "styled-components"; +import { mergeProps } from "@react-aria/utils"; + +import { notify, Notification } from '../Notifications'; + +import { Button } from "@/Button"; +import { Icon } from "@/Icons/Icon"; +import { X } from "@/Icons/X"; + +const appear = keyframes` + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +`; + +const Wrap = styled.div` + box-shadow: 0 0 0 1px var(--shadow-color---opacity-10); + background: var(--background-plus-2---opacity-med); + color: var(---body-text-color---opacity-80); + padding: 0.5rem 1rem; + border-radius: 1rem; + opacity: 0; + transition: all 0.25s ease-out; + animation: ${appear} 0.25s ease-out; + z-index: 1; + display: flex; + align-items: center; + gap: 0.5rem; + position: relative; + + button:not(& * button):last-child { + margin-right: -0.5rem; + margin-left: auto; + } + + svg:not(& * svg):first-child { + margin-left: -0.5rem; + } + + &:before { + content: ""; + position: absolute; + z-index: -1; + inset: 0; + border-radius: inherit; + background: var(--background-color—-opacity-med); + + @supports (backdrop-filter: blur(10px)) { + background: var(--background-color—-opacity-low); + backdrop-filter: blur(10px); + } + } + + &.visible { + opacity: 1; + } + + &.toast-loading { + padding: 0.75rem 1.5rem; + } + + &.toast-success { + background: var(--confirmation-color---opacity-10); + color: var(--confirmation-color); + } + + &.toast-warning { + background: var(--warning-color---opacity-15); + color: var(--warning-color); + } + + &.toast-error { + background: var(--error-color---opacity-15); + color: var(--error-color); + } +`; + +export const NotificationItem = (t: Notification) => { + const { isDismissable, onUndo, undoMessage, ...rest } = t; + const ref = React.useRef(null); + const [size, setSize] = React.useState({ width: undefined, height: undefined }); + + const setMinSize = () => { + if (ref.current) { + setSize({ width: ref.current.offsetWidth, height: ref.current.offsetHeight }); + } + }; + + const handleUndo = () => { + setMinSize(); + const message = t.undoMessage || "Undone"; + const resultProps = { id: rest.id, onUndo: undefined, isDismissable: true }; + const updateNotification = t.type !== 'blank' + ? notify[t.type || 'blank'](message, resultProps) + : notify(message, resultProps); + + onUndo(); + updateNotification() + } + + const handleDismiss = () => notify.dismiss(t.id); + + return ( + + {t.children} + {t.onUndo && ( + + )} + {t.isDismissable && ( + + )} + + ); +}; \ No newline at end of file diff --git a/src/js/components/Overlay/Backdrop.ts b/src/js/components/Overlay/Backdrop.ts new file mode 100644 index 0000000000..5db1ea1c30 --- /dev/null +++ b/src/js/components/Overlay/Backdrop.ts @@ -0,0 +1,14 @@ +import styled from 'styled-components'; + +export const Backdrop = styled.div` + position: fixed; + z-index: var(--zindex-modal); + top: 0; + left: 0; + bottom: 0; + right: 0; + background-color: ${props => props.hidden ? 'none' : 'rgba(0, 0, 0, 0.5)'}; + display: flex; + align-items: center; + justify-content: center; +`; \ No newline at end of file diff --git a/src/js/components/Overlay/Overlay.stories.tsx b/src/js/components/Overlay/Overlay.stories.tsx new file mode 100644 index 0000000000..109df2da99 --- /dev/null +++ b/src/js/components/Overlay/Overlay.stories.tsx @@ -0,0 +1,63 @@ +import styled from 'styled-components'; +import { Create } from '@material-ui/icons'; +import { BADGE, Storybook } from '@/utils/storybook'; + +import { Overlay } from './Overlay'; +import { Menu } from '@/Menu'; +import { Button } from '@/Button'; + +export default { + title: 'Components/Overlay', + component: Overlay, + argTypes: {}, + parameters: { + layout: 'centered', + badges: [BADGE.DEV, BADGE.IN_USE] + } +}; + +const MessageContent = styled.div` + display: flex; + place-items: center; + place-content: center; + flex-direction: column; + gap: 1rem; + padding: 2rem 1rem; + + svg { + font-size: 4rem; + margin: auto; + opacity: var(--opacity-high); + color: var(--link-color); + } + + span { + color: var(--body-text-color---opacity-med); + text-align: center; + display: block; + } +`; + +const Template = (args) => ; + +export const Basic = Template.bind({}); +Basic.args = { + children: 'Overlay', +}; + +export const Message = Template.bind({}); +Message.args = { + children: ( + + Write something insightful today + ), +}; + +export const AroundAMenu = Template.bind({}); +AroundAMenu.args = { + children: + + + + , +}; diff --git a/src/js/components/Overlay/Overlay.ts b/src/js/components/Overlay/Overlay.ts new file mode 100644 index 0000000000..48ecb32103 --- /dev/null +++ b/src/js/components/Overlay/Overlay.ts @@ -0,0 +1,56 @@ +import styled, { css, keyframes } from 'styled-components'; + +const overlayAppear = keyframes` + from { + opacity: 0; + transform: translateY(-10px); + } to { + opacity: 1; + transform: translateY(0); + } +`; + +/** + * A simple container with basic padding, background, shadow, etc. + */ +export const Overlay = styled.div` + display: inline-flex; + color: var(--body-text-color); + padding: 0.25rem; + min-width: 2em; + border-radius: calc(0.25rem + 0.25rem); // Button corner radius + container padding makes "concentric" container radius; + z-index: var(--zindex-dropdown); + min-height: 2em; + animation-fill-mode: both; + box-shadow: var(--depth-shadow-16), 0 0 0 1px rgb(0 0 0 / 0.05); + background: var(--background-plus-1); + position: relative; + + &:focus-visible, + &:focus { + box-shadow: var(--depth-shadow-16), 0 0 0 2px rgb(0 0 0 / 0.1); + outline: none; + } + + &.animate-in { + animation: ${overlayAppear} 0.125s; + } + + ${props => !!props.hasOutline && css` + .is-theme-dark & { + &:after { + content: ''; + inset: 0; + position: absolute; + box-shadow: inset 0 0 0 1px var(--body-text-color---opacity-lower); + z-index: 99999; + pointer-events: none; + border-radius: inherit; + } + } + `} +`; + +Overlay.defaultProps = { + hasOutline: true, +} diff --git a/src/js/components/Overlay/index.ts b/src/js/components/Overlay/index.ts new file mode 100644 index 0000000000..90c6828ee5 --- /dev/null +++ b/src/js/components/Overlay/index.ts @@ -0,0 +1,2 @@ +import { Overlay } from './Overlay'; +export { Overlay }; \ No newline at end of file diff --git a/src/js/components/Page/Page.tsx b/src/js/components/Page/Page.tsx deleted file mode 100644 index a09e008f7f..0000000000 --- a/src/js/components/Page/Page.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import { Box } from '@chakra-ui/react'; - -const PAGE_PROPS = { - as: "article", - display: "grid", - flexBasis: "100%", - gridTemplateAreas: "'header' 'content' 'footer'", - gridTemplateRows: "auto 1fr auto", - sx: { - "--page-padding-v": "6rem", - "--page-padding-h": "4rem" - } -} - -const TITLE_PROPS = { - position: "relative", - gridArea: "title", - fontSize: "var(--page-title-font-size, 2rem)", - overflow: "visible", - flexGrow: "1", - margin: "0", - whiteSpace: "pre-line", - wordBreak: "break-word", - fontWeight: "bold", -} - -export const PageContainer = ({ children, uid, type }) => {children} - -export const HeaderImage = ({ src }) => - -export const PageHeader = ({ children, image }) => - {image && } - {children} - - -export const PageBody = ({ children }) => {children} - -export const PageFooter = ({ children }) => {children} - -export const TitleContainer = ({ children }) => {children} - -export const DailyNotesPage = ({ isReal, children }) => {children} - -export const EditableTitleContainer = ({ children, isEditing, props }) => a": { - position: "relative", - zIndex: 2, - pointerEvents: "all", - } - }, - "span": { - gridArea: "main", - pointerEvents: "none", - "a, button": { - position: "relative", - zIndex: 2, - pointerEvents: "all", - }, - "& > span": { - position: "relative", - zIndex: 2, - } - }, - "abbr": { - gridArea: "main", - zIndex: 4, - "& > span": { - position: "relative", - zIndex: 2, - } - }, - "code, pre": { - fontFamily: "code", - fontSize: "0.85em", - }, - ".media-16-9": { - height: 0, - width: "calc(100% - 0.25rem)", - zIndex: 1, - transformOrigin: "right center", - transitionDuration: "0.2s", - transitionTimingFunction: "ease-in-out", - transitionProperty: "common", - paddingBottom: "56.25%", - marginBlock: "0.25rem", - marginInlineEnd: "0.25rem", - position: "relative", - }, - "iframe": { - border: 0, - boxShadow: "inset 0 0 0 0.125rem", - position: "absolute", - height: "100%", - width: "100%", - cursor: "default", - top: 0, - right: 0, - left: 0, - bottom: 0, - borderRadius: "0.25rem", - }, - "img": { - borderRadius: "0.25rem", - maxWidth: "calc(100% - 0.25rem)", - }, - "h1": { fontSize: "xl" }, - "h2": { fontSize: "lg" }, - "h3": { fontSize: "md" }, - "h4": { fontSize: "sm" }, - "h5": { fontSize: "xs" }, - "h6": { fontSize: "xs" }, - "blockquote": { - marginInline: "0.5em", - marginBlock: "0.125rem", - paddingBlock: "calc(0.5em - 0.125rem - 0.125rem)", - paddingInline: "1.5em", - borderRadius: "0.25em", - background: "background.basement", - borderInlineStart: "1px solid", - borderColor: "separator.divider", - color: "foreground.primary", - }, - "p": { - paddingBottom: "1em", - "&last:-child": { paddingBottom: 0 }, - }, - "mark.contents.highlight": { - padding: "0 0.2em", - borderRadius: "0.125rem", - background: "highlight", - } - }} - {...props}> - {children} -; \ No newline at end of file diff --git a/src/js/components/PresenceDetails/PresenceDetails.tsx b/src/js/components/PresenceDetails/PresenceDetails.tsx index 418d0b2c8f..d8abd91e88 100644 --- a/src/js/components/PresenceDetails/PresenceDetails.tsx +++ b/src/js/components/PresenceDetails/PresenceDetails.tsx @@ -1,9 +1,160 @@ -import { withErrorBoundary } from 'react-error-boundary'; +import styled, { keyframes } from "styled-components"; import React from "react"; -import { Text, Tooltip, Avatar, AvatarGroup, Menu, MenuDivider, MenuButton, MenuList, MenuGroup, MenuItem, Button, Portal } from '@chakra-ui/react'; +import { RefreshDouble, Lock } from "iconoir-react"; +import { mergeProps } from "@react-aria/utils"; +import { + useOverlay, + useOverlayTrigger, + useOverlayPosition, + useModal, + OverlayContainer, +} from "@react-aria/overlays"; +import { useOverlayTriggerState } from "@react-stately/overlays"; +import { FocusScope } from "@react-aria/focus"; +import { useDialog } from "@react-aria/dialog"; + +import { Button } from "@/Button"; +import { Menu } from "@/Menu"; +import { Overlay } from "@/Overlay"; +import { Backdrop } from "@/Overlay/Backdrop"; +import { Avatar } from "@/Avatar"; import { ProfileSettingsDialog } from "@/ProfileSettingsDialog"; +import { ConnectedGraphConnection } from "@/Icons/ConnectedGraphConnection"; +import { ConnectedGraphHost } from "@/Icons/ConnectedGraphHost"; +import { Icon } from "@/Icons/Icon"; + +const ConnectionButton = styled(Button)` + gap: 0.125rem; + transition: all 0s; + min-height: 2rem; + padding: 0.125em 0.5rem; + font-size: var(--font-size--text-xs); + border: 1px solid var(--border-color); + + &.connecting { + color: var(--link-color); + } + + &.reconnecting { + color: var(--highlight-color); + } + + &.offline { + svg { + color: var(--body-text-color); + } + } +`; + +const PresenceOverlay = styled(Overlay)` + min-width: 8rem; + flex-direction: column; + max-height: calc(100vh - 4rem); + overflow-y: auto; +`; + +const HostIconWrap = styled(Icon)` + --size: 2rem; + border-radius: 18%; + background: var(--background-minus-2); + padding: 0.25rem; +`; + +const HostIcon = () => ( + + + +); + +const PersonWrap = styled.div` + padding: 0.375rem 0.5rem; + display: flex; + align-items: center; +`; + +const Profile = styled.div` + display: flex; + align-items: center; + flex-wrap: wrap; + + svg { + margin-right: 0.5rem; + } + + button { + margin-left: auto; + font-size: var(--font-size--text-xs); + } +`; + +const Host = styled(Profile)` + padding-top: 0.25rem; + padding-bottom: 0.25rem; + + svg { + margin-left: 0.25rem; + } +`; + +const rotate = keyframes` + from { + transform: rotate(0deg); + } to { + transform: rotate(360deg); + } +`; + +const OfflineIcon = styled(Lock)` + width: 1.25rem; + height: 1.25rem; +`; + +const ActivityIcon = styled(RefreshDouble)` + width: 1.25rem; + height: 1.25rem; + animation: ${rotate} 2s linear infinite; + stroke-width: 2; + vector-effect: non-scaling-stroke; +`; + +const ConnectedGraphIconWrap = styled(Icon)` + --size: 1.5rem; +`; + +const connectionStatusIndicator = { + connected: ( + + + + ), + connecting: ( + <> + + Connecting... + + ), + reconnecting: ( + <> + + Reconnecting... + + ), + offline: ( + <> + + View Only + + ), +}; + +const connectionStatusHelpText = { + connecting: "Athens is connecting to the host.", + connected: "View connection details.", + reconnecting: "Athens is attempting to reconnect to the host.", + offline: "Athens is not connected.", +}; export interface PresenceDetailsProps { hostAddress: HostAddress; @@ -16,45 +167,50 @@ export interface PresenceDetailsProps { connectionStatus: ConnectionStatus; defaultOpen?: boolean; } -interface ConnectionButtonProps { - connectionStatus: ConnectionStatus; - showablePersons: Person[]; + +interface PresenceDetailsPopoverProps { + children: React.ReactNode; + isOpen: boolean; + onClose: () => void; } -const ConnectionButton = React.forwardRef((props: ConnectionButtonProps, ref) => { - const { connectionStatus, showablePersons } = props; - return ( - - - - ) -}); +const PresenceDetailsPopover = React.forwardRef( + ( + { isOpen, onClose, children, ...otherProps }: PresenceDetailsPopoverProps, + ref: RefObject + ) => { + let { overlayProps, underlayProps } = useOverlay( + { + onClose, + isOpen, + isDismissable: true, + }, + ref + ); + + let { modalProps } = useModal(); + let { dialogProps, titleProps } = useDialog({}, ref); + return ( + + + + ); + } +); -export const _PresenceDetails = (props: PresenceDetailsProps) => { +export const PresenceDetails = (props: PresenceDetailsProps) => { const { hostAddress, currentUser, @@ -64,113 +220,193 @@ export const _PresenceDetails = (props: PresenceDetailsProps) => { handlePressMember, handleUpdateProfile, connectionStatus, + defaultOpen, } = props; - const showablePersons = [ ...currentPageMembers, ...differentPageMembers ]; - const [ shouldShowProfileSettings, setShouldShowProfileSettings ] = React.useState(false); + const showablePersons = [...currentPageMembers, ...differentPageMembers]; + + // State and controllers for the menu + let menuState = useOverlayTriggerState({ defaultOpen: defaultOpen }); + + let triggerRef = React.useRef(); + let overlayRef = React.useRef(); + + let { triggerProps: presenceMenuTriggerProps, overlayProps: presenceMenuOverlayProps } = useOverlayTrigger( + { type: "listbox" }, + menuState, + triggerRef, + ); + + let { overlayProps: positionProps } = useOverlayPosition({ + targetRef: triggerRef, + overlayRef, + placement: "bottom end", + offset: 2, + isOpen: menuState.isOpen, + }); + + // State and controllers for the profile settings dialog + let profileSettingsState = useOverlayTriggerState({}); return connectionStatus === "local" ? ( <> ) : ( <> - - - - + + {connectionStatusIndicator[connectionStatus]} + + {connectionStatus === "connected" && ( + <> + {currentUser && ( + + )} + + {showablePersons.length > 0 && ( + + {showablePersons.map((member) => ( + + ))} + + )} + + )} + + + {menuState.isOpen && ( + + <> {hostAddress && ( - handleCopyHostAddress(hostAddress)} - display="flex" - flexDirection="column" - textAlign="left" - justifyContent="flex-start" - alignItems="stretch" - > - Copy address - - {hostAddress} - - + <> + + + {hostAddress} + + + )} {currentUser && ( - setShouldShowProfileSettings(true)} icon={}>Edit appearance + <> + + + You appear as + + + {currentUser.username} + + + + )} {currentPageMembers.length > 0 && ( <> - - + + On this page + {currentPageMembers.map((member) => ( - handlePressMember(member)} key={member.personId} - icon={} > - {member.username} - + + {member.username} + ))} - + )} {differentPageMembers.length > 0 && ( <> - - - {differentPageMembers.map((member) => ( - handlePressMember(member)} - key={member.personId} - icon={} - > - {member.username} - - ))} - + + On other pages + {differentPageMembers.map((member) => ( + + ))} )} - - - - - setShouldShowProfileSettings(false)} - onUpdatePerson={(person) => { - handleUpdateProfile(person); - setShouldShowProfileSettings(false) - }} - /> + + + ) + } + + {currentUser && ( + <> + { + handleUpdateProfile(person); + profileSettingsState.close(); + }} + /> + + )} ); }; -export const PresenceDetails = withErrorBoundary(_PresenceDetails, { fallback: <> }); +PresenceDetails.defaultProps = { + defaultOpen: false, +}; diff --git a/src/js/components/PresenceDetails/index.ts b/src/js/components/PresenceDetails/index.ts index a02932631b..1f09a57914 100644 --- a/src/js/components/PresenceDetails/index.ts +++ b/src/js/components/PresenceDetails/index.ts @@ -1,2 +1,2 @@ -import { PresenceDetails } from "./PresenceDetails"; -export { PresenceDetails }; +import { PresenceDetails, PresenceDetailsProps } from "./PresenceDetails"; +export { PresenceDetails, PresenceDetailsProps }; \ No newline at end of file diff --git a/src/js/components/ProfileSettingsDialog/ProfileSettingsDialog.stories.tsx b/src/js/components/ProfileSettingsDialog/ProfileSettingsDialog.stories.tsx new file mode 100644 index 0000000000..5608aab36c --- /dev/null +++ b/src/js/components/ProfileSettingsDialog/ProfileSettingsDialog.stories.tsx @@ -0,0 +1,67 @@ +import React from 'react'; + +import { useOverlayTriggerState } from "@react-stately/overlays"; + +import { BADGE, Storybook } from '@/utils/storybook'; +import { ProfileSettingsDialog } from './ProfileSettingsDialog'; + +import { Avatar } from '../Avatar'; +import { Button } from '@/Button'; + +export default { + title: 'Components/ProfileSettingsDialog', + component: ProfileSettingsDialog, + argTypes: {}, + parameters: { + layout: 'centered', + badges: [BADGE.DEV, BADGE.IN_USE] + } +}; + +const testPerson = { personId: '123', username: 'John Doe', color: '#0071ed' }; + +const Template = (args, context) => { + let profileSettingsState = useOverlayTriggerState({ + defaultOpen: args.defaultOpen, + onOpenChange: (open) => { + console.log('ProfileSettingsDialog open state changed to: ', open); + }, + }); + const [person, setPerson] = React.useState(testPerson); + + const handleUpdatePerson = React.useCallback((person) => { + setPerson(person); + profileSettingsState.close(); + }, []); + + return ( + <> +
+ + +
+ + + + ) +}; + +export const Default = Template.bind({}); +Default.args = { + defaultOpen: true +} diff --git a/src/js/components/ProfileSettingsDialog/ProfileSettingsDialog.tsx b/src/js/components/ProfileSettingsDialog/ProfileSettingsDialog.tsx index 4e7a6c0bf2..fd0ccdf63f 100644 --- a/src/js/components/ProfileSettingsDialog/ProfileSettingsDialog.tsx +++ b/src/js/components/ProfileSettingsDialog/ProfileSettingsDialog.tsx @@ -1,31 +1,58 @@ import React from 'react'; -import { withErrorBoundary } from 'react-error-boundary'; - -import { - keyframes, - Modal, - ModalOverlay, - ModalFooter, - Center, - Flex, - Box, - ButtonGroup, - ModalHeader, - ModalCloseButton, - ModalContent, - ModalBody, - Text, - FormControl, - FormHelperText, - Input, - Avatar, - Button -} from '@chakra-ui/react'; - +import styled, { keyframes } from 'styled-components'; +import { readableColor } from 'polished'; import { HexColorPicker } from "react-colorful"; import { AriaDialogProps } from '@react-types/dialog'; import { OverlayProps } from '@react-aria/overlays'; +import { Button } from '@/Button'; +import { Avatar } from '../Avatar'; +import { Input } from '../Input'; +import { Dialog } from '../Dialog'; + +const ProfileWrap = styled(Dialog.Body)` + width: 26rem; + + h3 { + text-align: center; + margin: 0; + font-weight: 600; + } + + hr { + margin: 1rem 0; + border: 0; + border-top: 1px solid var(--border-color); + } +`; + +const Actions = styled(Dialog.Actions)` + padding-Bottom: 1rem; + align-self: center; + gap: 1rem; + margin: 0; + + button { + width: 5em; + } +`; + +const AvatarWrap = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 20rem; + margin: 1em auto 2em; + border-radius: 1rem; + padding: 1rem; + border: 1px solid var(--border-color); + background: var(--background-plus-2); + + > * { + filter: drop-shadow(0 0.25rem 0.25rem var(--shadow-color---opacity-10)); + } +`; + const pulse = keyframes` from { transform: translate(-50%, -50%) scale(1) ; @@ -34,39 +61,42 @@ const pulse = keyframes` } `; -const ColorPickerWrap = ({ children }) => { - return ( *": { - borderRadius: "0.5rem", - height: "100%", - flex: "0 0 4rem", - } - }, - ".react-colorful__saturation": { - borderBottom: 0 - }, - ".react-colorful__interactive:focus .react-colorful__pointer": { - animation: `${pulse} 0.5s infinite alternate ease-in-out` - } - }} - > - {children} - ) -}; - -const Inputs = ({ children }) => { - return ( - {children} - ) -} +const ColorPickerWrap = styled.div` + .react-colorful { + width: 8.5rem; + height: 3rem; + gap: 1rem; + margin: -0.25rem 0 1rem; + flex-direction: row; + + > * { + border-radius: 0.5rem; + height: 100%; + flex: 0 0 4rem; + } + } + + .react-colorful__saturation { + border-bottom: 0; + } + + .react-colorful__interactive:focus + .react-colorful__pointer { + animation: ${pulse} 0.5s infinite alternate ease-in-out; + } +`; + +const Inputs = styled.div` + display: flex; + gap: 2rem; + align-items: flex-start; + justify-content: center; +`; + +const LabelWrapper = styled(Input.LabelWrapper)` + gap: 0.25rem; +`; + interface ProfileSettingsDialogProps extends OverlayProps, AriaDialogProps { person: Person; @@ -75,16 +105,16 @@ interface ProfileSettingsDialogProps extends OverlayProps, AriaDialogProps { /** * Dialog for modifying the current user's username and color */ -export const _ProfileSettingsDialog = ({ +export const ProfileSettingsDialog = ({ person, onClose: handleClose, onUpdatePerson: handleUpdatePerson, isOpen, ...rest }: ProfileSettingsDialogProps) => { - const [ editingUsername, setEditingUsername ] = React.useState(person.username || ''); - const [ editingColor, setEditingColor ] = React.useState(person.color || '#0071DB'); - const [ isValidUsername, setIsValidUsername ] = React.useState(!!editingUsername); + const [editingUsername, setEditingUsername] = React.useState(person.username || ''); + const [editingColor, setEditingColor] = React.useState(person.color || '#0071DB'); + const [isValidUsername, setIsValidUsername] = React.useState(!!editingUsername); const handleChangeUsername = (e: React.ChangeEvent) => { const attempt = e.target.value.trim(); @@ -93,60 +123,55 @@ export const _ProfileSettingsDialog = ({ } return ( - - - - - Change how you appear to others - - - -
+

How you appear to others

+ + + + {isValidUsername ? editingUsername : person.username} + + + + + + + + + At least 2 characters + + +
+ + +
- - - - - - - At least 2 characters - - - -
- - - - - - -
-
+ Save + + + +
) } - -export const ProfileSettingsDialog = withErrorBoundary(_ProfileSettingsDialog, { fallback: null }); diff --git a/src/js/components/Spinner/Spinner.stories.tsx b/src/js/components/Spinner/Spinner.stories.tsx new file mode 100644 index 0000000000..44f418ef9c --- /dev/null +++ b/src/js/components/Spinner/Spinner.stories.tsx @@ -0,0 +1,51 @@ +import { BADGE, Storybook } from '@/utils/storybook'; + +import { Spinner, Progress } from '@/Spinner/Spinner'; +import { Indeterminate } from '@/Spinner/components/Indeterminate'; + +export default { + title: 'Components/Spinner', + component: Spinner, + argTypes: {}, + parameters: { + layout: 'centered', + badges: [BADGE.DEV] + }, + decorators: [(Story, args) => Template(Story, args)] +}; + +const Template = (Story, args) => ; + +export const Basic = () => ; +export const ProgressSpinner = () => ; +export const IndeterminateProgressSpinner = () => <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +; diff --git a/src/js/components/Spinner/Spinner.tsx b/src/js/components/Spinner/Spinner.tsx new file mode 100644 index 0000000000..3856d47b63 --- /dev/null +++ b/src/js/components/Spinner/Spinner.tsx @@ -0,0 +1,88 @@ +import styled, { keyframes } from "styled-components"; +import { classnames } from "@/utils/classnames"; +import React from "react"; + +export const spin = keyframes` + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +`; + +const appearAndDrop = keyframes` + 0% { + transform: translateY(-40%); + opacity: 0; + } + 100% { + transform: translateY(0); + opacity: 1; + } +`; + +const Wrap = styled.div` + width: ${(props) => props.size}; + height: ${(props) => props.size}; + display: flex; + flex-direction: column; + gap: 0.5rem; + align-self: center; + margin: auto; + text-align: center; + place-items: center; + animation: ${appearAndDrop} 0.5s ease; + place-content: center; + + &.placement-center { + position: absolute; + top: calc(50% - ${(props) => props.size} / 2); + left: calc(50% - ${(props) => props.size} / 2); + } +`; + +export const Progress = styled.div` + width: 3em; + height: 3em; + border-radius: 1000em; + border: 1.5px solid var(--background-minus-1); + border-top-color: var(--link-color); + animation: ${spin} 1s linear infinite; +`; + +const Message = styled.span` + animation: ${appearAndDrop} ${(props) => props.messageDelay}s 0.75s + ease-in-out; + font-size: 14px; + animation-fill-mode: both; +`; + +interface SpinnerProps { + message?: string | React.ReactNode; + placement?: "center" | null; + size?: string; + messageDelay?: number; +} + +export const Spinner = ({ + message, + placement, + size, + messageDelay, +}: SpinnerProps): JSX.Element => ( + + + {message && {message}} + +); + +Spinner.defaultProps = { + message: "Loading...", + placement: "center", + size: "10rem", + messageDelay: 2, +}; diff --git a/src/js/components/Spinner/components/Indeterminate.tsx b/src/js/components/Spinner/components/Indeterminate.tsx new file mode 100644 index 0000000000..d2f8d2f56e --- /dev/null +++ b/src/js/components/Spinner/components/Indeterminate.tsx @@ -0,0 +1,24 @@ +import styled from 'styled-components'; +import { spin } from '../Spinner'; + +const Svg = styled.svg` + width: var(--size, 2em); + height: var(--size, 2em); + animation: ${spin} 3.33s linear infinite; +`; + +export const Indeterminate = (props) => + + diff --git a/src/js/components/Spinner/index.ts b/src/js/components/Spinner/index.ts new file mode 100644 index 0000000000..0a1710f571 --- /dev/null +++ b/src/js/components/Spinner/index.ts @@ -0,0 +1,2 @@ +import { Spinner } from './Spinner'; +export { Spinner }; diff --git a/src/js/components/StandaloneApp.stories.tsx b/src/js/components/StandaloneApp.stories.tsx new file mode 100644 index 0000000000..a44d036973 --- /dev/null +++ b/src/js/components/StandaloneApp.stories.tsx @@ -0,0 +1,254 @@ +import styled from 'styled-components'; +import { classnames } from '@/utils/classnames'; +import { Storybook } from '@/utils/storybook'; + +import { useAppState } from '@/utils/useAppState'; + +import { LeftSidebar } from '@/concept/LeftSidebar'; +import { RightSidebar } from '@/concept/RightSidebar'; +import { AppToolbar } from '@/AppToolbar'; +import { CommandBar } from '@/concept/CommandBar'; +import { AppLayout, MainContent } from '@/concept/App'; +import { Page } from '@/concept/Page'; +import { usePresenceProvider } from '@/concept/Block/hooks/usePresenceProvider'; + +import { mockPeople } from '@/Avatar/mockData'; +const mockPresence = mockPeople.map((p, index) => ({ ...p, uid: index.toString() })) + +import { + WithPresence +} from './concept/Block/Block.stories'; + +export default { + title: 'App/Standalone', + component: Window, + argTypes: { + connectionStatus: { + options: ['local', 'connecting', 'connected', 'reconnecting', 'offline'], + control: { type: 'radio' }, + defaultValue: 'local' + } + }, + parameters: { + layout: 'fullscreen' + }, + decorators: [(Story) => ] +}; + +const WindowWrapper = styled.div` + justify-self: stretch; + width: 100%; + border-radius: 5px; + box-shadow: 0 10px 12px rgb(0 0 0 / 0.1); + overflow: hidden; + position: relative; + background: var(--background-color); + + > * { + z-index: 1; + } + + &.os-windows { + border-radius: 4px; + } + + &.os-mac { + border-radius: 12px; + + &.is-electron { + &:before { + content: ''; + width: 12px; + height: 12px; + position: absolute; + border-radius: 100px; + left: 20px; + top: 19px; + background: #888; + z-index: 999999; + box-shadow: 20px 0 0 0 #888, 40px 0 0 0 #888; + } + + &.is-theme-dark { + &:after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + z-index: 2; + box-shadow: inset 0 0 1px #fff, 0 0 1px #000; + } + } + } + } + + &.is-win-maximized, + &.is-win-fullscreen { + border-radius: 0; + height: 100vh; + width: 100vw; + margin: 0; + } + + &.is-storybook-docs { + height: 700px; + } +`; + +const Template = (args, context) => { + const { + currentUser, + setCurrentUser, + route, + currentPageMembers, + differentPageMembers, + activeDatabase, + setActiveDatabase, + inactiveDatabases, + isSynced, + isElectron, + setRoute, + hostAddress, + isThemeDark, + setIsThemeDark, + isWinFullscreen, + isWinFocused, + isWinMaximized, + isLeftSidebarOpen, + setIsLeftSidebarOpen, + isRightSidebarOpen, + setIsRightSidebarOpen, + setIsSettingsOpen, + isCommandBarOpen, + setIsCommandBarOpen, + isMergeDialogOpen, + setIsMergeDialogOpen, + isDatabaseDialogOpen, + } = useAppState(); + + const { PresenceProvider, clearPresence } = usePresenceProvider({ presentPeople: mockPresence }); + + return ( + + + setActiveDatabase(database)} + handlePressAddDatabase={() => console.log('pressed add database')} + handlePressRemoveDatabase={() => console.log('pressed remove database')} + handlePressImportDatabase={() => console.log('pressed import database')} + handlePressMoveDatabase={() => console.log('pressed move database')} + handlePressMember={(person) => console.log(person)} + handlePressCommandBar={() => setIsCommandBarOpen(!isCommandBarOpen)} + handlePressDailyNotes={() => setRoute('/daily-notes')} + handlePressAllPages={() => setRoute('/all-pages')} + handlePressGraph={() => setRoute('/graph')} + handlePressThemeToggle={() => setIsThemeDark(!isThemeDark)} + handlePressMerge={() => setIsMergeDialogOpen(true)} + handlePressSettings={() => setIsSettingsOpen(true)} + handlePressHistoryBack={() => console.log('pressed go back')} + handlePressHistoryForward={() => console.log('pressed go forward')} + handlePressLeftSidebarToggle={() => setIsLeftSidebarOpen(!isLeftSidebarOpen)} + handlePressRightSidebarToggle={() => setIsRightSidebarOpen(!isRightSidebarOpen)} + handlePressMinimize={() => console.log('pressed minimize')} + handlePressMaximizeRestore={() => console.log('pressed maximize/restore')} + handlePressClose={() => console.log('pressed close')} + handlePressHostAddress={(hostAddress) => console.log('pressed', hostAddress)} + handleUpdateProfile={(person) => setCurrentUser(person)} + /> + null} + shortcuts={[{ + uid: "4b89dde0-3ccf-481a-875b-d11adfda3f7e", + title: "Passer domesticus", + order: 1 + }, { + uid: "bd4a892f-c7e5-45d8-bab8-68a8ed9d224f", + title: "Spermophilus richardsonii", + order: 2 + }, { + uid: "b60fc12e-bf48-415c-a059-a7a4d5ef686e", + title: "Leprocaulinus vipera", + order: 3 + }, { + uid: "c58d62e5-0e1b-4f30-a156-af8467317c1c", + title: "Rangifer tarandus", + order: 4 + }, { + uid: "dd099e5d-1f6d-4be7-8bf0-9fc0310ba489", + title: "Nycticorax nycticorax", + order: 5 + }]} + version="1.0.0" + /> + + null} + handlePressUnlinkedReferencesToggle={() => null} + > + + + + + + + {/* */} + {isCommandBarOpen && ( setIsCommandBarOpen(false)} + />) + } + + ) +}; + +export const MacOs = Template.bind({}); +MacOs.args = { + os: 'mac', + isElectron: true, +}; + +export const Windows = Template.bind({}); +Windows.args = { + os: 'windows', + isElectron: true +}; + +export const Linux = Template.bind({}); +Linux.args = { + os: 'linux', + isElectron: true +}; diff --git a/src/js/components/Toggle/Toggle.stories.tsx b/src/js/components/Toggle/Toggle.stories.tsx new file mode 100644 index 0000000000..97a82860f6 --- /dev/null +++ b/src/js/components/Toggle/Toggle.stories.tsx @@ -0,0 +1,41 @@ +import { Toggle } from './Toggle'; +import { BADGE, Storybook } from '@/utils/storybook'; + +export default { + title: 'Components/Toggle', + component: Toggle, + argTypes: {}, + parameters: { + layout: 'centered', + badges: [BADGE.DEV, BADGE.IN_USE] + }, + decorators: [(Story) => ] +}; + +const Template = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + defaultSelected: true +}; + +export const Wide = Template.bind({}); +Wide.args = { + toggleShape: { + width: 80, + height: 36, + inset: 1.5 + } +}; + +export const Labeled = Template.bind({}); +Labeled.args = { + checkedLabel: 'On', + unCheckedLabel: 'Off', + style: { fontSize: '2rem' }, + toggleShape: { + width: 120, + height: 50, + inset: 1.5 + } +}; diff --git a/src/js/components/Toggle/Toggle.tsx b/src/js/components/Toggle/Toggle.tsx new file mode 100644 index 0000000000..ac27f1616a --- /dev/null +++ b/src/js/components/Toggle/Toggle.tsx @@ -0,0 +1,189 @@ +import React from 'react'; +import styled from 'styled-components'; +import { useSwitch } from '@react-aria/switch' +import { useToggleState } from '@react-stately/toggle'; +import { HoverProps, useHover } from '@react-aria/interactions'; +import { useFocusRing } from '@react-aria/focus'; +import { usePress } from '@react-aria/interactions' +import { useVisuallyHidden } from '@react-aria/visually-hidden' +import { AriaSwitchProps } from '@react-types/switch'; +import { mergeProps } from '@react-aria/utils'; + +import { classnames } from '@/utils/classnames'; + +const Handle = styled.rect``; + +const Track = styled.rect``; + +const FocusRing = styled.rect``; + +const Svg = styled.svg` + height: 1em; + flex: 0 0 auto; + overflow: visible; + &, + & * { + transform-origin: center; + vector-effect: non-scaling-stroke; + transition: all 0.1s ease-in-out; + } +`; + +const ValueLabel = styled.text``; + +const Wrap = styled.label` + ${Track} { + fill: var(--body-text-color---opacity-low); + } + ${Handle} { + fill: var(--link-color---contrast); + } + &.is-selected { + ${Track} { + fill: var(--link-color); + } + ${Handle} { + fill: var(--link-color---contrast); + } + } + &.is-hovered { + ${Track} { + fill: var(--body-text-color---opacity-med); + } + ${Handle} { + fill: var(--link-color---contrast); + } + } + &.is-selected.is-hovered { + ${Track} { + fill: var(--link-color---opacity-high); + } + ${Handle} { + fill: var(--link-color---contrast); + } + } + &.is-pressed { + ${Track}, + ${Handle} { + transition-duration: 0s; + } + + ${Track} { + fill: var(--link-color); + } + ${Handle} { + fill: var(--link-color---contrast); + } + } +`; + +interface ToggleProps extends AriaSwitchProps, HoverProps { + toggleShape?: { width: number, height: number, inset: number }; + children?: React.ReactNode; + defaultValue?: boolean; + checkedLabel?: string; + unCheckedLabel?: string; + style: React.CSSProperties; + onChange?: (value: boolean) => void; +} + +export const Toggle = (props: ToggleProps) => { + const { + children, + toggleShape, + checkedLabel, + unCheckedLabel, + style + } = props; + + let state = useToggleState(props); + let ref = React.useRef(null); + let { inputProps } = useSwitch(props, state, ref); + let { hoverProps, isHovered } = useHover(props); + let { pressProps, isPressed } = usePress(props); + let { isFocusVisible, focusProps } = useFocusRing(); + let { visuallyHiddenProps } = useVisuallyHidden(); + + return ( + + + + + + {checkedLabel && + {checkedLabel} + } + {unCheckedLabel && {unCheckedLabel}} + + + {children} + + ) +} + +Toggle.defaultProps = { + toggleShape: { + width: 36, + height: 24, + inset: 1.5 + } +} + +Toggle.Wrap = Wrap; +Toggle.Svg = Svg; +Toggle.Handle = Handle; +Toggle.Track = Track; +Toggle.ValueLabel = ValueLabel; diff --git a/src/js/components/Toggle/index.ts b/src/js/components/Toggle/index.ts new file mode 100644 index 0000000000..f12465ea72 --- /dev/null +++ b/src/js/components/Toggle/index.ts @@ -0,0 +1,2 @@ +import { Toggle } from './Toggle'; +export { Toggle }; diff --git a/src/js/components/concept/App/AppLayout.ts b/src/js/components/concept/App/AppLayout.ts new file mode 100644 index 0000000000..1ef3504c98 --- /dev/null +++ b/src/js/components/concept/App/AppLayout.ts @@ -0,0 +1,19 @@ +import styled from 'styled-components'; + +/** + * Provides grid for high-level app layout + */ +export const AppLayout = styled.div.attrs({ id: 'app-layout' })` + --app-upper-spacing: 2.5rem; + display: grid; + grid-template-areas: 'app-header app-header app-header' + 'left-sidebar main-content secondary-content' + 'devtool devtool devtool'; + grid-template-columns: auto 1fr auto; + grid-template-rows: auto 1fr auto; + height: 100vh; + + .os-mac & { + --app-upper-spacing: calc(2.5rem + 48px); + } +`; \ No newline at end of file diff --git a/src/js/components/concept/App/MainContent.ts b/src/js/components/concept/App/MainContent.ts new file mode 100644 index 0000000000..b6c9c8da0e --- /dev/null +++ b/src/js/components/concept/App/MainContent.ts @@ -0,0 +1,31 @@ +import styled from 'styled-components'; + +export const MainContent = styled.div` + flex: 1 1 100%; + margin-left: auto; + margin-right: auto; + grid-area: main-content; + align-items: flex-start; + justify-content: stretch; + padding-top: var(--app-upper-spacing); + display: flex; + overflow-y: auto; + + @supports (overflow-y: overlay) { + overflow-y: overlay; + } + + &::-webkit-scrollbar { + background: var(--background-minus-1); + width: 0.5rem; + height: 0.5rem; + } + + &::-webkit-scrollbar-corner { + background: var(--background-minus-1); + } + + &::-webkit-scrollbar-thumb { + background: var(--background-minus-2); + } +`; \ No newline at end of file diff --git a/src/js/components/concept/App/index.ts b/src/js/components/concept/App/index.ts new file mode 100644 index 0000000000..d22a199262 --- /dev/null +++ b/src/js/components/concept/App/index.ts @@ -0,0 +1,3 @@ +import { MainContent } from './MainContent'; +import { AppLayout } from './AppLayout'; +export { AppLayout, MainContent }; \ No newline at end of file diff --git a/src/js/components/concept/Badge/Badge.stories.tsx b/src/js/components/concept/Badge/Badge.stories.tsx new file mode 100644 index 0000000000..1a85167157 --- /dev/null +++ b/src/js/components/concept/Badge/Badge.stories.tsx @@ -0,0 +1,76 @@ +import styled from 'styled-components'; +import { Badge } from './Badge'; +import { BADGE, Storybook } from '@/utils/storybook'; + +import { Home } from '@material-ui/icons'; + +export default { + title: 'Concepts/Badge', + component: Badge, + argTypes: {}, + parameters: { + layout: 'centered', + badges: [BADGE.DEV] + }, + decorators: [(Story) => ] +}; + +const Wrapper = styled.div` + display: flex; + gap: 2rem; + font-size: 2em; +`; + +const MockObject = styled.div` + border-radius: 0.25rem; + background: var(--body-text-color---opacity-low); + width: 2em; + height: 2em; +`; + +const Template = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + children: , +}; + +export const Content = Template.bind({}); +Content.args = { + children: , + badgeContent: '7' +}; + +const StyledIcon = styled(Home)` + background: var(--background-plus-2); + padding: 0.125em; + border-radius: 100em; + font-size: 2.5rem !important; +`; + +export const Position = () => + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/js/components/concept/Badge/Badge.tsx b/src/js/components/concept/Badge/Badge.tsx new file mode 100644 index 0000000000..a97b720c7c --- /dev/null +++ b/src/js/components/concept/Badge/Badge.tsx @@ -0,0 +1,65 @@ +import React, { ReactNode } from 'react'; +import styled from 'styled-components'; + +const BadgeEl = styled.b` + position: absolute; + border-radius: 100em; + background: var(--badge-background-color, var(--link-color)); + font-size: var(--font-size--text-xs); + color: var(--badge-text-color, #fff); + padding: 0.125em 0.35em; + line-height: 1; + + &:empty { + padding: 0; + width: var(--size, 0.5rem); + height: var(--size, 0.5rem); + } + + &.placement-top-right { + top: 0; + right: 0; + transform: translate(50%, -50%); + } + &.placement-top-left { + top: 0; + left: 0; + transform: translate(-50%, -50%); + } + &.placement-bottom-left { + bottom: 0; + left: 0; + transform: translate(-50%, 50%); + } + &.placement-bottom-right { + bottom: 0; + right: 0; + transform: translate(50%, 50%); + } +`; + +const BadgeWrap = styled.span` + position: relative; + display: inline-flex; +`; + +interface BadgeProps extends React.HTMLAttributes { + badgeContent?: ReactNode, + /** Where the badge should appear relative to the content */ + placement?: 'top-right' | 'top-left' | 'bottom-left' | 'bottom-right', +} + +/** + * Wrap the content with a badge + */ +export const Badge = ({ + badgeContent, + placement = "top-right", + children, + ...props +}: BadgeProps) => + {children} + + {badgeContent} + + diff --git a/src/js/components/concept/Badge/index.ts b/src/js/components/concept/Badge/index.ts new file mode 100644 index 0000000000..0450f88303 --- /dev/null +++ b/src/js/components/concept/Badge/index.ts @@ -0,0 +1,2 @@ +import { Badge } from './Badge'; +export { Badge } \ No newline at end of file diff --git a/src/js/components/concept/Block/Block.stories.tsx b/src/js/components/concept/Block/Block.stories.tsx new file mode 100644 index 0000000000..f9fd9dde88 --- /dev/null +++ b/src/js/components/concept/Block/Block.stories.tsx @@ -0,0 +1,134 @@ +import { mockPeople } from '@/Avatar/mockData'; + +import { Block } from './Block'; +import { BADGE, Storybook } from '@/utils/storybook'; +import { Meter } from '@/concept/Meter'; +import { blockTree } from './mockData'; +import { renderBlocks } from './utils/renderBlocks'; +import { useToggle } from './hooks/useToggle'; +import { usePresence } from './hooks/usePresence'; +import { useChecklist } from './hooks/useChecklist'; +import { useSelection } from './hooks/useSelection'; +import { useBlockState } from './hooks/useBlockState'; +import { usePresenceProvider } from './hooks/usePresenceProvider'; + +const mockPresence = mockPeople.map((p, index) => ({ ...p, uid: index.toString() })) + +export default { + title: 'Concepts/Block', + blockComponent: Block, + argTypes: {}, + decorators: [(Story) => ], + parameters: { + badges: [BADGE.DEV] + } +}; + +const Template = (args) => ; + +export const Basic = Template.bind({}); +Basic.args = { + ...blockTree.blocks["1"], +}; + +export const Editing = Template.bind({}); +Editing.args = { + ...blockTree.blocks["1"], + isEditing: true, +}; + +export const References = Template.bind({}); +References.args = { + ...blockTree.blocks["1"], + refsCount: 12 +}; + +export const Selected = Template.bind({}); +Selected.args = { + ...blockTree.blocks["1"], + isSelected: true, +}; + +export const WithToggle = () => { + const { blockGraph: withState, setBlockState } = useBlockState(blockTree); + const { blockGraph } = useToggle(withState, setBlockState); + + const blocks = renderBlocks({ + blockGraph: blockGraph, + setBlockState: setBlockState, + blockComponent: + }); + + return blocks; +} + + +export const WithPresence = () => { + const { blockGraph: withState, setBlockState: withStateState } = useBlockState(blockTree); + const { blockGraph, setBlockState } = usePresence(withState, withStateState); + + const blocks = renderBlocks({ + blockGraph: blockGraph, + setBlockState: setBlockState, + blockComponent: + }); + + return blocks; +} + +WithPresence.decorators = [(Story) => { + const { PresenceProvider, clearPresence } = usePresenceProvider({ presentPeople: mockPresence }); + + return + + + + + +}]; + +export const WithChecklist = () => { + const { blockGraph: withState, setBlockState } = useBlockState(blockTree); + const { blockGraph: withToggle } = useToggle(withState, setBlockState); + const { blockGraph, checked, total } = useChecklist(withToggle, setBlockState); + + const blocks = renderBlocks({ + blockGraph: blockGraph, + setBlockState: setBlockState, + blockComponent: + }); + + return <> + +
+ {blocks} + ; +} + +export const WithSelection = () => { + const { blockGraph: withState, setBlockState } = useBlockState(blockTree); + const { blockGraph: withToggle } = useToggle(withState, setBlockState); + const { blockGraph } = useSelection(withToggle, setBlockState); + + const blocks = renderBlocks({ + blockGraph: blockGraph, + setBlockState: setBlockState, + blockComponent: + }); + + return blocks; +} + +export const MultipleSelected = () => { + const { blockGraph: withState, setBlockState } = useBlockState(blockTree); + const { blockGraph: withToggle } = useToggle(withState, setBlockState); + const { blockGraph } = useSelection(withToggle, setBlockState, true); + + const blocks = renderBlocks({ + blockGraph: blockGraph, + setBlockState: setBlockState, + blockComponent: + }); + + return blocks; +} diff --git a/src/js/components/concept/Block/Block.tsx b/src/js/components/concept/Block/Block.tsx new file mode 100644 index 0000000000..e8844106bf --- /dev/null +++ b/src/js/components/concept/Block/Block.tsx @@ -0,0 +1,214 @@ +import React from "react"; +import styled from "styled-components"; +import { Popper } from "@material-ui/core"; + +import { DOMRoot } from "@/utils/config"; +import { classnames } from "@/utils/classnames"; + +import { Avatar } from "@/Avatar"; +import { Anchor } from "@/Block/components/Anchor"; +import { Toggle } from "@/Block/components/Toggle"; +import { Body } from "./components/Body"; +import { Content, ContentProps } from "./components/Content"; +import { Refs } from "./components/Refs"; +import { Container } from "./components/Container"; + +export interface BlockProps extends Block, ContentProps { + /** + * Whether this block is in editing mode + */ + isDragging?: boolean; + /** + * Whether this block is being dragged + */ + isEditing?: boolean; + /** + * Number of references to this block + */ + refsCount?: number; + /** + * Whether to display the avatar of a present user + */ + showPresentUser?: boolean; + /** + * + */ + linkedRef?: string; + /** + * When toggle is pressed + */ + handlePressToggle?: (uid) => void; + /** + * When anchor is pressed + */ + handlePressAnchor?: () => void; + /** + * When mouse is over block + */ + handleMouseEnterBlock?: () => void; + /** + * When mouse leaves block + */ + handleMouseLeaveBlock?: () => void; + /** + * When raw content of a block is modified. + * Returns the new value of the raw content. + */ + handleContentChange?: (e: any) => void; + /** + * When the content is clicked or tapped + */ + handlePressContainer?: () => void; + /** + * When a dragged item is over this block + */ + handleDragOver?: () => void; + /** + * When a dragged item is no longer over this block + */ + handleDragLeave?: () => void; + /** + * When a dragged item dropped on this block + */ + handleDrop?: () => void; +} + +export const Block = ({ + children, + rawContent, + renderedContent, + presentUser, + showPresentUser = true, + isOpen = true, + isSelected, + isEditable, + isEditing, + isLocked, + isDragging, + linkedRef, + refsCount, + uid, + contentProps, + textareaProps, + handleContentChange, + handleMouseEnterBlock, + handleMouseLeaveBlock, + handlePressToggle, + handlePressAnchor, + handlePressContainer, + handleDragOver, + handleDragLeave, + handleDrop, +}: BlockProps) => { + const [showEditableDom, setRenderEditableDom] = React.useState( + false + ); + const [ + avatarAnchorEl, + setAvatarAnchorEl, + ] = React.useState(null); + + return ( + <> + { + e.stopPropagation(); + handlePressContainer && handlePressContainer(e); + }} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + className={classnames( + children && "show-tree-indicator", + isOpen ? "is-open" : "is-closed", + linkedRef && "is-linked-ref", + isLocked && "is-locked", + isSelected && "is-selected", + presentUser && showPresentUser && "is-presence", + isSelected && isDragging && "is-dragging", + isEditing && "is-editing" + )} + > + {/* Drop area indicator before */} + { + handleMouseEnterBlock; + isEditable && setRenderEditableDom(true); + }} + onMouseLeave={() => { + handleMouseLeaveBlock; + isEditable && setRenderEditableDom(false); + }} + > + {children && !isLocked && ( + + )} + + {/* Tooltip el */} + + {refsCount >= 1 && } + + {/* inline search el */} + {/* slash menu el */} + {isOpen && children} + {/* Drop area indicator child */} + {/* Drop area indicator after */} + + + {showPresentUser && presentUser && ( + <> + + + + + )} + + ); +}; + +Block.Anchor = Anchor; +Block.Container = Container; +Block.Toggle = Toggle; +Block.Body = Body; +Block.Content = Content; +Block.ListContainer = styled.div` + display: flex; + flex-direction: column; +`; diff --git a/src/js/components/concept/Block/components/Body.ts b/src/js/components/concept/Block/components/Body.ts new file mode 100644 index 0000000000..d9c3bc5e5f --- /dev/null +++ b/src/js/components/concept/Block/components/Body.ts @@ -0,0 +1,12 @@ +import styled from 'styled-components'; + +export const Body = styled.div` + display: grid; + grid-template-areas: 'above above above above' + 'toggle bullet content refs' + 'below below below below'; + grid-template-columns: 1em 1em 1fr auto; + grid-template-rows: 0 1fr 0; + border-radius: 0.5rem; + position: relative; +`; diff --git a/src/js/components/concept/Block/components/Container.ts b/src/js/components/concept/Block/components/Container.ts new file mode 100644 index 0000000000..f35c757fd3 --- /dev/null +++ b/src/js/components/concept/Block/components/Container.ts @@ -0,0 +1,93 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + display: flex; + line-height: var(--line-height, 1.75em); + position: relative; + border-radius: 0.125rem; + justify-content: flex-start; + flex-direction: column; + flex: 1 1 100%; + color: inherit; + + &.show-tree-indicator:before { + content: ''; + position: absolute; + width: 1px; + left: calc(1.375em + 1px); + top: 2em; + bottom: 0; + transform: translateX(50%); + transition: background-color 0.2s ease-in-out; + background: var(--user-color, var(--border-color)); + } + + &.is-presence.show-tree-indicator:before { + opacity: var(--opacity-low); + transform: translateX(50%) scaleX(2); + } + + &:after { + content: ''; + position: absolute; + top: 0.75px; + right: 0; + bottom: 0.75px; + left: 0; + opacity: 0; + pointer-events: none; + border-radius: 0.25rem; + transition: opacity 0.075s ease; + background: var(--link-color---opacity-lower); + } + + &.is-selected:after { + opacity: 1; + } + + .is-selected &.is-selected { + &:after { + opacity: 0; + } + } + + .user-avatar { + position: absolute; + transition: transform 0.3s ease; + left: 4px; + top: 4px; + } + + .block-edit-toggle { + position: absolute; + appearance: none; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: none; + border: none; + cursor: text; + display: block; + z-index: 1; + } + + .block-content { + grid-area: content; + min-height: 1.5em; + + &:hover + .user-avatar { + transform: translateX(-2em); + } + } + + &.is-linked-ref { + background-color: var(--background-plus-2); + } + + /* Inset child blocks */ + & & { + margin-left: var(--block-child-inset-margin, 2em); + grid-area: body; + } +`; diff --git a/src/js/components/concept/Block/components/Content.tsx b/src/js/components/concept/Block/components/Content.tsx new file mode 100644 index 0000000000..cc261125a5 --- /dev/null +++ b/src/js/components/concept/Block/components/Content.tsx @@ -0,0 +1,253 @@ +import React from 'react'; +import styled from 'styled-components'; + +const ContentWrap = styled.div` + grid-area: content; + display: grid; + grid-template-areas: "main"; + place-items: stretch; + place-content: stretch; + position: relative; + overflow: visible; + z-index: 2; + flex-grow: 1; + word-break: break-word; + + .rendered-content, + textarea { + grid-area: main; + cursor: text; + font-size: inherit; + font-family: inherit; + color: inherit; + } + + textarea { + color: inherit; + font-size: inherit; + position: relative; + display: block; + -webkit-appearance: none; + resize: none; + transform: translate3d(0,0,0); + outline: none; + background: transparent; + caret-color: var(--link-color); + min-height: 100%; + padding: 0; + margin: 0; + border: 0; + opacity: 0; + } + + &.is-editing, + &.show-editable-dom { + textarea { + z-index: 3; + line-height: inherit; + opacity: 0; + } + } + + &.is-editing { + textarea { + opacity: 1; + } + + .rendered-content { + opacity: 0; + } + } + + &:not(.is-editing):hover textarea { + line-height: inherit; + } + + .is-locked > .block-body > & { + opacity: 0.5 + }; + + span.text-run { + pointer-events: none; + + > a { + position: relative; + z-index: 2; + pointer-events: auto; + } + + } + + span { + grid-area: main; + + > span { + > a { + position: relative; + z-index: 2; + } + } + } + + abbr { + grid-area: main; + z-index: 4; + + > span { + > a { + position: relative; + z-index: 2; + } + } + } + + code, pre { + font-family: 'IBM Plex Mono'; + } + + .media-16-9 { + height: 0; + width: calc(100% - 0.25rem); + z-index: 1; + transform-origin: right center; + transition: all 0.2s ease; + padding-bottom: calc(9 / 16 * 100%); + margin-block: 0.25rem; + margin-inline-end: 0.25rem; + position: relative; + } + + iframe { + border: 0; + box-shadow: inset 0 0 0 0.125rem var(background-minus-1); + position: absolute; + height: 100%; + width: 100%; + cursor: default; + top: 0; + right: 0; + left: 0; + bottom: 0; + border-radius: 0.25rem; + } + + img { + border-radius: 0.25rem; + max-width: calc(100% - 0.25rem); + } + + h1, h2, h3, h4, h5, h6 { + margin: 0; + color: var(--body-text-color---opacity-higher); + font-weight: 500; + } + + h1 { + padding: 0; + margin-block-start: "-0.1em"; + } + + h2, h3 { + padding: 0; + } + + h4 { + padding: 0.25em 0; + } + + h5 { + padding: 1em 0; + } + + h6 { + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 1em 0; + } + + p { + margin: 0; + padding-bottom: 1em; + } + + blockquote { + margin-block: 0.125rem; + margin-inline: 0.5em; + padding-block: calc(0.5em - 0.125rem - 0.125rem); + padding-inline: 1.5em; + border-radius: 0.25em; + background: var(--background-minus-1); + color: var(--body-text-color---opacity-high); + + p { + padding-bottom: 1em; + + &:last-child { + padding-bottom: 0; + } + } + } + + mark.content-visibility.highlight { + padding: 0 0.2em; + border-radius: 0.125rem; + background-color: var(--text-highlight-color); + } + +`; + +export interface ContentProps { + /** The raw content of the block */ + rawContent: string; + /** The rendered content of the block */ + renderedContent?: RenderedContent; + /** Whether the block is in editing mode */ + isEditable?: boolean; + /** Whether the block is in editing mode */ + isEditing?: boolean; + /** Whether the block has child blocks */ + isLocked?: boolean; + /** Whether the block should render its editable components or just the static content */ + showEditableDom?: boolean; + /** When raw content of a block is modified. Returns the new value of the raw content. */ + handleContentChange?: (e: any) => void; + /** When the content is clicked or tapped */ + handlePressContent?: () => void; + /** Props on the content container */ + contentProps: React.HTMLAttributes; + /** Props on the editable textarea */ + textareaProps: React.TextareaHTMLAttributes; +} + +export const Content = ({ + rawContent, + renderedContent, + isLocked, + isEditable, + isEditing, + showEditableDom, + handleContentChange, + contentProps, + textareaProps, +}: ContentProps) => ( + + {(isEditing || showEditableDom) && (