mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +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:
@@ -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
|
||||
|
||||
71
web/app/components/custom/nav-item.tsx
Normal file
71
web/app/components/custom/nav-item.tsx
Normal file
@@ -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}
|
||||
|
||||
27
web/app/components/icons/arrow-icon.tsx
Normal file
27
web/app/components/icons/arrow-icon.tsx
Normal file
@@ -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
|
||||
|
||||
28
web/app/components/sidebar/partials/link-collection.tsx
Normal file
28
web/app/components/sidebar/partials/link-collection.tsx
Normal file
@@ -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
|
||||
|
||||
|
||||
6
web/app/lib/schema/base.ts
Normal file
6
web/app/lib/schema/base.ts
Normal file
@@ -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)
|
||||
}
|
||||
26
web/app/lib/schema/folder.ts
Normal file
26
web/app/lib/schema/folder.ts
Normal file
@@ -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://",
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
37
web/shared/components/spinner.tsx
Normal file
37
web/shared/components/spinner.tsx
Normal file
@@ -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)
|
||||
90
web/shared/editor/components/bubble-menu/bubble-menu.tsx
Normal file
90
web/shared/editor/components/bubble-menu/bubble-menu.tsx
Normal file
@@ -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
|
||||
@@ -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,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> {
|
||||
43
web/shared/editor/editor.tsx
Normal file
43
web/shared/editor/editor.tsx
Normal file
@@ -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
|
||||
116
web/shared/editor/extensions/file-handler/index.ts
Normal file
116
web/shared/editor/extensions/file-handler/index.ts
Normal file
@@ -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,
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user