diff --git a/web/components/custom/content-header.tsx b/web/components/custom/content-header.tsx index a1cffd7b..500e0254 100644 --- a/web/components/custom/content-header.tsx +++ b/web/components/custom/content-header.tsx @@ -1,7 +1,6 @@ "use client" import React from "react" -import { Separator } from "@/components/ui/separator" import { Button } from "../ui/button" import { PanelLeftIcon } from "lucide-react" import { useAtom } from "jotai" @@ -16,7 +15,7 @@ export const ContentHeader = React.forwardRef { > - ) } diff --git a/web/components/custom/delete-modal.tsx b/web/components/custom/delete-modal.tsx deleted file mode 100644 index 207a9184..00000000 --- a/web/components/custom/delete-modal.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from "@/components/ui/dialog" - -interface DeleteModalProps { - isOpen: boolean - onClose: () => void - onConfirm: () => void - title: string -} - -export default function DeletePageModal({ isOpen, onClose, onConfirm, title }: DeleteModalProps) { - return ( - - - - Delete "{title}"? - This action cannot be undone. - - - - - - - - ) -} diff --git a/web/components/custom/topic-selector.tsx b/web/components/custom/topic-selector.tsx new file mode 100644 index 00000000..8a1f6691 --- /dev/null +++ b/web/components/custom/topic-selector.tsx @@ -0,0 +1,179 @@ +import React, { useMemo, useCallback, useRef, forwardRef } from "react" +import { atom, useAtom } from "jotai" +import { useVirtualizer } from "@tanstack/react-virtual" +import { Button, buttonVariants } from "@/components/ui/button" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { cn } from "@/lib/utils" +import { LaIcon } from "@/components/custom/la-icon" +import { Command, CommandInput, CommandList, CommandItem, CommandGroup } from "@/components/ui/command" +import { useCoState } from "@/lib/providers/jazz-provider" +import { PublicGlobalGroup } from "@/lib/schema/master/public-group" +import { ListOfTopics, Topic } from "@/lib/schema" +import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants" +import { VariantProps } from "class-variance-authority" + +interface TopicSelectorProps extends VariantProps { + showSearch?: boolean + defaultLabel?: string + searchPlaceholder?: string + value?: string | null + onChange?: (value: string) => void + onTopicChange?: (value: Topic) => void + className?: string + renderSelectedText?: (value?: string | null) => React.ReactNode + side?: "bottom" | "top" | "right" | "left" + align?: "center" | "end" | "start" +} + +export const topicSelectorAtom = atom(false) + +export const TopicSelector = forwardRef( + ( + { + showSearch = true, + defaultLabel = "Select topic", + searchPlaceholder = "Search topic...", + value, + onChange, + onTopicChange, + className, + renderSelectedText, + side = "bottom", + align = "end", + ...props + }, + ref + ) => { + const [isTopicSelectorOpen, setIsTopicSelectorOpen] = useAtom(topicSelectorAtom) + const group = useCoState(PublicGlobalGroup, JAZZ_GLOBAL_GROUP_ID, { root: { topics: [] } }) + + const handleSelect = useCallback( + (selectedTopicName: string, topic: Topic) => { + onChange?.(selectedTopicName) + onTopicChange?.(topic) + setIsTopicSelectorOpen(false) + }, + [onChange, setIsTopicSelectorOpen, onTopicChange] + ) + + const displaySelectedText = useMemo(() => { + if (renderSelectedText) { + return renderSelectedText(value) + } + return {value || defaultLabel} + }, [value, defaultLabel, renderSelectedText]) + + return ( + + + + + e.preventDefault()} + > + {group?.root.topics && ( + + )} + + + ) + } +) + +TopicSelector.displayName = "TopicSelector" + +interface TopicSelectorContentProps extends Omit { + onSelect: (value: string, topic: Topic) => void + topics: ListOfTopics +} + +const TopicSelectorContent: React.FC = React.memo( + ({ showSearch, searchPlaceholder, value, onSelect, topics }) => { + const [search, setSearch] = React.useState("") + const filteredTopics = useMemo( + () => topics.filter(topic => topic?.prettyName.toLowerCase().includes(search.toLowerCase())), + [topics, search] + ) + + const parentRef = useRef(null) + + const rowVirtualizer = useVirtualizer({ + count: filteredTopics.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 35, + overscan: 5 + }) + + return ( + + {showSearch && ( + + )} + +
+
+ + {rowVirtualizer.getVirtualItems().map(virtualRow => { + const topic = filteredTopics[virtualRow.index] + return ( + topic && ( + onSelect(value, topic)} + style={{ + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: `${virtualRow.size}px`, + transform: `translateY(${virtualRow.start}px)` + }} + > + {topic.prettyName} + + + ) + ) + })} + +
+
+
+
+ ) + } +) + +TopicSelectorContent.displayName = "TopicSelectorContent" + +export default TopicSelector diff --git a/web/components/la-editor/styles/index.css b/web/components/la-editor/styles/index.css index 451af6bd..8173c7b3 100644 --- a/web/components/la-editor/styles/index.css +++ b/web/components/la-editor/styles/index.css @@ -1,140 +1,6 @@ -:root { - --la-font-size-regular: 0.9375rem; - - --la-code-background: rgba(8, 43, 120, 0.047); - --la-code-color: rgb(212, 212, 212); - --la-secondary: rgb(157, 157, 159); - --la-pre-background: rgb(236, 236, 236); - --la-pre-border: rgb(224, 224, 224); - --la-pre-color: rgb(47, 47, 49); - --la-hr: rgb(220, 220, 220); - --la-drag-handle-hover: rgb(92, 92, 94); - - --hljs-string: rgb(170, 67, 15); - --hljs-title: rgb(176, 136, 54); - --hljs-comment: rgb(153, 153, 153); - --hljs-keyword: rgb(12, 94, 177); - --hljs-attr: rgb(58, 146, 188); - --hljs-literal: rgb(200, 43, 15); - --hljs-name: rgb(37, 151, 146); - --hljs-selector-tag: rgb(200, 80, 15); - --hljs-number: rgb(61, 160, 103); -} - -.dark .ProseMirror { - --la-code-background: rgba(255, 255, 255, 0.075); - --la-code-color: rgb(44, 46, 51); - --la-secondary: rgb(89, 90, 92); - --la-pre-background: rgb(8, 8, 8); - --la-pre-border: rgb(35, 37, 42); - --la-pre-color: rgb(227, 228, 230); - --la-hr: rgb(38, 40, 45); - --la-drag-handle-hover: rgb(150, 151, 153); - - --hljs-string: rgb(218, 147, 107); - --hljs-title: rgb(241, 213, 157); - --hljs-comment: rgb(170, 170, 170); - --hljs-keyword: rgb(102, 153, 204); - --hljs-attr: rgb(144, 202, 232); - --hljs-literal: rgb(242, 119, 122); - --hljs-name: rgb(95, 192, 160); - --hljs-selector-tag: rgb(232, 199, 133); - --hljs-number: rgb(182, 231, 182); -} - -.la-editor .ProseMirror { - @apply flex max-w-full flex-1 cursor-text flex-col; - @apply z-0 outline-0; -} - -.la-editor .ProseMirror > div.editor { - @apply block flex-1 whitespace-pre-wrap; -} - -.la-editor .ProseMirror .block-node:not(:last-child), -.la-editor .ProseMirror .list-node:not(:last-child), -.la-editor .ProseMirror .text-node:not(:last-child) { - @apply mb-2.5; -} - -.la-editor .ProseMirror ol, -.la-editor .ProseMirror ul { - @apply pl-6; -} - -.la-editor .ProseMirror blockquote, -.la-editor .ProseMirror dl, -.la-editor .ProseMirror ol, -.la-editor .ProseMirror p, -.la-editor .ProseMirror pre, -.la-editor .ProseMirror ul { - @apply m-0; -} - -.la-editor .ProseMirror li { - @apply leading-7; -} - -.la-editor .ProseMirror p { - @apply break-words; -} - -.la-editor .ProseMirror li .text-node:has(+ .list-node), -.la-editor .ProseMirror li > .list-node, -.la-editor .ProseMirror li > .text-node, -.la-editor .ProseMirror li p { - @apply mb-0; -} - -.la-editor .ProseMirror blockquote { - @apply relative pl-3.5; -} - -.la-editor .ProseMirror blockquote::before, -.la-editor .ProseMirror blockquote.is-empty::before { - @apply bg-accent absolute bottom-0 left-0 top-0 h-full w-1 rounded-sm content-['']; -} - -.la-editor .ProseMirror hr { - @apply my-3 h-0.5 w-full border-none bg-[var(--la-hr)]; -} - -.la-editor .ProseMirror-focused hr.ProseMirror-selectednode { - @apply outline-muted-foreground rounded-full outline outline-2 outline-offset-1; -} - -.la-editor .ProseMirror .ProseMirror-gapcursor { - @apply pointer-events-none absolute hidden; -} - -.la-editor .ProseMirror .ProseMirror-hideselection { - @apply caret-transparent; -} - -.la-editor .ProseMirror.resize-cursor { - @apply cursor-col-resize; -} - -.la-editor .ProseMirror .selection { - @apply inline-block; -} - -.la-editor .ProseMirror .selection, -.la-editor .ProseMirror *::selection, -::selection { - @apply bg-primary/40; -} - -/* Override native selection when custom selection is present */ -.la-editor .ProseMirror .selection::selection { - background: transparent; -} - -[data-theme="slash-command"] { - width: 1000vw; -} - -@import "./partials/code.css"; -@import "./partials/placeholder.css"; -@import "./partials/lists.css"; -@import "./partials/typography.css"; +@import "partials/vars.css"; +@import "partials/prosemirror-base.css"; +@import "partials/code-highlight.css"; +@import "partials/lists.css"; +@import "partials/typography.css"; +@import "partials/misc.css"; diff --git a/web/components/la-editor/styles/partials/code-highlight.css b/web/components/la-editor/styles/partials/code-highlight.css new file mode 100644 index 00000000..62d349c7 --- /dev/null +++ b/web/components/la-editor/styles/partials/code-highlight.css @@ -0,0 +1,86 @@ +.la-editor .ProseMirror code.inline { + @apply rounded border border-[var(--la-code-color)] bg-[var(--la-code-background)] px-1 py-0.5 text-sm; +} + +.la-editor .ProseMirror pre { + @apply relative overflow-auto rounded border font-mono text-sm; + @apply border-[var(--la-pre-border)] bg-[var(--la-pre-background)] text-[var(--la-pre-color)]; + @apply hyphens-none whitespace-pre text-left; +} + +.la-editor .ProseMirror code { + @apply break-words leading-[1.7em]; +} + +.la-editor .ProseMirror pre code { + @apply block overflow-x-auto p-3.5; +} + +.la-editor .ProseMirror pre { + .hljs-keyword, + .hljs-operator, + .hljs-function, + .hljs-built_in, + .hljs-builtin-name { + color: var(--hljs-keyword); + } + + .hljs-attr, + .hljs-symbol, + .hljs-property, + .hljs-attribute, + .hljs-variable, + .hljs-template-variable, + .hljs-params { + color: var(--hljs-attr); + } + + .hljs-name, + .hljs-regexp, + .hljs-link, + .hljs-type, + .hljs-addition { + color: var(--hljs-name); + } + + .hljs-string, + .hljs-bullet { + color: var(--hljs-string); + } + + .hljs-title, + .hljs-subst, + .hljs-section { + color: var(--hljs-title); + } + + .hljs-literal, + .hljs-type, + .hljs-deletion { + color: var(--hljs-literal); + } + + .hljs-selector-tag, + .hljs-selector-id, + .hljs-selector-class { + color: var(--hljs-selector-tag); + } + + .hljs-number { + color: var(--hljs-number); + } + + .hljs-comment, + .hljs-meta, + .hljs-quote { + color: var(--hljs-comment); + } + + .hljs-emphasis { + @apply italic; + } + + .hljs-strong { + @apply font-bold; + } +} diff --git a/web/components/la-editor/styles/partials/placeholder.css b/web/components/la-editor/styles/partials/misc.css similarity index 77% rename from web/components/la-editor/styles/partials/placeholder.css rename to web/components/la-editor/styles/partials/misc.css index e3448388..b9c72aec 100644 --- a/web/components/la-editor/styles/partials/placeholder.css +++ b/web/components/la-editor/styles/partials/misc.css @@ -1,3 +1,7 @@ +[data-theme="slash-command"] { + width: 1000vw; +} + .la-editor .ProseMirror .is-empty::before { @apply pointer-events-none float-left h-0 w-full text-[var(--la-secondary)]; } @@ -10,3 +14,7 @@ content: attr(data-placeholder); @apply pointer-events-none float-left h-0 text-[var(--la-secondary)]; } + +.la-editor div.tiptap p { + @apply text-[var(--la-font-size-regular)]; +} diff --git a/web/components/la-editor/styles/partials/prosemirror-base.css b/web/components/la-editor/styles/partials/prosemirror-base.css new file mode 100644 index 00000000..224db27d --- /dev/null +++ b/web/components/la-editor/styles/partials/prosemirror-base.css @@ -0,0 +1,86 @@ +.la-editor .ProseMirror { + @apply block flex-1 whitespace-pre-wrap outline-0 focus:outline-none; +} + +.la-editor .ProseMirror .block-node:not(:last-child), +.la-editor .ProseMirror .list-node:not(:last-child), +.la-editor .ProseMirror .text-node:not(:last-child) { + @apply mb-2.5; +} + +.la-editor .ProseMirror ol, +.la-editor .ProseMirror ul { + @apply pl-6; +} + +.la-editor .ProseMirror blockquote, +.la-editor .ProseMirror dl, +.la-editor .ProseMirror ol, +.la-editor .ProseMirror p, +.la-editor .ProseMirror pre, +.la-editor .ProseMirror ul { + @apply m-0; +} + +.la-editor .ProseMirror li { + @apply leading-7; +} + +.la-editor .ProseMirror p { + @apply break-words; +} + +.la-editor .ProseMirror li .text-node:has(+ .list-node), +.la-editor .ProseMirror li > .list-node, +.la-editor .ProseMirror li > .text-node, +.la-editor .ProseMirror li p { + @apply mb-0; +} + +.la-editor .ProseMirror blockquote { + @apply relative pl-3.5; +} + +.la-editor .ProseMirror blockquote::before, +.la-editor .ProseMirror blockquote.is-empty::before { + @apply bg-accent-foreground/15 absolute bottom-0 left-0 top-0 h-full w-1 rounded-sm content-['']; +} + +.la-editor .ProseMirror hr { + @apply my-3 h-0.5 w-full border-none bg-[var(--la-hr)]; +} + +.la-editor .ProseMirror-focused hr.ProseMirror-selectednode { + @apply outline-muted-foreground rounded-full outline outline-2 outline-offset-1; +} + +.la-editor .ProseMirror .ProseMirror-gapcursor { + @apply pointer-events-none absolute hidden; +} + +.la-editor .ProseMirror .ProseMirror-hideselection { + @apply caret-transparent; +} + +.la-editor .ProseMirror.resize-cursor { + @apply cursor-col-resize; +} + +.la-editor .ProseMirror .selection { + @apply inline-block; +} + +.la-editor .ProseMirror .selection, +.la-editor .ProseMirror *::selection, +::selection { + @apply bg-primary/40; +} + +/* Override native selection when custom selection is present */ +.la-editor .ProseMirror .selection::selection { + background: transparent; +} + +[data-theme="slash-command"] { + width: 1000vw; +} diff --git a/web/components/la-editor/styles/partials/vars.css b/web/components/la-editor/styles/partials/vars.css new file mode 100644 index 00000000..02aeb608 --- /dev/null +++ b/web/components/la-editor/styles/partials/vars.css @@ -0,0 +1,43 @@ +:root { + --la-font-size-regular: 0.9375rem; + + --la-code-background: rgba(8, 43, 120, 0.047); + --la-code-color: rgb(212, 212, 212); + --la-secondary: rgb(157, 157, 159); + --la-pre-background: rgb(236, 236, 236); + --la-pre-border: rgb(224, 224, 224); + --la-pre-color: rgb(47, 47, 49); + --la-hr: rgb(220, 220, 220); + --la-drag-handle-hover: rgb(92, 92, 94); + + --hljs-string: rgb(170, 67, 15); + --hljs-title: rgb(176, 136, 54); + --hljs-comment: rgb(153, 153, 153); + --hljs-keyword: rgb(12, 94, 177); + --hljs-attr: rgb(58, 146, 188); + --hljs-literal: rgb(200, 43, 15); + --hljs-name: rgb(37, 151, 146); + --hljs-selector-tag: rgb(200, 80, 15); + --hljs-number: rgb(61, 160, 103); +} + +.dark .ProseMirror { + --la-code-background: rgba(255, 255, 255, 0.075); + --la-code-color: rgb(44, 46, 51); + --la-secondary: rgb(89, 90, 92); + --la-pre-background: rgb(8, 8, 8); + --la-pre-border: rgb(35, 37, 42); + --la-pre-color: rgb(227, 228, 230); + --la-hr: rgb(38, 40, 45); + --la-drag-handle-hover: rgb(150, 151, 153); + + --hljs-string: rgb(218, 147, 107); + --hljs-title: rgb(241, 213, 157); + --hljs-comment: rgb(170, 170, 170); + --hljs-keyword: rgb(102, 153, 204); + --hljs-attr: rgb(144, 202, 232); + --hljs-literal: rgb(242, 119, 122); + --hljs-name: rgb(95, 192, 160); + --hljs-selector-tag: rgb(232, 199, 133); + --hljs-number: rgb(182, 231, 182); +} diff --git a/web/components/routes/PublicHomeRoute.tsx b/web/components/routes/PublicHomeRoute.tsx index de55ca15..42739692 100644 --- a/web/components/routes/PublicHomeRoute.tsx +++ b/web/components/routes/PublicHomeRoute.tsx @@ -2,10 +2,9 @@ import * as react from "react" import { useCoState } from "@/lib/providers/jazz-provider" import { PublicGlobalGroup } from "@/lib/schema/master/public-group" -import { ID } from "jazz-tools" import dynamic from "next/dynamic" -import { Button } from "../ui/button" import Link from "next/link" +import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants" let graph_data_promise = import("./graph-data.json").then(a => a.default) const ForceGraphClient = dynamic(() => import("./force-graph-client-lazy"), { ssr: false }) @@ -16,15 +15,11 @@ export function PublicHomeRoute() { const [placeholder, setPlaceholder] = react.useState("Search something...") const [currentTopicIndex, setCurrentTopicIndex] = react.useState(0) const [currentCharIndex, setCurrentCharIndex] = react.useState(0) - const globalGroup = useCoState( - PublicGlobalGroup, - process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID, - { - root: { - topics: [] - } + const globalGroup = useCoState(PublicGlobalGroup, JAZZ_GLOBAL_GROUP_ID, { + root: { + topics: [] } - ) + }) const topics = globalGroup?.root.topics?.map(topic => topic?.prettyName) || [] react.useEffect(() => { diff --git a/web/components/routes/link/header.tsx b/web/components/routes/link/header.tsx index 717c61a7..2c7c07f0 100644 --- a/web/components/routes/link/header.tsx +++ b/web/components/routes/link/header.tsx @@ -42,7 +42,7 @@ export const LinkHeader = React.memo(() => { {isTablet && ( -
+
)} diff --git a/web/components/routes/link/partials/form/link-form.tsx b/web/components/routes/link/partials/form/link-form.tsx index cc7dfb2b..36c325c3 100644 --- a/web/components/routes/link/partials/form/link-form.tsx +++ b/web/components/routes/link/partials/form/link-form.tsx @@ -12,12 +12,13 @@ import { UrlInput } from "./url-input" import { UrlBadge } from "./url-badge" import { TitleInput } from "./title-input" import { NotesSection } from "./notes-section" -import { TopicSelector } from "./topic-selector" import { DescriptionInput } from "./description-input" import { atom, useAtom } from "jotai" -import { linkLearningStateSelectorAtom, linkTopicSelectorAtom } from "@/store/link" +import { linkLearningStateSelectorAtom } from "@/store/link" import { FormField, FormItem, FormLabel } from "@/components/ui/form" import { LearningStateSelector } from "@/components/custom/learning-state-selector" +import { TopicSelector, topicSelectorAtom } from "@/components/custom/topic-selector" +import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants" export const globalLinkFormExceptionRefsAtom = atom[]>([]) @@ -47,8 +48,7 @@ export const LinkForm: React.FC = ({ onClose, exceptionsRefs = [] }) => { - const [selectedTopic, setSelectedTopic] = React.useState() - const [istopicSelectorOpen] = useAtom(linkTopicSelectorAtom) + const [istopicSelectorOpen] = useAtom(topicSelectorAtom) const [islearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom) const [globalExceptionRefs] = useAtom(globalLinkFormExceptionRefsAtom) @@ -65,6 +65,14 @@ export const LinkForm: React.FC = ({ mode: "all" }) + const topicName = form.watch("topic") + const findTopic = React.useMemo( + () => me && Topic.findUnique({ topicName }, JAZZ_GLOBAL_GROUP_ID, me), + [topicName, me] + ) + + const selectedTopic = useCoState(Topic, findTopic, {}) + const allExceptionRefs = React.useMemo( () => [...exceptionsRefs, ...globalExceptionRefs], [exceptionsRefs, globalExceptionRefs] @@ -101,10 +109,11 @@ export const LinkForm: React.FC = ({ description: selectedLink.description, completed: selectedLink.completed, notes: selectedLink.notes, - learningState: selectedLink.learningState + learningState: selectedLink.learningState, + topic: selectedLink.topic?.name }) } - }, [selectedLink, form]) + }, [selectedLink, selectedLink?.topic, form]) const fetchMetadata = async (url: string) => { setIsFetching(true) @@ -214,7 +223,20 @@ export const LinkForm: React.FC = ({ )} /> - setSelectedTopic(topic)} /> + + ( + + Topic + {selectedTopic?.prettyName || "Select a topic"}} + /> + + )} + />
@@ -222,17 +244,7 @@ export const LinkForm: React.FC = ({ -
{ - if (!(e.target as HTMLElement).closest("button")) { - const notesInput = e.currentTarget.querySelector("input") - if (notesInput) { - notesInput.focus() - } - } - }} - > +
{isFetching ? ( diff --git a/web/components/routes/link/partials/form/notes-section.tsx b/web/components/routes/link/partials/form/notes-section.tsx index 5b9da4b9..ff3f5456 100644 --- a/web/components/routes/link/partials/form/notes-section.tsx +++ b/web/components/routes/link/partials/form/notes-section.tsx @@ -13,7 +13,7 @@ export const NotesSection: React.FC = () => { control={form.control} name="notes" render={({ field }) => ( - + Note <> diff --git a/web/components/routes/link/partials/form/topic-selector.tsx b/web/components/routes/link/partials/form/topic-selector.tsx deleted file mode 100644 index 8cb7eb68..00000000 --- a/web/components/routes/link/partials/form/topic-selector.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { Button } from "@/components/ui/button" -import { Command, CommandInput, CommandList, CommandItem, CommandGroup } from "@/components/ui/command" -import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" -import { ScrollArea } from "@/components/ui/scroll-area" -import { CheckIcon } from "lucide-react" -import { useFormContext } from "react-hook-form" -import { cn } from "@/lib/utils" -import { useAtom } from "jotai" -import { linkTopicSelectorAtom } from "@/store/link" -import { LinkFormValues } from "./schema" -import { useCoState } from "@/lib/providers/jazz-provider" -import { PublicGlobalGroup } from "@/lib/schema/master/public-group" -import { ID } from "jazz-tools" -import { LaIcon } from "@/components/custom/la-icon" -import { Topic } from "@/lib/schema" - -interface TopicSelectorProps { - onSelect?: (value: Topic) => void -} - -export const TopicSelector: React.FC = ({ onSelect }) => { - const globalGroup = useCoState( - PublicGlobalGroup, - process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID, - { - root: { - topics: [] - } - } - ) - const [isTopicSelectorOpen, setIsTopicSelectorOpen] = useAtom(linkTopicSelectorAtom) - const form = useFormContext() - - const handleSelect = (value: string) => { - const topic = globalGroup?.root.topics.find(topic => topic?.name === value) - if (topic) { - onSelect?.(topic) - form?.setValue("topic", value) - } - setIsTopicSelectorOpen(false) - } - - const selectedValue = form ? form.watch("topic") : null - - return ( - - - - - e.preventDefault()} - > - - - - - - {globalGroup?.root.topics.map( - topic => - topic?.id && ( - - {topic.prettyName} - - - ) - )} - - - - - - - ) -} diff --git a/web/components/routes/page/detail/PageDetailRoute.tsx b/web/components/routes/page/detail/PageDetailRoute.tsx index f268134e..d3db2476 100644 --- a/web/components/routes/page/detail/PageDetailRoute.tsx +++ b/web/components/routes/page/detail/PageDetailRoute.tsx @@ -1,10 +1,9 @@ "use client" import * as React from "react" -import { useAtom } from "jotai" import { ID } from "jazz-tools" -import { PersonalPage, Topic } from "@/lib/schema" -import { useCallback, useRef, useEffect, useState } from "react" +import { PersonalPage } from "@/lib/schema" +import { useCallback, useRef, useEffect } from "react" import { LAEditor, LAEditorRef } from "@/components/la-editor" import { Content, EditorContent, useEditor } from "@tiptap/react" import { StarterKit } from "@/components/la-editor/extensions/starter-kit" @@ -13,26 +12,46 @@ import { useAccount, useCoState } from "@/lib/providers/jazz-provider" import { EditorView } from "@tiptap/pm/view" import { Editor } from "@tiptap/core" import { generateUniqueSlug } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { LaIcon } from "@/components/custom/la-icon" -import { pageTopicSelectorAtom } from "@/store/page" -import { TopicSelector } from "@/components/routes/link/partials/form/topic-selector" import { FocusClasses } from "@tiptap/extension-focus" -import DeletePageModal from "@/components/custom/delete-modal" +import { DetailPageHeader } from "./header" +import { useMedia } from "react-use" +import TopicSelector from "@/components/custom/topic-selector" const TITLE_PLACEHOLDER = "Untitled" export function PageDetailRoute({ pageId }: { pageId: string }) { + const isMobile = useMedia("(max-width: 770px)") const page = useCoState(PersonalPage, pageId as ID) - if (!page) return
Loading...
+ if (!page) return null return ( -
+
+
+ + {!isMobile && ( +
+
+
+ Page actions +
+ +
+ { + page.topic = topic + page.updatedAt = new Date() + }} + /> +
+
+
+ )}
) @@ -42,9 +61,6 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => { const { me } = useAccount() const titleEditorRef = useRef(null) const contentEditorRef = useRef(null) - const [, setTopicSelectorOpen] = useAtom(pageTopicSelectorAtom) - const [, setSelectedPageTopic] = useState(page.topic || null) - const [deleteModalOpen, setDeleteModalOpen] = useState(false) const isTitleInitialMount = useRef(true) const isContentInitialMount = useRef(true) @@ -55,7 +71,6 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => { return } - console.log("Updating page content") model.content = content model.updatedAt = new Date() } @@ -66,22 +81,6 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => { return } - /* - * The logic changed, but we keep this commented code for reference - */ - // const newTitle = editor.getText().trim() - - // if (!newTitle) { - // toast.error("Update failed", { - // description: "Title must be longer than or equal to 1 character" - // }) - // editor.commands.setContent(page.title || "") - // return - // } - - // if (newTitle === page.title) return - - console.log("Updating page title") const personalPages = me?.root?.personalPages?.toJSON() || [] const slug = generateUniqueSlug(personalPages, page.slug || "") @@ -101,29 +100,44 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => { const { selection } = state const { $anchor } = selection - if ((event.key === "ArrowLeft" || event.key === "ArrowUp") && $anchor.pos - 1 === 0) { - event.preventDefault() - titleEditorRef.current?.commands.focus("end") - return true + switch (event.key) { + case "ArrowRight": + case "ArrowDown": + if ($anchor.pos === state.doc.content.size - 1) { + event.preventDefault() + contentEditorRef.current?.editor?.commands.focus("start") + return true + } + break + case "Enter": + if (!event.shiftKey) { + event.preventDefault() + contentEditorRef.current?.editor?.commands.focus("start") + return true + } + break } + return false }, []) const handleContentKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => { const editor = contentEditorRef.current?.editor if (!editor) return false + const { state } = editor const { selection } = state const { $anchor } = selection + + if ((event.key === "ArrowLeft" || event.key === "ArrowUp") && $anchor.pos - 1 === 0) { + event.preventDefault() + titleEditorRef.current?.commands.focus("end") + return true + } + return false }, []) - const confirmDelete = (page: PersonalPage) => { - console.log("Deleting page:", page.id) - setDeleteModalOpen(false) - //TODO: add delete logic - } - const titleEditor = useEditor({ immediatelyRender: false, autofocus: true, @@ -139,7 +153,6 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => { strike: false, focus: false, gapcursor: false, - history: false, placeholder: { placeholder: TITLE_PLACEHOLDER } @@ -152,7 +165,8 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => { "aria-readonly": "false", "aria-multiline": "false", "aria-label": TITLE_PLACEHOLDER, - translate: "no" + translate: "no", + class: "focus:outline-none" }, handleKeyDown: handleTitleKeyDown }, @@ -160,9 +174,7 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => { if (page.title) editor.commands.setContent(`

${page.title}

`) }, onBlur: ({ editor }) => handleUpdateTitle(editor), - onUpdate: ({ editor }) => { - handleUpdateTitle(editor) - } + onUpdate: ({ editor }) => handleUpdateTitle(editor) }) useEffect(() => { @@ -177,37 +189,20 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => { }, []) return ( -
-
+
+
-
+
-
- { - page.topic = topic - setSelectedPageTopic(topic) - setTopicSelectorOpen(false) - }} - /> - -
{
- - setDeleteModalOpen(false)} - onConfirm={() => { - confirmDelete(page) - }} - title={page.title || ""} - />
) } diff --git a/web/components/routes/page/detail/header.tsx b/web/components/routes/page/detail/header.tsx index 55e1e20e..215e5a7e 100644 --- a/web/components/routes/page/detail/header.tsx +++ b/web/components/routes/page/detail/header.tsx @@ -2,24 +2,33 @@ import * as React from "react" import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header" -import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from "@/components/ui/breadcrumb" import { PersonalPage } from "@/lib/schema/personal-page" -import { ID } from "jazz-tools" +import { useMedia } from "react-use" +import { TopicSelector } from "@/components/custom/topic-selector" + +export const DetailPageHeader = ({ page }: { page: PersonalPage }) => { + const isMobile = useMedia("(max-width: 770px)") -export const DetailPageHeader = ({ pageId }: { pageId: ID }) => { return ( - -
- + isMobile && ( + <> + +
+ +
+
- - - - Pages - - - -
-
+
+ { + page.topic = topic + page.updatedAt = new Date() + }} + align="start" + /> +
+ + ) ) } diff --git a/web/components/routes/search/wrapper.tsx b/web/components/routes/search/wrapper.tsx index 8d51c652..11d59430 100644 --- a/web/components/routes/search/wrapper.tsx +++ b/web/components/routes/search/wrapper.tsx @@ -3,10 +3,10 @@ import { useState } from "react" import { useAccount, useCoState } from "@/lib/providers/jazz-provider" import { LaIcon } from "@/components/custom/la-icon" import AiSearch from "../../custom/ai-search" -import { ID } from "jazz-tools" import Link from "next/link" import { Topic, PersonalLink, PersonalPage } from "@/lib/schema" import { PublicGlobalGroup } from "@/lib/schema/master/public-group" +import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants" interface SearchTitleProps { title: string @@ -80,15 +80,11 @@ export const SearchWrapper = () => { root: { personalLinks: [], personalPages: [] } }) - const globalGroup = useCoState( - PublicGlobalGroup, - process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID, - { - root: { - topics: [] - } + const globalGroup = useCoState(PublicGlobalGroup, JAZZ_GLOBAL_GROUP_ID, { + root: { + topics: [] } - ) + }) const handleSearch = (e: React.ChangeEvent) => { const value = e.target.value.toLowerCase() diff --git a/web/components/routes/topics/detail/partials/section.tsx b/web/components/routes/topics/detail/partials/section.tsx index 23dbc865..f4b8398d 100644 --- a/web/components/routes/topics/detail/partials/section.tsx +++ b/web/components/routes/topics/detail/partials/section.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { LinkItem } from "./link-item" import { LaAccount, PersonalLinkLists, Section as SectionSchema, Topic, UserRoot } from "@/lib/schema" import { Skeleton } from "@/components/ui/skeleton" -import { Loader2 } from "lucide-react" +import { LaIcon } from "@/components/custom/la-icon" interface SectionProps { topic: Topic @@ -29,7 +29,7 @@ export function Section({ me, personalLinks }: SectionProps) { - const [nLinksToLoad, setNLinksToLoad] = useState(10); + const [nLinksToLoad, setNLinksToLoad] = useState(10) const linksToLoad = useMemo(() => { return section.links?.slice(0, nLinksToLoad) @@ -43,25 +43,24 @@ export function Section({
- {linksToLoad?.map( - (link, index) => - link?.url ? ( - { - linkRefs.current[startIndex + index] = el - }} - me={me} - personalLinks={personalLinks} - /> - ) : ( - - ) + {linksToLoad?.map((link, index) => + link?.url ? ( + { + linkRefs.current[startIndex + index] = el + }} + me={me} + personalLinks={personalLinks} + /> + ) : ( + + ) )} {section.links?.length && section.links?.length > nLinksToLoad && ( setNLinksToLoad(n => n + 10)} /> @@ -88,23 +87,25 @@ const LoadMoreSpinner = ({ onLoadMore }: { onLoadMore: () => void }) => { const observer = new IntersectionObserver(handleIntersection, { root: null, rootMargin: "0px", - threshold: 1.0, + threshold: 1.0 }) - if (spinnerRef.current) { - observer.observe(spinnerRef.current) + const currentSpinnerRef = spinnerRef.current + + if (currentSpinnerRef) { + observer.observe(currentSpinnerRef) } return () => { - if (spinnerRef.current) { - observer.unobserve(spinnerRef.current) + if (currentSpinnerRef) { + observer.unobserve(currentSpinnerRef) } } }, [handleIntersection]) return (
- +
) } diff --git a/web/hooks/use-topic-data.ts b/web/hooks/use-topic-data.ts index bfdc10f2..3592586d 100644 --- a/web/hooks/use-topic-data.ts +++ b/web/hooks/use-topic-data.ts @@ -1,15 +1,13 @@ import { useMemo } from "react" import { useCoState } from "@/lib/providers/jazz-provider" -import { PublicGlobalGroup } from "@/lib/schema/master/public-group" -import { Account, ID } from "jazz-tools" -import { Link, Topic } from "@/lib/schema" - -const GLOBAL_GROUP_ID = process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID +import { Account } from "jazz-tools" +import { Topic } from "@/lib/schema" +import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants" export function useTopicData(topicName: string, me: Account | undefined) { - const topicID = useMemo(() => me && Topic.findUnique({topicName}, GLOBAL_GROUP_ID, me), [topicName, me]) + const topicID = useMemo(() => me && Topic.findUnique({ topicName }, JAZZ_GLOBAL_GROUP_ID, me), [topicName, me]) - const topic = useCoState(Topic, topicID, {latestGlobalGuide: {sections: [{links: []}]}}) + const topic = useCoState(Topic, topicID, { latestGlobalGuide: { sections: [{ links: [] }] } }) return { topic } } diff --git a/web/lib/constants.ts b/web/lib/constants.ts index 3b024258..79db382a 100644 --- a/web/lib/constants.ts +++ b/web/lib/constants.ts @@ -1,4 +1,6 @@ +import { ID } from "jazz-tools" import { icons } from "lucide-react" +import { PublicGlobalGroup } from "./schema/master/public-group" export type LearningStateValue = "wantToLearn" | "learning" | "learned" export type LearningState = { @@ -13,3 +15,5 @@ export const LEARNING_STATES: LearningState[] = [ { label: "Learning", value: "learning", icon: "GraduationCap", className: "text-[#D29752]" }, { label: "Learned", value: "learned", icon: "Check", className: "text-[#708F51]" } ] as const + +export const JAZZ_GLOBAL_GROUP_ID = process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID diff --git a/web/store/link.ts b/web/store/link.ts index 566cfd28..6596857c 100644 --- a/web/store/link.ts +++ b/web/store/link.ts @@ -5,5 +5,4 @@ export const linkSortAtom = atomWithStorage("sort", "manual") export const linkShowCreateAtom = atom(false) export const linkEditIdAtom = atom(null) export const linkLearningStateSelectorAtom = atom(false) -export const linkTopicSelectorAtom = atom(false) export const linkOpenPopoverForIdAtom = atom(null) diff --git a/web/store/page.ts b/web/store/page.ts deleted file mode 100644 index 202937d1..00000000 --- a/web/store/page.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { atom } from "jotai" - -export const pageTopicSelectorAtom = atom(false)