mirror of
https://github.com/linsa-io/linsa.git
synced 2026-11-13 12:23:37 +01:00
chore: Enhancement + New Feature (#185)
* wip * wip page * chore: style * wip pages * wip pages * chore: toggle * chore: link * feat: topic search * chore: page section * refactor: apply tailwind class ordering * fix: handle loggedIn user for guest route * feat: folder & image schema * chore: move utils to shared * refactor: tailwind class ordering * feat: img ext for editor * refactor: remove qa * fix: tanstack start * fix: wrong import * chore: use toast * chore: schema
This commit is contained in:
1 parent
c93c634a77
commit
a440828f8c
158 files changed
+2808
-1064
No files matched your search
+1
-1
@@ -3,7 +3,7 @@ import tsConfigPaths from "vite-tsconfig-paths"
|
||||
|
||||
export default defineConfig({
|
||||
vite: {
|
||||
plugins: () => [
|
||||
plugins: [
|
||||
tsConfigPaths({
|
||||
projects: ["./tsconfig.json"],
|
||||
}),
|
||||
|
||||
@@ -17,28 +17,28 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
console.error(error)
|
||||
|
||||
return (
|
||||
<div className="min-w-0 flex-1 p-4 flex flex-col items-center justify-center gap-6">
|
||||
<div className="flex min-w-0 flex-1 flex-col items-center justify-center gap-6 p-4">
|
||||
<ErrorComponent error={error} />
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
router.invalidate()
|
||||
}}
|
||||
className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`}
|
||||
className={`rounded bg-gray-600 px-2 py-1 font-extrabold uppercase text-white dark:bg-gray-700`}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
{isRoot ? (
|
||||
<Link
|
||||
to="/"
|
||||
className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`}
|
||||
className={`rounded bg-gray-600 px-2 py-1 font-extrabold uppercase text-white dark:bg-gray-700`}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/"
|
||||
className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`}
|
||||
className={`rounded bg-gray-600 px-2 py-1 font-extrabold uppercase text-white dark:bg-gray-700`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
window.history.back()
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from "react"
|
||||
import { useKeyDown, KeyFilter, Options } from "@/hooks/use-key-down"
|
||||
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
import { isModKey, isServer } from "@/lib/utils"
|
||||
import { useAtom } from "jotai"
|
||||
import { usePageActions } from "~/hooks/actions/use-page-actions"
|
||||
import { useAuth } from "@clerk/tanstack-start"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import queryString from "query-string"
|
||||
import { commandPaletteOpenAtom } from "~/store/any-store"
|
||||
import { isModKey, isServer } from "@shared/utils"
|
||||
|
||||
type RegisterKeyDownProps = {
|
||||
trigger: KeyFilter
|
||||
|
||||
@@ -6,16 +6,16 @@ export function NotFound({ children }: { children?: any }) {
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
{children || <p>The page you are looking for does not exist.</p>}
|
||||
</div>
|
||||
<p className="flex items-center gap-2 flex-wrap">
|
||||
<p className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="bg-emerald-500 text-white px-2 py-1 rounded uppercase font-black text-sm"
|
||||
className="rounded bg-emerald-500 px-2 py-1 text-sm font-black uppercase text-white"
|
||||
>
|
||||
Go back
|
||||
</button>
|
||||
<Link
|
||||
to="/"
|
||||
className="bg-cyan-600 text-white px-2 py-1 rounded uppercase font-black text-sm"
|
||||
className="rounded bg-cyan-600 px-2 py-1 text-sm font-black uppercase text-white"
|
||||
>
|
||||
Start Over
|
||||
</Link>
|
||||
|
||||
@@ -42,7 +42,7 @@ export function Onboarding() {
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="text-foreground/70 space-y-4 text-base leading-5">
|
||||
<div className="space-y-4 text-base leading-5 text-foreground/70">
|
||||
<p>
|
||||
Learn Anything is a learning platform that organizes knowledge in
|
||||
a social way. You can create pages, add links, track learning
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { icons } from "lucide-react"
|
||||
import { LaAccount } from "@/lib/schema"
|
||||
import { HTMLLikeElement } from "@/lib/utils"
|
||||
import { useCommandActions } from "~/hooks/use-command-actions"
|
||||
import { useCommandActions } from "~/hooks/actions/use-command-actions"
|
||||
|
||||
export type CommandAction = string | (() => void)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { CommandGroup } from "./command-group"
|
||||
import { CommandAction, createCommandGroups } from "./command-data"
|
||||
import { useAccount, useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
import { useAtom } from "jotai"
|
||||
import { useCommandActions } from "~/hooks/use-command-actions"
|
||||
import { useCommandActions } from "~/hooks/actions/use-command-actions"
|
||||
import {
|
||||
filterItems,
|
||||
getTopics,
|
||||
|
||||
@@ -15,7 +15,7 @@ export const ContentHeader = React.forwardRef<
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex min-h-10 min-w-0 shrink-0 items-center gap-3 pl-8 pr-6 transition-opacity max-lg:px-4",
|
||||
"flex min-h-10 min-w-0 shrink-0 items-center gap-3 border-b border-b-[var(--la-border-new)] px-6 py-3 transition-opacity max-lg:px-4",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
@@ -48,7 +48,7 @@ export const SidebarToggleButton: React.FC = () => {
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label="Menu"
|
||||
className="text-primary/60"
|
||||
className="-ml-2 cursor-default text-muted-foreground hover:bg-transparent"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<LaIcon name="PanelLeft" />
|
||||
|
||||
@@ -66,7 +66,7 @@ export const LearningStateSelector: React.FC<LearningStateSelectorProps> = ({
|
||||
type="button"
|
||||
role="combobox"
|
||||
variant="secondary"
|
||||
className={cn("gap-x-2 text-sm", className)}
|
||||
className={cn("h-7 gap-x-2 text-sm", className)}
|
||||
>
|
||||
{iconName && (
|
||||
<LaIcon
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LaIcon } from "~/components/custom/la-icon"
|
||||
import { icons } from "lucide-react"
|
||||
import type { NavigateOptions } from "@tanstack/react-router"
|
||||
|
||||
interface NavItemProps extends NavigateOptions {
|
||||
title: string
|
||||
count: number
|
||||
icon: keyof typeof icons
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function NavItem({
|
||||
title,
|
||||
count,
|
||||
icon,
|
||||
className,
|
||||
...linkProps
|
||||
}: NavItemProps) {
|
||||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
"group/p",
|
||||
"flex h-[30px] cursor-default items-center gap-px rounded-md px-2 text-sm font-medium",
|
||||
"text-[var(--less-foreground)] hover:bg-[var(--item-hover)] focus-visible:outline-none focus-visible:ring-0",
|
||||
className,
|
||||
)}
|
||||
activeProps={{
|
||||
className:
|
||||
'bg-[var(--item-active)] data-[status="active"]:hover:bg-[var(--item-active)]',
|
||||
}}
|
||||
{...linkProps}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<LaIcon
|
||||
name={icon}
|
||||
className={cn("group-hover/p:text-foreground", {
|
||||
"text-foreground": isActive,
|
||||
"text-muted-foreground": !isActive,
|
||||
})}
|
||||
/>
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<span className="flex-grow" />
|
||||
{count > 0 && <BadgeCount count={count} isActive={isActive} />}
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
interface BadgeCountProps {
|
||||
count: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
function BadgeCount({ count, isActive }: BadgeCountProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn("font-mono", {
|
||||
"text-muted-foreground": !isActive,
|
||||
"text-foreground": isActive,
|
||||
})}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const TextareaAutosize = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
return (
|
||||
<BaseTextareaAutosize
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
export const ArrowIcon = ({
|
||||
className,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement>) => {
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M18.1125 12.4381C18.4579 12.2481 18.4579 11.7519 18.1125 11.5619L8.74096 6.40753C8.40773 6.22425 8 6.46533 8 6.84564V17.1544C8 17.5347 8.40773 17.7757 8.74096 17.5925L18.1125 12.4381Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -66,7 +66,7 @@ const ShortcutKey: React.FC<{ keyChar: string }> = ({ keyChar }) => (
|
||||
const ShortcutItem: React.FC<ShortcutItem> = ({ label, keys, then }) => (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<dt className="flex grow items-center">
|
||||
<span className="text-muted-foreground text-left text-sm">{label}</span>
|
||||
<span className="text-left text-sm text-muted-foreground">{label}</span>
|
||||
</dt>
|
||||
<dd className="flex items-end">
|
||||
<span className="text-left">
|
||||
@@ -79,7 +79,7 @@ const ShortcutItem: React.FC<ShortcutItem> = ({ label, keys, then }) => (
|
||||
))}
|
||||
{then && (
|
||||
<>
|
||||
<span className="text-muted-foreground text-xs">then</span>
|
||||
<span className="text-xs text-muted-foreground">then</span>
|
||||
{then.map((key, index) => (
|
||||
<ShortcutKey key={`then-${index}`} keyChar={key} />
|
||||
))}
|
||||
@@ -149,7 +149,7 @@ export function Shortcut() {
|
||||
"size-6 p-0",
|
||||
)}
|
||||
>
|
||||
<LaIcon name="X" className="text-muted-foreground size-5" />
|
||||
<LaIcon name="X" className="size-5 text-muted-foreground" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</header>
|
||||
@@ -158,12 +158,12 @@ export function Shortcut() {
|
||||
<form className="relative flex items-center">
|
||||
<LaIcon
|
||||
name="Search"
|
||||
className="text-muted-foreground absolute left-3 size-4"
|
||||
className="absolute left-3 size-4 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search shortcuts"
|
||||
className="border-muted-foreground/50 focus-visible:border-muted-foreground h-10 pl-10 focus-visible:ring-0"
|
||||
className="h-10 border-muted-foreground/50 pl-10 focus-visible:border-muted-foreground focus-visible:ring-0"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -109,7 +109,7 @@ export function Feedback() {
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"flex flex-col p-4 sm:max-w-2xl",
|
||||
)}
|
||||
>
|
||||
@@ -134,7 +134,7 @@ export function Feedback() {
|
||||
{...field}
|
||||
throttleDelay={500}
|
||||
className={cn(
|
||||
"border-muted-foreground/40 focus-within:border-muted-foreground/80 min-h-52 rounded-lg",
|
||||
"min-h-52 rounded-lg border-muted-foreground/40 focus-within:border-muted-foreground/80",
|
||||
{
|
||||
"border-destructive focus-within:border-destructive":
|
||||
form.formState.errors.content,
|
||||
|
||||
@@ -75,7 +75,7 @@ const JournalSectionHeader: React.FC<JournalHeaderProps> = ({
|
||||
<p className="text-xs">
|
||||
Journal
|
||||
{entriesCount > 0 && (
|
||||
<span className="text-muted-foreground ml-1">({entriesCount})</span>
|
||||
<span className="ml-1 text-muted-foreground">({entriesCount})</span>
|
||||
)}
|
||||
</p>
|
||||
</Link>
|
||||
@@ -104,7 +104,7 @@ const JournalEntryItem: React.FC<JournalEntryItemProps> = ({ entry }) => (
|
||||
href={`/journal/${entry.id}`}
|
||||
className="group/journal-entry relative flex min-w-0 flex-1"
|
||||
>
|
||||
<div className="relative flex h-8 w-full items-center gap-2 rounded-md p-1.5 font-medium">
|
||||
<div className="relative flex h-[30px] w-full items-center gap-2 rounded-md p-1.5 font-medium">
|
||||
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
|
||||
<LaIcon name="FileText" className="opacity-60" />
|
||||
<p
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { NavItem } from "~/components/custom/nav-item"
|
||||
|
||||
export const LinkCollection: React.FC = () => {
|
||||
const { me } = useAccount({
|
||||
root: {
|
||||
personalLinks: [],
|
||||
topicsWantToLearn: [],
|
||||
topicsLearning: [],
|
||||
topicsLearned: [],
|
||||
},
|
||||
})
|
||||
|
||||
const linkCount = me?.root.personalLinks?.length || 0
|
||||
|
||||
const topicCount =
|
||||
(me?.root.topicsWantToLearn?.length || 0) +
|
||||
(me?.root.topicsLearning?.length || 0) +
|
||||
(me?.root.topicsLearned?.length || 0)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-px py-2">
|
||||
<NavItem to="/links" title="Links" icon="Link" count={linkCount} />
|
||||
<NavItem to="/topics" title="Topics" icon="Hash" count={topicCount} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PersonalLinkLists } from "@/lib/schema/personal-link"
|
||||
import { LearningStateValue } from "~/lib/constants"
|
||||
import { LaIcon } from "~/components/custom/la-icon"
|
||||
|
||||
export const LinkSection: React.FC = () => {
|
||||
const { me } = useAccount({ root: { personalLinks: [] } })
|
||||
@@ -13,7 +14,7 @@ export const LinkSection: React.FC = () => {
|
||||
const linkCount = me.root.personalLinks?.length || 0
|
||||
|
||||
return (
|
||||
<div className="group/pages flex flex-col gap-px py-2">
|
||||
<div className="flex flex-col gap-px py-2">
|
||||
<LinkSectionHeader linkCount={linkCount} />
|
||||
<LinkList personalLinks={me.root.personalLinks} />
|
||||
</div>
|
||||
@@ -24,22 +25,41 @@ interface LinkSectionHeaderProps {
|
||||
linkCount: number
|
||||
}
|
||||
|
||||
const LinkSectionHeader: React.FC<LinkSectionHeaderProps> = ({ linkCount }) => (
|
||||
<Link
|
||||
to="/links"
|
||||
className={cn(
|
||||
"flex h-9 items-center gap-px rounded-md px-2 py-1 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-0 sm:h-[30px] sm:text-xs",
|
||||
)}
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
Links
|
||||
{linkCount > 0 && (
|
||||
<span className="text-muted-foreground ml-1">{linkCount}</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
const LinkSectionHeader: React.FC<LinkSectionHeaderProps> = ({ linkCount }) => {
|
||||
return (
|
||||
<Link
|
||||
to="/links"
|
||||
className={cn(
|
||||
"flex h-[30px] items-center gap-px rounded-md px-2 text-sm font-medium hover:bg-[var(--item-hover)] focus-visible:outline-none focus-visible:ring-0",
|
||||
)}
|
||||
activeProps={{
|
||||
className:
|
||||
"bg-[var(--item-active)] data-[status='active']:hover:bg-[var(--item-active)]",
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<LaIcon name="Link" className="" />
|
||||
<span>Links</span>
|
||||
</div>
|
||||
<span className="flex flex-auto"></span>
|
||||
{linkCount > 0 && (
|
||||
<span
|
||||
className={cn("font-mono text-muted-foreground", {
|
||||
"text-foreground": isActive,
|
||||
})}
|
||||
>
|
||||
{linkCount}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
interface LinkListProps {
|
||||
personalLinks: PersonalLinkLists
|
||||
@@ -87,29 +107,34 @@ interface LinkListItemProps {
|
||||
}
|
||||
|
||||
const LinkListItem: React.FC<LinkListItemProps> = ({ label, state, count }) => (
|
||||
<div className="group/reorder-page relative">
|
||||
<div className="group/topic-link relative flex min-w-0 flex-1">
|
||||
<Link
|
||||
to="/links"
|
||||
search={{ state }}
|
||||
className={cn(
|
||||
"relative flex h-9 w-full items-center gap-2 rounded-md p-1.5 font-medium hover:bg-accent hover:text-accent-foreground sm:h-8",
|
||||
)}
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
|
||||
<p className="truncate opacity-95 group-hover/topic-link:opacity-100">
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
{count > 0 && (
|
||||
<span className="absolute right-2 top-1/2 z-[1] -translate-y-1/2 rounded p-1 text-sm">
|
||||
{count}
|
||||
</span>
|
||||
<div className="relative flex min-w-0 flex-1">
|
||||
<Link
|
||||
to="/links"
|
||||
search={{ state }}
|
||||
className={cn(
|
||||
"relative flex h-[30px] w-full items-center gap-2 rounded-md px-1.5 text-sm font-medium hover:bg-[var(--item-hover)]",
|
||||
)}
|
||||
</div>
|
||||
activeProps={{
|
||||
className:
|
||||
"bg-[var(--item-active)] data-[status='active']:hover:bg-[var(--item-active)]",
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate">
|
||||
<p className="truncate">{label}</p>
|
||||
</div>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className={cn("font-mono text-muted-foreground", {
|
||||
"text-foreground": isActive,
|
||||
})}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { usePageActions } from "~/hooks/actions/use-page-actions"
|
||||
import { icons } from "lucide-react"
|
||||
import { ArrowIcon } from "~/components/icons/arrow-icon"
|
||||
|
||||
type SortOption = "title" | "recent"
|
||||
type ShowOption = 5 | 10 | 15 | 20 | 0
|
||||
@@ -44,56 +45,82 @@ const SHOWS: Option<ShowOption>[] = [
|
||||
|
||||
const pageSortAtom = atomWithStorage<SortOption>("pageSort", "title")
|
||||
const pageShowAtom = atomWithStorage<ShowOption>("pageShow", 5)
|
||||
const isExpandedAtom = atomWithStorage("isPageSectionExpanded", true)
|
||||
|
||||
export const PageSection: React.FC = () => {
|
||||
const { me } = useAccount({
|
||||
root: {
|
||||
personalPages: [],
|
||||
},
|
||||
})
|
||||
const { me } = useAccount({ root: { personalPages: [] } })
|
||||
const [sort] = useAtom(pageSortAtom)
|
||||
const [show] = useAtom(pageShowAtom)
|
||||
const [isExpanded, setIsExpanded] = useAtom(isExpandedAtom)
|
||||
|
||||
if (!me) return null
|
||||
|
||||
const pageCount = me.root.personalPages?.length || 0
|
||||
|
||||
return (
|
||||
<div className="group/pages flex flex-col gap-px py-2">
|
||||
<PageSectionHeader pageCount={pageCount} />
|
||||
<PageList personalPages={me.root.personalPages} sort={sort} show={show} />
|
||||
<div className="flex flex-col gap-px py-2">
|
||||
<PageSectionHeader
|
||||
pageCount={pageCount}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={() => setIsExpanded(!isExpanded)}
|
||||
/>
|
||||
{isExpanded && (
|
||||
<PageList
|
||||
personalPages={me.root.personalPages}
|
||||
sort={sort}
|
||||
show={show}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PageSectionHeaderProps {
|
||||
pageCount: number
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
const PageSectionHeader: React.FC<PageSectionHeaderProps> = ({ pageCount }) => (
|
||||
<Link
|
||||
to="/pages"
|
||||
const PageSectionHeader: React.FC<PageSectionHeaderProps> = ({
|
||||
pageCount,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 flex-1 items-center justify-start gap-px rounded-md px-2 py-1",
|
||||
"hover:bg-accent hover:text-accent-foreground sm:h-[30px]",
|
||||
"group/pages",
|
||||
"relative flex h-7 cursor-default items-center gap-px rounded-md py-0 font-medium text-muted-foreground hover:bg-[var(--item-hover)] focus-visible:outline-none focus-visible:ring-0",
|
||||
)}
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
<div className="flex grow items-center justify-between">
|
||||
<p className="text-sm sm:text-xs">
|
||||
Pages
|
||||
{pageCount > 0 && (
|
||||
<span className="text-muted-foreground ml-1">{pageCount}</span>
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-7 w-full justify-start gap-1 px-2 py-0 text-xs hover:bg-inherit"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span>Pages</span>
|
||||
{pageCount > 0 && <span className="text-xs">({pageCount})</span>}
|
||||
<ArrowIcon
|
||||
className={cn("size-3 transition-transform duration-200 ease-in-out", {
|
||||
"rotate-90": isExpanded,
|
||||
"opacity-0 group-hover/pages:opacity-100": !isExpanded,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-1 top-1/2 -translate-y-1/2",
|
||||
"transition-all duration-200 ease-in-out",
|
||||
{
|
||||
"opacity-100": isExpanded,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-px">
|
||||
<ShowAllForm />
|
||||
<NewPageButton />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
|
||||
const NewPageButton: React.FC = () => {
|
||||
@@ -122,11 +149,11 @@ const NewPageButton: React.FC = () => {
|
||||
variant="ghost"
|
||||
aria-label="New Page"
|
||||
className={cn(
|
||||
"flex size-5 items-center justify-center p-0.5 shadow-none",
|
||||
"hover:bg-accent-foreground/10",
|
||||
"flex size-5 cursor-default items-center justify-center p-0.5 shadow-none",
|
||||
"text-muted-foreground hover:bg-inherit hover:text-foreground",
|
||||
"opacity-0 transition-opacity duration-200",
|
||||
"group-hover/pages:opacity-100 group-has-[[data-state='open']]/pages:opacity-100",
|
||||
"data-[state='open']:opacity-100 focus-visible:outline-none focus-visible:ring-0",
|
||||
"focus-visible:outline-none focus-visible:ring-0 data-[state='open']:opacity-100",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
@@ -168,29 +195,31 @@ interface PageListItemProps {
|
||||
|
||||
const PageListItem: React.FC<PageListItemProps> = ({ page }) => {
|
||||
return (
|
||||
<div className="group/reorder-page relative">
|
||||
<div className="group/sidebar-link relative flex min-w-0 flex-1">
|
||||
<Link
|
||||
to="/pages/$pageId"
|
||||
params={{ pageId: page.id }}
|
||||
className={cn(
|
||||
"relative flex h-9 w-full items-center gap-2 rounded-md p-1.5 font-medium sm:h-8",
|
||||
"group-hover/sidebar-link:bg-accent group-hover/sidebar-link:text-accent-foreground",
|
||||
)}
|
||||
activeOptions={{ exact: true }}
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
<div className="flex max-w-[calc(100%-1rem)] flex-1 items-center gap-1.5 truncate text-sm">
|
||||
<LaIcon name="FileText" className="flex-shrink-0 opacity-60" />
|
||||
<p className="truncate opacity-95 group-hover/sidebar-link:opacity-100">
|
||||
{page.title || "Untitled"}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to="/pages/$pageId"
|
||||
params={{ pageId: page.id }}
|
||||
className={cn(
|
||||
"group/p cursor-default text-[var(--less-foreground)]",
|
||||
"relative flex h-[30px] w-full items-center gap-2 rounded-md px-1.5 text-sm font-medium hover:bg-[var(--item-hover)]",
|
||||
)}
|
||||
activeProps={{
|
||||
className:
|
||||
"bg-[var(--item-active)] data-[status='active']:hover:bg-[var(--item-active)]",
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate">
|
||||
<LaIcon
|
||||
name="File"
|
||||
className={cn("flex-shrink-0 group-hover/p:text-foreground", {
|
||||
"text-foreground": isActive,
|
||||
"text-muted-foreground": !isActive,
|
||||
})}
|
||||
/>
|
||||
<p className="truncate">{page.title || "Untitled"}</p>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -212,11 +241,11 @@ const SubMenu = <T extends string | number>({
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<span className="flex items-center gap-2">
|
||||
<LaIcon name={icon} />
|
||||
<LaIcon name={icon} className="" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{options.find((option) => option.value === currentValue)?.label}
|
||||
</span>
|
||||
<LaIcon name="ChevronRight" />
|
||||
@@ -251,11 +280,11 @@ const ShowAllForm: React.FC = () => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"flex size-5 items-center justify-center p-0.5 shadow-none",
|
||||
"hover:bg-accent-foreground/10",
|
||||
"flex size-5 cursor-default items-center justify-center p-0.5 shadow-none",
|
||||
"text-muted-foreground hover:bg-inherit hover:text-foreground",
|
||||
"opacity-0 transition-opacity duration-200",
|
||||
"group-hover/pages:opacity-100 group-has-[[data-state='open']]/pages:opacity-100",
|
||||
"data-[state='open']:opacity-100 focus-visible:outline-none focus-visible:ring-0",
|
||||
"focus-visible:outline-none focus-visible:ring-0 data-[state='open']:opacity-100",
|
||||
)}
|
||||
>
|
||||
<LaIcon name="Ellipsis" />
|
||||
|
||||
@@ -56,6 +56,7 @@ export const ProfileSection: React.FC = () => {
|
||||
signOut={signOut}
|
||||
setShowShortcut={setShowShortcut}
|
||||
/>
|
||||
<span className="flex flex-auto"></span>
|
||||
<Feedback />
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,12 +84,12 @@ const ProfileDropdown: React.FC<ProfileDropdownProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label="Profile"
|
||||
className="hover:bg-accent focus-visible:ring-ring hover:text-accent-foreground flex h-auto items-center gap-1.5 truncate rounded py-1 pl-1 pr-1.5 focus-visible:outline-none focus-visible:ring-1"
|
||||
className="flex h-auto items-center gap-1.5 truncate rounded py-1 pl-1 pr-1.5 hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={user.imageUrl} alt={user.fullName || ""} />
|
||||
</Avatar>
|
||||
<span className="truncate text-left text-sm font-medium -tracking-wider">
|
||||
<span className="truncate text-left text-sm font-medium -tracking-wide">
|
||||
{user.fullName}
|
||||
</span>
|
||||
<LaIcon
|
||||
|
||||
@@ -93,7 +93,7 @@ const TaskSectionHeader: React.FC<TaskSectionHeaderProps> = ({
|
||||
<Link
|
||||
to="/tasks"
|
||||
className={cn(
|
||||
"flex flex-1 min-h-[30px] gap-px items-center justify-start hover:bg-accent hover:text-accent-foreground rounded-md px-2 py-1 focus-visible:outline-none focus-visible:ring-0",
|
||||
"flex min-h-[30px] flex-1 items-center justify-start gap-px rounded-md px-2 py-1 hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-0",
|
||||
)}
|
||||
search={{ filter }}
|
||||
activeProps={{
|
||||
@@ -104,7 +104,7 @@ const TaskSectionHeader: React.FC<TaskSectionHeaderProps> = ({
|
||||
|
||||
<p className="text-sm">
|
||||
{title}
|
||||
{count > 0 && <span className="text-muted-foreground ml-1">{count}</span>}
|
||||
{count > 0 && <span className="ml-1 text-muted-foreground">{count}</span>}
|
||||
</p>
|
||||
</Link>
|
||||
)
|
||||
@@ -1,142 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { ListOfTopics } from "@/lib/schema"
|
||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
|
||||
export const TopicSection: React.FC = () => {
|
||||
const { me } = useAccount({
|
||||
root: {
|
||||
topicsWantToLearn: [],
|
||||
topicsLearning: [],
|
||||
topicsLearned: [],
|
||||
},
|
||||
})
|
||||
|
||||
const topicCount =
|
||||
(me?.root.topicsWantToLearn?.length || 0) +
|
||||
(me?.root.topicsLearning?.length || 0) +
|
||||
(me?.root.topicsLearned?.length || 0)
|
||||
|
||||
if (!me) return null
|
||||
|
||||
return (
|
||||
<div className="group/topics flex flex-col gap-px py-2">
|
||||
<TopicSectionHeader topicCount={topicCount} />
|
||||
<List
|
||||
topicsWantToLearn={me.root.topicsWantToLearn}
|
||||
topicsLearning={me.root.topicsLearning}
|
||||
topicsLearned={me.root.topicsLearned}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TopicSectionHeaderProps {
|
||||
topicCount: number
|
||||
}
|
||||
|
||||
const TopicSectionHeader: React.FC<TopicSectionHeaderProps> = ({
|
||||
topicCount,
|
||||
}) => (
|
||||
<Link
|
||||
to="/topics"
|
||||
className="flex h-9 items-center gap-px rounded-md px-2 py-1 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-0 sm:h-[30px] sm:text-xs"
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
<p className="text-sm sm:text-xs">
|
||||
Topics
|
||||
{topicCount > 0 && (
|
||||
<span className="text-muted-foreground ml-1">{topicCount}</span>
|
||||
)}
|
||||
</p>
|
||||
</Link>
|
||||
)
|
||||
|
||||
interface ListProps {
|
||||
topicsWantToLearn: ListOfTopics
|
||||
topicsLearning: ListOfTopics
|
||||
topicsLearned: ListOfTopics
|
||||
}
|
||||
|
||||
const List: React.FC<ListProps> = ({
|
||||
topicsWantToLearn,
|
||||
topicsLearning,
|
||||
topicsLearned,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
<ListItem
|
||||
key={topicsWantToLearn.id}
|
||||
count={topicsWantToLearn.length}
|
||||
label="To Learn"
|
||||
value="wantToLearn"
|
||||
/>
|
||||
<ListItem
|
||||
key={topicsLearning.id}
|
||||
label="Learning"
|
||||
value="learning"
|
||||
count={topicsLearning.length}
|
||||
/>
|
||||
<ListItem
|
||||
key={topicsLearned.id}
|
||||
label="Learned"
|
||||
value="learned"
|
||||
count={topicsLearned.length}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ListItemProps {
|
||||
label: string
|
||||
value: LearningStateValue
|
||||
count: number
|
||||
}
|
||||
|
||||
const ListItem: React.FC<ListItemProps> = ({ label, value, count }) => {
|
||||
const le = LEARNING_STATES.find((l) => l.value === value)
|
||||
|
||||
if (!le) return null
|
||||
|
||||
return (
|
||||
<div className="group/reorder-page relative">
|
||||
<div className="group/topic-link relative flex min-w-0 flex-1">
|
||||
<Link
|
||||
to="/topics"
|
||||
search={{ learningState: value }}
|
||||
className={cn(
|
||||
"group-hover/topic-link:bg-accent relative flex h-9 w-full items-center gap-2 rounded-md p-1.5 font-medium sm:h-8",
|
||||
le.className,
|
||||
)}
|
||||
activeOptions={{ exact: true }}
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
|
||||
<LaIcon name={le.icon} className="flex-shrink-0 opacity-60" />
|
||||
<p
|
||||
className={cn(
|
||||
"truncate opacity-95 group-hover/topic-link:opacity-100",
|
||||
le.className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{count > 0 && (
|
||||
<span className="absolute right-2 top-1/2 z-[1] -translate-y-1/2 rounded p-1 text-sm">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from "react"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { useAtom } from "jotai"
|
||||
import { LogoIcon } from "@/components/icons/logo-icon"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { isCollapseAtom } from "@/store/sidebar"
|
||||
@@ -9,12 +8,12 @@ import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { Link, useLocation } from "@tanstack/react-router"
|
||||
|
||||
import { LinkSection } from "./partials/link-section"
|
||||
// import { LinkSection } from "./partials/link-section"
|
||||
import { PageSection } from "./partials/page-section"
|
||||
import { TopicSection } from "./partials/topic-section"
|
||||
import { ProfileSection } from "./partials/profile-section"
|
||||
import { JournalSection } from "./partials/journal-section"
|
||||
import { TaskSection } from "./partials/task-section"
|
||||
import { LinkCollection } from "./partials/link-collection"
|
||||
|
||||
interface SidebarContextType {
|
||||
isCollapsed: boolean
|
||||
@@ -64,14 +63,14 @@ const SidebarItem: React.FC<SidebarItemProps> = React.memo(
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
className="text-secondary-foreground flex h-8 grow items-center truncate rounded-md pl-1.5 pr-1 text-sm font-medium"
|
||||
className="flex h-8 grow items-center truncate rounded-md pl-1.5 pr-1 text-sm font-medium text-secondary-foreground"
|
||||
to={url}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-primary/60 group-hover:text-primary mr-2 size-4",
|
||||
"mr-2 size-4 text-primary/60 group-hover:text-primary",
|
||||
{ "text-primary": isActive },
|
||||
)}
|
||||
>
|
||||
@@ -102,7 +101,7 @@ const LogoAndSearch: React.FC = React.memo(() => {
|
||||
to={pathname === "/search" ? "/" : "/search"}
|
||||
className={cn(
|
||||
buttonVariants({ size: "sm", variant: "secondary" }),
|
||||
"text-primary/60 flex w-20 items-center justify-start py-4 pl-2",
|
||||
"flex w-20 items-center justify-start py-4 pl-2 text-primary/60",
|
||||
)}
|
||||
activeProps={{
|
||||
className: "text-md font-medium",
|
||||
@@ -126,14 +125,14 @@ const SidebarContent: React.FC = React.memo(() => {
|
||||
const { me } = useAccountOrGuest()
|
||||
|
||||
return (
|
||||
<nav className="bg-background relative flex h-full w-full shrink-0 flex-col">
|
||||
<nav className="relative flex h-full w-full shrink-0 flex-col bg-[var(--body-background)]">
|
||||
<div>
|
||||
<LogoAndSearch />
|
||||
</div>
|
||||
<div className="relative mb-0.5 mt-1.5 flex grow flex-col overflow-y-auto rounded-md px-3 outline-none">
|
||||
<div className="h-2 shrink-0" />
|
||||
{me._type === "Account" && <LinkSection />}
|
||||
{me._type === "Account" && <TopicSection />}
|
||||
{me._type === "Account" && <LinkCollection />}
|
||||
{/* {me._type === "Account" && <LinkSection />} */}
|
||||
{me._type === "Account" && <JournalSection />}
|
||||
{me._type === "Account" && <TaskSection />}
|
||||
{me._type === "Account" && <PageSection />}
|
||||
@@ -156,7 +155,7 @@ const Sidebar: React.FC = () => {
|
||||
)
|
||||
|
||||
const sidebarInnerClasses = cn(
|
||||
"h-full w-56 min-w-56 transition-transform duration-300 ease-in-out",
|
||||
"h-full w-60 min-w-60 transition-transform duration-300 ease-in-out",
|
||||
isCollapsed ? "-translate-x-full" : "translate-x-0",
|
||||
)
|
||||
|
||||
@@ -184,7 +183,7 @@ const Sidebar: React.FC = () => {
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(sidebarInnerClasses, "border-r-primary/5 border-r")}
|
||||
className={cn(sidebarInnerClasses, "border-r border-r-primary/5")}
|
||||
>
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<SidebarContent />
|
||||
|
||||
@@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -19,7 +19,7 @@ const SheetOverlay = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ensureUrlProtocol } from "@/lib/utils"
|
||||
import { useTheme } from "next-themes"
|
||||
import { toast } from "sonner"
|
||||
import { LaAccount } from "@/lib/schema"
|
||||
import { usePageActions } from "./actions/use-page-actions"
|
||||
import { usePageActions } from "./use-page-actions"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
|
||||
export const useCommandActions = () => {
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react"
|
||||
import { isModKey, isServer, isTextInput } from "@/lib/utils"
|
||||
import { isTextInput } from "@/lib/utils"
|
||||
import { isModKey, isServer } from "@shared/utils"
|
||||
|
||||
export type KeyFilter = ((event: KeyboardEvent) => boolean) | string
|
||||
export type Options = { allowInInput?: boolean }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isClient } from "@shared/utils"
|
||||
import * as React from "react"
|
||||
import { isClient } from "~/lib/utils"
|
||||
|
||||
export function useTouchSensor() {
|
||||
const [isTouchDevice, setIsTouchDevice] = React.useState(false)
|
||||
|
||||
@@ -28,13 +28,13 @@ export const LEARNING_STATES: LearningState[] = [
|
||||
label: "Learning",
|
||||
value: "learning",
|
||||
icon: "GraduationCap",
|
||||
className: "text-[#D29752]",
|
||||
className: "text-yellow-600 hover:text-yellow-700",
|
||||
},
|
||||
{
|
||||
label: "Learned",
|
||||
value: "learned",
|
||||
icon: "Check",
|
||||
className: "text-[#708F51]",
|
||||
className: "text-green-700 hover:text-green-800",
|
||||
},
|
||||
] as const
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { co, CoMap, Encoders } from "jazz-tools"
|
||||
|
||||
export class BaseModel extends CoMap {
|
||||
createdAt = co.encoded(Encoders.Date)
|
||||
updatedAt = co.encoded(Encoders.Date)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { co, CoList, ImageDefinition } from "jazz-tools"
|
||||
import { BaseModel } from "./base"
|
||||
|
||||
export class Image extends BaseModel {
|
||||
fileName = co.optional.string
|
||||
fileSize = co.optional.number
|
||||
width = co.optional.number
|
||||
height = co.optional.number
|
||||
aspectRatio = co.optional.number
|
||||
mimeType = co.optional.string
|
||||
lastModified = co.optional.string
|
||||
url = co.optional.string
|
||||
content = co.optional.ref(ImageDefinition)
|
||||
}
|
||||
|
||||
export class ImageLists extends CoList.Of(co.ref(Image)) {}
|
||||
|
||||
export class Folder extends BaseModel {
|
||||
name = co.string
|
||||
description = co.optional.string
|
||||
icon = co.optional.string
|
||||
images = co.optional.ref(ImageLists)
|
||||
parent = co.optional.ref(Folder)
|
||||
}
|
||||
|
||||
export class FolderLists extends CoList.Of(co.ref(Folder)) {}
|
||||
@@ -4,6 +4,7 @@ import { PersonalLinkLists } from "./personal-link"
|
||||
import { ListOfTopics } from "./master/topic"
|
||||
import { ListOfTasks } from "./task"
|
||||
import { JournalEntryLists } from "./journal"
|
||||
import { FolderLists, ImageLists } from "./folder"
|
||||
|
||||
declare module "jazz-tools" {
|
||||
interface Profile {
|
||||
@@ -12,6 +13,7 @@ declare module "jazz-tools" {
|
||||
}
|
||||
|
||||
export class UserRoot extends CoMap {
|
||||
version = co.optional.number
|
||||
name = co.string
|
||||
username = co.string
|
||||
avatar = co.optional.string
|
||||
@@ -28,6 +30,9 @@ export class UserRoot extends CoMap {
|
||||
|
||||
tasks = co.ref(ListOfTasks)
|
||||
journalEntries = co.ref(JournalEntryLists)
|
||||
|
||||
folders = co.ref(FolderLists)
|
||||
images = co.ref(ImageLists)
|
||||
}
|
||||
|
||||
export class LaAccount extends Account {
|
||||
@@ -38,9 +43,6 @@ export class LaAccount extends Account {
|
||||
this: LaAccount,
|
||||
creationProps?: { name: string; avatarUrl?: string },
|
||||
) {
|
||||
// since we dont have a custom AuthProvider yet.
|
||||
// and still using the DemoAuth. the creationProps will only accept name.
|
||||
// so just do default profile create provided by jazz-tools
|
||||
super.migrate(creationProps)
|
||||
|
||||
if (!this._refs.root && creationProps) {
|
||||
@@ -62,6 +64,11 @@ export class LaAccount extends Account {
|
||||
|
||||
tasks: ListOfTasks.create([], { owner: this }),
|
||||
journalEntries: JournalEntryLists.create([], { owner: this }),
|
||||
|
||||
folders: FolderLists.create([], { owner: this }),
|
||||
images: ImageLists.create([], { owner: this }),
|
||||
|
||||
version: 1,
|
||||
},
|
||||
{ owner: this },
|
||||
)
|
||||
@@ -72,3 +79,6 @@ export class LaAccount extends Account {
|
||||
export * from "./master/topic"
|
||||
export * from "./personal-link"
|
||||
export * from "./personal-page"
|
||||
export * from "./task"
|
||||
export * from "./journal"
|
||||
export * from "./folder"
|
||||
@@ -1,11 +1,10 @@
|
||||
import { co, CoList, CoMap, Encoders } from "jazz-tools"
|
||||
import { co, CoList, Encoders } from "jazz-tools"
|
||||
import { BaseModel } from "./base"
|
||||
|
||||
export class JournalEntry extends CoMap {
|
||||
export class JournalEntry extends BaseModel {
|
||||
title = co.string
|
||||
content = co.json()
|
||||
date = co.encoded(Encoders.Date)
|
||||
createdAt = co.encoded(Encoders.Date)
|
||||
updatedAt = co.encoded(Encoders.Date)
|
||||
}
|
||||
|
||||
export class JournalEntryLists extends CoList.Of(co.ref(JournalEntry)) {}
|
||||
@@ -1,14 +1,10 @@
|
||||
import { co, CoList, CoMap, Encoders } from "jazz-tools"
|
||||
import { co, CoList } from "jazz-tools"
|
||||
import { Link, Topic } from "./master/topic"
|
||||
|
||||
class BaseModel extends CoMap {
|
||||
createdAt = co.encoded(Encoders.Date)
|
||||
updatedAt = co.encoded(Encoders.Date)
|
||||
}
|
||||
import { BaseModel } from "./base"
|
||||
|
||||
export class PersonalLink extends BaseModel {
|
||||
url = co.string
|
||||
icon = co.optional.string // is an icon URL
|
||||
icon = co.optional.string
|
||||
link = co.optional.ref(Link)
|
||||
title = co.string
|
||||
slug = co.string
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import { co, CoList, CoMap, Encoders } from "jazz-tools"
|
||||
import { co, CoList } from "jazz-tools"
|
||||
import { Topic } from "./master/topic"
|
||||
import { BaseModel } from "./base"
|
||||
|
||||
/*
|
||||
* Page, content that user can write to. Similar to Notion/Reflect page. It holds ProseMirror editor content + metadata.
|
||||
* - slug: make it unique
|
||||
* - Public Access, url should be learn-anything.xyz/@user/slug
|
||||
* - if public, certain members (can do read/write access accordingly), personal (end to end encrypted, only accessed by user)
|
||||
*/
|
||||
export class PersonalPage extends CoMap {
|
||||
export class PersonalPage extends BaseModel {
|
||||
title = co.optional.string
|
||||
slug = co.optional.string // is used only when `public: true` for sharing, `@user/page-slug`
|
||||
slug = co.optional.string
|
||||
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
|
||||
}
|
||||
|
||||
export class PersonalPageLists extends CoList.Of(co.ref(PersonalPage)) {}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { co, CoList, CoMap, Encoders } from "jazz-tools"
|
||||
import { co, CoList, Encoders } from "jazz-tools"
|
||||
import { BaseModel } from "./base"
|
||||
|
||||
export class Task extends CoMap {
|
||||
export class Task extends BaseModel {
|
||||
title = co.string
|
||||
description = co.optional.string
|
||||
status = co.literal("todo", "in_progress", "done")
|
||||
createdAt = co.encoded(Encoders.Date)
|
||||
updatedAt = co.encoded(Encoders.Date)
|
||||
dueDate = co.optional.encoded(Encoders.Date)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,10 +24,6 @@ export function shuffleArray<T>(array: T[]): T[] {
|
||||
return shuffled
|
||||
}
|
||||
|
||||
export const isClient = () => typeof window !== "undefined"
|
||||
|
||||
export const isServer = () => !isClient()
|
||||
|
||||
const inputs = ["input", "select", "button", "textarea"] // detect if node is a text input element
|
||||
|
||||
export function isTextInput(element: Element): boolean {
|
||||
@@ -75,7 +71,6 @@ export function calendarFormatDate(date: Date): string {
|
||||
}
|
||||
|
||||
export * from "./force-graph"
|
||||
export * from "./keyboard"
|
||||
export * from "./env"
|
||||
export * from "./slug"
|
||||
export * from "./url"
|
||||
@@ -1,18 +1,3 @@
|
||||
export function isValidUrl(string: string): boolean {
|
||||
try {
|
||||
new URL(string)
|
||||
return true
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isUrl(text: string): boolean {
|
||||
const pattern: RegExp =
|
||||
/^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/
|
||||
return pattern.test(text)
|
||||
}
|
||||
|
||||
export function ensureUrlProtocol(
|
||||
url: string,
|
||||
defaultProtocol: string = "https://",
|
||||
|
||||
+42
-33
@@ -169,14 +169,14 @@ declare module '@tanstack/react-router' {
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
'/_layout/(auth)': {
|
||||
id: '/_layout/'
|
||||
id: '/_layout/(auth)'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof LayoutauthImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
'/_layout/(auth)/_auth': {
|
||||
id: '/_layout/_auth'
|
||||
id: '/_layout/(auth)/_auth'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof LayoutauthAuthImport
|
||||
@@ -190,28 +190,28 @@ declare module '@tanstack/react-router' {
|
||||
parentRoute: typeof LayoutPagesImport
|
||||
}
|
||||
'/_layout/(landing)/': {
|
||||
id: '/_layout/'
|
||||
id: '/_layout/(landing)/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof LayoutlandingIndexImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
'/_layout/_pages/(topic)/$': {
|
||||
id: '/_layout/_pages/$'
|
||||
id: '/_layout/_pages/(topic)/$'
|
||||
path: '/$'
|
||||
fullPath: '/$'
|
||||
preLoaderRoute: typeof LayoutPagestopicSplatImport
|
||||
parentRoute: typeof LayoutPagesImport
|
||||
}
|
||||
'/_layout/(auth)/_auth/sign-in/$': {
|
||||
id: '/_layout/_auth/sign-in/$'
|
||||
id: '/_layout/(auth)/_auth/sign-in/$'
|
||||
path: '/sign-in/$'
|
||||
fullPath: '/sign-in/$'
|
||||
preLoaderRoute: typeof LayoutauthAuthSignInSplatImport
|
||||
parentRoute: typeof LayoutauthAuthImport
|
||||
}
|
||||
'/_layout/(auth)/_auth/sign-up/$': {
|
||||
id: '/_layout/_auth/sign-up/$'
|
||||
id: '/_layout/(auth)/_auth/sign-up/$'
|
||||
path: '/sign-up/$'
|
||||
fullPath: '/sign-up/$'
|
||||
preLoaderRoute: typeof LayoutauthAuthSignUpSplatImport
|
||||
@@ -432,12 +432,13 @@ export interface FileRoutesById {
|
||||
__root__: typeof rootRoute
|
||||
'/_layout': typeof LayoutRouteWithChildren
|
||||
'/_layout/_pages': typeof LayoutPagesRouteWithChildren
|
||||
'/_layout/': typeof LayoutlandingIndexRoute
|
||||
'/_layout/_auth': typeof LayoutauthAuthRouteWithChildren
|
||||
'/_layout/(auth)': typeof LayoutauthRouteWithChildren
|
||||
'/_layout/(auth)/_auth': typeof LayoutauthAuthRouteWithChildren
|
||||
'/_layout/_pages/_protected': typeof LayoutPagesProtectedRouteWithChildren
|
||||
'/_layout/_pages/$': typeof LayoutPagestopicSplatRoute
|
||||
'/_layout/_auth/sign-in/$': typeof LayoutauthAuthSignInSplatRoute
|
||||
'/_layout/_auth/sign-up/$': typeof LayoutauthAuthSignUpSplatRoute
|
||||
'/_layout/(landing)/': typeof LayoutlandingIndexRoute
|
||||
'/_layout/_pages/(topic)/$': typeof LayoutPagestopicSplatRoute
|
||||
'/_layout/(auth)/_auth/sign-in/$': typeof LayoutauthAuthSignInSplatRoute
|
||||
'/_layout/(auth)/_auth/sign-up/$': typeof LayoutauthAuthSignUpSplatRoute
|
||||
'/_layout/_pages/_protected/journals/': typeof LayoutPagesProtectedJournalsIndexRoute
|
||||
'/_layout/_pages/_protected/links/': typeof LayoutPagesProtectedLinksIndexRoute
|
||||
'/_layout/_pages/_protected/onboarding/': typeof LayoutPagesProtectedOnboardingIndexRoute
|
||||
@@ -492,12 +493,13 @@ export interface FileRouteTypes {
|
||||
| '__root__'
|
||||
| '/_layout'
|
||||
| '/_layout/_pages'
|
||||
| '/_layout/'
|
||||
| '/_layout/_auth'
|
||||
| '/_layout/(auth)'
|
||||
| '/_layout/(auth)/_auth'
|
||||
| '/_layout/_pages/_protected'
|
||||
| '/_layout/_pages/$'
|
||||
| '/_layout/_auth/sign-in/$'
|
||||
| '/_layout/_auth/sign-up/$'
|
||||
| '/_layout/(landing)/'
|
||||
| '/_layout/_pages/(topic)/$'
|
||||
| '/_layout/(auth)/_auth/sign-in/$'
|
||||
| '/_layout/(auth)/_auth/sign-up/$'
|
||||
| '/_layout/_pages/_protected/journals/'
|
||||
| '/_layout/_pages/_protected/links/'
|
||||
| '/_layout/_pages/_protected/onboarding/'
|
||||
@@ -539,8 +541,8 @@ export const routeTree = rootRoute
|
||||
"filePath": "_layout.tsx",
|
||||
"children": [
|
||||
"/_layout/_pages",
|
||||
"/_layout/",
|
||||
"/_layout/"
|
||||
"/_layout/(auth)",
|
||||
"/_layout/(landing)/"
|
||||
]
|
||||
},
|
||||
"/_layout/_pages": {
|
||||
@@ -548,19 +550,22 @@ export const routeTree = rootRoute
|
||||
"parent": "/_layout",
|
||||
"children": [
|
||||
"/_layout/_pages/_protected",
|
||||
"/_layout/_pages/$"
|
||||
"/_layout/_pages/(topic)/$"
|
||||
]
|
||||
},
|
||||
"/_layout/": {
|
||||
"filePath": "_layout/(landing)/index.tsx",
|
||||
"parent": "/_layout"
|
||||
},
|
||||
"/_layout/_auth": {
|
||||
"filePath": "_layout/(auth)/_auth.tsx",
|
||||
"parent": "/_layout/",
|
||||
"/_layout/(auth)": {
|
||||
"filePath": "_layout/(auth)",
|
||||
"parent": "/_layout",
|
||||
"children": [
|
||||
"/_layout/_auth/sign-in/$",
|
||||
"/_layout/_auth/sign-up/$"
|
||||
"/_layout/(auth)/_auth"
|
||||
]
|
||||
},
|
||||
"/_layout/(auth)/_auth": {
|
||||
"filePath": "_layout/(auth)/_auth.tsx",
|
||||
"parent": "/_layout/(auth)",
|
||||
"children": [
|
||||
"/_layout/(auth)/_auth/sign-in/$",
|
||||
"/_layout/(auth)/_auth/sign-up/$"
|
||||
]
|
||||
},
|
||||
"/_layout/_pages/_protected": {
|
||||
@@ -580,17 +585,21 @@ export const routeTree = rootRoute
|
||||
"/_layout/_pages/_protected/pages/$pageId/"
|
||||
]
|
||||
},
|
||||
"/_layout/_pages/$": {
|
||||
"/_layout/(landing)/": {
|
||||
"filePath": "_layout/(landing)/index.tsx",
|
||||
"parent": "/_layout"
|
||||
},
|
||||
"/_layout/_pages/(topic)/$": {
|
||||
"filePath": "_layout/_pages/(topic)/$.tsx",
|
||||
"parent": "/_layout/_pages"
|
||||
},
|
||||
"/_layout/_auth/sign-in/$": {
|
||||
"/_layout/(auth)/_auth/sign-in/$": {
|
||||
"filePath": "_layout/(auth)/_auth.sign-in.$.tsx",
|
||||
"parent": "/_layout/_auth"
|
||||
"parent": "/_layout/(auth)/_auth"
|
||||
},
|
||||
"/_layout/_auth/sign-up/$": {
|
||||
"/_layout/(auth)/_auth/sign-up/$": {
|
||||
"filePath": "_layout/(auth)/_auth.sign-up.$.tsx",
|
||||
"parent": "/_layout/_auth"
|
||||
"parent": "/_layout/(auth)/_auth"
|
||||
},
|
||||
"/_layout/_pages/_protected/journals/": {
|
||||
"filePath": "_layout/_pages/_protected/journals/index.tsx",
|
||||
|
||||
@@ -124,7 +124,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
|
||||
<React.Suspense>
|
||||
<TanStackRouterDevtools position="bottom-right" />
|
||||
<ReactQueryDevtools buttonPosition="bottom-left" />
|
||||
<ReactQueryDevtools buttonPosition="bottom-right" />
|
||||
</React.Suspense>
|
||||
|
||||
<ScrollRestoration />
|
||||
|
||||
@@ -8,7 +8,7 @@ export const Route = createFileRoute("/_layout")({
|
||||
|
||||
function LayoutComponent() {
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
|
||||
<ClerkProvider>
|
||||
<Outlet />
|
||||
</ClerkProvider>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router"
|
||||
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/_layout/(auth)/_auth")({
|
||||
beforeLoad({ context }) {
|
||||
if (context.auth) {
|
||||
throw redirect({ to: "/links", replace: true })
|
||||
}
|
||||
},
|
||||
component: () => (
|
||||
<main className="h-full">
|
||||
<Outlet />
|
||||
|
||||
@@ -104,10 +104,13 @@ export function Autocomplete({
|
||||
|
||||
return (
|
||||
<Command
|
||||
className={cn("relative mx-auto max-w-md overflow-visible shadow-md", {
|
||||
"rounded-lg border": !open,
|
||||
"rounded-none rounded-t-lg border-l border-r border-t": open,
|
||||
})}
|
||||
className={cn(
|
||||
"relative mx-auto max-w-md overflow-visible bg-background shadow-md",
|
||||
{
|
||||
"rounded-lg border": !open,
|
||||
"rounded-none rounded-t-lg border-l border-r border-t": open,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="relative flex items-center">
|
||||
<CommandPrimitive.Input
|
||||
@@ -125,7 +128,7 @@ export function Autocomplete({
|
||||
}}
|
||||
placeholder={filteredTopics[0]?.prettyName}
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex-1 bg-transparent min-h-10 px-3 py-1 sm:py-3 sm:px-4 outline-none",
|
||||
"min-h-10 flex-1 bg-transparent px-3 py-1 outline-none placeholder:text-muted-foreground sm:px-4 sm:py-3",
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
@@ -138,7 +141,7 @@ export function Autocomplete({
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
className="bg-background absolute left-0 right-0 z-10 -mx-px rounded-b-lg border-b border-l border-r border-t shadow-lg"
|
||||
className="absolute left-0 right-0 z-10 -mx-px rounded-b-lg border-b border-l border-r border-t bg-background shadow-lg"
|
||||
>
|
||||
<CommandList className="max-h-56">
|
||||
<CommandGroup className="my-2">
|
||||
@@ -150,7 +153,7 @@ export function Autocomplete({
|
||||
className="min-h-10 rounded-none px-3 py-1.5"
|
||||
>
|
||||
<span>{topic.prettyName}</span>
|
||||
<span className="text-muted-foreground/80 ml-auto text-xs">
|
||||
<span className="ml-auto text-xs text-muted-foreground/80">
|
||||
{topic.connectedTopics.length > 0 &&
|
||||
topic.connectedTopics.join(", ")}
|
||||
</span>
|
||||
|
||||
@@ -25,7 +25,7 @@ function LandingComponent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-screen">
|
||||
<div className="relative h-full w-screen bg-background">
|
||||
<ForceGraphClient
|
||||
raw_nodes={GraphData}
|
||||
onNodeClick={handleTopicSelect}
|
||||
@@ -40,7 +40,7 @@ function LandingComponent() {
|
||||
>
|
||||
<motion.h1
|
||||
className={cn(
|
||||
"mb-2 text-center text-5xl font-bold tracking-tight sm:mb-4 md:text-7xl font-raleway",
|
||||
"mb-2 text-center font-raleway text-5xl font-bold tracking-tight sm:mb-4 md:text-7xl",
|
||||
)}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
|
||||
@@ -33,7 +33,7 @@ function LayoutContent() {
|
||||
return (
|
||||
<>
|
||||
<Toaster expand={false} />
|
||||
<div className="flex min-h-full size-full flex-row items-stretch overflow-hidden">
|
||||
<div className="flex size-full min-h-full flex-row items-stretch overflow-hidden">
|
||||
<Sidebar />
|
||||
<Shortcut />
|
||||
<GlobalKeyboardHandler />
|
||||
@@ -49,7 +49,7 @@ function LayoutContent() {
|
||||
function MainContent() {
|
||||
return (
|
||||
<div className="relative flex min-w-0 flex-1 flex-col">
|
||||
<main className="relative flex flex-auto flex-col place-items-stretch overflow-auto lg:my-2 lg:mr-2 lg:rounded-md lg:border">
|
||||
<main className="relative flex flex-auto flex-col place-items-stretch overflow-auto bg-[var(--container-background)] lg:my-2 lg:mr-2 lg:rounded-md lg:border">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,6 @@ export const Route = createFileRoute("/_layout/_pages/(topic)/$")({
|
||||
export const openPopoverForIdAtom = atom<string | null>(null)
|
||||
|
||||
export function TopicDetailComponent() {
|
||||
console.log("TopicDetailComponent")
|
||||
const params = useParams({ from: "/_layout/_pages/$" })
|
||||
const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
|
||||
|
||||
@@ -30,34 +29,65 @@ export function TopicDetailComponent() {
|
||||
latestGlobalGuide: { sections: [] },
|
||||
})
|
||||
const [activeIndex, setActiveIndex] = React.useState(-1)
|
||||
const [searchQuery, setSearchQuery] = React.useState("")
|
||||
|
||||
const topicExists = GraphData.find((node) => {
|
||||
return node.name === params._splat
|
||||
})
|
||||
const topicExists = React.useMemo(
|
||||
() => GraphData.find((node) => node.name === params._splat),
|
||||
[params._splat],
|
||||
)
|
||||
|
||||
const latestGlobalGuide = React.useMemo(
|
||||
() => topic?.latestGlobalGuide,
|
||||
[topic?.latestGlobalGuide],
|
||||
)
|
||||
|
||||
const flattenedItems = React.useMemo(
|
||||
() =>
|
||||
latestGlobalGuide?.sections.flatMap((section) => [
|
||||
{ type: "section" as const, data: section },
|
||||
...(section?.links?.map((link) => ({
|
||||
type: "link" as const,
|
||||
data: link,
|
||||
})) || []),
|
||||
]) || [],
|
||||
[latestGlobalGuide],
|
||||
)
|
||||
|
||||
const filteredItems = React.useMemo(() => {
|
||||
if (!searchQuery) return flattenedItems
|
||||
return flattenedItems.filter((item) => {
|
||||
if (item.type === "section") {
|
||||
return item.data?.title
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())
|
||||
}
|
||||
if (item.type === "link") {
|
||||
return (
|
||||
item.data?.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.data?.url.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
}
|
||||
return false
|
||||
})
|
||||
}, [flattenedItems, searchQuery])
|
||||
|
||||
if (!topicExists) {
|
||||
return <NotFoundPlaceholder />
|
||||
}
|
||||
|
||||
const flattenedItems = topic?.latestGlobalGuide?.sections.flatMap(
|
||||
(section) => [
|
||||
{ type: "section" as const, data: section },
|
||||
...(section?.links?.map((link) => ({
|
||||
type: "link" as const,
|
||||
data: link,
|
||||
})) || []),
|
||||
],
|
||||
)
|
||||
|
||||
if (!topic || !me || !flattenedItems) {
|
||||
if (!topic || !me) {
|
||||
return <TopicDetailSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopicDetailHeader topic={topic} />
|
||||
<TopicDetailHeader
|
||||
topic={topic}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
/>
|
||||
<TopicDetailList
|
||||
items={flattenedItems}
|
||||
items={filteredItems}
|
||||
topic={topic}
|
||||
activeIndex={activeIndex}
|
||||
setActiveIndex={setActiveIndex}
|
||||
@@ -88,7 +118,7 @@ function TopicDetailSkeleton() {
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<Skeleton className="h-6 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-36" />
|
||||
<Skeleton className="h-7 w-28" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-6 max-lg:px-4">
|
||||
|
||||
@@ -10,13 +10,19 @@ import { LearningStateValue } from "@/lib/constants"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { useClerk } from "@clerk/tanstack-start"
|
||||
import { useLocation } from "@tanstack/react-router"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { LaIcon } from "~/components/custom/la-icon"
|
||||
|
||||
interface TopicDetailHeaderProps {
|
||||
topic: Topic
|
||||
searchQuery: string
|
||||
setSearchQuery: (query: string) => void
|
||||
}
|
||||
|
||||
export const TopicDetailHeader = React.memo(function TopicDetailHeader({
|
||||
topic,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
}: TopicDetailHeaderProps) {
|
||||
const clerk = useClerk()
|
||||
const { pathname } = useLocation()
|
||||
@@ -111,28 +117,51 @@ export const TopicDetailHeader = React.memo(function TopicDetailHeader({
|
||||
topicLists[learningState]?.push(topic)
|
||||
}
|
||||
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(event.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentHeader className="px-6 py-5 max-lg:px-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 min-w-0 flex-1 items-center">
|
||||
<h1 className="truncate text-left font-bold lg:text-xl">
|
||||
{topic.prettyName}
|
||||
</h1>
|
||||
<>
|
||||
<ContentHeader>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 min-w-0 flex-1 items-center">
|
||||
<h1 className="truncate text-left font-semibold lg:text-lg">
|
||||
{topic.prettyName}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-auto"></div>
|
||||
{/* <GuideCommunityToggle topicName={topic.name} /> */}
|
||||
|
||||
<LearningStateSelector
|
||||
showSearch={false}
|
||||
value={p?.learningState || ""}
|
||||
onChange={handleAddToProfile}
|
||||
defaultLabel={isMobile ? "" : "Add to profile"}
|
||||
defaultIcon="Circle"
|
||||
/>
|
||||
</ContentHeader>
|
||||
<div className="flex min-h-10 flex-row items-center justify-between border-b border-b-[var(--la-border-new)] px-6 py-2 max-lg:px-4">
|
||||
<div className="flex flex-1 flex-row items-center gap-2">
|
||||
<span className="text-tertiary flex h-5 w-5 items-center justify-center">
|
||||
<LaIcon name="Search" className="text-muted-foreground" />
|
||||
</span>
|
||||
<Input
|
||||
className="h-6 flex-1 border-none bg-transparent p-0 focus-visible:ring-0"
|
||||
placeholder="Search..."
|
||||
role="searchbox"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-auto"></div>
|
||||
{/* <GuideCommunityToggle topicName={topic.name} /> */}
|
||||
|
||||
<LearningStateSelector
|
||||
showSearch={false}
|
||||
value={p?.learningState || ""}
|
||||
onChange={handleAddToProfile}
|
||||
defaultLabel={isMobile ? "" : "Add to profile"}
|
||||
defaultIcon="Circle"
|
||||
/>
|
||||
</ContentHeader>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -178,11 +178,8 @@ export const LinkItem = React.memo(
|
||||
size="sm"
|
||||
type="button"
|
||||
role="combobox"
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"size-7 shrink-0 p-0",
|
||||
"hover:bg-accent-foreground/10",
|
||||
)}
|
||||
variant="ghost"
|
||||
className="h-auto shrink-0 cursor-default p-0 text-muted-foreground/75 hover:bg-inherit hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{selectedLearningState?.icon ? (
|
||||
@@ -215,7 +212,7 @@ export const LinkItem = React.memo(
|
||||
<div className="gap-x-2 space-y-0.5 xl:flex xl:flex-row">
|
||||
<p
|
||||
className={cn(
|
||||
"text-primary hover:text-primary line-clamp-1 text-sm font-medium",
|
||||
"line-clamp-1 text-sm font-medium text-primary hover:text-primary",
|
||||
isActive && "font-bold",
|
||||
)}
|
||||
>
|
||||
@@ -226,14 +223,14 @@ export const LinkItem = React.memo(
|
||||
<LaIcon
|
||||
name="Link"
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground group-hover:text-primary size-3.5 flex-none"
|
||||
className="flex-none text-muted-foreground group-hover:text-primary"
|
||||
/>
|
||||
|
||||
<Link
|
||||
to={ensureUrlProtocol(link.url)}
|
||||
target="_blank"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-muted-foreground hover:text-primary text-xs"
|
||||
className="text-xs text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<span className="line-clamp-1">{link.url}</span>
|
||||
</Link>
|
||||
|
||||
@@ -51,10 +51,10 @@ export function TopicDetailList({
|
||||
className="flex flex-col"
|
||||
>
|
||||
<div className="flex items-center gap-4 px-6 py-2 max-lg:px-4">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
<p className="text-[13px] font-medium text-muted-foreground">
|
||||
{item.data?.title}
|
||||
</p>
|
||||
<div className="flex-1 border-b" />
|
||||
<div className="flex-1 border-b border-[var(--la-border-new)]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -82,7 +82,7 @@ export function TopicDetailList({
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="flex-1 overflow-auto">
|
||||
<div ref={parentRef} className="flex-1 overflow-auto py-4">
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/_layout/_pages/_protected")({
|
||||
beforeLoad: async ({ context, location, cause }) => {
|
||||
beforeLoad: async ({ context, location }) => {
|
||||
if (!context?.auth?.userId) {
|
||||
throw redirect({
|
||||
to: "/sign-in/$",
|
||||
|
||||
@@ -106,7 +106,7 @@ export function QuestionThread({ question, onClose }: QuestionThreadProps) {
|
||||
>
|
||||
<div className="flex items-center justify-between pb-1">
|
||||
<div className="flex items-center">
|
||||
<div className="bg-accent mr-2 h-6 w-6 rounded-full"></div>
|
||||
<div className="mr-2 h-6 w-6 rounded-full bg-accent"></div>
|
||||
<span className="text-sm">{answer.author}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
@@ -147,16 +147,16 @@ export function QuestionThread({ question, onClose }: QuestionThreadProps) {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="border-accent bg-background fixed bottom-0 right-0 top-0 z-50 flex h-full w-[40%] flex-col border-l">
|
||||
<div className="border-accent flex w-full justify-between border-b p-4">
|
||||
<div className="fixed bottom-0 right-0 top-0 z-50 flex h-full w-[40%] flex-col border-l border-accent bg-background">
|
||||
<div className="flex w-full justify-between border-b border-accent p-4">
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="mb-2 flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-accent h-8 w-8 rounded-full"></div>
|
||||
<div className="h-8 w-8 rounded-full bg-accent"></div>
|
||||
<h2 className="opacity-70">{question.author}</h2>
|
||||
</div>
|
||||
<button
|
||||
className="bg-accent rounded-full p-1.5 opacity-50 hover:opacity-80"
|
||||
className="rounded-full bg-accent p-1.5 opacity-50 hover:opacity-80"
|
||||
onClick={onClose}
|
||||
>
|
||||
<LaIcon name="X" className="text-primary" />
|
||||
@@ -167,7 +167,7 @@ export function QuestionThread({ question, onClose }: QuestionThreadProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow overflow-y-auto">{renderAnswers(answers)}</div>
|
||||
<div className="border-accent border-t p-4">
|
||||
<div className="border-t border-accent p-4">
|
||||
<form className="relative" onSubmit={sendAnswer}>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
@@ -176,7 +176,7 @@ export function QuestionThread({ question, onClose }: QuestionThreadProps) {
|
||||
value={newAnswer}
|
||||
onChange={changeInput}
|
||||
placeholder="Answer the question..."
|
||||
className="bg-input w-full rounded p-2 text-opacity-70 placeholder:text-opacity-50 focus:outline-none focus:ring-0"
|
||||
className="w-full rounded bg-input p-2 text-opacity-70 placeholder:text-opacity-50 focus:outline-none focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<button className="absolute right-2 top-1/2 -translate-y-1/2 transform opacity-50 hover:opacity-90">
|
||||
|
||||
@@ -28,7 +28,7 @@ export const GuideCommunityToggle: React.FC<GuideCommunityToggleProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-accent/70 relative flex h-8 w-48 items-center rounded-md">
|
||||
<div className="relative flex h-8 w-48 items-center rounded-md bg-accent/70">
|
||||
<div
|
||||
className="absolute h-8 w-[calc(50%-4px)] rounded-md transition-all duration-300 ease-in-out"
|
||||
style={{ left: view === "guide" ? "2px" : "calc(50% + 2px)" }}
|
||||
@@ -36,7 +36,7 @@ export const GuideCommunityToggle: React.FC<GuideCommunityToggleProps> = ({
|
||||
<button
|
||||
className={cn(
|
||||
"relative z-10 h-full flex-1 rounded-md text-sm font-medium transition-colors",
|
||||
view === "guide" ? "text-primary bg-accent" : "text-primary/50",
|
||||
view === "guide" ? "bg-accent text-primary" : "text-primary/50",
|
||||
)}
|
||||
onClick={() => handleToggle("guide")}
|
||||
>
|
||||
@@ -45,7 +45,7 @@ export const GuideCommunityToggle: React.FC<GuideCommunityToggleProps> = ({
|
||||
<button
|
||||
className={cn(
|
||||
"relative z-10 h-full flex-1 rounded-md text-sm font-medium transition-colors",
|
||||
view === "community" ? "text-primary bg-accent" : "text-primary/50",
|
||||
view === "community" ? "bg-accent text-primary" : "text-primary/50",
|
||||
)}
|
||||
onClick={() => handleToggle("community")}
|
||||
>
|
||||
|
||||
@@ -45,7 +45,7 @@ function CommunityTopicComponent() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col">
|
||||
<ContentHeader className="px-6 py-4">
|
||||
<ContentHeader>
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 flex-col items-start">
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn, getShortcutKeys } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { useAtom } from "jotai"
|
||||
import { useConfirm } from "@omit/react-confirm-dialog"
|
||||
@@ -17,6 +17,7 @@ import { ID } from "jazz-tools"
|
||||
import { globalLinkFormExceptionRefsAtom } from "./-link-form"
|
||||
import { useLinkActions } from "~/hooks/actions/use-link-actions"
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
import { getShortcutKeys } from "@shared/utils"
|
||||
|
||||
interface ToolbarButtonProps
|
||||
extends React.ComponentPropsWithoutRef<typeof Button> {
|
||||
@@ -146,7 +147,7 @@ export const LinkBottomBar: React.FC = () => {
|
||||
const shortcutText = getShortcutKeys(["c"])
|
||||
|
||||
return (
|
||||
<div className="bg-background min-h-11 border-t">
|
||||
<div className="min-h-11 border-t">
|
||||
<AnimatePresence mode="wait">
|
||||
{editId && (
|
||||
<motion.div
|
||||
|
||||
@@ -27,7 +27,7 @@ export const DescriptionInput: React.FC<DescriptionInputProps> = () => {
|
||||
{...field}
|
||||
autoComplete="off"
|
||||
placeholder="Description"
|
||||
className="placeholder:text-muted-foreground/70 resize-none overflow-y-auto border-none p-1.5 text-[13px] font-medium shadow-none focus-visible:ring-0"
|
||||
className="resize-none overflow-y-auto border-none p-1.5 text-sm font-medium shadow-none placeholder:text-muted-foreground/70 focus-visible:ring-0"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
|
||||
@@ -1,77 +1,37 @@
|
||||
import * as React from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import {
|
||||
ContentHeader,
|
||||
SidebarToggleButton,
|
||||
} from "@/components/custom/content-header"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
import { FancySwitch, OptionValue } from "@omit/react-fancy-switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||
import { useAtom } from "jotai"
|
||||
import { linkSortAtom } from "@/store/link"
|
||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||
import { FancySwitch } from "@omit/react-fancy-switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
const ALL_STATES = [
|
||||
{ label: "All", value: "all", icon: "List", className: "text-foreground" },
|
||||
...LEARNING_STATES,
|
||||
]
|
||||
|
||||
export const LinkHeader = React.memo(() => {
|
||||
const isTablet = useMedia("(max-width: 1024px)")
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentHeader className="px-6 max-lg:px-4 lg:py-4">
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 items-center">
|
||||
<span className="truncate text-left font-bold lg:text-xl">
|
||||
Links
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isTablet && <LearningTab />}
|
||||
|
||||
<div className="flex flex-auto"></div>
|
||||
|
||||
<FilterAndSort />
|
||||
</ContentHeader>
|
||||
|
||||
{isTablet && (
|
||||
<div className="flex flex-row items-start justify-between border-b px-6 pb-4 pt-2 max-lg:pl-4">
|
||||
<LearningTab />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
LinkHeader.displayName = "LinkHeader"
|
||||
|
||||
const LearningTab = React.memo(() => {
|
||||
const LearningTab: React.FC = React.memo(() => {
|
||||
const navigate = useNavigate()
|
||||
const { state } = useSearch({
|
||||
from: "/_layout/_pages/_protected/links/",
|
||||
})
|
||||
const { state } = useSearch({ from: "/_layout/_pages/_protected/links/" })
|
||||
|
||||
const handleTabChange = React.useCallback(
|
||||
async (value: string) => {
|
||||
(value: OptionValue) => {
|
||||
if (value !== state) {
|
||||
navigate({
|
||||
to: "/links",
|
||||
@@ -85,30 +45,22 @@ const LearningTab = React.memo(() => {
|
||||
return (
|
||||
<FancySwitch
|
||||
value={state}
|
||||
onChange={(value) => {
|
||||
handleTabChange(value as string)
|
||||
}}
|
||||
onChange={handleTabChange}
|
||||
options={ALL_STATES}
|
||||
className="bg-muted flex rounded-lg"
|
||||
className="flex rounded-md"
|
||||
highlighterClassName="bg-muted-foreground/10 rounded-md"
|
||||
radioClassName={cn(
|
||||
"relative mx-2 flex h-8 cursor-pointer items-center justify-center rounded-full px-1 text-sm text-secondary-foreground/60 data-[checked]:text-secondary-foreground font-medium transition-colors focus:outline-none",
|
||||
"relative mx-2 flex h-6 cursor-pointer items-center justify-center rounded-full px-1 text-sm text-muted-foreground data-[checked]:text-foreground data-[checked]:font-medium transition-colors focus:outline-none",
|
||||
)}
|
||||
highlighterIncludeMargin={true}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
LearningTab.displayName = "LearningTab"
|
||||
|
||||
const FilterAndSort = React.memo(() => {
|
||||
const FilterAndSort: React.FC = React.memo(() => {
|
||||
const [sort, setSort] = useAtom(linkSortAtom)
|
||||
const [sortOpen, setSortOpen] = React.useState(false)
|
||||
|
||||
const getFilterText = React.useCallback(() => {
|
||||
return sort.charAt(0).toUpperCase() + sort.slice(1)
|
||||
}, [sort])
|
||||
|
||||
const handleSortChange = React.useCallback(
|
||||
(value: string) => {
|
||||
setSort(value)
|
||||
@@ -120,40 +72,80 @@ const FilterAndSort = React.memo(() => {
|
||||
return (
|
||||
<div className="flex w-auto items-center justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover open={sortOpen} onOpenChange={setSortOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<DropdownMenu open={sortOpen} onOpenChange={setSortOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="min-w-8 gap-x-2 text-sm max-sm:p-0"
|
||||
variant="ghost"
|
||||
className="h-7 gap-x-2 text-sm max-sm:p-0"
|
||||
>
|
||||
<LaIcon name="ListFilter" className="text-primary/60" />
|
||||
<span className="hidden md:block">Filter: {getFilterText()}</span>
|
||||
<LaIcon name="ChevronDown" />
|
||||
<span className="hidden md:block">Display</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="end">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex min-w-8 flex-row items-center">
|
||||
<Label>Sort by</Label>
|
||||
<div className="flex flex-auto flex-row items-center justify-end">
|
||||
<Select value={sort} onValueChange={handleSortChange}>
|
||||
<SelectTrigger className="h-6 w-auto">
|
||||
<SelectValue placeholder="Select"></SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="title">Title</SelectItem>
|
||||
<SelectItem value="manual">Manual</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end" side="top">
|
||||
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">
|
||||
Display
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem>
|
||||
<LaIcon name="List" className="mr-2 h-4 w-4" />
|
||||
<span>List</span>
|
||||
<LaIcon name="Check" className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">
|
||||
Ordering
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => handleSortChange("title")}>
|
||||
<span>Title</span>
|
||||
{sort === "title" && (
|
||||
<LaIcon name="Check" className="ml-auto h-4 w-4" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleSortChange("manual")}>
|
||||
<span>Manual</span>
|
||||
{sort === "manual" && (
|
||||
<LaIcon name="Check" className="ml-auto h-4 w-4" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const LinkHeader: React.FC = React.memo(() => {
|
||||
const isTablet = useMedia("(max-width: 1024px)")
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentHeader>
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 items-center">
|
||||
<span className="truncate text-left font-semibold lg:text-lg">
|
||||
Links
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isTablet && <LearningTab />}
|
||||
|
||||
<div className="flex flex-auto" />
|
||||
|
||||
<FilterAndSort />
|
||||
</ContentHeader>
|
||||
|
||||
{isTablet && (
|
||||
<div className="flex flex-row items-start justify-between border-b px-6 py-2 max-lg:pl-4">
|
||||
<LearningTab />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
LinkHeader.displayName = "LinkHeader"
|
||||
LearningTab.displayName = "LearningTab"
|
||||
FilterAndSort.displayName = "FilterAndSort"
|
||||
@@ -115,15 +115,15 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
data-disabled={disabled}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"w-full overflow-visible border-b-[0.5px] border-transparent outline-none",
|
||||
"data-[active='true']:bg-[var(--link-background-muted)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]",
|
||||
"w-full cursor-default overflow-visible border-b-[0.5px] border-transparent outline-none",
|
||||
"data-[active='true']:bg-[var(--link-background-muted-new)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]",
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full grow overflow-visible outline-none",
|
||||
"flex items-center gap-x-2 py-2 max-lg:px-4 sm:px-5 sm:py-2",
|
||||
"flex items-center gap-x-2 py-2 sm:px-5 sm:py-2 max-lg:px-4",
|
||||
)}
|
||||
>
|
||||
<Popover
|
||||
@@ -137,8 +137,8 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
size="sm"
|
||||
type="button"
|
||||
role="combobox"
|
||||
variant="secondary"
|
||||
className="size-7 shrink-0 p-0"
|
||||
variant="ghost"
|
||||
className="size-7 shrink-0 cursor-default p-0 text-muted-foreground/75 hover:bg-inherit hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -148,7 +148,7 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
className={cn(selectedLearningState.className)}
|
||||
/>
|
||||
) : (
|
||||
<LaIcon name="Circle" />
|
||||
<LaIcon name="Circle" strokeWidth={2.5} />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -167,22 +167,22 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
</Popover>
|
||||
|
||||
<div className="flex min-w-0 flex-col items-start gap-y-1.5 overflow-hidden md:flex-row md:items-center md:gap-x-2">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{personalLink.icon && (
|
||||
<img
|
||||
src={personalLink.icon as string}
|
||||
alt={personalLink.title}
|
||||
className="size-5 shrink-0 rounded-full"
|
||||
className="size-4 shrink-0 rounded-full"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
)}
|
||||
<p className="text-primary hover:text-primary line-clamp-1 text-sm font-medium">
|
||||
<p className="line-clamp-1 text-sm font-medium text-primary hover:text-primary">
|
||||
{personalLink.title}
|
||||
</p>
|
||||
</div>
|
||||
{personalLink.url && (
|
||||
<div className="text-muted-foreground flex min-w-0 shrink items-center gap-x-1">
|
||||
<div className="flex min-w-0 shrink items-center gap-x-1 text-muted-foreground">
|
||||
<LaIcon
|
||||
name="Link"
|
||||
aria-hidden="true"
|
||||
@@ -192,7 +192,7 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
to={ensureUrlProtocol(personalLink.url)}
|
||||
target="_blank"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="hover:text-primary mr-1 truncate text-xs"
|
||||
className="mr-1 truncate text-xs hover:text-primary"
|
||||
>
|
||||
{personalLink.url}
|
||||
</Link>
|
||||
@@ -204,14 +204,17 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
|
||||
<div className="flex shrink-0 items-center justify-end">
|
||||
{personalLink.topic && (
|
||||
<Badge variant="secondary" className="border-muted-foreground/25">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="border-muted-foreground/25 font-medium"
|
||||
>
|
||||
{personalLink.topic.prettyName}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-[0.5px] w-full after:absolute after:left-0 after:right-0 after:block after:h-full after:bg-[var(--link-border-after)]"></div>
|
||||
<div className="relative h-[0.5px] w-full after:absolute after:left-0 after:right-0 after:block after:h-full after:bg-[var(--la-border-new)]"></div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -19,7 +19,8 @@ import { DescriptionInput } from "./-description-input"
|
||||
import { UrlBadge } from "./-url-badge"
|
||||
import { NotesSection } from "./-notes-section"
|
||||
import { useOnClickOutside } from "~/hooks/use-on-click-outside"
|
||||
import TopicSelector, {
|
||||
import {
|
||||
TopicSelector,
|
||||
topicSelectorAtom,
|
||||
} from "~/components/custom/topic-selector"
|
||||
import { createServerFn } from "@tanstack/start"
|
||||
@@ -291,7 +292,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-muted/30 relative rounded-md border",
|
||||
"relative rounded-md border bg-muted/30",
|
||||
isFetching && "opacity-50",
|
||||
)}
|
||||
>
|
||||
@@ -369,7 +370,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
|
||||
{isFetching ? (
|
||||
<div className="flex w-auto items-center justify-end gap-x-2">
|
||||
<span className="text-muted-foreground flex items-center text-sm">
|
||||
<span className="flex items-center text-sm text-muted-foreground">
|
||||
<svg
|
||||
className="mr-2 h-4 w-4 animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -27,12 +27,12 @@ import { linkSortAtom } from "@/store/link"
|
||||
import { LinkItem } from "./-item"
|
||||
import { useConfirm } from "@omit/react-confirm-dialog"
|
||||
import { useKeyDown } from "@/hooks/use-key-down"
|
||||
import { isModKey } from "@/lib/utils"
|
||||
import { useTouchSensor } from "~/hooks/use-touch-sensor"
|
||||
import { useActiveItemScroll } from "~/hooks/use-active-item-scroll"
|
||||
import { isDeleteConfirmShownAtom } from "."
|
||||
import { useLinkActions } from "~/hooks/actions/use-link-actions"
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
import { isModKey } from "@shared/utils"
|
||||
|
||||
interface LinkListProps {}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export const NotesSection: React.FC = () => {
|
||||
autoComplete="off"
|
||||
placeholder="Notes"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground/70 border-none pl-8 shadow-none focus-visible:ring-0",
|
||||
"border-none pl-8 shadow-none placeholder:text-muted-foreground/70 focus-visible:ring-0",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -31,7 +31,7 @@ export const TitleInput: React.FC<TitleInputProps> = ({ urlFetched }) => {
|
||||
maxLength={100}
|
||||
autoFocus
|
||||
placeholder="Title"
|
||||
className="placeholder:text-muted-foreground/70 h-8 border-none p-1.5 text-[15px] font-semibold shadow-none focus-visible:ring-0"
|
||||
className="h-8 border-none p-1.5 text-[15px] font-semibold shadow-none placeholder:text-muted-foreground/70 focus-visible:ring-0"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
|
||||
@@ -27,9 +27,9 @@ export const UrlBadge: React.FC<UrlBadgeProps> = ({
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={handleResetUrl}
|
||||
className="text-muted-foreground hover:text-foreground ml-2 size-4 rounded-full bg-transparent hover:bg-transparent"
|
||||
className="ml-2 size-4 rounded-full bg-transparent text-muted-foreground hover:bg-transparent hover:text-foreground"
|
||||
>
|
||||
<LaIcon name="X" className="size-3.5" />
|
||||
<LaIcon name="X" className="" />
|
||||
</Button>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -65,14 +65,14 @@ export const UrlInput: React.FC<UrlInputProps> = ({
|
||||
maxLength={100}
|
||||
autoFocus
|
||||
placeholder="Paste a link or write a link"
|
||||
className="placeholder:text-muted-foreground/70 h-8 border-none p-1.5 text-[15px] font-semibold shadow-none focus-visible:ring-0"
|
||||
className="h-8 border-none p-1.5 text-[15px] font-semibold shadow-none placeholder:text-muted-foreground/70 focus-visible:ring-0"
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="center" side="top">
|
||||
<TooltipArrow className="text-primary fill-current" />
|
||||
<TooltipArrow className="fill-current text-primary" />
|
||||
<span>
|
||||
Press <kbd className="px-1.5">Enter</kbd> to fetch metadata
|
||||
</span>
|
||||
|
||||
@@ -60,7 +60,7 @@ const StepItem = ({
|
||||
done: boolean
|
||||
}) => (
|
||||
<div className="flex items-start space-x-4 py-4">
|
||||
<div className="border-foreground/20 w-6 flex-shrink-0 items-center justify-center rounded-3xl border text-center opacity-70">
|
||||
<div className="w-6 flex-shrink-0 items-center justify-center rounded-3xl border border-foreground/20 text-center opacity-70">
|
||||
{number}
|
||||
</div>
|
||||
<div className="flex-grow space-y-2">
|
||||
|
||||
@@ -23,7 +23,7 @@ export const DetailPageHeader: React.FC<DetailPageHeaderProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentHeader className="lg:min-h-0">
|
||||
<ContentHeader>
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<SidebarToggleButton />
|
||||
</div>
|
||||
@@ -45,7 +45,7 @@ export const DetailPageHeader: React.FC<DetailPageHeaderProps> = ({
|
||||
)}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleDelete}>
|
||||
<LaIcon name="Trash" className="mr-2 size-3.5" />
|
||||
<LaIcon name="Trash" className="mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -15,14 +15,14 @@ import { Button } from "@/components/ui/button"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { useConfirm } from "@omit/react-confirm-dialog"
|
||||
import { usePageActions } from "~/hooks/actions/use-page-actions"
|
||||
import { Paragraph } from "@shared/la-editor/extensions/paragraph"
|
||||
import { StarterKit } from "@shared/la-editor/extensions/starter-kit"
|
||||
import { LAEditor, LAEditorRef } from "@shared/la-editor"
|
||||
import { Paragraph } from "@shared/editor/extensions/paragraph"
|
||||
import { StarterKit } from "@shared/editor/extensions/starter-kit"
|
||||
import { LaEditor } from "@shared/editor"
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_layout/_pages/_protected/pages/$pageId/",
|
||||
)({
|
||||
component: () => <PageDetailComponent />,
|
||||
component: PageDetailComponent,
|
||||
})
|
||||
|
||||
const TITLE_PLACEHOLDER = "Untitled"
|
||||
@@ -73,20 +73,22 @@ function PageDetailComponent() {
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarActions = ({
|
||||
page,
|
||||
handleDelete,
|
||||
}: {
|
||||
page: PersonalPage
|
||||
handleDelete: () => void
|
||||
}) => (
|
||||
<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-5">
|
||||
<span className="text-left text-[13px] font-medium">Page actions</span>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 top-10 space-y-3 overflow-y-auto px-4 py-1.5">
|
||||
<div className="flex flex-row">
|
||||
const SidebarActions = React.memo(
|
||||
({
|
||||
page,
|
||||
handleDelete,
|
||||
}: {
|
||||
page: PersonalPage
|
||||
handleDelete: () => void
|
||||
}) => (
|
||||
<div className="relative min-w-56 max-w-72 border-l bg-[var(--body-background)]">
|
||||
<div className="flex">
|
||||
<div className="flex h-10 flex-auto flex-row items-center justify-between px-5">
|
||||
<span className="text-left text-[13px] font-medium text-muted-foreground">
|
||||
Page actions
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 top-10 space-y-3 overflow-y-auto px-4 py-1.5">
|
||||
<TopicSelector
|
||||
value={page.topic?.name}
|
||||
onTopicChange={(topic) => {
|
||||
@@ -101,52 +103,40 @@ const SidebarActions = ({
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleDelete}
|
||||
className="-ml-1.5"
|
||||
>
|
||||
<LaIcon name="Trash" className="mr-2 size-3.5" />
|
||||
<LaIcon name="Trash" className="mr-2" />
|
||||
<span className="text-sm">Delete</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
|
||||
const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
SidebarActions.displayName = "SidebarActions"
|
||||
|
||||
const DetailPageForm = React.memo(({ page }: { page: PersonalPage }) => {
|
||||
const titleEditorRef = React.useRef<Editor | null>(null)
|
||||
const contentEditorRef = React.useRef<LAEditorRef>(null)
|
||||
const isTitleInitialMount = React.useRef(true)
|
||||
const isContentInitialMount = React.useRef(true)
|
||||
const isInitialFocusApplied = React.useRef(false)
|
||||
const contentEditorRef = React.useRef<Editor | null>(null)
|
||||
|
||||
const updatePageContent = React.useCallback(
|
||||
(content: Content, model: PersonalPage) => {
|
||||
if (isContentInitialMount.current) {
|
||||
isContentInitialMount.current = false
|
||||
return
|
||||
}
|
||||
model.content = content
|
||||
model.updatedAt = new Date()
|
||||
(content: Content) => {
|
||||
page.content = content
|
||||
page.updatedAt = new Date()
|
||||
},
|
||||
[],
|
||||
[page],
|
||||
)
|
||||
|
||||
const handleUpdateTitle = React.useCallback(
|
||||
(editor: Editor) => {
|
||||
if (isTitleInitialMount.current) {
|
||||
isTitleInitialMount.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const newTitle = editor.getText()
|
||||
if (newTitle !== page.title) {
|
||||
const slug = generateUniqueSlug(page.title?.toString() || "")
|
||||
const slug = generateUniqueSlug(newTitle || "")
|
||||
page.title = newTitle
|
||||
page.slug = slug
|
||||
page.updatedAt = new Date()
|
||||
@@ -164,22 +154,18 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
const { selection } = state
|
||||
const { $anchor } = selection
|
||||
|
||||
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
|
||||
if (
|
||||
(event.key === "ArrowRight" || event.key === "ArrowDown") &&
|
||||
$anchor.pos === state.doc.content.size - 1
|
||||
) {
|
||||
event.preventDefault()
|
||||
contentEditorRef.current?.commands.focus("start")
|
||||
return true
|
||||
}
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
contentEditorRef.current?.commands.focus("start")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -188,7 +174,7 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
|
||||
const handleContentKeyDown = React.useCallback(
|
||||
(view: EditorView, event: KeyboardEvent) => {
|
||||
const editor = contentEditorRef.current?.editor
|
||||
const editor = contentEditorRef.current
|
||||
if (!editor) return false
|
||||
|
||||
const { state } = editor
|
||||
@@ -239,34 +225,21 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
},
|
||||
onCreate: ({ editor }) => {
|
||||
if (page.title) editor.commands.setContent(`<p>${page.title}</p>`)
|
||||
titleEditorRef.current = editor
|
||||
},
|
||||
onBlur: ({ editor }) => handleUpdateTitle(editor),
|
||||
onUpdate: ({ editor }) => handleUpdateTitle(editor),
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (titleEditor) {
|
||||
titleEditorRef.current = titleEditor
|
||||
}
|
||||
}, [titleEditor])
|
||||
|
||||
React.useEffect(() => {
|
||||
isTitleInitialMount.current = true
|
||||
isContentInitialMount.current = true
|
||||
|
||||
if (
|
||||
!isInitialFocusApplied.current &&
|
||||
titleEditor &&
|
||||
contentEditorRef.current?.editor
|
||||
) {
|
||||
isInitialFocusApplied.current = true
|
||||
if (!page.title) {
|
||||
titleEditor?.commands.focus()
|
||||
} else {
|
||||
contentEditorRef.current.editor.commands.focus()
|
||||
const handleCreate = React.useCallback(
|
||||
({ editor }: { editor: Editor }) => {
|
||||
if (page.content) {
|
||||
editor.commands.setContent(page.content as Content)
|
||||
}
|
||||
}
|
||||
}, [page.title, titleEditor])
|
||||
contentEditorRef.current = editor
|
||||
},
|
||||
[page.content],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative flex grow flex-col overflow-y-auto [scrollbar-gutter:stable]">
|
||||
@@ -275,21 +248,21 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
<div className="mb-2 mt-8 py-1.5">
|
||||
<EditorContent
|
||||
editor={titleEditor}
|
||||
className="la-editor no-command grow cursor-text select-text text-2xl font-semibold leading-[calc(1.33333)] tracking-[-0.00625rem]"
|
||||
className="title-editor no-command grow cursor-text select-text text-2xl font-semibold leading-[calc(1.33333)] tracking-[-0.00625rem]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-auto flex-col">
|
||||
<div className="relative flex h-full max-w-full grow flex-col items-stretch p-0">
|
||||
<LAEditor
|
||||
ref={contentEditorRef}
|
||||
<LaEditor
|
||||
editorClassName="-mx-3.5 px-3.5 py-2.5 flex-auto focus:outline-none"
|
||||
value={page.content}
|
||||
value={page.content as Content}
|
||||
placeholder="Add content..."
|
||||
output="json"
|
||||
throttleDelay={3000}
|
||||
onUpdate={(c) => updatePageContent(c, page)}
|
||||
handleKeyDown={handleContentKeyDown}
|
||||
onBlur={(c) => updatePageContent(c, page)}
|
||||
editorProps={{ handleKeyDown: handleContentKeyDown }}
|
||||
onCreate={handleCreate}
|
||||
onUpdate={updatePageContent}
|
||||
onBlur={updatePageContent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -297,4 +270,6 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
DetailPageForm.displayName = "DetailPageForm"
|
||||
@@ -24,7 +24,7 @@ export const PageHeader: React.FC<PageHeaderProps> = React.memo(() => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentHeader className="px-6 py-4 max-lg:px-4">
|
||||
<ContentHeader>
|
||||
<HeaderTitle />
|
||||
<div className="flex flex-auto" />
|
||||
<NewPageButton onClick={handleNewPageClick} />
|
||||
|
||||
@@ -36,8 +36,8 @@ export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
className={cn(
|
||||
"relative block cursor-default outline-none",
|
||||
"min-h-12 py-2 max-lg:px-4 sm:px-6",
|
||||
"data-[active='true']:bg-[var(--link-background-muted)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]",
|
||||
"min-h-12 py-2 sm:px-6 max-lg:px-4",
|
||||
"data-[active='true']:bg-[var(--link-background-muted-new)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]",
|
||||
)}
|
||||
to={`/pages/${page.id}`}
|
||||
aria-selected={isActive}
|
||||
@@ -47,7 +47,7 @@ export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(
|
||||
>
|
||||
<div className="flex h-full items-center gap-4">
|
||||
<Column.Wrapper style={columnStyles.title}>
|
||||
<Column.Text className="truncate text-[13px] font-medium">
|
||||
<Column.Text className="truncate text-sm font-medium">
|
||||
{page.title || "Untitled"}
|
||||
</Column.Text>
|
||||
</Column.Wrapper>
|
||||
@@ -64,7 +64,7 @@ export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(
|
||||
style={columnStyles.updated}
|
||||
className="flex justify-end"
|
||||
>
|
||||
<Column.Text className="text-[13px]">
|
||||
<Column.Text className="text-sm">
|
||||
{format(new Date(page.updatedAt), "d MMM yyyy")}
|
||||
</Column.Text>
|
||||
</Column.Wrapper>
|
||||
|
||||
@@ -54,7 +54,7 @@ export const PageList: React.FC<PageListProps> = () => {
|
||||
<div className="flex h-full w-full flex-col overflow-hidden border-t">
|
||||
{!isTablet && <ColumnHeader />}
|
||||
<Primitive.div
|
||||
className="divide-primary/5 flex flex-1 flex-col divide-y overflow-y-auto outline-none [scrollbar-gutter:stable]"
|
||||
className="flex flex-1 flex-col divide-y divide-primary/5 overflow-y-auto outline-none [scrollbar-gutter:stable]"
|
||||
tabIndex={-1}
|
||||
role="list"
|
||||
>
|
||||
@@ -110,7 +110,7 @@ export const ColumnHeader: React.FC = () => {
|
||||
const columnStyles = useColumnStyles()
|
||||
|
||||
return (
|
||||
<div className="flex h-8 shrink-0 grow-0 flex-row gap-4 border-b max-lg:px-4 sm:px-6">
|
||||
<div className="flex h-8 shrink-0 grow-0 flex-row gap-4 border-b sm:px-6 max-lg:px-4">
|
||||
<Column.Wrapper style={columnStyles.title}>
|
||||
<Column.Text>Title</Column.Text>
|
||||
</Column.Wrapper>
|
||||
|
||||
@@ -137,7 +137,7 @@ function ProfileComponent() {
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={changeName}
|
||||
className="border-result mb-3 mr-3 text-[25px] font-semibold"
|
||||
className="mb-3 mr-3 border-result text-[25px] font-semibold"
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-red-500 text-opacity-70">{error}</p>
|
||||
|
||||
@@ -28,7 +28,7 @@ const SearchTitle: React.FC<SearchTitleProps> = ({ title, count }) => (
|
||||
<div className="flex w-full items-center">
|
||||
<h2 className="text-md font-semibold">{title}</h2>
|
||||
<div className="mx-4 flex-grow">
|
||||
<div className="bg-result h-px"></div>
|
||||
<div className="h-px bg-result"></div>
|
||||
</div>
|
||||
<span className="text-base font-light text-opacity-55">{count}</span>
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@ const SearchItem: React.FC<SearchItemProps> = ({
|
||||
subtitle,
|
||||
topic,
|
||||
}) => (
|
||||
<div className="hover:bg-result group flex min-w-0 items-center gap-x-4 rounded-md p-2">
|
||||
<div className="group flex min-w-0 items-center gap-x-4 rounded-md p-2 hover:bg-result">
|
||||
<LaIcon
|
||||
name={icon as "Square"}
|
||||
className="size-4 flex-shrink-0 opacity-0 transition-opacity duration-200 group-hover:opacity-50"
|
||||
@@ -50,7 +50,7 @@ const SearchItem: React.FC<SearchItemProps> = ({
|
||||
<Link
|
||||
to={href}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="hover:text-primary text-sm font-medium hover:opacity-70"
|
||||
className="text-sm font-medium hover:text-primary hover:opacity-70"
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
@@ -58,7 +58,7 @@ const SearchItem: React.FC<SearchItemProps> = ({
|
||||
<Link
|
||||
to={href}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-muted-foreground ml-2 truncate text-xs hover:underline"
|
||||
className="ml-2 truncate text-xs text-muted-foreground hover:underline"
|
||||
>
|
||||
{subtitle}
|
||||
</Link>
|
||||
@@ -138,7 +138,7 @@ const SearchComponent = () => {
|
||||
<div className="relative my-5 flex w-full items-center space-x-2">
|
||||
<LaIcon
|
||||
name="Search"
|
||||
className="text-foreground absolute left-4 size-4 flex-shrink-0"
|
||||
className="absolute left-4 size-4 flex-shrink-0 text-foreground"
|
||||
/>
|
||||
<input
|
||||
autoFocus
|
||||
@@ -146,12 +146,12 @@ const SearchComponent = () => {
|
||||
value={searchText}
|
||||
onChange={handleSearch}
|
||||
placeholder="Search topics, links, pages"
|
||||
className="dark:bg-input w-full rounded-lg border border-neutral-300 p-2 pl-8 focus:outline-none dark:border-neutral-600"
|
||||
className="w-full rounded-lg border border-neutral-300 p-2 pl-8 focus:outline-none dark:border-neutral-600 dark:bg-input"
|
||||
/>
|
||||
{searchText && (
|
||||
<LaIcon
|
||||
name="X"
|
||||
className="text-foreground/50 absolute right-3 size-4 flex-shrink-0 cursor-pointer"
|
||||
className="absolute right-3 size-4 flex-shrink-0 cursor-pointer text-foreground/50"
|
||||
onClick={clearSearch}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -115,7 +115,7 @@ export const TaskForm: React.FC = () => {
|
||||
<div
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-result flex w-full items-center justify-between rounded-lg px-2 py-1"
|
||||
className="flex w-full items-center justify-between rounded-lg bg-result px-2 py-1"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<Checkbox
|
||||
|
||||
@@ -58,7 +58,7 @@ export const TaskItem: React.FC<TaskItemProps> = ({
|
||||
: "No due date"
|
||||
|
||||
return (
|
||||
<li className="bg-result transitiion-opacity flex items-center justify-between rounded-lg p-2 px-3 hover:opacity-60">
|
||||
<li className="transitiion-opacity flex items-center justify-between rounded-lg bg-result p-2 px-3 hover:opacity-60">
|
||||
<div className="flex flex-grow flex-row items-center gap-3">
|
||||
<Checkbox
|
||||
checked={task.status === "done"}
|
||||
@@ -77,7 +77,7 @@ export const TaskItem: React.FC<TaskItemProps> = ({
|
||||
<p
|
||||
className={
|
||||
task.status === "done"
|
||||
? "text-foreground flex-grow line-through"
|
||||
? "flex-grow text-foreground line-through"
|
||||
: "flex-grow"
|
||||
}
|
||||
onClick={clickTitle}
|
||||
@@ -86,7 +86,7 @@ export const TaskItem: React.FC<TaskItemProps> = ({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs">{formattedDate}</span>
|
||||
<span className="text-xs text-muted-foreground">{formattedDate}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export const TopicHeader: React.FC<TopicHeaderProps> = React.memo(() => {
|
||||
if (!me) return null
|
||||
|
||||
return (
|
||||
<ContentHeader className="px-6 py-4 max-lg:px-4">
|
||||
<ContentHeader>
|
||||
<HeaderTitle />
|
||||
<div className="flex flex-auto" />
|
||||
</ContentHeader>
|
||||
@@ -26,7 +26,9 @@ const HeaderTitle: React.FC = () => (
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 items-center">
|
||||
<span className="truncate text-left font-bold lg:text-xl">Topics</span>
|
||||
<span className="truncate text-left font-semibold lg:text-lg">
|
||||
Topics
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -142,8 +142,8 @@ export const TopicItem = React.forwardRef<HTMLAnchorElement, TopicItemProps>(
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
className={cn(
|
||||
"relative block cursor-default outline-none",
|
||||
"min-h-12 py-2 max-lg:px-4 sm:px-6",
|
||||
"data-[active='true']:bg-[var(--link-background-muted)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]",
|
||||
"min-h-12 py-2 sm:px-6 max-lg:px-4",
|
||||
"data-[active='true']:bg-[var(--link-background-muted-new)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]",
|
||||
)}
|
||||
aria-selected={isActive}
|
||||
data-active={isActive}
|
||||
@@ -155,7 +155,7 @@ export const TopicItem = React.forwardRef<HTMLAnchorElement, TopicItemProps>(
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<Column.Wrapper style={columnStyles.title}>
|
||||
<Column.Text className="truncate text-[13px] font-medium">
|
||||
<Column.Text className="truncate text-sm font-medium">
|
||||
{topic.prettyName}
|
||||
</Column.Text>
|
||||
</Column.Wrapper>
|
||||
|
||||
@@ -94,10 +94,10 @@ export const MainTopicList: React.FC<MainTopicListProps> = ({ me }) => {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden border-t">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
{!isTablet && <ColumnHeader />}
|
||||
<Primitive.div
|
||||
className="divide-primary/5 flex flex-1 flex-col divide-y overflow-y-auto outline-none [scrollbar-gutter:stable]"
|
||||
className="flex flex-1 flex-col divide-y divide-primary/5 overflow-y-auto outline-none [scrollbar-gutter:stable]"
|
||||
tabIndex={-1}
|
||||
role="list"
|
||||
>
|
||||
@@ -144,7 +144,7 @@ export const ColumnHeader: React.FC = () => {
|
||||
const columnStyles = useColumnStyles()
|
||||
|
||||
return (
|
||||
<div className="flex h-8 shrink-0 grow-0 flex-row gap-4 border-b max-lg:px-4 sm:px-6">
|
||||
<div className="flex h-8 shrink-0 grow-0 flex-row gap-4 border-b sm:px-6 max-lg:px-4">
|
||||
<Column.Wrapper style={columnStyles.title}>
|
||||
<Column.Text>Name</Column.Text>
|
||||
</Column.Wrapper>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover: 220, 5.66%, 10.39%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
@@ -55,7 +55,7 @@
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--border: 240 3.7% 20%;
|
||||
--input: 220 9% 10%;
|
||||
--result: 0 0% 7%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
@@ -77,7 +77,7 @@
|
||||
}
|
||||
body,
|
||||
div#root {
|
||||
@apply bg-background text-foreground size-full font-sans antialiased;
|
||||
@apply size-full bg-[var(--body-background)] font-sans text-foreground antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,11 +116,11 @@
|
||||
.la [cmdk-group-heading] {
|
||||
font-size: 13px;
|
||||
height: 30px;
|
||||
@apply text-muted-foreground flex items-center px-2;
|
||||
@apply flex items-center px-2 text-muted-foreground;
|
||||
}
|
||||
|
||||
.la [cmdk-empty] {
|
||||
@apply text-muted-foreground flex h-16 items-center justify-center whitespace-pre-wrap text-sm;
|
||||
@apply flex h-16 items-center justify-center whitespace-pre-wrap text-sm text-muted-foreground;
|
||||
}
|
||||
|
||||
.la [cmdk-item] {
|
||||
|
||||
@@ -1,11 +1,42 @@
|
||||
:root {
|
||||
--link-background-muted: hsl(0, 0%, 97.3%);
|
||||
--link-border-after: hsl(0, 0%, 91%);
|
||||
--link-background-muted-new: hsl(0, 0%, 97.3%);
|
||||
--la-border: hsl(0, 0%, 91%);
|
||||
--la-border-new: hsl(0, 0%, 91%);
|
||||
--link-shadow: hsl(240, 5.6%, 82.5%);
|
||||
--less-foreground: hsl(240 10% 3.9%);
|
||||
|
||||
--item-active: rgb(228, 228, 229);
|
||||
--item-hover: rgb(237, 237, 239);
|
||||
--body-background: rgb(248, 248, 249);
|
||||
--container-background: rgb(255, 255, 255);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--link-background-muted: hsl(220, 6.7%, 8.8%);
|
||||
--link-border-after: hsl(230, 10%, 11.8%);
|
||||
--link-background-muted-new: rgb(28, 29, 32);
|
||||
--la-border: hsl(230, 10%, 11.8%);
|
||||
--la-border-new: hsl(230, 10%, 15%);
|
||||
--link-shadow: hsl(234.9, 27.1%, 25.3%);
|
||||
--less-foreground: #e5e7eb;
|
||||
|
||||
--item-active: rgb(53, 54, 57);
|
||||
--item-hover: rgb(42, 43, 46);
|
||||
--body-background: rgb(31, 32, 35);
|
||||
--container-background: rgb(24, 25, 28);
|
||||
}
|
||||
|
||||
.title-editor .ProseMirror .is-empty::before {
|
||||
@apply pointer-events-none float-left h-0 w-full text-[var(--la-secondary)];
|
||||
}
|
||||
|
||||
.title-editor:not(.no-command)
|
||||
.ProseMirror.ProseMirror-focused
|
||||
> p.has-focus.is-empty::before {
|
||||
content: "Type / for commands...";
|
||||
}
|
||||
|
||||
.title-editor .ProseMirror > p.is-editor-empty::before {
|
||||
content: attr(data-placeholder);
|
||||
@apply pointer-events-none float-left h-0 text-[var(--la-secondary)];
|
||||
}
|
||||
+32
-23
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/tanstack-start": "0.4.1",
|
||||
"@clerk/themes": "^2.1.35",
|
||||
"@clerk/themes": "^2.1.37",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
@@ -39,12 +39,12 @@
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"@tanstack/react-router": "^1.62.0",
|
||||
"@tanstack/react-router-with-query": "^1.62.0",
|
||||
"@tanstack/react-query": "^5.59.15",
|
||||
"@tanstack/react-virtual": "^3.10.8",
|
||||
"@tanstack/router-zod-adapter": "^1.62.0",
|
||||
"@tanstack/start": "^1.62.0",
|
||||
"@tanstack/react-router": "^1.70.1",
|
||||
"@tanstack/react-router-with-query": "^1.70.1",
|
||||
"@tanstack/router-zod-adapter": "^1.70.1",
|
||||
"@tanstack/start": "^1.70.1",
|
||||
"@tiptap/core": "^2.8.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.8.0",
|
||||
"@tiptap/extension-color": "^2.8.0",
|
||||
@@ -67,50 +67,59 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"framer-motion": "^11.11.1",
|
||||
"jazz-react": "^0.8.2",
|
||||
"jazz-react-auth-clerk": "^0.8.2",
|
||||
"jazz-tools": "^0.8.2",
|
||||
"jotai": "^2.10.0",
|
||||
"framer-motion": "^11.11.9",
|
||||
"jazz-browser-media-images": "^0.8.7",
|
||||
"jazz-react": "^0.8.7",
|
||||
"jazz-react-auth-clerk": "^0.8.7",
|
||||
"jazz-tools": "^0.8.5",
|
||||
"jotai": "^2.10.1",
|
||||
"lowlight": "^3.1.0",
|
||||
"lucide-react": "^0.446.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"query-string": "^9.1.0",
|
||||
"query-string": "^9.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"react-medium-image-zoom": "^5.2.10",
|
||||
"react-textarea-autosize": "^8.5.4",
|
||||
"ronin": "^4.3.1",
|
||||
"slugify": "^1.6.6",
|
||||
"sonner": "^1.5.0",
|
||||
"streaming-markdown": "^0.0.14",
|
||||
"tailwind-merge": "^2.5.3",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^10.0.0",
|
||||
"vinxi": "0.4.3",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ronin/learn-anything": "^0.0.0-3456082797916",
|
||||
"@ronin/learn-anything": "^0.0.0-3457754034220",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tanstack/react-query-devtools": "^5.59.0",
|
||||
"@tanstack/router-devtools": "^1.62.0",
|
||||
"@types/node": "^22.7.4",
|
||||
"@tanstack/react-query-devtools": "^5.59.15",
|
||||
"@tanstack/router-devtools": "^1.70.1",
|
||||
"@types/node": "^22.7.6",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"dotenv": "^16.4.5",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.6.2",
|
||||
"vite-tsconfig-paths": "^5.0.1"
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3",
|
||||
"vite-tsconfig-paths": "^5.0.1",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.5.14"
|
||||
},
|
||||
"prettier": {
|
||||
"plugins": [
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"semi": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SpinnerProps extends React.SVGProps<SVGSVGElement> {}
|
||||
|
||||
const SpinnerComponent = React.forwardRef<SVGSVGElement, SpinnerProps>(
|
||||
function Spinner({ className, ...props }, ref) {
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className={cn("animate-spin", className)}
|
||||
{...props}
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
SpinnerComponent.displayName = "Spinner"
|
||||
|
||||
export const Spinner = React.memo(SpinnerComponent)
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useTextmenuCommands } from "../../hooks/use-text-menu-commands"
|
||||
import { PopoverWrapper } from "../ui/popover-wrapper"
|
||||
import { useTextmenuStates } from "../../hooks/use-text-menu-states"
|
||||
import { BubbleMenu as TiptapBubbleMenu, Editor } from "@tiptap/react"
|
||||
import { ToolbarButton } from "../ui/toolbar-button"
|
||||
import { Icon } from "../ui/icon"
|
||||
|
||||
export type BubbleMenuProps = {
|
||||
editor: Editor
|
||||
}
|
||||
|
||||
export const BubbleMenu = ({ editor }: BubbleMenuProps) => {
|
||||
const commands = useTextmenuCommands(editor)
|
||||
const states = useTextmenuStates(editor)
|
||||
|
||||
return (
|
||||
<TiptapBubbleMenu
|
||||
tippyOptions={{
|
||||
// duration: [0, 999999],
|
||||
popperOptions: { placement: "top-start" },
|
||||
}}
|
||||
editor={editor}
|
||||
pluginKey="textMenu"
|
||||
shouldShow={states.shouldShow}
|
||||
updateDelay={100}
|
||||
>
|
||||
<PopoverWrapper>
|
||||
<div className="flex space-x-1">
|
||||
<ToolbarButton
|
||||
value="bold"
|
||||
aria-label="Bold"
|
||||
onPressedChange={commands.onBold}
|
||||
isActive={states.isBold}
|
||||
>
|
||||
<Icon name="Bold" strokeWidth={2.5} />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
value="italic"
|
||||
aria-label="Italic"
|
||||
onClick={commands.onItalic}
|
||||
isActive={states.isItalic}
|
||||
>
|
||||
<Icon name="Italic" strokeWidth={2.5} />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
value="strikethrough"
|
||||
aria-label="Strikethrough"
|
||||
onClick={commands.onStrike}
|
||||
isActive={states.isStrike}
|
||||
>
|
||||
<Icon name="Strikethrough" strokeWidth={2.5} />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
value="quote"
|
||||
aria-label="Quote"
|
||||
onClick={commands.onCode}
|
||||
isActive={states.isCode}
|
||||
>
|
||||
<Icon name="Quote" strokeWidth={2.5} />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
value="inline code"
|
||||
aria-label="Inline code"
|
||||
onClick={commands.onCode}
|
||||
isActive={states.isCode}
|
||||
>
|
||||
<Icon name="Braces" strokeWidth={2.5} />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
value="code block"
|
||||
aria-label="Code block"
|
||||
onClick={commands.onCodeBlock}
|
||||
>
|
||||
<Icon name="Code" strokeWidth={2.5} />
|
||||
</ToolbarButton>
|
||||
{/* <ToolbarButton value="list" aria-label="List">
|
||||
<Icon name="List" strokeWidth={2.5} />
|
||||
</ToolbarButton> */}
|
||||
</div>
|
||||
</PopoverWrapper>
|
||||
</TiptapBubbleMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default BubbleMenu
|
||||
File renamed without changes.
File renamed without changes.
+1
-1
@@ -10,7 +10,7 @@ export const PopoverWrapper = React.forwardRef<
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground rounded-lg border shadow-sm",
|
||||
"rounded-lg border bg-popover text-popover-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getShortcutKey } from "@/lib/utils"
|
||||
import { getShortcutKey } from "@shared/utils"
|
||||
|
||||
export interface ShortcutKeyWrapperProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
File renamed without changes.
@@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
import "./styles/index.css"
|
||||
|
||||
import { EditorContent } from "@tiptap/react"
|
||||
import { Content } from "@tiptap/core"
|
||||
import { BubbleMenu } from "./components/bubble-menu"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useLaEditor, UseLaEditorProps } from "./hooks/use-la-editor"
|
||||
|
||||
export interface LaEditorProps extends UseLaEditorProps {
|
||||
value?: Content
|
||||
className?: string
|
||||
editorContentClassName?: string
|
||||
}
|
||||
|
||||
export const LaEditor = React.memo(
|
||||
React.forwardRef<HTMLDivElement, LaEditorProps>(
|
||||
({ className, editorContentClassName, ...props }, ref) => {
|
||||
const editor = useLaEditor(props)
|
||||
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative flex h-full w-full grow flex-col", className)}
|
||||
ref={ref}
|
||||
>
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className={cn("la-editor", editorContentClassName)}
|
||||
/>
|
||||
<BubbleMenu editor={editor} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
LaEditor.displayName = "LaEditor"
|
||||
|
||||
export default LaEditor
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,116 @@
|
||||
import { type Editor, Extension } from "@tiptap/core"
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state"
|
||||
import type { FileError, FileValidationOptions } from "@shared/editor/lib/utils"
|
||||
import { filterFiles } from "@shared/editor/lib/utils"
|
||||
|
||||
type FileHandlePluginOptions = {
|
||||
key?: PluginKey
|
||||
editor: Editor
|
||||
onPaste?: (editor: Editor, files: File[], pasteContent?: string) => void
|
||||
onDrop?: (editor: Editor, files: File[], pos: number) => void
|
||||
onValidationError?: (errors: FileError[]) => void
|
||||
} & FileValidationOptions
|
||||
|
||||
const FileHandlePlugin = (options: FileHandlePluginOptions) => {
|
||||
const {
|
||||
key,
|
||||
editor,
|
||||
onPaste,
|
||||
onDrop,
|
||||
onValidationError,
|
||||
allowedMimeTypes,
|
||||
maxFileSize,
|
||||
} = options
|
||||
|
||||
return new Plugin({
|
||||
key: key || new PluginKey("fileHandler"),
|
||||
|
||||
props: {
|
||||
handleDrop(view, event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const { dataTransfer } = event
|
||||
|
||||
if (!dataTransfer?.files.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const pos = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
})
|
||||
|
||||
const [validFiles, errors] = filterFiles(
|
||||
Array.from(dataTransfer.files),
|
||||
{
|
||||
allowedMimeTypes,
|
||||
maxFileSize,
|
||||
allowBase64: options.allowBase64,
|
||||
},
|
||||
)
|
||||
|
||||
if (errors.length > 0 && onValidationError) {
|
||||
onValidationError(errors)
|
||||
}
|
||||
|
||||
if (validFiles.length > 0 && onDrop) {
|
||||
onDrop(editor, validFiles, pos?.pos ?? 0)
|
||||
}
|
||||
},
|
||||
|
||||
handlePaste(_, event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const { clipboardData } = event
|
||||
|
||||
if (!clipboardData?.files.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const [validFiles, errors] = filterFiles(
|
||||
Array.from(clipboardData.files),
|
||||
{
|
||||
allowedMimeTypes,
|
||||
maxFileSize,
|
||||
allowBase64: options.allowBase64,
|
||||
},
|
||||
)
|
||||
const html = clipboardData.getData("text/html")
|
||||
|
||||
if (errors.length > 0 && onValidationError) {
|
||||
onValidationError(errors)
|
||||
}
|
||||
|
||||
if (validFiles.length > 0 && onPaste) {
|
||||
onPaste(editor, validFiles, html)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const FileHandler = Extension.create<
|
||||
Omit<FileHandlePluginOptions, "key" | "editor">
|
||||
>({
|
||||
name: "fileHandler",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
allowBase64: false,
|
||||
allowedMimeTypes: [],
|
||||
maxFileSize: 0,
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
FileHandlePlugin({
|
||||
key: new PluginKey(this.name),
|
||||
editor: this.editor,
|
||||
...this.options,
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
File renamed without changes.
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user