diff --git a/src/cljs/athens/events/remote.cljs b/src/cljs/athens/events/remote.cljs index 1c6fac5185..4e65e78317 100644 --- a/src/cljs/athens/events/remote.cljs +++ b/src/cljs/athens/events/remote.cljs @@ -307,3 +307,11 @@ ;; Remove the server event after everything is done. true (into [[:remote/clear-server-event event]]))]]}))) + +;; Subs + +(rf/reg-sub + :remote/event-sync-memory-log + (fn [db _] + (when-let [event-sync (:remote/event-sync db)] + (event-sync/stage-log event-sync :memory)))) diff --git a/src/cljs/athens/router.cljs b/src/cljs/athens/router.cljs index ebc2a84d9d..3943195fe3 100644 --- a/src/cljs/athens/router.cljs +++ b/src/cljs/athens/router.cljs @@ -156,6 +156,7 @@ (def routes ["/" ["" {:name :home}] + ["quick-capture" {:name :quickcapture}] ["settings" {:name :settings}] ["pages" {:name :pages}] ["page-t/:title" {:name :page-by-title}] diff --git a/src/cljs/athens/views.cljs b/src/cljs/athens/views.cljs index 7b7652a74f..8c1a546b9d 100644 --- a/src/cljs/athens/views.cljs +++ b/src/cljs/athens/views.cljs @@ -13,8 +13,10 @@ [athens.views.app-toolbar :as app-toolbar] [athens.views.athena :refer [athena-component]] [athens.views.help :refer [help-popup]] + [athens.views.hoc.perf-mon :as perf-mon] [athens.views.left-sidebar :as left-sidebar] [athens.views.pages.core :as pages] + [athens.views.pages.quick-capture :as quick-capture] [athens.views.pages.settings :as settings] [athens.views.right-sidebar.core :as right-sidebar] [re-frame.core :as rf])) @@ -34,6 +36,7 @@ [] (let [loading (rf/subscribe [:loading?]) modal (rf/subscribe [:modal]) + route-name (rf/subscribe [:current-route/name]) right-sidebar-open? (rf/subscribe [:right-sidebar/open]) right-sidebar-width (rf/subscribe [:right-sidebar/width]) settings-open? (rf/subscribe [:settings/open?])] @@ -42,52 +45,57 @@ (zoom)) [:> ChakraProvider {:theme theme, :bg "background.basement"} - [:> ContextMenuProvider - [:> LayoutProvider - [help-popup] - [alert] - [athena-component] - (cond - (and @loading @modal) [db-modal/window] + [:> LayoutProvider + [:> ContextMenuProvider + (if + (= @route-name :quickcapture) + [perf-mon/hoc-perfmon-no-new-tx {:span-name "quick-capture"} + [:f> quick-capture/quick-capture]] + [:<> + [help-popup] + [alert] + [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 + [:> Center {:height "100vh"} + [:> Flex {:width 28 + :flexDirection "column" + :gap 2 + :color "foreground.secondary" + :borderRadius "lg" + :placeItems "center" + :placeContent "center" + :height 28} + [:> Spinner {:size "xl"}]]] - :else [:<> - (when @modal - [db-modal/window]) - (when @settings-open? - [settings/page]) - [:> VStack {:overscrollBehavior "contain" - :id "main-layout" - :spacing 0 - :overflowY "auto" - :height "100vh" - :bg "background.floor" - :transitionDuration "fast" - :transitionProperty "background" - :transitionTimingFunction "ease-in-out" - :align "stretch" - :position "relative"} - [app-toolbar/app-toolbar] - [:> HStack {:overscrollBehavior "contain" - :align "stretch" - :spacing 0 - :flex 1} - [left-sidebar/left-sidebar] - [:> MainContent {:rightSidebarWidth @right-sidebar-width - :isRightSidebarOpen @right-sidebar-open?} - [pages/view]] - [:> RightSidebarResizeControl {:rightSidebarWidth @right-sidebar-width - :isRightSidebarOpen @right-sidebar-open? - :onResizeSidebar #(rf/dispatch [:right-sidebar/set-width %])}] - [right-sidebar/right-sidebar]]]])]]]]))) + :else [:<> + (when @modal + [db-modal/window]) + (when @settings-open? + [settings/page]) + [:> VStack {:overscrollBehavior "contain" + :id "main-layout" + :spacing 0 + :overflowY "auto" + :height "100vh" + :bg "background.floor" + :transitionDuration "fast" + :transitionProperty "background" + :transitionTimingFunction "ease-in-out" + :align "stretch" + :position "relative"} + [app-toolbar/app-toolbar] + [:> HStack {:overscrollBehavior "contain" + :align "stretch" + :spacing 0 + :flex 1} + [left-sidebar/left-sidebar] + [:> MainContent {:rightSidebarWidth @right-sidebar-width + :isRightSidebarOpen @right-sidebar-open?} + [pages/view]] + [:> RightSidebarResizeControl {:rightSidebarWidth @right-sidebar-width + :isRightSidebarOpen @right-sidebar-open? + :onResizeSidebar #(rf/dispatch [:right-sidebar/set-width %])}] + [right-sidebar/right-sidebar]]]])])]]]]))) diff --git a/src/cljs/athens/views/pages/core.cljs b/src/cljs/athens/views/pages/core.cljs index 8c39b43c09..a0ba996607 100644 --- a/src/cljs/athens/views/pages/core.cljs +++ b/src/cljs/athens/views/pages/core.cljs @@ -6,6 +6,7 @@ [athens.views.pages.daily-notes :as daily-notes] [athens.views.pages.graph :as graph] [athens.views.pages.page :as page] + [athens.views.pages.quick-capture :as quick-capture] [re-frame.core :as rf])) @@ -20,6 +21,8 @@ :title "Reconnecting to server..."}))) [:<> (case @route-name + :quickcapture [perf-mon/hoc-perfmon-no-new-tx {:span-name "quick-capture"} + [quick-capture/quick-capture]] :pages [perf-mon/hoc-perfmon-no-new-tx {:span-name "pages/all-pages"} [all-pages/page]] :page [perf-mon/hoc-perfmon {:span-name "pages/page"} diff --git a/src/cljs/athens/views/pages/quick_capture.cljs b/src/cljs/athens/views/pages/quick_capture.cljs new file mode 100644 index 0000000000..2029b1e214 --- /dev/null +++ b/src/cljs/athens/views/pages/quick_capture.cljs @@ -0,0 +1,71 @@ +(ns athens.views.pages.quick-capture + (:require + [athens.common-events.graph.ops :as graph-ops] + [athens.common.utils :as utils] + [athens.dates :as dates] + [athens.electron.db-menu.core :refer [db-menu]] + [clojure.data :as data] + ["@chakra-ui/react" :refer [Button]] + ["/components/Quick/QuickCapture" :refer [QuickCapture]] + ["react" :as react] + [reagent.core :as r] + [re-frame.core :as rf])) + + +(defn quick-capture + [] + (let [[notes setNotes] (react/useState []) + [lastSyncTime, setLastSyncTime] (react/useState nil) + memory-log @(rf/subscribe [:remote/event-sync-memory-log]) + unsynced-uids (->> memory-log + (map #(-> % second :event/op :op/consequences first :op/args :block/uid)) + (filter some?) + set) + unsynced-uids-notes (->> notes + (filter #(-> % :isSaved false?)) + (map :uid) + set) + mock-sync (fn [notes] + (prn notes) + (setNotes []) + (setLastSyncTime (js/Date.now))) + mock-add-item (fn [string] + ;; Send via reframe. + (rf/dispatch [:properties/update-in [:node/title (:title (dates/get-day))] + ["last-qc-message"] + ;; TODO: need to support first/last, and deep path positions + #_["Quick Capture" current-username :last] + (fn [db uid] + (let [new-note (merge {:string string :timestamp (js/Date.now) :uid uid} + (when-not memory-log {:isSaved true}))] + ;; Save on local comp state. + (setNotes (conj notes new-note)) + ;; Save on graph. + [(graph-ops/build-block-save-op db uid string)]))]))] + + (when memory-log + (let [[a b] (data/diff unsynced-uids unsynced-uids-notes)] + (when (or a b) + (println notes a b) + (setNotes (map (fn [{:keys [uid] :as x}] + (cond + (and a (a uid)) (assoc x :isSaved false) + (and b (b uid)) (assoc x :isSaved true) + :else x)) + notes))))) + + [:<> [:> QuickCapture {:dbMenu (r/as-element [db-menu]) + :notes notes + :lastSyncTime lastSyncTime + :onAddItem mock-add-item + :newEventId utils/gen-event-id}] + [:> Button {:position "fixed" + :variant "text" + :size "xs" + :left "50%" + :top 5 + :transform "translateX(-50%)" + :onClick mock-sync} "mock update"]])) + +;; state +;; unsaved changes \ No newline at end of file diff --git a/src/js/components/Quick/QuickCapture.tsx b/src/js/components/Quick/QuickCapture.tsx new file mode 100644 index 0000000000..5d60114214 --- /dev/null +++ b/src/js/components/Quick/QuickCapture.tsx @@ -0,0 +1,221 @@ +import React from 'react' +import { AlertDialog, AlertDialogBody, AlertDialogContent, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, Box, Button, FormControl, HStack, Text, Textarea, VStack } from "@chakra-ui/react" +import { CheckmarkCircleFillIcon } from '@/Icons/Icons' +import { AnimatePresence, motion } from 'framer-motion' + +const FloatingInput = (props) => { + const { onSubmit } = props + const [string, setString] = React.useState("") + const inputRef = React.useRef(null) + + const handleSubmit = (e) => { + if (string.length) { + e.preventDefault() + onSubmit(string) + setString("") + inputRef.current.focus() + } + } + + React.useEffect(() => { + inputRef.current.focus() + }, []) + + return <HStack + mt="auto" + flex="0 0 auto" + position="sticky" + inset={0} + py={4} + top="auto" + align="center" + justifyContent="center" + > + <FormControl + width="100%" + flex="0 1 auto" + > + <Textarea + ref={inputRef} + height="20vh" + borderRadius="lg" + resize="none" + placeholder="Tap to begin writing" + border="none" + outline="none" + shadow="page" + _focus={{ + outline: "none", + shadow: "page", + }} + background="background.attic" + enterkeyhint="send" + onKeyDown={(e) => { + if (e.key === "Enter") { + handleSubmit(e) + } + }} + value={string} + onChange={e => setString(e.target.value)} + /> + </FormControl> + </HStack> +} + +const QueuedNote = (props) => { + const { string, timestamp, isSaved } = props; + const itemRef = React.useRef(null) + + React.useLayoutEffect(() => { + if (itemRef.current) { + itemRef.current.scrollIntoView({ behavior: "smooth", block: "start" }) + } + }, []) + + return (<VStack + flex="0 0 auto" + ref={itemRef} + layout + as={motion.div} + initial={{ + opacity: 0, + height: 0, + y: "20vh", + }} + animate={{ + opacity: 1, + height: "auto", + y: 0, + }} + exit={{ + opacity: 0, + height: 0, + y: -200, + scale: 0.5, + }} + spacing={0} + alignSelf="stretch" + > + <VStack + borderRadius="lg" + spacing={1} + alignSelf="stretch" + align="stretch" + overflow="hidden" + background="background.upper" + px={4} + py={3} + > + <HStack + fontSize="xs" + color="foreground.secondary" + justifyContent="space-between" + > + <Text>{new Date(timestamp).toLocaleDateString()}</Text> + <Text + display="inline-flex" + gap={1} + alignItems="center" + > + {isSaved ? ( + <>Saved <CheckmarkCircleFillIcon /></>) : "Waiting to save"}</Text> + </HStack> + <Text>{string}</Text></VStack> + </VStack>); +} + +const Message = ({ children, ...props }) => <Text + layout + as={motion.p} + fontSize="sm" + textAlign="center" + color="foreground.secondary" + exit={{ + opacity: 0, + height: 0, + }} + {...props} +>{children}</Text>; + +export const QuickCapture = ({ dbMenu, notes, onAddItem, lastSyncTime }) => { + const [isSwitchDialogOpen, setIsSwitchDialogOpen] = React.useState(false); + const containerRef = React.useRef(null) + const confirmationCancelRef = React.useRef(null) + + return <> + <style> + {` + html, body { + height: 100%; + width: 100%; + position: fixed; + overflow: hidden; + margin: 0; + padding: 0; + } + `} + </style> + <VStack + align="stretch" + backgroundAttachment="fixed" + pt={4} + overflow="hidden" + height="100dvh" + width="100vw" + position="relative" + justifyContent="flex-end" + sx={{ + maskImage: "linear-gradient(to bottom, #00000000 1rem, #000000ff 3rem, #000000ff 100%)" + }} + > + <VStack + align="stretch" + maxHeight="100%" + pt={10} + px={4} + bg="linear-gradient(to bottom, #00000000 50%, #00000011)" + ref={containerRef} + overflowY="scroll" + overscrollBehaviorY='contain' + > + <AnimatePresence initial={true}> + {(notes.length) && <Message key="today">Today</Message>} + {lastSyncTime && <Message key="lastsynced">Last synced: {new Date(lastSyncTime).toLocaleDateString()}</Message>} + {notes.length && notes.map((note, index) => <QueuedNote key={note.timestamp} {...note} />)} + {!(notes.length || lastSyncTime) && <Message key="placeholder">Save a message to today's Daily Notes</Message>} + <FloatingInput onSubmit={onAddItem} /> + </AnimatePresence> + </VStack> + </VStack> + <HStack position="fixed" justifyContent="space-between" inset={3} bottom="auto" as={motion.div}> + {dbMenu} + <Button + borderRadius="full" + onClick={() => setIsSwitchDialogOpen(true)} + size="sm" + sx={{ + backdropFilter: "blur(10px)", + }} + >Switch to Athens</Button> + </HStack> + <AlertDialog + size="sm" + isOpen={isSwitchDialogOpen} + onClose={() => setIsSwitchDialogOpen(false)} + leastDestructiveRef={confirmationCancelRef} + > + <AlertDialogOverlay /> + <AlertDialogContent py={3}> + <AlertDialogHeader py={1}>Switch to Athens?</AlertDialogHeader> + <AlertDialogBody py={1}> + <Text>Notes that have not been synced will be lost.</Text> + </AlertDialogBody> + <AlertDialogFooter py={1}> + <Button variant="secondary" ref={confirmationCancelRef} onClick={() => setIsSwitchDialogOpen(false)}>Go back</Button> + <Button variant="secondary" colorScheme="destructive" onClick={() => setIsSwitchDialogOpen(false)}>Switch to Athens</Button> + </AlertDialogFooter> + </AlertDialogContent> + + </AlertDialog> + </> +} \ No newline at end of file diff --git a/src/js/theme/theme.js b/src/js/theme/theme.js index 6d4ebf9028..83248cf1ce 100644 --- a/src/js/theme/theme.js +++ b/src/js/theme/theme.js @@ -524,9 +524,12 @@ const components = { }, dialog: { shadow: "dialog", - border: "1px solid", - borderColor: 'separator.divider', - bg: 'background.upper' + border: "none", + maxWidth: 'calc(100% - 2rem)', + maxHeight: 'calc(100% - 2rem)', + overflow: 'auto', + margin: "auto", + bg: 'background.upper', } } },