mirror of
https://github.com/linsa-io/linsa.git
synced 2026-04-21 07:51:38 +02:00
fix: topic selector (#129)
* feat: add jazz globa group cons * chore: remove topic selector atom * chore: use jazz from constant * chore: remove delete model and add new topic selector * chore: use jazz group id form constant in search component * chore: use jazz group id form constant in public home route * fix: topic selector in link * fix: topic section in detail topic * chore: update la editor * chore: content header tweak class * chore: add btn variant to topic selector * refactor: tweak border for link header * chore: page header * fix: page detail route
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import { Button } from "../ui/button"
|
import { Button } from "../ui/button"
|
||||||
import { PanelLeftIcon } from "lucide-react"
|
import { PanelLeftIcon } from "lucide-react"
|
||||||
import { useAtom } from "jotai"
|
import { useAtom } from "jotai"
|
||||||
@@ -16,7 +15,7 @@ export const ContentHeader = React.forwardRef<HTMLDivElement, ContentHeaderProps
|
|||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-10 min-w-0 max-w-[100vw] shrink-0 items-center gap-3 pl-8 pr-6 transition-opacity max-lg:pl-4 max-lg:pr-5",
|
"flex min-h-10 min-w-0 max-w-[100vw] shrink-0 items-center gap-3 pl-8 pr-6 transition-opacity max-lg:px-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -55,7 +54,6 @@ export const SidebarToggleButton: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<PanelLeftIcon size={16} />
|
<PanelLeftIcon size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
<Separator orientation="vertical" />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete "{title}"?</DialogTitle>
|
|
||||||
<DialogDescription>This action cannot be undone.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" className="bg-red-700" onClick={onConfirm}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
179
web/components/custom/topic-selector.tsx
Normal file
179
web/components/custom/topic-selector.tsx
Normal file
@@ -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<typeof buttonVariants> {
|
||||||
|
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<HTMLButtonElement, TopicSelectorProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
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 <span className="truncate">{value || defaultLabel}</span>
|
||||||
|
}, [value, defaultLabel, renderSelectedText])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={isTopicSelectorOpen} onOpenChange={setIsTopicSelectorOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
role="combobox"
|
||||||
|
variant="secondary"
|
||||||
|
className={cn("gap-x-2 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{displaySelectedText}
|
||||||
|
<LaIcon name="ChevronDown" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-52 rounded-lg p-0"
|
||||||
|
side={side}
|
||||||
|
align={align}
|
||||||
|
onCloseAutoFocus={e => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{group?.root.topics && (
|
||||||
|
<TopicSelectorContent
|
||||||
|
showSearch={showSearch}
|
||||||
|
searchPlaceholder={searchPlaceholder}
|
||||||
|
value={value}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
topics={group.root.topics}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
TopicSelector.displayName = "TopicSelector"
|
||||||
|
|
||||||
|
interface TopicSelectorContentProps extends Omit<TopicSelectorProps, "onChange" | "onTopicChange"> {
|
||||||
|
onSelect: (value: string, topic: Topic) => void
|
||||||
|
topics: ListOfTopics
|
||||||
|
}
|
||||||
|
|
||||||
|
const TopicSelectorContent: React.FC<TopicSelectorContentProps> = 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<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: filteredTopics.length,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
estimateSize: () => 35,
|
||||||
|
overscan: 5
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Command>
|
||||||
|
{showSearch && (
|
||||||
|
<CommandInput placeholder={searchPlaceholder} className="h-9" value={search} onValueChange={setSearch} />
|
||||||
|
)}
|
||||||
|
<CommandList>
|
||||||
|
<div ref={parentRef} style={{ height: "200px", overflow: "auto" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||||
|
width: "100%",
|
||||||
|
position: "relative"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CommandGroup>
|
||||||
|
{rowVirtualizer.getVirtualItems().map(virtualRow => {
|
||||||
|
const topic = filteredTopics[virtualRow.index]
|
||||||
|
return (
|
||||||
|
topic && (
|
||||||
|
<CommandItem
|
||||||
|
key={virtualRow.key}
|
||||||
|
value={topic.name}
|
||||||
|
onSelect={value => onSelect(value, topic)}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: `${virtualRow.size}px`,
|
||||||
|
transform: `translateY(${virtualRow.start}px)`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{topic.prettyName}</span>
|
||||||
|
<LaIcon
|
||||||
|
name="Check"
|
||||||
|
className={cn("absolute right-3", topic.name === value ? "text-primary" : "text-transparent")}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
TopicSelectorContent.displayName = "TopicSelectorContent"
|
||||||
|
|
||||||
|
export default TopicSelector
|
||||||
@@ -1,140 +1,6 @@
|
|||||||
:root {
|
@import "partials/vars.css";
|
||||||
--la-font-size-regular: 0.9375rem;
|
@import "partials/prosemirror-base.css";
|
||||||
|
@import "partials/code-highlight.css";
|
||||||
--la-code-background: rgba(8, 43, 120, 0.047);
|
@import "partials/lists.css";
|
||||||
--la-code-color: rgb(212, 212, 212);
|
@import "partials/typography.css";
|
||||||
--la-secondary: rgb(157, 157, 159);
|
@import "partials/misc.css";
|
||||||
--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";
|
|
||||||
|
|||||||
86
web/components/la-editor/styles/partials/code-highlight.css
Normal file
86
web/components/la-editor/styles/partials/code-highlight.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
[data-theme="slash-command"] {
|
||||||
|
width: 1000vw;
|
||||||
|
}
|
||||||
|
|
||||||
.la-editor .ProseMirror .is-empty::before {
|
.la-editor .ProseMirror .is-empty::before {
|
||||||
@apply pointer-events-none float-left h-0 w-full text-[var(--la-secondary)];
|
@apply pointer-events-none float-left h-0 w-full text-[var(--la-secondary)];
|
||||||
}
|
}
|
||||||
@@ -10,3 +14,7 @@
|
|||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
@apply pointer-events-none float-left h-0 text-[var(--la-secondary)];
|
@apply pointer-events-none float-left h-0 text-[var(--la-secondary)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.la-editor div.tiptap p {
|
||||||
|
@apply text-[var(--la-font-size-regular)];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
43
web/components/la-editor/styles/partials/vars.css
Normal file
43
web/components/la-editor/styles/partials/vars.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -2,10 +2,9 @@
|
|||||||
import * as react from "react"
|
import * as react from "react"
|
||||||
import { useCoState } from "@/lib/providers/jazz-provider"
|
import { useCoState } from "@/lib/providers/jazz-provider"
|
||||||
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
|
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
|
||||||
import { ID } from "jazz-tools"
|
|
||||||
import dynamic from "next/dynamic"
|
import dynamic from "next/dynamic"
|
||||||
import { Button } from "../ui/button"
|
|
||||||
import Link from "next/link"
|
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)
|
let graph_data_promise = import("./graph-data.json").then(a => a.default)
|
||||||
const ForceGraphClient = dynamic(() => import("./force-graph-client-lazy"), { ssr: false })
|
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 [placeholder, setPlaceholder] = react.useState("Search something...")
|
||||||
const [currentTopicIndex, setCurrentTopicIndex] = react.useState(0)
|
const [currentTopicIndex, setCurrentTopicIndex] = react.useState(0)
|
||||||
const [currentCharIndex, setCurrentCharIndex] = react.useState(0)
|
const [currentCharIndex, setCurrentCharIndex] = react.useState(0)
|
||||||
const globalGroup = useCoState(
|
const globalGroup = useCoState(PublicGlobalGroup, JAZZ_GLOBAL_GROUP_ID, {
|
||||||
PublicGlobalGroup,
|
root: {
|
||||||
process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID<PublicGlobalGroup>,
|
topics: []
|
||||||
{
|
|
||||||
root: {
|
|
||||||
topics: []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
})
|
||||||
const topics = globalGroup?.root.topics?.map(topic => topic?.prettyName) || []
|
const topics = globalGroup?.root.topics?.map(topic => topic?.prettyName) || []
|
||||||
|
|
||||||
react.useEffect(() => {
|
react.useEffect(() => {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const LinkHeader = React.memo(() => {
|
|||||||
</ContentHeader>
|
</ContentHeader>
|
||||||
|
|
||||||
{isTablet && (
|
{isTablet && (
|
||||||
<div className="border-b-primary/5 flex min-h-10 flex-row items-start justify-between border-b px-6 py-2 max-lg:pl-4">
|
<div className="flex min-h-10 flex-row items-start justify-between border-b px-6 py-2 max-lg:pl-4">
|
||||||
<LearningTab />
|
<LearningTab />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ import { UrlInput } from "./url-input"
|
|||||||
import { UrlBadge } from "./url-badge"
|
import { UrlBadge } from "./url-badge"
|
||||||
import { TitleInput } from "./title-input"
|
import { TitleInput } from "./title-input"
|
||||||
import { NotesSection } from "./notes-section"
|
import { NotesSection } from "./notes-section"
|
||||||
import { TopicSelector } from "./topic-selector"
|
|
||||||
import { DescriptionInput } from "./description-input"
|
import { DescriptionInput } from "./description-input"
|
||||||
import { atom, useAtom } from "jotai"
|
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 { FormField, FormItem, FormLabel } from "@/components/ui/form"
|
||||||
import { LearningStateSelector } from "@/components/custom/learning-state-selector"
|
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<React.RefObject<HTMLElement>[]>([])
|
export const globalLinkFormExceptionRefsAtom = atom<React.RefObject<HTMLElement>[]>([])
|
||||||
|
|
||||||
@@ -47,8 +48,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
exceptionsRefs = []
|
exceptionsRefs = []
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedTopic, setSelectedTopic] = React.useState<Topic | undefined>()
|
const [istopicSelectorOpen] = useAtom(topicSelectorAtom)
|
||||||
const [istopicSelectorOpen] = useAtom(linkTopicSelectorAtom)
|
|
||||||
const [islearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom)
|
const [islearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom)
|
||||||
const [globalExceptionRefs] = useAtom(globalLinkFormExceptionRefsAtom)
|
const [globalExceptionRefs] = useAtom(globalLinkFormExceptionRefsAtom)
|
||||||
|
|
||||||
@@ -65,6 +65,14 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
|||||||
mode: "all"
|
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(
|
const allExceptionRefs = React.useMemo(
|
||||||
() => [...exceptionsRefs, ...globalExceptionRefs],
|
() => [...exceptionsRefs, ...globalExceptionRefs],
|
||||||
[exceptionsRefs, globalExceptionRefs]
|
[exceptionsRefs, globalExceptionRefs]
|
||||||
@@ -101,10 +109,11 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
|||||||
description: selectedLink.description,
|
description: selectedLink.description,
|
||||||
completed: selectedLink.completed,
|
completed: selectedLink.completed,
|
||||||
notes: selectedLink.notes,
|
notes: selectedLink.notes,
|
||||||
learningState: selectedLink.learningState
|
learningState: selectedLink.learningState,
|
||||||
|
topic: selectedLink.topic?.name
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [selectedLink, form])
|
}, [selectedLink, selectedLink?.topic, form])
|
||||||
|
|
||||||
const fetchMetadata = async (url: string) => {
|
const fetchMetadata = async (url: string) => {
|
||||||
setIsFetching(true)
|
setIsFetching(true)
|
||||||
@@ -214,7 +223,20 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<TopicSelector onSelect={topic => setSelectedTopic(topic)} />
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="topic"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="space-y-0">
|
||||||
|
<FormLabel className="sr-only">Topic</FormLabel>
|
||||||
|
<TopicSelector
|
||||||
|
{...field}
|
||||||
|
renderSelectedText={() => <span>{selectedTopic?.prettyName || "Select a topic"}</span>}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -222,17 +244,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
|||||||
<UrlBadge urlFetched={urlFetched} handleResetUrl={handleResetUrl} />
|
<UrlBadge urlFetched={urlFetched} handleResetUrl={handleResetUrl} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="flex flex-row items-center justify-between gap-2 rounded-b-md border-t px-3 py-2">
|
||||||
className="flex flex-row items-center justify-between gap-2 rounded-b-md border-t px-3 py-2"
|
|
||||||
onClick={e => {
|
|
||||||
if (!(e.target as HTMLElement).closest("button")) {
|
|
||||||
const notesInput = e.currentTarget.querySelector("input")
|
|
||||||
if (notesInput) {
|
|
||||||
notesInput.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<NotesSection />
|
<NotesSection />
|
||||||
|
|
||||||
{isFetching ? (
|
{isFetching ? (
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const NotesSection: React.FC = () => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="notes"
|
name="notes"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="relative space-y-0">
|
<FormItem className="relative grow space-y-0">
|
||||||
<FormLabel className="sr-only">Note</FormLabel>
|
<FormLabel className="sr-only">Note</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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<TopicSelectorProps> = ({ onSelect }) => {
|
|
||||||
const globalGroup = useCoState(
|
|
||||||
PublicGlobalGroup,
|
|
||||||
process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID<PublicGlobalGroup>,
|
|
||||||
{
|
|
||||||
root: {
|
|
||||||
topics: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
const [isTopicSelectorOpen, setIsTopicSelectorOpen] = useAtom(linkTopicSelectorAtom)
|
|
||||||
const form = useFormContext<LinkFormValues>()
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Popover open={isTopicSelectorOpen} onOpenChange={setIsTopicSelectorOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button size="sm" type="button" role="combobox" variant="secondary" className="gap-x-2 text-sm">
|
|
||||||
<span className="truncate">
|
|
||||||
{selectedValue
|
|
||||||
? globalGroup?.root.topics.find(topic => topic?.id && topic.name === selectedValue)?.prettyName
|
|
||||||
: "Topic"}
|
|
||||||
</span>
|
|
||||||
<LaIcon name="ChevronDown" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
className="z-50 w-52 rounded-lg p-0"
|
|
||||||
side="bottom"
|
|
||||||
align="end"
|
|
||||||
onCloseAutoFocus={e => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="Search topic..." className="h-9" />
|
|
||||||
<CommandList>
|
|
||||||
<ScrollArea>
|
|
||||||
<CommandGroup>
|
|
||||||
{globalGroup?.root.topics.map(
|
|
||||||
topic =>
|
|
||||||
topic?.id && (
|
|
||||||
<CommandItem key={topic.id} value={topic.name} onSelect={handleSelect}>
|
|
||||||
{topic.prettyName}
|
|
||||||
<CheckIcon
|
|
||||||
size={16}
|
|
||||||
className={cn(
|
|
||||||
"absolute right-3",
|
|
||||||
topic.name === selectedValue ? "text-primary" : "text-transparent"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CommandItem>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</CommandGroup>
|
|
||||||
</ScrollArea>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useAtom } from "jotai"
|
|
||||||
import { ID } from "jazz-tools"
|
import { ID } from "jazz-tools"
|
||||||
import { PersonalPage, Topic } from "@/lib/schema"
|
import { PersonalPage } from "@/lib/schema"
|
||||||
import { useCallback, useRef, useEffect, useState } from "react"
|
import { useCallback, useRef, useEffect } from "react"
|
||||||
import { LAEditor, LAEditorRef } from "@/components/la-editor"
|
import { LAEditor, LAEditorRef } from "@/components/la-editor"
|
||||||
import { Content, EditorContent, useEditor } from "@tiptap/react"
|
import { Content, EditorContent, useEditor } from "@tiptap/react"
|
||||||
import { StarterKit } from "@/components/la-editor/extensions/starter-kit"
|
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 { EditorView } from "@tiptap/pm/view"
|
||||||
import { Editor } from "@tiptap/core"
|
import { Editor } from "@tiptap/core"
|
||||||
import { generateUniqueSlug } from "@/lib/utils"
|
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 { 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"
|
const TITLE_PLACEHOLDER = "Untitled"
|
||||||
|
|
||||||
export function PageDetailRoute({ pageId }: { pageId: string }) {
|
export function PageDetailRoute({ pageId }: { pageId: string }) {
|
||||||
|
const isMobile = useMedia("(max-width: 770px)")
|
||||||
const page = useCoState(PersonalPage, pageId as ID<PersonalPage>)
|
const page = useCoState(PersonalPage, pageId as ID<PersonalPage>)
|
||||||
|
|
||||||
if (!page) return <div>Loading...</div>
|
if (!page) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row">
|
<div className="absolute inset-0 flex flex-row overflow-hidden">
|
||||||
<div className="flex h-full w-full">
|
<div className="flex h-full w-full">
|
||||||
<div className="relative flex min-w-0 grow basis-[760px] flex-col">
|
<div className="relative flex min-w-0 grow basis-[760px] flex-col">
|
||||||
|
<DetailPageHeader page={page} />
|
||||||
<DetailPageForm page={page} />
|
<DetailPageForm page={page} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="relative min-w-56 max-w-72 border-l">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex h-10 flex-auto flex-row items-center justify-between px-4">
|
||||||
|
<span className="text-left text-sm font-medium">Page actions</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 top-10 overflow-y-auto px-4 py-1.5">
|
||||||
|
<TopicSelector
|
||||||
|
value={page.topic?.name}
|
||||||
|
onTopicChange={topic => {
|
||||||
|
page.topic = topic
|
||||||
|
page.updatedAt = new Date()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -42,9 +61,6 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
const { me } = useAccount()
|
const { me } = useAccount()
|
||||||
const titleEditorRef = useRef<Editor | null>(null)
|
const titleEditorRef = useRef<Editor | null>(null)
|
||||||
const contentEditorRef = useRef<LAEditorRef>(null)
|
const contentEditorRef = useRef<LAEditorRef>(null)
|
||||||
const [, setTopicSelectorOpen] = useAtom(pageTopicSelectorAtom)
|
|
||||||
const [, setSelectedPageTopic] = useState<Topic | null>(page.topic || null)
|
|
||||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false)
|
|
||||||
|
|
||||||
const isTitleInitialMount = useRef(true)
|
const isTitleInitialMount = useRef(true)
|
||||||
const isContentInitialMount = useRef(true)
|
const isContentInitialMount = useRef(true)
|
||||||
@@ -55,7 +71,6 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Updating page content")
|
|
||||||
model.content = content
|
model.content = content
|
||||||
model.updatedAt = new Date()
|
model.updatedAt = new Date()
|
||||||
}
|
}
|
||||||
@@ -66,22 +81,6 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
return
|
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 personalPages = me?.root?.personalPages?.toJSON() || []
|
||||||
const slug = generateUniqueSlug(personalPages, page.slug || "")
|
const slug = generateUniqueSlug(personalPages, page.slug || "")
|
||||||
|
|
||||||
@@ -101,29 +100,44 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
const { selection } = state
|
const { selection } = state
|
||||||
const { $anchor } = selection
|
const { $anchor } = selection
|
||||||
|
|
||||||
if ((event.key === "ArrowLeft" || event.key === "ArrowUp") && $anchor.pos - 1 === 0) {
|
switch (event.key) {
|
||||||
event.preventDefault()
|
case "ArrowRight":
|
||||||
titleEditorRef.current?.commands.focus("end")
|
case "ArrowDown":
|
||||||
return true
|
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
|
return false
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleContentKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => {
|
const handleContentKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => {
|
||||||
const editor = contentEditorRef.current?.editor
|
const editor = contentEditorRef.current?.editor
|
||||||
if (!editor) return false
|
if (!editor) return false
|
||||||
|
|
||||||
const { state } = editor
|
const { state } = editor
|
||||||
const { selection } = state
|
const { selection } = state
|
||||||
const { $anchor } = selection
|
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
|
return false
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const confirmDelete = (page: PersonalPage) => {
|
|
||||||
console.log("Deleting page:", page.id)
|
|
||||||
setDeleteModalOpen(false)
|
|
||||||
//TODO: add delete logic
|
|
||||||
}
|
|
||||||
|
|
||||||
const titleEditor = useEditor({
|
const titleEditor = useEditor({
|
||||||
immediatelyRender: false,
|
immediatelyRender: false,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
@@ -139,7 +153,6 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
strike: false,
|
strike: false,
|
||||||
focus: false,
|
focus: false,
|
||||||
gapcursor: false,
|
gapcursor: false,
|
||||||
history: false,
|
|
||||||
placeholder: {
|
placeholder: {
|
||||||
placeholder: TITLE_PLACEHOLDER
|
placeholder: TITLE_PLACEHOLDER
|
||||||
}
|
}
|
||||||
@@ -152,7 +165,8 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
"aria-readonly": "false",
|
"aria-readonly": "false",
|
||||||
"aria-multiline": "false",
|
"aria-multiline": "false",
|
||||||
"aria-label": TITLE_PLACEHOLDER,
|
"aria-label": TITLE_PLACEHOLDER,
|
||||||
translate: "no"
|
translate: "no",
|
||||||
|
class: "focus:outline-none"
|
||||||
},
|
},
|
||||||
handleKeyDown: handleTitleKeyDown
|
handleKeyDown: handleTitleKeyDown
|
||||||
},
|
},
|
||||||
@@ -160,9 +174,7 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
if (page.title) editor.commands.setContent(`<p>${page.title}</p>`)
|
if (page.title) editor.commands.setContent(`<p>${page.title}</p>`)
|
||||||
},
|
},
|
||||||
onBlur: ({ editor }) => handleUpdateTitle(editor),
|
onBlur: ({ editor }) => handleUpdateTitle(editor),
|
||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => handleUpdateTitle(editor)
|
||||||
handleUpdateTitle(editor)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -177,37 +189,20 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div tabIndex={0} className="relative flex grow flex-col overflow-y-auto">
|
<div className="relative flex grow flex-col overflow-y-auto [scrollbar-gutter:stable]">
|
||||||
<div className="relative mx-auto flex h-full w-[calc(100%-40px)] shrink-0 grow flex-col sm:w-[calc(100%-80px)]">
|
<div className="relative mx-auto flex h-full w-[calc(100%-80px)] shrink-0 grow flex-col max-lg:w-[calc(100%-40px)] max-lg:max-w-[unset]">
|
||||||
<form className="flex shrink-0 flex-col">
|
<form className="flex shrink-0 flex-col">
|
||||||
<div className="mb-2 mt-8 flex flex-row justify-between py-1.5">
|
<div className="mb-2 mt-8 py-1.5">
|
||||||
<EditorContent
|
<EditorContent
|
||||||
editor={titleEditor}
|
editor={titleEditor}
|
||||||
className="la-editor no-command grow cursor-text select-text text-2xl font-semibold leading-[calc(1.33333)] tracking-[-0.00625rem]"
|
className="la-editor no-command grow cursor-text select-text text-2xl font-semibold leading-[calc(1.33333)] tracking-[-0.00625rem]"
|
||||||
/>
|
/>
|
||||||
<div className="items-center space-x-4">
|
|
||||||
<TopicSelector
|
|
||||||
onSelect={topic => {
|
|
||||||
page.topic = topic
|
|
||||||
setSelectedPageTopic(topic)
|
|
||||||
setTopicSelectorOpen(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
className="text-foreground bg-truncat"
|
|
||||||
onClick={() => setDeleteModalOpen(true)}
|
|
||||||
>
|
|
||||||
<LaIcon name="Trash" className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-auto flex-col">
|
<div className="flex flex-auto flex-col">
|
||||||
<div className="relative flex h-full max-w-full grow flex-col items-stretch p-0">
|
<div className="relative flex h-full max-w-full grow flex-col items-stretch p-0">
|
||||||
<LAEditor
|
<LAEditor
|
||||||
ref={contentEditorRef}
|
ref={contentEditorRef}
|
||||||
editorClassName="-mx-3.5 px-3.5 py-2.5 flex-auto"
|
editorClassName="-mx-3.5 px-3.5 py-2.5 flex-auto focus:outline-none"
|
||||||
value={page.content}
|
value={page.content}
|
||||||
placeholder="Add content..."
|
placeholder="Add content..."
|
||||||
output="json"
|
output="json"
|
||||||
@@ -221,15 +216,6 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DeletePageModal
|
|
||||||
isOpen={deleteModalOpen}
|
|
||||||
onClose={() => setDeleteModalOpen(false)}
|
|
||||||
onConfirm={() => {
|
|
||||||
confirmDelete(page)
|
|
||||||
}}
|
|
||||||
title={page.title || ""}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,24 +2,33 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
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 { 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<PersonalPage> }) => {
|
|
||||||
return (
|
return (
|
||||||
<ContentHeader>
|
isMobile && (
|
||||||
<div className="flex min-w-0 gap-2">
|
<>
|
||||||
<SidebarToggleButton />
|
<ContentHeader className="lg:min-h-0">
|
||||||
|
<div className="flex min-w-0 gap-2">
|
||||||
|
<SidebarToggleButton />
|
||||||
|
</div>
|
||||||
|
</ContentHeader>
|
||||||
|
|
||||||
<Breadcrumb className="flex flex-row items-center">
|
<div className="flex flex-row items-start justify-between border-b px-6 py-2 max-lg:pl-4">
|
||||||
<BreadcrumbList className="sm:gap-2">
|
<TopicSelector
|
||||||
<BreadcrumbItem>
|
value={page.topic?.name}
|
||||||
<BreadcrumbPage className="text-foreground font-medium">Pages</BreadcrumbPage>
|
onTopicChange={topic => {
|
||||||
</BreadcrumbItem>
|
page.topic = topic
|
||||||
</BreadcrumbList>
|
page.updatedAt = new Date()
|
||||||
</Breadcrumb>
|
}}
|
||||||
</div>
|
align="start"
|
||||||
</ContentHeader>
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { useState } from "react"
|
|||||||
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
|
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
|
||||||
import { LaIcon } from "@/components/custom/la-icon"
|
import { LaIcon } from "@/components/custom/la-icon"
|
||||||
import AiSearch from "../../custom/ai-search"
|
import AiSearch from "../../custom/ai-search"
|
||||||
import { ID } from "jazz-tools"
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Topic, PersonalLink, PersonalPage } from "@/lib/schema"
|
import { Topic, PersonalLink, PersonalPage } from "@/lib/schema"
|
||||||
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
|
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
|
||||||
|
import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants"
|
||||||
|
|
||||||
interface SearchTitleProps {
|
interface SearchTitleProps {
|
||||||
title: string
|
title: string
|
||||||
@@ -80,15 +80,11 @@ export const SearchWrapper = () => {
|
|||||||
root: { personalLinks: [], personalPages: [] }
|
root: { personalLinks: [], personalPages: [] }
|
||||||
})
|
})
|
||||||
|
|
||||||
const globalGroup = useCoState(
|
const globalGroup = useCoState(PublicGlobalGroup, JAZZ_GLOBAL_GROUP_ID, {
|
||||||
PublicGlobalGroup,
|
root: {
|
||||||
process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID<PublicGlobalGroup>,
|
topics: []
|
||||||
{
|
|
||||||
root: {
|
|
||||||
topics: []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
})
|
||||||
|
|
||||||
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = e.target.value.toLowerCase()
|
const value = e.target.value.toLowerCase()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|||||||
import { LinkItem } from "./link-item"
|
import { LinkItem } from "./link-item"
|
||||||
import { LaAccount, PersonalLinkLists, Section as SectionSchema, Topic, UserRoot } from "@/lib/schema"
|
import { LaAccount, PersonalLinkLists, Section as SectionSchema, Topic, UserRoot } from "@/lib/schema"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Loader2 } from "lucide-react"
|
import { LaIcon } from "@/components/custom/la-icon"
|
||||||
|
|
||||||
interface SectionProps {
|
interface SectionProps {
|
||||||
topic: Topic
|
topic: Topic
|
||||||
@@ -29,7 +29,7 @@ export function Section({
|
|||||||
me,
|
me,
|
||||||
personalLinks
|
personalLinks
|
||||||
}: SectionProps) {
|
}: SectionProps) {
|
||||||
const [nLinksToLoad, setNLinksToLoad] = useState(10);
|
const [nLinksToLoad, setNLinksToLoad] = useState(10)
|
||||||
|
|
||||||
const linksToLoad = useMemo(() => {
|
const linksToLoad = useMemo(() => {
|
||||||
return section.links?.slice(0, nLinksToLoad)
|
return section.links?.slice(0, nLinksToLoad)
|
||||||
@@ -43,25 +43,24 @@ export function Section({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-px py-2">
|
<div className="flex flex-col gap-px py-2">
|
||||||
{linksToLoad?.map(
|
{linksToLoad?.map((link, index) =>
|
||||||
(link, index) =>
|
link?.url ? (
|
||||||
link?.url ? (
|
<LinkItem
|
||||||
<LinkItem
|
key={index}
|
||||||
key={index}
|
topic={topic}
|
||||||
topic={topic}
|
link={link}
|
||||||
link={link}
|
isActive={activeIndex === startIndex + index}
|
||||||
isActive={activeIndex === startIndex + index}
|
index={startIndex + index}
|
||||||
index={startIndex + index}
|
setActiveIndex={setActiveIndex}
|
||||||
setActiveIndex={setActiveIndex}
|
ref={el => {
|
||||||
ref={el => {
|
linkRefs.current[startIndex + index] = el
|
||||||
linkRefs.current[startIndex + index] = el
|
}}
|
||||||
}}
|
me={me}
|
||||||
me={me}
|
personalLinks={personalLinks}
|
||||||
personalLinks={personalLinks}
|
/>
|
||||||
/>
|
) : (
|
||||||
) : (
|
<Skeleton key={index} className="h-14 w-full xl:h-11" />
|
||||||
<Skeleton key={index} className="h-14 xl:h-11 w-full" />
|
)
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
{section.links?.length && section.links?.length > nLinksToLoad && (
|
{section.links?.length && section.links?.length > nLinksToLoad && (
|
||||||
<LoadMoreSpinner onLoadMore={() => setNLinksToLoad(n => n + 10)} />
|
<LoadMoreSpinner onLoadMore={() => setNLinksToLoad(n => n + 10)} />
|
||||||
@@ -88,23 +87,25 @@ const LoadMoreSpinner = ({ onLoadMore }: { onLoadMore: () => void }) => {
|
|||||||
const observer = new IntersectionObserver(handleIntersection, {
|
const observer = new IntersectionObserver(handleIntersection, {
|
||||||
root: null,
|
root: null,
|
||||||
rootMargin: "0px",
|
rootMargin: "0px",
|
||||||
threshold: 1.0,
|
threshold: 1.0
|
||||||
})
|
})
|
||||||
|
|
||||||
if (spinnerRef.current) {
|
const currentSpinnerRef = spinnerRef.current
|
||||||
observer.observe(spinnerRef.current)
|
|
||||||
|
if (currentSpinnerRef) {
|
||||||
|
observer.observe(currentSpinnerRef)
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (spinnerRef.current) {
|
if (currentSpinnerRef) {
|
||||||
observer.unobserve(spinnerRef.current)
|
observer.unobserve(currentSpinnerRef)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [handleIntersection])
|
}, [handleIntersection])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={spinnerRef} className="flex justify-center py-4">
|
<div ref={spinnerRef} className="flex justify-center py-4">
|
||||||
<Loader2 className="h-6 w-6 animate-spin" />
|
<LaIcon name="Loader" className="size-6 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import { useCoState } from "@/lib/providers/jazz-provider"
|
import { useCoState } from "@/lib/providers/jazz-provider"
|
||||||
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
|
import { Account } from "jazz-tools"
|
||||||
import { Account, ID } from "jazz-tools"
|
import { Topic } from "@/lib/schema"
|
||||||
import { Link, Topic } from "@/lib/schema"
|
import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants"
|
||||||
|
|
||||||
const GLOBAL_GROUP_ID = process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID<PublicGlobalGroup>
|
|
||||||
|
|
||||||
export function useTopicData(topicName: string, me: Account | undefined) {
|
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 }
|
return { topic }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { ID } from "jazz-tools"
|
||||||
import { icons } from "lucide-react"
|
import { icons } from "lucide-react"
|
||||||
|
import { PublicGlobalGroup } from "./schema/master/public-group"
|
||||||
|
|
||||||
export type LearningStateValue = "wantToLearn" | "learning" | "learned"
|
export type LearningStateValue = "wantToLearn" | "learning" | "learned"
|
||||||
export type LearningState = {
|
export type LearningState = {
|
||||||
@@ -13,3 +15,5 @@ export const LEARNING_STATES: LearningState[] = [
|
|||||||
{ label: "Learning", value: "learning", icon: "GraduationCap", className: "text-[#D29752]" },
|
{ label: "Learning", value: "learning", icon: "GraduationCap", className: "text-[#D29752]" },
|
||||||
{ label: "Learned", value: "learned", icon: "Check", className: "text-[#708F51]" }
|
{ label: "Learned", value: "learned", icon: "Check", className: "text-[#708F51]" }
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
export const JAZZ_GLOBAL_GROUP_ID = process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID<PublicGlobalGroup>
|
||||||
|
|||||||
@@ -5,5 +5,4 @@ export const linkSortAtom = atomWithStorage("sort", "manual")
|
|||||||
export const linkShowCreateAtom = atom(false)
|
export const linkShowCreateAtom = atom(false)
|
||||||
export const linkEditIdAtom = atom<string | null>(null)
|
export const linkEditIdAtom = atom<string | null>(null)
|
||||||
export const linkLearningStateSelectorAtom = atom(false)
|
export const linkLearningStateSelectorAtom = atom(false)
|
||||||
export const linkTopicSelectorAtom = atom(false)
|
|
||||||
export const linkOpenPopoverForIdAtom = atom<string | null>(null)
|
export const linkOpenPopoverForIdAtom = atom<string | null>(null)
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import { atom } from "jotai"
|
|
||||||
|
|
||||||
export const pageTopicSelectorAtom = atom(false)
|
|
||||||
Reference in New Issue
Block a user