@@ -156,7 +183,7 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
{
onConfirm={() => {
confirmDelete(page)
}}
- title={page.title.charAt(0).toUpperCase() + page.title.slice(1)}
+ title={page.title || ""}
/>
)
diff --git a/web/components/routes/page/detail/header.tsx b/web/components/routes/page/detail/header.tsx
index d54bc0df..55e1e20e 100644
--- a/web/components/routes/page/detail/header.tsx
+++ b/web/components/routes/page/detail/header.tsx
@@ -2,20 +2,11 @@
import * as React from "react"
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
-import {
- Breadcrumb,
- BreadcrumbItem,
- BreadcrumbList,
- BreadcrumbPage,
- BreadcrumbSeparator
-} from "@/components/ui/breadcrumb"
-import { useCoState } from "@/lib/providers/jazz-provider"
+import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from "@/components/ui/breadcrumb"
import { PersonalPage } from "@/lib/schema/personal-page"
import { ID } from "jazz-tools"
export const DetailPageHeader = ({ pageId }: { pageId: ID
}) => {
- const page = useCoState(PersonalPage, pageId)
-
return (
diff --git a/web/components/routes/topics/detail/Header.tsx b/web/components/routes/topics/detail/Header.tsx
index 8a72ac2d..0b9a9834 100644
--- a/web/components/routes/topics/detail/Header.tsx
+++ b/web/components/routes/topics/detail/Header.tsx
@@ -1,32 +1,98 @@
"use client"
import * as React from "react"
-import { Button } from "@/components/ui/button"
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
-import { Topic } from "@/lib/schema"
+import { ListOfTopics, Topic } from "@/lib/schema"
+import { LearningStateSelector } from "@/components/custom/learning-state-selector"
+import { useAccount } from "@/lib/providers/jazz-provider"
+import { LearningStateValue } from "@/lib/constants"
interface TopicDetailHeaderProps {
topic: Topic
}
export const TopicDetailHeader = React.memo(function TopicDetailHeader({ topic }: TopicDetailHeaderProps) {
+ const { me } = useAccount({
+ root: {
+ topicsWantToLearn: [],
+ topicsLearning: [],
+ topicsLearned: []
+ }
+ })
+
+ let p: {
+ index: number
+ topic?: Topic | null
+ learningState: LearningStateValue
+ } | null = null
+
+ const wantToLearnIndex = me?.root.topicsWantToLearn.findIndex(t => t?.id === topic.id) ?? -1
+ if (wantToLearnIndex !== -1) {
+ p = {
+ index: wantToLearnIndex,
+ topic: me?.root.topicsWantToLearn[wantToLearnIndex],
+ learningState: "wantToLearn"
+ }
+ }
+
+ const learningIndex = me?.root.topicsLearning.findIndex(t => t?.id === topic.id) ?? -1
+ if (learningIndex !== -1) {
+ p = {
+ index: learningIndex,
+ topic: me?.root.topicsLearning[learningIndex],
+ learningState: "learning"
+ }
+ }
+
+ const learnedIndex = me?.root.topicsLearned.findIndex(t => t?.id === topic.id) ?? -1
+ if (learnedIndex !== -1) {
+ p = {
+ index: learnedIndex,
+ topic: me?.root.topicsLearned[learnedIndex],
+ learningState: "learned"
+ }
+ }
+
+ const handleAddToProfile = (learningState: LearningStateValue) => {
+ const topicLists: Record
= {
+ wantToLearn: me?.root.topicsWantToLearn,
+ learning: me?.root.topicsLearning,
+ learned: me?.root.topicsLearned
+ }
+
+ const removeFromList = (state: LearningStateValue, index: number) => {
+ topicLists[state]?.splice(index, 1)
+ }
+
+ if (p) {
+ if (learningState === p.learningState) {
+ removeFromList(p.learningState, p.index)
+ return
+ }
+ removeFromList(p.learningState, p.index)
+ }
+
+ topicLists[learningState]?.push(topic)
+ }
+
return (
- <>
-
-
-
-
- {topic.prettyName}
-
+
+
+
+
+ {topic.prettyName}
+
-
+
-
-
- >
+
+
)
})
diff --git a/web/components/routes/topics/detail/TopicDetailRoute.tsx b/web/components/routes/topics/detail/TopicDetailRoute.tsx
index c4d9c331..6843b7c5 100644
--- a/web/components/routes/topics/detail/TopicDetailRoute.tsx
+++ b/web/components/routes/topics/detail/TopicDetailRoute.tsx
@@ -1,108 +1,41 @@
"use client"
import React from "react"
-import Link from "next/link"
-import { useCoState } from "@/lib/providers/jazz-provider"
-import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
-import { ID } from "jazz-tools"
import { TopicDetailHeader } from "./Header"
-import { LaIcon } from "@/components/custom/la-icon"
-import { cn, ensureUrlProtocol } from "@/lib/utils"
-import { Section as SectionSchema, Link as LinkSchema } from "@/lib/schema"
+import { TopicSections } from "./partials/topic-sections"
+import { useLinkNavigation } from "./use-link-navigation"
+import { useTopicData } from "@/hooks/use-topic-data"
+import { atom } from "jotai"
+import { useAccount } from "@/lib/providers/jazz-provider"
interface TopicDetailRouteProps {
topicName: string
}
+export const openPopoverForIdAtom = atom
(null)
+
export function TopicDetailRoute({ topicName }: TopicDetailRouteProps) {
- const topics = useCoState(PublicGlobalGroup, process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID, {
- root: {
- topics: []
- }
- })
+ const { me } = useAccount({ root: { personalLinks: [] } })
+ const { topic, allLinks } = useTopicData(topicName)
+ const { activeIndex, setActiveIndex, containerRef, linkRefs } = useLinkNavigation(allLinks)
- const topic = topics?.root.topics.find(topic => topic?.name === topicName)
-
- if (!topic) {
+ if (!topic || !me) {
return null
}
return (
-
-
-
- {topic.latestGlobalGuide?.sections?.map(
- (section, index) => section?.id &&
- )}
-
-
-
+
)
}
-
-interface SectionProps {
- section: SectionSchema
-}
-
-function Section({ section }: SectionProps) {
- return (
-
-
-
-
- {section.links?.map((link, index) => link?.url && )}
-
-
- )
-}
-
-interface LinkItemProps {
- link: LinkSchema
-}
-
-function LinkItem({ link }: LinkItemProps) {
- return (
-
-
-
-
-
-
-
- {link.title}
-
-
-
-
- e.stopPropagation()}
- className="text-muted-foreground hover:text-primary text-xs"
- >
- {link.url}
-
-
-
-
-
-
-
-
- )
-}
diff --git a/web/components/routes/topics/detail/partials/link-item.tsx b/web/components/routes/topics/detail/partials/link-item.tsx
new file mode 100644
index 00000000..7e89d818
--- /dev/null
+++ b/web/components/routes/topics/detail/partials/link-item.tsx
@@ -0,0 +1,198 @@
+import React, { useCallback, useMemo, useState } from "react"
+import Link from "next/link"
+import { useRouter } from "next/navigation"
+import { useAtom } from "jotai"
+import { toast } from "sonner"
+
+import { LaIcon } from "@/components/custom/la-icon"
+import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
+import { Button } from "@/components/ui/button"
+import { LearningStateSelectorContent } from "@/components/custom/learning-state-selector"
+
+import { cn, ensureUrlProtocol, generateUniqueSlug } from "@/lib/utils"
+import { LaAccount, Link as LinkSchema, PersonalLink, PersonalLinkLists, Topic, UserRoot } from "@/lib/schema"
+import { openPopoverForIdAtom } from "../TopicDetailRoute"
+import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
+
+interface LinkItemProps {
+ topic: Topic
+ link: LinkSchema
+ isActive: boolean
+ index: number
+ setActiveIndex: (index: number) => void
+ me: {
+ root: {
+ personalLinks: PersonalLinkLists
+ } & UserRoot
+ } & LaAccount
+ personalLinks: PersonalLinkLists
+}
+
+export const LinkItem = React.memo(
+ React.forwardRef(
+ ({ topic, link, isActive, index, setActiveIndex, me, personalLinks }, ref) => {
+ const router = useRouter()
+ const [, setOpenPopoverForId] = useAtom(openPopoverForIdAtom)
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false)
+
+ const personalLink = useMemo(() => {
+ return personalLinks.find(pl => pl?.link?.id === link.id)
+ }, [personalLinks, link.id])
+
+ const selectedLearningState = useMemo(() => {
+ return LEARNING_STATES.find(ls => ls.value === personalLink?.learningState)
+ }, [personalLink?.learningState])
+
+ const handleClick = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault()
+ setActiveIndex(index)
+ },
+ [index, setActiveIndex]
+ )
+
+ const handleSelectLearningState = useCallback(
+ (learningState: LearningStateValue) => {
+ const defaultToast = {
+ duration: 5000,
+ position: "bottom-right" as const,
+ closeButton: true,
+ action: {
+ label: "Go to list",
+ onClick: () => router.push("/")
+ }
+ }
+
+ if (personalLink) {
+ if (personalLink.learningState === learningState) {
+ personalLink.learningState = undefined
+ toast.error("Link learning state removed", defaultToast)
+ } else {
+ personalLink.learningState = learningState
+ toast.success("Link learning state updated", defaultToast)
+ }
+ } else {
+ const slug = generateUniqueSlug(personalLinks.toJSON(), link.title)
+ const newPersonalLink = PersonalLink.create(
+ {
+ url: link.url,
+ title: link.title,
+ slug,
+ link,
+ learningState,
+ sequence: personalLinks.length + 1,
+ completed: false,
+ topic,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ },
+ { owner: me }
+ )
+
+ personalLinks.push(newPersonalLink)
+
+ toast.success("Link added.", {
+ ...defaultToast,
+ description: `${link.title} has been added to your personal link.`
+ })
+ }
+
+ setOpenPopoverForId(null)
+ setIsPopoverOpen(false)
+ },
+ [personalLink, personalLinks, me, link, router, setOpenPopoverForId]
+ )
+
+ const handlePopoverOpenChange = useCallback(
+ (open: boolean) => {
+ setIsPopoverOpen(open)
+ setOpenPopoverForId(open ? link.id : null)
+ },
+ [link.id, setOpenPopoverForId]
+ )
+
+ return (
+
+
+
+
+
+
+
+ e.preventDefault()}
+ >
+ handleSelectLearningState(value as LearningStateValue)}
+ />
+
+
+
+
+
+
+ {link.title}
+
+
+
+
+
+ e.stopPropagation()}
+ className="text-muted-foreground hover:text-primary text-xs"
+ >
+ {link.url}
+
+
+
+
+
+
+
+
+ )
+ }
+ )
+)
+
+LinkItem.displayName = "LinkItem"
diff --git a/web/components/routes/topics/detail/partials/section.tsx b/web/components/routes/topics/detail/partials/section.tsx
new file mode 100644
index 00000000..4a711d84
--- /dev/null
+++ b/web/components/routes/topics/detail/partials/section.tsx
@@ -0,0 +1,59 @@
+import React from "react"
+import { LinkItem } from "./link-item"
+import { LaAccount, PersonalLinkLists, Section as SectionSchema, Topic, UserRoot } from "@/lib/schema"
+
+interface SectionProps {
+ topic: Topic
+ section: SectionSchema
+ activeIndex: number
+ startIndex: number
+ linkRefs: React.MutableRefObject<(HTMLLIElement | null)[]>
+ setActiveIndex: (index: number) => void
+ me: {
+ root: {
+ personalLinks: PersonalLinkLists
+ } & UserRoot
+ } & LaAccount
+ personalLinks: PersonalLinkLists
+}
+
+export function Section({
+ topic,
+ section,
+ activeIndex,
+ setActiveIndex,
+ startIndex,
+ linkRefs,
+ me,
+ personalLinks
+}: SectionProps) {
+ return (
+
+
+
+
+ {section.links?.map(
+ (link, index) =>
+ link?.url && (
+ {
+ linkRefs.current[startIndex + index] = el
+ }}
+ me={me}
+ personalLinks={personalLinks}
+ />
+ )
+ )}
+
+
+ )
+}
diff --git a/web/components/routes/topics/detail/partials/topic-sections.tsx b/web/components/routes/topics/detail/partials/topic-sections.tsx
new file mode 100644
index 00000000..b4c13dc7
--- /dev/null
+++ b/web/components/routes/topics/detail/partials/topic-sections.tsx
@@ -0,0 +1,54 @@
+import React from "react"
+import { Section } from "./section"
+import { LaAccount, ListOfSections, PersonalLinkLists, Topic, UserRoot } from "@/lib/schema"
+
+interface TopicSectionsProps {
+ topic: Topic
+ sections: (ListOfSections | null) | undefined
+ activeIndex: number
+ setActiveIndex: (index: number) => void
+ linkRefs: React.MutableRefObject<(HTMLLIElement | null)[]>
+ containerRef: React.RefObject
+ me: {
+ root: {
+ personalLinks: PersonalLinkLists
+ } & UserRoot
+ } & LaAccount
+ personalLinks: PersonalLinkLists
+}
+
+export function TopicSections({
+ topic,
+ sections,
+ activeIndex,
+ setActiveIndex,
+ linkRefs,
+ containerRef,
+ me,
+ personalLinks
+}: TopicSectionsProps) {
+ return (
+
+
+
+ {sections?.map(
+ (section, sectionIndex) =>
+ section?.id && (
+ acc + (s?.links?.length || 0), 0)}
+ linkRefs={linkRefs}
+ me={me}
+ personalLinks={personalLinks}
+ />
+ )
+ )}
+
+
+
+ )
+}
diff --git a/web/components/routes/topics/detail/use-link-navigation.ts b/web/components/routes/topics/detail/use-link-navigation.ts
new file mode 100644
index 00000000..5726626a
--- /dev/null
+++ b/web/components/routes/topics/detail/use-link-navigation.ts
@@ -0,0 +1,61 @@
+import { useState, useRef, useCallback, useEffect } from "react"
+import { Link as LinkSchema } from "@/lib/schema"
+import { ensureUrlProtocol } from "@/lib/utils"
+
+export function useLinkNavigation(allLinks: (LinkSchema | null)[]) {
+ const [activeIndex, setActiveIndex] = useState(-1)
+ const containerRef = useRef(null)
+ const linkRefs = useRef<(HTMLLIElement | null)[]>(allLinks.map(() => null))
+
+ const scrollToLink = useCallback((index: number) => {
+ if (linkRefs.current[index] && containerRef.current) {
+ const linkElement = linkRefs.current[index]
+ const container = containerRef.current
+
+ const linkRect = linkElement?.getBoundingClientRect()
+ const containerRect = container.getBoundingClientRect()
+
+ if (linkRect && containerRect) {
+ if (linkRect.bottom > containerRect.bottom) {
+ container.scrollTop += linkRect.bottom - containerRect.bottom
+ } else if (linkRect.top < containerRect.top) {
+ container.scrollTop -= containerRect.top - linkRect.top
+ }
+ }
+ }
+ }, [])
+
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ console.log("handleKeyDown")
+ if (e.key === "ArrowDown") {
+ e.preventDefault()
+ setActiveIndex(prevIndex => {
+ const newIndex = (prevIndex + 1) % allLinks.length
+ scrollToLink(newIndex)
+ return newIndex
+ })
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault()
+ setActiveIndex(prevIndex => {
+ const newIndex = (prevIndex - 1 + allLinks.length) % allLinks.length
+ scrollToLink(newIndex)
+ return newIndex
+ })
+ } else if (e.key === "Enter" && activeIndex !== -1) {
+ const link = allLinks[activeIndex]
+ if (link) {
+ window.open(ensureUrlProtocol(link.url), "_blank")
+ }
+ }
+ },
+ [activeIndex, allLinks, scrollToLink]
+ )
+
+ useEffect(() => {
+ window.addEventListener("keydown", handleKeyDown)
+ return () => window.removeEventListener("keydown", handleKeyDown)
+ }, [handleKeyDown])
+
+ return { activeIndex, setActiveIndex, containerRef, linkRefs }
+}
diff --git a/web/components/ui/dropdown-menu.tsx b/web/components/ui/dropdown-menu.tsx
index a62791af..348480ab 100644
--- a/web/components/ui/dropdown-menu.tsx
+++ b/web/components/ui/dropdown-menu.tsx
@@ -34,7 +34,6 @@ const DropdownMenuSubTrigger = React.forwardRef<
{...props}
>
{children}
-
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
diff --git a/web/hooks/use-topic-data.ts b/web/hooks/use-topic-data.ts
new file mode 100644
index 00000000..80b8efd9
--- /dev/null
+++ b/web/hooks/use-topic-data.ts
@@ -0,0 +1,29 @@
+import { useMemo } from "react"
+import { useCoState } from "@/lib/providers/jazz-provider"
+import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
+import { ID } from "jazz-tools"
+import { Link } from "@/lib/schema"
+
+const GLOBAL_GROUP_ID = process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID
+
+export function useTopicData(topicName: string) {
+ const group = useCoState(PublicGlobalGroup, GLOBAL_GROUP_ID, {
+ root: { topics: [] }
+ })
+
+ // const topic = useCoState(Topic, "co_zS3TH4Lkj5MK9GEehinxhjjNTxB" as ID, {})
+ const topic = useMemo(
+ () => group?.root.topics.find(topic => topic?.name === topicName),
+ [group?.root.topics, topicName]
+ )
+
+ const allLinks = useMemo(() => {
+ if (!topic?.latestGlobalGuide?.sections) return []
+
+ return topic.latestGlobalGuide.sections.flatMap(
+ section => section?.links?.filter((link): link is Link => !!link?.url) ?? []
+ )
+ }, [topic?.latestGlobalGuide?.sections])
+
+ return { topic, allLinks }
+}
diff --git a/web/lib/constants.ts b/web/lib/constants.ts
index a08d0631..3b024258 100644
--- a/web/lib/constants.ts
+++ b/web/lib/constants.ts
@@ -3,7 +3,7 @@ import { icons } from "lucide-react"
export type LearningStateValue = "wantToLearn" | "learning" | "learned"
export type LearningState = {
label: string
- value: string
+ value: LearningStateValue
icon: keyof typeof icons
className: string
}
diff --git a/web/lib/schema/index.ts b/web/lib/schema/index.ts
index fa5d333b..0528647f 100644
--- a/web/lib/schema/index.ts
+++ b/web/lib/schema/index.ts
@@ -24,7 +24,6 @@ export class UserRoot extends CoMap {
personalLinks = co.ref(PersonalLinkLists)
personalPages = co.ref(PersonalPageLists)
- // not implemented yet
topicsWantToLearn = co.ref(ListOfTopics)
topicsLearning = co.ref(ListOfTopics)
topicsLearned = co.ref(ListOfTopics)
@@ -53,7 +52,6 @@ export class LaAccount extends Account {
personalLinks: PersonalLinkLists.create([], { owner: this }),
personalPages: PersonalPageLists.create([], { owner: this }),
- // not implemented yet
topicsWantToLearn: ListOfTopics.create([], { owner: this }),
topicsLearning: ListOfTopics.create([], { owner: this }),
topicsLearned: ListOfTopics.create([], { owner: this })
diff --git a/web/lib/schema/master/topic.ts b/web/lib/schema/master/topic.ts
index 63cb4112..a39e351b 100644
--- a/web/lib/schema/master/topic.ts
+++ b/web/lib/schema/master/topic.ts
@@ -1,5 +1,6 @@
import { co, CoList, CoMap } from "jazz-tools"
+// TODO: this should be GlobalLink but it's not because lookup of 100k elements is slow
export class Link extends CoMap {
title = co.string
url = co.string
diff --git a/web/lib/schema/personal-link.ts b/web/lib/schema/personal-link.ts
index dea1e220..a71def64 100644
--- a/web/lib/schema/personal-link.ts
+++ b/web/lib/schema/personal-link.ts
@@ -1,5 +1,5 @@
import { co, CoList, CoMap, Encoders, ID } from "jazz-tools"
-import { Topic } from "./master/topic"
+import { Link, Topic } from "./master/topic"
class BaseModel extends CoMap {
createdAt = co.encoded(Encoders.Date)
@@ -9,6 +9,7 @@ class BaseModel extends CoMap {
export class PersonalLink extends BaseModel {
url = co.string
icon = co.optional.string // is an icon URL
+ link = co.optional.ref(Link)
title = co.string
slug = co.string
description = co.optional.string
diff --git a/web/lib/schema/personal-page.ts b/web/lib/schema/personal-page.ts
index fae2108a..6db76862 100644
--- a/web/lib/schema/personal-page.ts
+++ b/web/lib/schema/personal-page.ts
@@ -1,4 +1,4 @@
-import { co, CoList, CoMap } from "jazz-tools"
+import { co, CoList, CoMap, Encoders } from "jazz-tools"
import { Topic } from "./master/topic"
/*
@@ -8,10 +8,13 @@ import { Topic } from "./master/topic"
* - if public, certain members (can do read/write access accordingly), personal (end to end encrypted, only accessed by user)
*/
export class PersonalPage extends CoMap {
- title = co.string
- slug = co.string
+ title = co.optional.string
+ slug = co.optional.string // is used only when `public: true` for sharing, `@user/page-slug`
+ public = co.boolean
content = co.optional.json()
topic = co.optional.ref(Topic)
+ createdAt = co.encoded(Encoders.Date)
+ updatedAt = co.encoded(Encoders.Date)
// backlinks = co.optional.ref() // other PersonalPages linking to this page TODO: add, think through how to do it well, efficiently
}
diff --git a/web/lib/utils/index.ts b/web/lib/utils/index.ts
index f77bb88d..742f4e90 100644
--- a/web/lib/utils/index.ts
+++ b/web/lib/utils/index.ts
@@ -11,3 +11,4 @@ export const randomId = () => {
export * from "./urls"
export * from "./slug"
+export * from "./keyboard"
diff --git a/web/lib/utils/keyboard.ts b/web/lib/utils/keyboard.ts
new file mode 100644
index 00000000..faaaf7ce
--- /dev/null
+++ b/web/lib/utils/keyboard.ts
@@ -0,0 +1,88 @@
+let isMac: boolean | undefined
+
+interface Navigator {
+ userAgentData?: {
+ brands: { brand: string; version: string }[]
+ mobile: boolean
+ platform: string
+ getHighEntropyValues: (hints: string[]) => Promise<{
+ platform: string
+ platformVersion: string
+ uaFullVersion: string
+ }>
+ }
+}
+
+function getPlatform(): string {
+ const nav = navigator as Navigator
+
+ if (nav.userAgentData) {
+ if (nav.userAgentData.platform) {
+ return nav.userAgentData.platform
+ }
+
+ nav.userAgentData.getHighEntropyValues(["platform"]).then(highEntropyValues => {
+ if (highEntropyValues.platform) {
+ return highEntropyValues.platform
+ }
+ })
+ }
+
+ if (typeof navigator.platform === "string") {
+ return navigator.platform
+ }
+
+ return ""
+}
+
+export function isMacOS() {
+ if (isMac === undefined) {
+ isMac = getPlatform().toLowerCase().includes("mac")
+ }
+
+ return isMac
+}
+
+interface ShortcutKeyResult {
+ symbol: string
+ readable: string
+}
+
+export function getShortcutKey(key: string): ShortcutKeyResult {
+ const lowercaseKey = key.toLowerCase()
+ if (lowercaseKey === "mod") {
+ return isMacOS() ? { symbol: "⌘", readable: "Command" } : { symbol: "Ctrl", readable: "Control" }
+ } else if (lowercaseKey === "alt") {
+ return isMacOS() ? { symbol: "⌥", readable: "Option" } : { symbol: "Alt", readable: "Alt" }
+ } else if (lowercaseKey === "shift") {
+ return { symbol: "⇧", readable: "Shift" }
+ } else if (lowercaseKey === "control") {
+ return { symbol: "⌃", readable: "Control" }
+ } else if (lowercaseKey === "windows" && !isMacOS()) {
+ return { symbol: "Win", readable: "Windows" }
+ } else {
+ return { symbol: key.toUpperCase(), readable: key }
+ }
+}
+
+export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] {
+ return keys.map(key => getShortcutKey(key))
+}
+
+export function getSpecialShortcut(shortcutName: string): ShortcutKeyResult[] {
+ if (shortcutName === "expandToolbar") {
+ return isMacOS()
+ ? [getShortcutKey("control"), getShortcutKey("mod"), getShortcutKey("n")]
+ : [getShortcutKey("mod"), getShortcutKey("windows"), getShortcutKey("n")]
+ }
+
+ return []
+}
+
+export function formatShortcut(shortcutKeys: ShortcutKeyResult[]): string {
+ return shortcutKeys.map(key => key.symbol).join("")
+}
+
+export function formatReadableShortcut(shortcutKeys: ShortcutKeyResult[]): string {
+ return shortcutKeys.map(key => key.readable).join(" + ")
+}
diff --git a/web/package.json b/web/package.json
index 28a9456b..4b9982ef 100644
--- a/web/package.json
+++ b/web/package.json
@@ -95,7 +95,7 @@
"@types/node": "^22.5.0",
"@types/react": "^18.3.4",
"@types/react-dom": "^18.3.0",
- "eslint": "^9.9.1",
+ "eslint": "^8.57.0",
"eslint-config-next": "14.2.5",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
diff --git a/web/store/link.ts b/web/store/link.ts
index 3368710b..566cfd28 100644
--- a/web/store/link.ts
+++ b/web/store/link.ts
@@ -3,6 +3,7 @@ import { atomWithStorage } from "jotai/utils"
export const linkSortAtom = atomWithStorage("sort", "manual")
export const linkShowCreateAtom = atom(false)
-export const linkEditIdAtom = atom(null)
+export const linkEditIdAtom = atom(null)
export const linkLearningStateSelectorAtom = atom(false)
export const linkTopicSelectorAtom = atom(false)
+export const linkOpenPopoverForIdAtom = atom(null)