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:
Aslam
2024-10-18 21:18:20 +07:00
committed by GitHub
parent c93c634a77
commit a440828f8c
158 changed files with 2808 additions and 1064 deletions
View File
+1 -1
View File
@@ -3,7 +3,7 @@ import tsConfigPaths from "vite-tsconfig-paths"
export default defineConfig({ export default defineConfig({
vite: { vite: {
plugins: () => [ plugins: [
tsConfigPaths({ tsConfigPaths({
projects: ["./tsconfig.json"], projects: ["./tsconfig.json"],
}), }),
+5 -5
View File
@@ -17,28 +17,28 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
console.error(error) console.error(error)
return ( 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} /> <ErrorComponent error={error} />
<div className="flex gap-2 items-center flex-wrap"> <div className="flex flex-wrap items-center gap-2">
<button <button
onClick={() => { onClick={() => {
router.invalidate() 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 Try Again
</button> </button>
{isRoot ? ( {isRoot ? (
<Link <Link
to="/" 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 Home
</Link> </Link>
) : ( ) : (
<Link <Link
to="/" 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) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
window.history.back() window.history.back()
+1 -1
View File
@@ -1,13 +1,13 @@
import * as React from "react" import * as React from "react"
import { useKeyDown, KeyFilter, Options } from "@/hooks/use-key-down" import { useKeyDown, KeyFilter, Options } from "@/hooks/use-key-down"
import { useAccountOrGuest } from "@/lib/providers/jazz-provider" import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
import { isModKey, isServer } from "@/lib/utils"
import { useAtom } from "jotai" import { useAtom } from "jotai"
import { usePageActions } from "~/hooks/actions/use-page-actions" import { usePageActions } from "~/hooks/actions/use-page-actions"
import { useAuth } from "@clerk/tanstack-start" import { useAuth } from "@clerk/tanstack-start"
import { useNavigate } from "@tanstack/react-router" import { useNavigate } from "@tanstack/react-router"
import queryString from "query-string" import queryString from "query-string"
import { commandPaletteOpenAtom } from "~/store/any-store" import { commandPaletteOpenAtom } from "~/store/any-store"
import { isModKey, isServer } from "@shared/utils"
type RegisterKeyDownProps = { type RegisterKeyDownProps = {
trigger: KeyFilter trigger: KeyFilter
+3 -3
View File
@@ -6,16 +6,16 @@ export function NotFound({ children }: { children?: any }) {
<div className="text-gray-600 dark:text-gray-400"> <div className="text-gray-600 dark:text-gray-400">
{children || <p>The page you are looking for does not exist.</p>} {children || <p>The page you are looking for does not exist.</p>}
</div> </div>
<p className="flex items-center gap-2 flex-wrap"> <p className="flex flex-wrap items-center gap-2">
<button <button
onClick={() => window.history.back()} 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 Go back
</button> </button>
<Link <Link
to="/" 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 Start Over
</Link> </Link>
+1 -1
View File
@@ -42,7 +42,7 @@ export function Onboarding() {
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogDescription asChild> <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> <p>
Learn Anything is a learning platform that organizes knowledge in Learn Anything is a learning platform that organizes knowledge in
a social way. You can create pages, add links, track learning a social way. You can create pages, add links, track learning
@@ -1,7 +1,7 @@
import { icons } from "lucide-react" import { icons } from "lucide-react"
import { LaAccount } from "@/lib/schema" import { LaAccount } from "@/lib/schema"
import { HTMLLikeElement } from "@/lib/utils" 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) export type CommandAction = string | (() => void)
@@ -12,7 +12,7 @@ import { CommandGroup } from "./command-group"
import { CommandAction, createCommandGroups } from "./command-data" import { CommandAction, createCommandGroups } from "./command-data"
import { useAccount, useAccountOrGuest } from "@/lib/providers/jazz-provider" import { useAccount, useAccountOrGuest } from "@/lib/providers/jazz-provider"
import { useAtom } from "jotai" import { useAtom } from "jotai"
import { useCommandActions } from "~/hooks/use-command-actions" import { useCommandActions } from "~/hooks/actions/use-command-actions"
import { import {
filterItems, filterItems,
getTopics, getTopics,
+2 -2
View File
@@ -15,7 +15,7 @@ export const ContentHeader = React.forwardRef<
return ( return (
<header <header
className={cn( 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, className,
)} )}
ref={ref} ref={ref}
@@ -48,7 +48,7 @@ export const SidebarToggleButton: React.FC = () => {
size="icon" size="icon"
variant="ghost" variant="ghost"
aria-label="Menu" aria-label="Menu"
className="text-primary/60" className="-ml-2 cursor-default text-muted-foreground hover:bg-transparent"
onClick={handleClick} onClick={handleClick}
> >
<LaIcon name="PanelLeft" /> <LaIcon name="PanelLeft" />
@@ -66,7 +66,7 @@ export const LearningStateSelector: React.FC<LearningStateSelectorProps> = ({
type="button" type="button"
role="combobox" role="combobox"
variant="secondary" variant="secondary"
className={cn("gap-x-2 text-sm", className)} className={cn("h-7 gap-x-2 text-sm", className)}
> >
{iconName && ( {iconName && (
<LaIcon <LaIcon
+71
View 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 ( return (
<BaseTextareaAutosize <BaseTextareaAutosize
className={cn( 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, className,
)} )}
ref={ref} ref={ref}
+27
View 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>
)
}
+5 -5
View File
@@ -66,7 +66,7 @@ const ShortcutKey: React.FC<{ keyChar: string }> = ({ keyChar }) => (
const ShortcutItem: React.FC<ShortcutItem> = ({ label, keys, then }) => ( const ShortcutItem: React.FC<ShortcutItem> = ({ label, keys, then }) => (
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<dt className="flex grow items-center"> <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> </dt>
<dd className="flex items-end"> <dd className="flex items-end">
<span className="text-left"> <span className="text-left">
@@ -79,7 +79,7 @@ const ShortcutItem: React.FC<ShortcutItem> = ({ label, keys, then }) => (
))} ))}
{then && ( {then && (
<> <>
<span className="text-muted-foreground text-xs">then</span> <span className="text-xs text-muted-foreground">then</span>
{then.map((key, index) => ( {then.map((key, index) => (
<ShortcutKey key={`then-${index}`} keyChar={key} /> <ShortcutKey key={`then-${index}`} keyChar={key} />
))} ))}
@@ -149,7 +149,7 @@ export function Shortcut() {
"size-6 p-0", "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> <span className="sr-only">Close</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
</header> </header>
@@ -158,12 +158,12 @@ export function Shortcut() {
<form className="relative flex items-center"> <form className="relative flex items-center">
<LaIcon <LaIcon
name="Search" name="Search"
className="text-muted-foreground absolute left-3 size-4" className="absolute left-3 size-4 text-muted-foreground"
/> />
<Input <Input
autoFocus autoFocus
placeholder="Search shortcuts" 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} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
@@ -109,7 +109,7 @@ export function Feedback() {
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
className={cn( 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", "flex flex-col p-4 sm:max-w-2xl",
)} )}
> >
@@ -134,7 +134,7 @@ export function Feedback() {
{...field} {...field}
throttleDelay={500} throttleDelay={500}
className={cn( 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": "border-destructive focus-within:border-destructive":
form.formState.errors.content, form.formState.errors.content,
@@ -75,7 +75,7 @@ const JournalSectionHeader: React.FC<JournalHeaderProps> = ({
<p className="text-xs"> <p className="text-xs">
Journal Journal
{entriesCount > 0 && ( {entriesCount > 0 && (
<span className="text-muted-foreground ml-1">({entriesCount})</span> <span className="ml-1 text-muted-foreground">({entriesCount})</span>
)} )}
</p> </p>
</Link> </Link>
@@ -104,7 +104,7 @@ const JournalEntryItem: React.FC<JournalEntryItemProps> = ({ entry }) => (
href={`/journal/${entry.id}`} href={`/journal/${entry.id}`}
className="group/journal-entry relative flex min-w-0 flex-1" 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"> <div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
<LaIcon name="FileText" className="opacity-60" /> <LaIcon name="FileText" className="opacity-60" />
<p <p
@@ -0,0 +1,28 @@
import * as React from "react"
import { useAccount } from "@/lib/providers/jazz-provider"
import { NavItem } from "~/components/custom/nav-item"
export const LinkCollection: React.FC = () => {
const { me } = useAccount({
root: {
personalLinks: [],
topicsWantToLearn: [],
topicsLearning: [],
topicsLearned: [],
},
})
const linkCount = me?.root.personalLinks?.length || 0
const topicCount =
(me?.root.topicsWantToLearn?.length || 0) +
(me?.root.topicsLearning?.length || 0) +
(me?.root.topicsLearned?.length || 0)
return (
<div className="flex flex-col gap-px py-2">
<NavItem to="/links" title="Links" icon="Link" count={linkCount} />
<NavItem to="/topics" title="Topics" icon="Hash" count={topicCount} />
</div>
)
}
@@ -4,6 +4,7 @@ import { useAccount } from "@/lib/providers/jazz-provider"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PersonalLinkLists } from "@/lib/schema/personal-link" import { PersonalLinkLists } from "@/lib/schema/personal-link"
import { LearningStateValue } from "~/lib/constants" import { LearningStateValue } from "~/lib/constants"
import { LaIcon } from "~/components/custom/la-icon"
export const LinkSection: React.FC = () => { export const LinkSection: React.FC = () => {
const { me } = useAccount({ root: { personalLinks: [] } }) const { me } = useAccount({ root: { personalLinks: [] } })
@@ -13,7 +14,7 @@ export const LinkSection: React.FC = () => {
const linkCount = me.root.personalLinks?.length || 0 const linkCount = me.root.personalLinks?.length || 0
return ( return (
<div className="group/pages flex flex-col gap-px py-2"> <div className="flex flex-col gap-px py-2">
<LinkSectionHeader linkCount={linkCount} /> <LinkSectionHeader linkCount={linkCount} />
<LinkList personalLinks={me.root.personalLinks} /> <LinkList personalLinks={me.root.personalLinks} />
</div> </div>
@@ -24,22 +25,41 @@ interface LinkSectionHeaderProps {
linkCount: number linkCount: number
} }
const LinkSectionHeader: React.FC<LinkSectionHeaderProps> = ({ linkCount }) => ( const LinkSectionHeader: React.FC<LinkSectionHeaderProps> = ({ linkCount }) => {
<Link return (
to="/links" <Link
className={cn( to="/links"
"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", 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-accent text-accent-foreground", activeProps={{
}} className:
> "bg-[var(--item-active)] data-[status='active']:hover:bg-[var(--item-active)]",
Links }}
{linkCount > 0 && ( >
<span className="text-muted-foreground ml-1">{linkCount}</span> {({ isActive }) => {
)} return (
</Link> <>
) <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 { interface LinkListProps {
personalLinks: PersonalLinkLists personalLinks: PersonalLinkLists
@@ -87,29 +107,34 @@ interface LinkListItemProps {
} }
const LinkListItem: React.FC<LinkListItemProps> = ({ label, state, count }) => ( const LinkListItem: React.FC<LinkListItemProps> = ({ label, state, count }) => (
<div className="group/reorder-page relative"> <div className="relative flex min-w-0 flex-1">
<div className="group/topic-link relative flex min-w-0 flex-1"> <Link
<Link to="/links"
to="/links" search={{ state }}
search={{ state }} className={cn(
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)]",
"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> 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> </div>
) )
@@ -20,6 +20,7 @@ import {
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { usePageActions } from "~/hooks/actions/use-page-actions" import { usePageActions } from "~/hooks/actions/use-page-actions"
import { icons } from "lucide-react" import { icons } from "lucide-react"
import { ArrowIcon } from "~/components/icons/arrow-icon"
type SortOption = "title" | "recent" type SortOption = "title" | "recent"
type ShowOption = 5 | 10 | 15 | 20 | 0 type ShowOption = 5 | 10 | 15 | 20 | 0
@@ -44,56 +45,82 @@ const SHOWS: Option<ShowOption>[] = [
const pageSortAtom = atomWithStorage<SortOption>("pageSort", "title") const pageSortAtom = atomWithStorage<SortOption>("pageSort", "title")
const pageShowAtom = atomWithStorage<ShowOption>("pageShow", 5) const pageShowAtom = atomWithStorage<ShowOption>("pageShow", 5)
const isExpandedAtom = atomWithStorage("isPageSectionExpanded", true)
export const PageSection: React.FC = () => { export const PageSection: React.FC = () => {
const { me } = useAccount({ const { me } = useAccount({ root: { personalPages: [] } })
root: {
personalPages: [],
},
})
const [sort] = useAtom(pageSortAtom) const [sort] = useAtom(pageSortAtom)
const [show] = useAtom(pageShowAtom) const [show] = useAtom(pageShowAtom)
const [isExpanded, setIsExpanded] = useAtom(isExpandedAtom)
if (!me) return null if (!me) return null
const pageCount = me.root.personalPages?.length || 0 const pageCount = me.root.personalPages?.length || 0
return ( return (
<div className="group/pages flex flex-col gap-px py-2"> <div className="flex flex-col gap-px py-2">
<PageSectionHeader pageCount={pageCount} /> <PageSectionHeader
<PageList personalPages={me.root.personalPages} sort={sort} show={show} /> pageCount={pageCount}
isExpanded={isExpanded}
onToggle={() => setIsExpanded(!isExpanded)}
/>
{isExpanded && (
<PageList
personalPages={me.root.personalPages}
sort={sort}
show={show}
/>
)}
</div> </div>
) )
} }
interface PageSectionHeaderProps { interface PageSectionHeaderProps {
pageCount: number pageCount: number
isExpanded: boolean
onToggle: () => void
} }
const PageSectionHeader: React.FC<PageSectionHeaderProps> = ({ pageCount }) => ( const PageSectionHeader: React.FC<PageSectionHeaderProps> = ({
<Link pageCount,
to="/pages" isExpanded,
onToggle,
}) => (
<div
className={cn( className={cn(
"flex h-9 flex-1 items-center justify-start gap-px rounded-md px-2 py-1", "group/pages",
"hover:bg-accent hover:text-accent-foreground sm:h-[30px]", "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"> <Button
<p className="text-sm sm:text-xs"> variant="ghost"
Pages className="h-7 w-full justify-start gap-1 px-2 py-0 text-xs hover:bg-inherit"
{pageCount > 0 && ( onClick={onToggle}
<span className="text-muted-foreground ml-1">{pageCount}</span> >
)} <span>Pages</span>
</p> {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"> <div className="flex items-center gap-px">
<ShowAllForm /> <ShowAllForm />
<NewPageButton /> <NewPageButton />
</div> </div>
</div> </div>
</Link> </div>
) )
const NewPageButton: React.FC = () => { const NewPageButton: React.FC = () => {
@@ -122,11 +149,11 @@ const NewPageButton: React.FC = () => {
variant="ghost" variant="ghost"
aria-label="New Page" aria-label="New Page"
className={cn( className={cn(
"flex size-5 items-center justify-center p-0.5 shadow-none", "flex size-5 cursor-default items-center justify-center p-0.5 shadow-none",
"hover:bg-accent-foreground/10", "text-muted-foreground hover:bg-inherit hover:text-foreground",
"opacity-0 transition-opacity duration-200", "opacity-0 transition-opacity duration-200",
"group-hover/pages:opacity-100 group-has-[[data-state='open']]/pages:opacity-100", "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} onClick={handleClick}
> >
@@ -168,29 +195,31 @@ interface PageListItemProps {
const PageListItem: React.FC<PageListItemProps> = ({ page }) => { const PageListItem: React.FC<PageListItemProps> = ({ page }) => {
return ( return (
<div className="group/reorder-page relative"> <Link
<div className="group/sidebar-link relative flex min-w-0 flex-1"> to="/pages/$pageId"
<Link params={{ pageId: page.id }}
to="/pages/$pageId" className={cn(
params={{ pageId: page.id }} "group/p cursor-default text-[var(--less-foreground)]",
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)]",
"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", activeProps={{
)} className:
activeOptions={{ exact: true }} "bg-[var(--item-active)] data-[status='active']:hover:bg-[var(--item-active)]",
activeProps={{ }}
className: "bg-accent text-accent-foreground", >
}} {({ isActive }) => (
> <div className="flex max-w-full flex-1 items-center gap-1.5 truncate">
<div className="flex max-w-[calc(100%-1rem)] flex-1 items-center gap-1.5 truncate text-sm"> <LaIcon
<LaIcon name="FileText" className="flex-shrink-0 opacity-60" /> name="File"
<p className="truncate opacity-95 group-hover/sidebar-link:opacity-100"> className={cn("flex-shrink-0 group-hover/p:text-foreground", {
{page.title || "Untitled"} "text-foreground": isActive,
</p> "text-muted-foreground": !isActive,
</div> })}
</Link> />
</div> <p className="truncate">{page.title || "Untitled"}</p>
</div> </div>
)}
</Link>
) )
} }
@@ -212,11 +241,11 @@ const SubMenu = <T extends string | number>({
<DropdownMenuSub> <DropdownMenuSub>
<DropdownMenuSubTrigger> <DropdownMenuSubTrigger>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<LaIcon name={icon} /> <LaIcon name={icon} className="" />
<span>{label}</span> <span>{label}</span>
</span> </span>
<span className="ml-auto flex items-center gap-1"> <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} {options.find((option) => option.value === currentValue)?.label}
</span> </span>
<LaIcon name="ChevronRight" /> <LaIcon name="ChevronRight" />
@@ -251,11 +280,11 @@ const ShowAllForm: React.FC = () => {
variant="ghost" variant="ghost"
size="sm" size="sm"
className={cn( className={cn(
"flex size-5 items-center justify-center p-0.5 shadow-none", "flex size-5 cursor-default items-center justify-center p-0.5 shadow-none",
"hover:bg-accent-foreground/10", "text-muted-foreground hover:bg-inherit hover:text-foreground",
"opacity-0 transition-opacity duration-200", "opacity-0 transition-opacity duration-200",
"group-hover/pages:opacity-100 group-has-[[data-state='open']]/pages:opacity-100", "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" /> <LaIcon name="Ellipsis" />
@@ -56,6 +56,7 @@ export const ProfileSection: React.FC = () => {
signOut={signOut} signOut={signOut}
setShowShortcut={setShowShortcut} setShowShortcut={setShowShortcut}
/> />
<span className="flex flex-auto"></span>
<Feedback /> <Feedback />
</div> </div>
</div> </div>
@@ -83,12 +84,12 @@ const ProfileDropdown: React.FC<ProfileDropdownProps> = ({
<Button <Button
variant="ghost" variant="ghost"
aria-label="Profile" 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"> <Avatar className="size-6">
<AvatarImage src={user.imageUrl} alt={user.fullName || ""} /> <AvatarImage src={user.imageUrl} alt={user.fullName || ""} />
</Avatar> </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} {user.fullName}
</span> </span>
<LaIcon <LaIcon
@@ -93,7 +93,7 @@ const TaskSectionHeader: React.FC<TaskSectionHeaderProps> = ({
<Link <Link
to="/tasks" to="/tasks"
className={cn( 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 }} search={{ filter }}
activeProps={{ activeProps={{
@@ -104,7 +104,7 @@ const TaskSectionHeader: React.FC<TaskSectionHeaderProps> = ({
<p className="text-sm"> <p className="text-sm">
{title} {title}
{count > 0 && <span className="text-muted-foreground ml-1">{count}</span>} {count > 0 && <span className="ml-1 text-muted-foreground">{count}</span>}
</p> </p>
</Link> </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>
)
}
+10 -11
View File
@@ -1,7 +1,6 @@
import * as React from "react" import * as React from "react"
import { useMedia } from "@/hooks/use-media" import { useMedia } from "@/hooks/use-media"
import { useAtom } from "jotai" import { useAtom } from "jotai"
import { LogoIcon } from "@/components/icons/logo-icon"
import { buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { isCollapseAtom } from "@/store/sidebar" import { isCollapseAtom } from "@/store/sidebar"
@@ -9,12 +8,12 @@ import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
import { LaIcon } from "@/components/custom/la-icon" import { LaIcon } from "@/components/custom/la-icon"
import { Link, useLocation } from "@tanstack/react-router" 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 { PageSection } from "./partials/page-section"
import { TopicSection } from "./partials/topic-section"
import { ProfileSection } from "./partials/profile-section" import { ProfileSection } from "./partials/profile-section"
import { JournalSection } from "./partials/journal-section" import { JournalSection } from "./partials/journal-section"
import { TaskSection } from "./partials/task-section" import { TaskSection } from "./partials/task-section"
import { LinkCollection } from "./partials/link-collection"
interface SidebarContextType { interface SidebarContextType {
isCollapsed: boolean isCollapsed: boolean
@@ -64,14 +63,14 @@ const SidebarItem: React.FC<SidebarItemProps> = React.memo(
)} )}
> >
<Link <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} to={url}
onClick={onClick} onClick={onClick}
> >
{icon && ( {icon && (
<span <span
className={cn( 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 }, { "text-primary": isActive },
)} )}
> >
@@ -102,7 +101,7 @@ const LogoAndSearch: React.FC = React.memo(() => {
to={pathname === "/search" ? "/" : "/search"} to={pathname === "/search" ? "/" : "/search"}
className={cn( className={cn(
buttonVariants({ size: "sm", variant: "secondary" }), 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={{ activeProps={{
className: "text-md font-medium", className: "text-md font-medium",
@@ -126,14 +125,14 @@ const SidebarContent: React.FC = React.memo(() => {
const { me } = useAccountOrGuest() const { me } = useAccountOrGuest()
return ( 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> <div>
<LogoAndSearch /> <LogoAndSearch />
</div> </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="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" /> <div className="h-2 shrink-0" />
{me._type === "Account" && <LinkSection />} {me._type === "Account" && <LinkCollection />}
{me._type === "Account" && <TopicSection />} {/* {me._type === "Account" && <LinkSection />} */}
{me._type === "Account" && <JournalSection />} {me._type === "Account" && <JournalSection />}
{me._type === "Account" && <TaskSection />} {me._type === "Account" && <TaskSection />}
{me._type === "Account" && <PageSection />} {me._type === "Account" && <PageSection />}
@@ -156,7 +155,7 @@ const Sidebar: React.FC = () => {
) )
const sidebarInnerClasses = cn( 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", isCollapsed ? "-translate-x-full" : "translate-x-0",
) )
@@ -184,7 +183,7 @@ const Sidebar: React.FC = () => {
)} )}
> >
<div <div
className={cn(sidebarInnerClasses, "border-r-primary/5 border-r")} className={cn(sidebarInnerClasses, "border-r border-r-primary/5")}
> >
<SidebarContext.Provider value={contextValue}> <SidebarContext.Provider value={contextValue}>
<SidebarContent /> <SidebarContent />
+1 -1
View File
@@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...props}
+1 -1
View File
@@ -19,7 +19,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
className={cn( 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, className,
)} )}
{...props} {...props}
@@ -3,7 +3,7 @@ import { ensureUrlProtocol } from "@/lib/utils"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { toast } from "sonner" import { toast } from "sonner"
import { LaAccount } from "@/lib/schema" import { LaAccount } from "@/lib/schema"
import { usePageActions } from "./actions/use-page-actions" import { usePageActions } from "./use-page-actions"
import { useNavigate } from "@tanstack/react-router" import { useNavigate } from "@tanstack/react-router"
export const useCommandActions = () => { export const useCommandActions = () => {
+2 -1
View File
@@ -1,5 +1,6 @@
import * as React from "react" 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 KeyFilter = ((event: KeyboardEvent) => boolean) | string
export type Options = { allowInInput?: boolean } export type Options = { allowInInput?: boolean }
+1 -1
View File
@@ -1,5 +1,5 @@
import { isClient } from "@shared/utils"
import * as React from "react" import * as React from "react"
import { isClient } from "~/lib/utils"
export function useTouchSensor() { export function useTouchSensor() {
const [isTouchDevice, setIsTouchDevice] = React.useState(false) const [isTouchDevice, setIsTouchDevice] = React.useState(false)
+2 -2
View File
@@ -28,13 +28,13 @@ export const LEARNING_STATES: LearningState[] = [
label: "Learning", label: "Learning",
value: "learning", value: "learning",
icon: "GraduationCap", icon: "GraduationCap",
className: "text-[#D29752]", className: "text-yellow-600 hover:text-yellow-700",
}, },
{ {
label: "Learned", label: "Learned",
value: "learned", value: "learned",
icon: "Check", icon: "Check",
className: "text-[#708F51]", className: "text-green-700 hover:text-green-800",
}, },
] as const ] as const
+6
View 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
View 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)) {}
+13 -3
View File
@@ -4,6 +4,7 @@ import { PersonalLinkLists } from "./personal-link"
import { ListOfTopics } from "./master/topic" import { ListOfTopics } from "./master/topic"
import { ListOfTasks } from "./task" import { ListOfTasks } from "./task"
import { JournalEntryLists } from "./journal" import { JournalEntryLists } from "./journal"
import { FolderLists, ImageLists } from "./folder"
declare module "jazz-tools" { declare module "jazz-tools" {
interface Profile { interface Profile {
@@ -12,6 +13,7 @@ declare module "jazz-tools" {
} }
export class UserRoot extends CoMap { export class UserRoot extends CoMap {
version = co.optional.number
name = co.string name = co.string
username = co.string username = co.string
avatar = co.optional.string avatar = co.optional.string
@@ -28,6 +30,9 @@ export class UserRoot extends CoMap {
tasks = co.ref(ListOfTasks) tasks = co.ref(ListOfTasks)
journalEntries = co.ref(JournalEntryLists) journalEntries = co.ref(JournalEntryLists)
folders = co.ref(FolderLists)
images = co.ref(ImageLists)
} }
export class LaAccount extends Account { export class LaAccount extends Account {
@@ -38,9 +43,6 @@ export class LaAccount extends Account {
this: LaAccount, this: LaAccount,
creationProps?: { name: string; avatarUrl?: string }, 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) super.migrate(creationProps)
if (!this._refs.root && creationProps) { if (!this._refs.root && creationProps) {
@@ -62,6 +64,11 @@ export class LaAccount extends Account {
tasks: ListOfTasks.create([], { owner: this }), tasks: ListOfTasks.create([], { owner: this }),
journalEntries: JournalEntryLists.create([], { owner: this }), journalEntries: JournalEntryLists.create([], { owner: this }),
folders: FolderLists.create([], { owner: this }),
images: ImageLists.create([], { owner: this }),
version: 1,
}, },
{ owner: this }, { owner: this },
) )
@@ -72,3 +79,6 @@ export class LaAccount extends Account {
export * from "./master/topic" export * from "./master/topic"
export * from "./personal-link" export * from "./personal-link"
export * from "./personal-page" export * from "./personal-page"
export * from "./task"
export * from "./journal"
export * from "./folder"
+3 -4
View File
@@ -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 title = co.string
content = co.json() content = co.json()
date = co.encoded(Encoders.Date) date = co.encoded(Encoders.Date)
createdAt = co.encoded(Encoders.Date)
updatedAt = co.encoded(Encoders.Date)
} }
export class JournalEntryLists extends CoList.Of(co.ref(JournalEntry)) {} export class JournalEntryLists extends CoList.Of(co.ref(JournalEntry)) {}
+3 -7
View File
@@ -1,14 +1,10 @@
import { co, CoList, CoMap, Encoders } from "jazz-tools" import { co, CoList } from "jazz-tools"
import { Link, Topic } from "./master/topic" import { Link, Topic } from "./master/topic"
import { BaseModel } from "./base"
class BaseModel extends CoMap {
createdAt = co.encoded(Encoders.Date)
updatedAt = co.encoded(Encoders.Date)
}
export class PersonalLink extends BaseModel { export class PersonalLink extends BaseModel {
url = co.string url = co.string
icon = co.optional.string // is an icon URL icon = co.optional.string
link = co.optional.ref(Link) link = co.optional.ref(Link)
title = co.string title = co.string
slug = co.string slug = co.string
+4 -12
View File
@@ -1,21 +1,13 @@
import { co, CoList, CoMap, Encoders } from "jazz-tools" import { co, CoList } from "jazz-tools"
import { Topic } from "./master/topic" import { Topic } from "./master/topic"
import { BaseModel } from "./base"
/* export class PersonalPage extends BaseModel {
* 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 {
title = co.optional.string 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 public = co.boolean
content = co.optional.json() content = co.optional.json()
topic = co.optional.ref(Topic) 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)) {} export class PersonalPageLists extends CoList.Of(co.ref(PersonalPage)) {}
+3 -4
View File
@@ -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 title = co.string
description = co.optional.string description = co.optional.string
status = co.literal("todo", "in_progress", "done") status = co.literal("todo", "in_progress", "done")
createdAt = co.encoded(Encoders.Date)
updatedAt = co.encoded(Encoders.Date)
dueDate = co.optional.encoded(Encoders.Date) dueDate = co.optional.encoded(Encoders.Date)
} }
-5
View File
@@ -24,10 +24,6 @@ export function shuffleArray<T>(array: T[]): T[] {
return shuffled 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 const inputs = ["input", "select", "button", "textarea"] // detect if node is a text input element
export function isTextInput(element: Element): boolean { export function isTextInput(element: Element): boolean {
@@ -75,7 +71,6 @@ export function calendarFormatDate(date: Date): string {
} }
export * from "./force-graph" export * from "./force-graph"
export * from "./keyboard"
export * from "./env" export * from "./env"
export * from "./slug" export * from "./slug"
export * from "./url" export * from "./url"
-15
View File
@@ -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( export function ensureUrlProtocol(
url: string, url: string,
defaultProtocol: string = "https://", defaultProtocol: string = "https://",
+42 -33
View File
@@ -169,14 +169,14 @@ declare module '@tanstack/react-router' {
parentRoute: typeof LayoutImport parentRoute: typeof LayoutImport
} }
'/_layout/(auth)': { '/_layout/(auth)': {
id: '/_layout/' id: '/_layout/(auth)'
path: '/' path: '/'
fullPath: '/' fullPath: '/'
preLoaderRoute: typeof LayoutauthImport preLoaderRoute: typeof LayoutauthImport
parentRoute: typeof LayoutImport parentRoute: typeof LayoutImport
} }
'/_layout/(auth)/_auth': { '/_layout/(auth)/_auth': {
id: '/_layout/_auth' id: '/_layout/(auth)/_auth'
path: '/' path: '/'
fullPath: '/' fullPath: '/'
preLoaderRoute: typeof LayoutauthAuthImport preLoaderRoute: typeof LayoutauthAuthImport
@@ -190,28 +190,28 @@ declare module '@tanstack/react-router' {
parentRoute: typeof LayoutPagesImport parentRoute: typeof LayoutPagesImport
} }
'/_layout/(landing)/': { '/_layout/(landing)/': {
id: '/_layout/' id: '/_layout/(landing)/'
path: '/' path: '/'
fullPath: '/' fullPath: '/'
preLoaderRoute: typeof LayoutlandingIndexImport preLoaderRoute: typeof LayoutlandingIndexImport
parentRoute: typeof LayoutImport parentRoute: typeof LayoutImport
} }
'/_layout/_pages/(topic)/$': { '/_layout/_pages/(topic)/$': {
id: '/_layout/_pages/$' id: '/_layout/_pages/(topic)/$'
path: '/$' path: '/$'
fullPath: '/$' fullPath: '/$'
preLoaderRoute: typeof LayoutPagestopicSplatImport preLoaderRoute: typeof LayoutPagestopicSplatImport
parentRoute: typeof LayoutPagesImport parentRoute: typeof LayoutPagesImport
} }
'/_layout/(auth)/_auth/sign-in/$': { '/_layout/(auth)/_auth/sign-in/$': {
id: '/_layout/_auth/sign-in/$' id: '/_layout/(auth)/_auth/sign-in/$'
path: '/sign-in/$' path: '/sign-in/$'
fullPath: '/sign-in/$' fullPath: '/sign-in/$'
preLoaderRoute: typeof LayoutauthAuthSignInSplatImport preLoaderRoute: typeof LayoutauthAuthSignInSplatImport
parentRoute: typeof LayoutauthAuthImport parentRoute: typeof LayoutauthAuthImport
} }
'/_layout/(auth)/_auth/sign-up/$': { '/_layout/(auth)/_auth/sign-up/$': {
id: '/_layout/_auth/sign-up/$' id: '/_layout/(auth)/_auth/sign-up/$'
path: '/sign-up/$' path: '/sign-up/$'
fullPath: '/sign-up/$' fullPath: '/sign-up/$'
preLoaderRoute: typeof LayoutauthAuthSignUpSplatImport preLoaderRoute: typeof LayoutauthAuthSignUpSplatImport
@@ -432,12 +432,13 @@ export interface FileRoutesById {
__root__: typeof rootRoute __root__: typeof rootRoute
'/_layout': typeof LayoutRouteWithChildren '/_layout': typeof LayoutRouteWithChildren
'/_layout/_pages': typeof LayoutPagesRouteWithChildren '/_layout/_pages': typeof LayoutPagesRouteWithChildren
'/_layout/': typeof LayoutlandingIndexRoute '/_layout/(auth)': typeof LayoutauthRouteWithChildren
'/_layout/_auth': typeof LayoutauthAuthRouteWithChildren '/_layout/(auth)/_auth': typeof LayoutauthAuthRouteWithChildren
'/_layout/_pages/_protected': typeof LayoutPagesProtectedRouteWithChildren '/_layout/_pages/_protected': typeof LayoutPagesProtectedRouteWithChildren
'/_layout/_pages/$': typeof LayoutPagestopicSplatRoute '/_layout/(landing)/': typeof LayoutlandingIndexRoute
'/_layout/_auth/sign-in/$': typeof LayoutauthAuthSignInSplatRoute '/_layout/_pages/(topic)/$': typeof LayoutPagestopicSplatRoute
'/_layout/_auth/sign-up/$': typeof LayoutauthAuthSignUpSplatRoute '/_layout/(auth)/_auth/sign-in/$': typeof LayoutauthAuthSignInSplatRoute
'/_layout/(auth)/_auth/sign-up/$': typeof LayoutauthAuthSignUpSplatRoute
'/_layout/_pages/_protected/journals/': typeof LayoutPagesProtectedJournalsIndexRoute '/_layout/_pages/_protected/journals/': typeof LayoutPagesProtectedJournalsIndexRoute
'/_layout/_pages/_protected/links/': typeof LayoutPagesProtectedLinksIndexRoute '/_layout/_pages/_protected/links/': typeof LayoutPagesProtectedLinksIndexRoute
'/_layout/_pages/_protected/onboarding/': typeof LayoutPagesProtectedOnboardingIndexRoute '/_layout/_pages/_protected/onboarding/': typeof LayoutPagesProtectedOnboardingIndexRoute
@@ -492,12 +493,13 @@ export interface FileRouteTypes {
| '__root__' | '__root__'
| '/_layout' | '/_layout'
| '/_layout/_pages' | '/_layout/_pages'
| '/_layout/' | '/_layout/(auth)'
| '/_layout/_auth' | '/_layout/(auth)/_auth'
| '/_layout/_pages/_protected' | '/_layout/_pages/_protected'
| '/_layout/_pages/$' | '/_layout/(landing)/'
| '/_layout/_auth/sign-in/$' | '/_layout/_pages/(topic)/$'
| '/_layout/_auth/sign-up/$' | '/_layout/(auth)/_auth/sign-in/$'
| '/_layout/(auth)/_auth/sign-up/$'
| '/_layout/_pages/_protected/journals/' | '/_layout/_pages/_protected/journals/'
| '/_layout/_pages/_protected/links/' | '/_layout/_pages/_protected/links/'
| '/_layout/_pages/_protected/onboarding/' | '/_layout/_pages/_protected/onboarding/'
@@ -539,8 +541,8 @@ export const routeTree = rootRoute
"filePath": "_layout.tsx", "filePath": "_layout.tsx",
"children": [ "children": [
"/_layout/_pages", "/_layout/_pages",
"/_layout/", "/_layout/(auth)",
"/_layout/" "/_layout/(landing)/"
] ]
}, },
"/_layout/_pages": { "/_layout/_pages": {
@@ -548,19 +550,22 @@ export const routeTree = rootRoute
"parent": "/_layout", "parent": "/_layout",
"children": [ "children": [
"/_layout/_pages/_protected", "/_layout/_pages/_protected",
"/_layout/_pages/$" "/_layout/_pages/(topic)/$"
] ]
}, },
"/_layout/": { "/_layout/(auth)": {
"filePath": "_layout/(landing)/index.tsx", "filePath": "_layout/(auth)",
"parent": "/_layout" "parent": "/_layout",
},
"/_layout/_auth": {
"filePath": "_layout/(auth)/_auth.tsx",
"parent": "/_layout/",
"children": [ "children": [
"/_layout/_auth/sign-in/$", "/_layout/(auth)/_auth"
"/_layout/_auth/sign-up/$" ]
},
"/_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": { "/_layout/_pages/_protected": {
@@ -580,17 +585,21 @@ export const routeTree = rootRoute
"/_layout/_pages/_protected/pages/$pageId/" "/_layout/_pages/_protected/pages/$pageId/"
] ]
}, },
"/_layout/_pages/$": { "/_layout/(landing)/": {
"filePath": "_layout/(landing)/index.tsx",
"parent": "/_layout"
},
"/_layout/_pages/(topic)/$": {
"filePath": "_layout/_pages/(topic)/$.tsx", "filePath": "_layout/_pages/(topic)/$.tsx",
"parent": "/_layout/_pages" "parent": "/_layout/_pages"
}, },
"/_layout/_auth/sign-in/$": { "/_layout/(auth)/_auth/sign-in/$": {
"filePath": "_layout/(auth)/_auth.sign-in.$.tsx", "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", "filePath": "_layout/(auth)/_auth.sign-up.$.tsx",
"parent": "/_layout/_auth" "parent": "/_layout/(auth)/_auth"
}, },
"/_layout/_pages/_protected/journals/": { "/_layout/_pages/_protected/journals/": {
"filePath": "_layout/_pages/_protected/journals/index.tsx", "filePath": "_layout/_pages/_protected/journals/index.tsx",
+1 -1
View File
@@ -124,7 +124,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<React.Suspense> <React.Suspense>
<TanStackRouterDevtools position="bottom-right" /> <TanStackRouterDevtools position="bottom-right" />
<ReactQueryDevtools buttonPosition="bottom-left" /> <ReactQueryDevtools buttonPosition="bottom-right" />
</React.Suspense> </React.Suspense>
<ScrollRestoration /> <ScrollRestoration />
+1 -1
View File
@@ -8,7 +8,7 @@ export const Route = createFileRoute("/_layout")({
function LayoutComponent() { function LayoutComponent() {
return ( return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
<ClerkProvider> <ClerkProvider>
<Outlet /> <Outlet />
</ClerkProvider> </ClerkProvider>
+6 -1
View File
@@ -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")({ export const Route = createFileRoute("/_layout/(auth)/_auth")({
beforeLoad({ context }) {
if (context.auth) {
throw redirect({ to: "/links", replace: true })
}
},
component: () => ( component: () => (
<main className="h-full"> <main className="h-full">
<Outlet /> <Outlet />
@@ -104,10 +104,13 @@ export function Autocomplete({
return ( return (
<Command <Command
className={cn("relative mx-auto max-w-md overflow-visible shadow-md", { className={cn(
"rounded-lg border": !open, "relative mx-auto max-w-md overflow-visible bg-background shadow-md",
"rounded-none rounded-t-lg border-l border-r border-t": open, {
})} "rounded-lg border": !open,
"rounded-none rounded-t-lg border-l border-r border-t": open,
},
)}
> >
<div className="relative flex items-center"> <div className="relative flex items-center">
<CommandPrimitive.Input <CommandPrimitive.Input
@@ -125,7 +128,7 @@ export function Autocomplete({
}} }}
placeholder={filteredTopics[0]?.prettyName} placeholder={filteredTopics[0]?.prettyName}
className={cn( 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 autoFocus
/> />
@@ -138,7 +141,7 @@ export function Autocomplete({
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }} exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.1 }} 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"> <CommandList className="max-h-56">
<CommandGroup className="my-2"> <CommandGroup className="my-2">
@@ -150,7 +153,7 @@ export function Autocomplete({
className="min-h-10 rounded-none px-3 py-1.5" className="min-h-10 rounded-none px-3 py-1.5"
> >
<span>{topic.prettyName}</span> <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.length > 0 &&
topic.connectedTopics.join(", ")} topic.connectedTopics.join(", ")}
</span> </span>
+2 -2
View File
@@ -25,7 +25,7 @@ function LandingComponent() {
} }
return ( return (
<div className="relative h-full w-screen"> <div className="relative h-full w-screen bg-background">
<ForceGraphClient <ForceGraphClient
raw_nodes={GraphData} raw_nodes={GraphData}
onNodeClick={handleTopicSelect} onNodeClick={handleTopicSelect}
@@ -40,7 +40,7 @@ function LandingComponent() {
> >
<motion.h1 <motion.h1
className={cn( 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 }} initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
+2 -2
View File
@@ -33,7 +33,7 @@ function LayoutContent() {
return ( return (
<> <>
<Toaster expand={false} /> <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 /> <Sidebar />
<Shortcut /> <Shortcut />
<GlobalKeyboardHandler /> <GlobalKeyboardHandler />
@@ -49,7 +49,7 @@ function LayoutContent() {
function MainContent() { function MainContent() {
return ( return (
<div className="relative flex min-w-0 flex-1 flex-col"> <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 /> <Outlet />
</main> </main>
</div> </div>
+48 -18
View File
@@ -16,7 +16,6 @@ export const Route = createFileRoute("/_layout/_pages/(topic)/$")({
export const openPopoverForIdAtom = atom<string | null>(null) export const openPopoverForIdAtom = atom<string | null>(null)
export function TopicDetailComponent() { export function TopicDetailComponent() {
console.log("TopicDetailComponent")
const params = useParams({ from: "/_layout/_pages/$" }) const params = useParams({ from: "/_layout/_pages/$" })
const { me } = useAccountOrGuest({ root: { personalLinks: [] } }) const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
@@ -30,34 +29,65 @@ export function TopicDetailComponent() {
latestGlobalGuide: { sections: [] }, latestGlobalGuide: { sections: [] },
}) })
const [activeIndex, setActiveIndex] = React.useState(-1) const [activeIndex, setActiveIndex] = React.useState(-1)
const [searchQuery, setSearchQuery] = React.useState("")
const topicExists = GraphData.find((node) => { const topicExists = React.useMemo(
return node.name === params._splat () => 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) { if (!topicExists) {
return <NotFoundPlaceholder /> return <NotFoundPlaceholder />
} }
const flattenedItems = topic?.latestGlobalGuide?.sections.flatMap( if (!topic || !me) {
(section) => [
{ type: "section" as const, data: section },
...(section?.links?.map((link) => ({
type: "link" as const,
data: link,
})) || []),
],
)
if (!topic || !me || !flattenedItems) {
return <TopicDetailSkeleton /> return <TopicDetailSkeleton />
} }
return ( return (
<> <>
<TopicDetailHeader topic={topic} /> <TopicDetailHeader
topic={topic}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
<TopicDetailList <TopicDetailList
items={flattenedItems} items={filteredItems}
topic={topic} topic={topic}
activeIndex={activeIndex} activeIndex={activeIndex}
setActiveIndex={setActiveIndex} setActiveIndex={setActiveIndex}
@@ -88,7 +118,7 @@ function TopicDetailSkeleton() {
<Skeleton className="h-8 w-8 rounded-full" /> <Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-6 w-48" /> <Skeleton className="h-6 w-48" />
</div> </div>
<Skeleton className="h-9 w-36" /> <Skeleton className="h-7 w-28" />
</div> </div>
<div className="space-y-4 p-6 max-lg:px-4"> <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 { useMedia } from "@/hooks/use-media"
import { useClerk } from "@clerk/tanstack-start" import { useClerk } from "@clerk/tanstack-start"
import { useLocation } from "@tanstack/react-router" import { useLocation } from "@tanstack/react-router"
import { Input } from "~/components/ui/input"
import { LaIcon } from "~/components/custom/la-icon"
interface TopicDetailHeaderProps { interface TopicDetailHeaderProps {
topic: Topic topic: Topic
searchQuery: string
setSearchQuery: (query: string) => void
} }
export const TopicDetailHeader = React.memo(function TopicDetailHeader({ export const TopicDetailHeader = React.memo(function TopicDetailHeader({
topic, topic,
searchQuery,
setSearchQuery,
}: TopicDetailHeaderProps) { }: TopicDetailHeaderProps) {
const clerk = useClerk() const clerk = useClerk()
const { pathname } = useLocation() const { pathname } = useLocation()
@@ -111,28 +117,51 @@ export const TopicDetailHeader = React.memo(function TopicDetailHeader({
topicLists[learningState]?.push(topic) topicLists[learningState]?.push(topic)
} }
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(event.target.value)
}
return ( return (
<ContentHeader className="px-6 py-5 max-lg:px-4"> <>
<div className="flex min-w-0 flex-1 items-center gap-1.5"> <ContentHeader>
<SidebarToggleButton /> <div className="flex min-w-0 flex-1 items-center gap-1.5">
<div className="flex min-h-0 min-w-0 flex-1 items-center"> <SidebarToggleButton />
<h1 className="truncate text-left font-bold lg:text-xl"> <div className="flex min-h-0 min-w-0 flex-1 items-center">
{topic.prettyName} <h1 className="truncate text-left font-semibold lg:text-lg">
</h1> {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> </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" size="sm"
type="button" type="button"
role="combobox" role="combobox"
variant="secondary" variant="ghost"
className={cn( className="h-auto shrink-0 cursor-default p-0 text-muted-foreground/75 hover:bg-inherit hover:text-foreground"
"size-7 shrink-0 p-0",
"hover:bg-accent-foreground/10",
)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{selectedLearningState?.icon ? ( {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"> <div className="gap-x-2 space-y-0.5 xl:flex xl:flex-row">
<p <p
className={cn( 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", isActive && "font-bold",
)} )}
> >
@@ -226,14 +223,14 @@ export const LinkItem = React.memo(
<LaIcon <LaIcon
name="Link" name="Link"
aria-hidden="true" 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 <Link
to={ensureUrlProtocol(link.url)} to={ensureUrlProtocol(link.url)}
target="_blank" target="_blank"
onClick={(e) => e.stopPropagation()} 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> <span className="line-clamp-1">{link.url}</span>
</Link> </Link>
@@ -51,10 +51,10 @@ export function TopicDetailList({
className="flex flex-col" className="flex flex-col"
> >
<div className="flex items-center gap-4 px-6 py-2 max-lg:px-4"> <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} {item.data?.title}
</p> </p>
<div className="flex-1 border-b" /> <div className="flex-1 border-b border-[var(--la-border-new)]" />
</div> </div>
</div> </div>
) )
@@ -82,7 +82,7 @@ export function TopicDetailList({
) )
return ( return (
<div ref={parentRef} className="flex-1 overflow-auto"> <div ref={parentRef} className="flex-1 overflow-auto py-4">
<div <div
style={{ style={{
height: `${virtualizer.getTotalSize()}px`, height: `${virtualizer.getTotalSize()}px`,
+1 -1
View File
@@ -1,7 +1,7 @@
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router" import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
export const Route = createFileRoute("/_layout/_pages/_protected")({ export const Route = createFileRoute("/_layout/_pages/_protected")({
beforeLoad: async ({ context, location, cause }) => { beforeLoad: async ({ context, location }) => {
if (!context?.auth?.userId) { if (!context?.auth?.userId) {
throw redirect({ throw redirect({
to: "/sign-in/$", 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 justify-between pb-1">
<div className="flex items-center"> <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> <span className="text-sm">{answer.author}</span>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
@@ -147,16 +147,16 @@ export function QuestionThread({ question, onClose }: QuestionThreadProps) {
) )
return ( 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="fixed bottom-0 right-0 top-0 z-50 flex h-full w-[40%] flex-col border-l border-accent bg-background">
<div className="border-accent flex w-full justify-between border-b p-4"> <div className="flex w-full justify-between border-b border-accent p-4">
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
<div className="mb-2 flex w-full items-center justify-between"> <div className="mb-2 flex w-full items-center justify-between">
<div className="flex items-center gap-3"> <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> <h2 className="opacity-70">{question.author}</h2>
</div> </div>
<button <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} onClick={onClose}
> >
<LaIcon name="X" className="text-primary" /> <LaIcon name="X" className="text-primary" />
@@ -167,7 +167,7 @@ export function QuestionThread({ question, onClose }: QuestionThreadProps) {
</div> </div>
</div> </div>
<div className="flex-grow overflow-y-auto">{renderAnswers(answers)}</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}> <form className="relative" onSubmit={sendAnswer}>
<div className="relative flex items-center"> <div className="relative flex items-center">
<input <input
@@ -176,7 +176,7 @@ export function QuestionThread({ question, onClose }: QuestionThreadProps) {
value={newAnswer} value={newAnswer}
onChange={changeInput} onChange={changeInput}
placeholder="Answer the question..." 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> </div>
<button className="absolute right-2 top-1/2 -translate-y-1/2 transform opacity-50 hover:opacity-90"> <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 ( 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 <div
className="absolute h-8 w-[calc(50%-4px)] rounded-md transition-all duration-300 ease-in-out" 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)" }} style={{ left: view === "guide" ? "2px" : "calc(50% + 2px)" }}
@@ -36,7 +36,7 @@ export const GuideCommunityToggle: React.FC<GuideCommunityToggleProps> = ({
<button <button
className={cn( className={cn(
"relative z-10 h-full flex-1 rounded-md text-sm font-medium transition-colors", "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")} onClick={() => handleToggle("guide")}
> >
@@ -45,7 +45,7 @@ export const GuideCommunityToggle: React.FC<GuideCommunityToggleProps> = ({
<button <button
className={cn( className={cn(
"relative z-10 h-full flex-1 rounded-md text-sm font-medium transition-colors", "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")} onClick={() => handleToggle("community")}
> >
@@ -45,7 +45,7 @@ function CommunityTopicComponent() {
return ( return (
<div className="flex h-full flex-auto flex-col"> <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"> <div className="flex min-w-0 shrink-0 items-center gap-1.5">
<SidebarToggleButton /> <SidebarToggleButton />
<div className="flex min-h-0 flex-col items-start"> <div className="flex min-h-0 flex-col items-start">
@@ -7,7 +7,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import { cn, getShortcutKeys } from "@/lib/utils" import { cn } from "@/lib/utils"
import { LaIcon } from "@/components/custom/la-icon" import { LaIcon } from "@/components/custom/la-icon"
import { useAtom } from "jotai" import { useAtom } from "jotai"
import { useConfirm } from "@omit/react-confirm-dialog" import { useConfirm } from "@omit/react-confirm-dialog"
@@ -17,6 +17,7 @@ import { ID } from "jazz-tools"
import { globalLinkFormExceptionRefsAtom } from "./-link-form" import { globalLinkFormExceptionRefsAtom } from "./-link-form"
import { useLinkActions } from "~/hooks/actions/use-link-actions" import { useLinkActions } from "~/hooks/actions/use-link-actions"
import { useNavigate, useSearch } from "@tanstack/react-router" import { useNavigate, useSearch } from "@tanstack/react-router"
import { getShortcutKeys } from "@shared/utils"
interface ToolbarButtonProps interface ToolbarButtonProps
extends React.ComponentPropsWithoutRef<typeof Button> { extends React.ComponentPropsWithoutRef<typeof Button> {
@@ -146,7 +147,7 @@ export const LinkBottomBar: React.FC = () => {
const shortcutText = getShortcutKeys(["c"]) const shortcutText = getShortcutKeys(["c"])
return ( return (
<div className="bg-background min-h-11 border-t"> <div className="min-h-11 border-t">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{editId && ( {editId && (
<motion.div <motion.div
@@ -27,7 +27,7 @@ export const DescriptionInput: React.FC<DescriptionInputProps> = () => {
{...field} {...field}
autoComplete="off" autoComplete="off"
placeholder="Description" 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> </FormControl>
</FormItem> </FormItem>
@@ -1,77 +1,37 @@
import * as React from "react" import * as React from "react"
import { Button } from "@/components/ui/button" import { useMedia } from "@/hooks/use-media"
import { import {
ContentHeader, ContentHeader,
SidebarToggleButton, SidebarToggleButton,
} from "@/components/custom/content-header" } from "@/components/custom/content-header"
import { useMedia } from "@/hooks/use-media" import { useNavigate, useSearch } from "@tanstack/react-router"
import { import { FancySwitch, OptionValue } from "@omit/react-fancy-switch"
Popover, import { cn } from "@/lib/utils"
PopoverContent, import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
PopoverTrigger,
} from "@/components/ui/popover"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useAtom } from "jotai" import { useAtom } from "jotai"
import { linkSortAtom } from "@/store/link" import { linkSortAtom } from "@/store/link"
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants" import { Button } from "@/components/ui/button"
import { FancySwitch } from "@omit/react-fancy-switch"
import { cn } from "@/lib/utils"
import { LaIcon } from "@/components/custom/la-icon" 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 = [ const ALL_STATES = [
{ label: "All", value: "all", icon: "List", className: "text-foreground" }, { label: "All", value: "all", icon: "List", className: "text-foreground" },
...LEARNING_STATES, ...LEARNING_STATES,
] ]
export const LinkHeader = React.memo(() => { const LearningTab: React.FC = 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 navigate = useNavigate() const navigate = useNavigate()
const { state } = useSearch({ const { state } = useSearch({ from: "/_layout/_pages/_protected/links/" })
from: "/_layout/_pages/_protected/links/",
})
const handleTabChange = React.useCallback( const handleTabChange = React.useCallback(
async (value: string) => { (value: OptionValue) => {
if (value !== state) { if (value !== state) {
navigate({ navigate({
to: "/links", to: "/links",
@@ -85,30 +45,22 @@ const LearningTab = React.memo(() => {
return ( return (
<FancySwitch <FancySwitch
value={state} value={state}
onChange={(value) => { onChange={handleTabChange}
handleTabChange(value as string)
}}
options={ALL_STATES} options={ALL_STATES}
className="bg-muted flex rounded-lg" className="flex rounded-md"
highlighterClassName="bg-muted-foreground/10 rounded-md" highlighterClassName="bg-muted-foreground/10 rounded-md"
radioClassName={cn( 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} highlighterIncludeMargin={true}
/> />
) )
}) })
LearningTab.displayName = "LearningTab" const FilterAndSort: React.FC = React.memo(() => {
const FilterAndSort = React.memo(() => {
const [sort, setSort] = useAtom(linkSortAtom) const [sort, setSort] = useAtom(linkSortAtom)
const [sortOpen, setSortOpen] = React.useState(false) const [sortOpen, setSortOpen] = React.useState(false)
const getFilterText = React.useCallback(() => {
return sort.charAt(0).toUpperCase() + sort.slice(1)
}, [sort])
const handleSortChange = React.useCallback( const handleSortChange = React.useCallback(
(value: string) => { (value: string) => {
setSort(value) setSort(value)
@@ -120,40 +72,80 @@ const FilterAndSort = React.memo(() => {
return ( return (
<div className="flex w-auto items-center justify-end"> <div className="flex w-auto items-center justify-end">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Popover open={sortOpen} onOpenChange={setSortOpen}> <DropdownMenu open={sortOpen} onOpenChange={setSortOpen}>
<PopoverTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
size="sm" size="sm"
type="button" variant="ghost"
variant="secondary" className="h-7 gap-x-2 text-sm max-sm:p-0"
className="min-w-8 gap-x-2 text-sm max-sm:p-0"
> >
<LaIcon name="ListFilter" className="text-primary/60" /> <LaIcon name="ChevronDown" />
<span className="hidden md:block">Filter: {getFilterText()}</span> <span className="hidden md:block">Display</span>
</Button> </Button>
</PopoverTrigger> </DropdownMenuTrigger>
<PopoverContent className="w-72" align="end"> <DropdownMenuContent className="w-56" align="end" side="top">
<div className="flex flex-col"> <DropdownMenuLabel className="text-xs font-medium text-muted-foreground">
<div className="flex min-w-8 flex-row items-center"> Display
<Label>Sort by</Label> </DropdownMenuLabel>
<div className="flex flex-auto flex-row items-center justify-end"> <DropdownMenuItem>
<Select value={sort} onValueChange={handleSortChange}> <LaIcon name="List" className="mr-2 h-4 w-4" />
<SelectTrigger className="h-6 w-auto"> <span>List</span>
<SelectValue placeholder="Select"></SelectValue> <LaIcon name="Check" className="ml-auto h-4 w-4" />
</SelectTrigger> </DropdownMenuItem>
<SelectContent> <DropdownMenuSeparator />
<SelectItem value="title">Title</SelectItem> <DropdownMenuLabel className="text-xs font-medium text-muted-foreground">
<SelectItem value="manual">Manual</SelectItem> Ordering
</SelectContent> </DropdownMenuLabel>
</Select> <DropdownMenuItem onClick={() => handleSortChange("title")}>
</div> <span>Title</span>
</div> {sort === "title" && (
</div> <LaIcon name="Check" className="ml-auto h-4 w-4" />
</PopoverContent> )}
</Popover> </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>
</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" FilterAndSort.displayName = "FilterAndSort"
@@ -115,15 +115,15 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
data-disabled={disabled} data-disabled={disabled}
data-active={isActive} data-active={isActive}
className={cn( className={cn(
"w-full overflow-visible border-b-[0.5px] border-transparent outline-none", "w-full cursor-default 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]", "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} onKeyDown={handleKeyDown}
> >
<div <div
className={cn( className={cn(
"w-full grow overflow-visible outline-none", "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 <Popover
@@ -137,8 +137,8 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
size="sm" size="sm"
type="button" type="button"
role="combobox" role="combobox"
variant="secondary" variant="ghost"
className="size-7 shrink-0 p-0" className="size-7 shrink-0 cursor-default p-0 text-muted-foreground/75 hover:bg-inherit hover:text-foreground"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()}
> >
@@ -148,7 +148,7 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
className={cn(selectedLearningState.className)} className={cn(selectedLearningState.className)}
/> />
) : ( ) : (
<LaIcon name="Circle" /> <LaIcon name="Circle" strokeWidth={2.5} />
)} )}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@@ -167,22 +167,22 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
</Popover> </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 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 && ( {personalLink.icon && (
<img <img
src={personalLink.icon as string} src={personalLink.icon as string}
alt={personalLink.title} alt={personalLink.title}
className="size-5 shrink-0 rounded-full" className="size-4 shrink-0 rounded-full"
width={16} width={16}
height={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} {personalLink.title}
</p> </p>
</div> </div>
{personalLink.url && ( {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 <LaIcon
name="Link" name="Link"
aria-hidden="true" aria-hidden="true"
@@ -192,7 +192,7 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
to={ensureUrlProtocol(personalLink.url)} to={ensureUrlProtocol(personalLink.url)}
target="_blank" target="_blank"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="hover:text-primary mr-1 truncate text-xs" className="mr-1 truncate text-xs hover:text-primary"
> >
{personalLink.url} {personalLink.url}
</Link> </Link>
@@ -204,14 +204,17 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
<div className="flex shrink-0 items-center justify-end"> <div className="flex shrink-0 items-center justify-end">
{personalLink.topic && ( {personalLink.topic && (
<Badge variant="secondary" className="border-muted-foreground/25"> <Badge
variant="secondary"
className="border-muted-foreground/25 font-medium"
>
{personalLink.topic.prettyName} {personalLink.topic.prettyName}
</Badge> </Badge>
)} )}
</div> </div>
</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> </div>
) )
}, },
@@ -19,7 +19,8 @@ import { DescriptionInput } from "./-description-input"
import { UrlBadge } from "./-url-badge" import { UrlBadge } from "./-url-badge"
import { NotesSection } from "./-notes-section" import { NotesSection } from "./-notes-section"
import { useOnClickOutside } from "~/hooks/use-on-click-outside" import { useOnClickOutside } from "~/hooks/use-on-click-outside"
import TopicSelector, { import {
TopicSelector,
topicSelectorAtom, topicSelectorAtom,
} from "~/components/custom/topic-selector" } from "~/components/custom/topic-selector"
import { createServerFn } from "@tanstack/start" import { createServerFn } from "@tanstack/start"
@@ -291,7 +292,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
> >
<div <div
className={cn( className={cn(
"bg-muted/30 relative rounded-md border", "relative rounded-md border bg-muted/30",
isFetching && "opacity-50", isFetching && "opacity-50",
)} )}
> >
@@ -369,7 +370,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
{isFetching ? ( {isFetching ? (
<div className="flex w-auto items-center justify-end gap-x-2"> <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 <svg
className="mr-2 h-4 w-4 animate-spin" className="mr-2 h-4 w-4 animate-spin"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -27,12 +27,12 @@ import { linkSortAtom } from "@/store/link"
import { LinkItem } from "./-item" import { LinkItem } from "./-item"
import { useConfirm } from "@omit/react-confirm-dialog" import { useConfirm } from "@omit/react-confirm-dialog"
import { useKeyDown } from "@/hooks/use-key-down" import { useKeyDown } from "@/hooks/use-key-down"
import { isModKey } from "@/lib/utils"
import { useTouchSensor } from "~/hooks/use-touch-sensor" import { useTouchSensor } from "~/hooks/use-touch-sensor"
import { useActiveItemScroll } from "~/hooks/use-active-item-scroll" import { useActiveItemScroll } from "~/hooks/use-active-item-scroll"
import { isDeleteConfirmShownAtom } from "." import { isDeleteConfirmShownAtom } from "."
import { useLinkActions } from "~/hooks/actions/use-link-actions" import { useLinkActions } from "~/hooks/actions/use-link-actions"
import { useNavigate, useSearch } from "@tanstack/react-router" import { useNavigate, useSearch } from "@tanstack/react-router"
import { isModKey } from "@shared/utils"
interface LinkListProps {} interface LinkListProps {}
@@ -35,7 +35,7 @@ export const NotesSection: React.FC = () => {
autoComplete="off" autoComplete="off"
placeholder="Notes" placeholder="Notes"
className={cn( 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} maxLength={100}
autoFocus autoFocus
placeholder="Title" 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> </FormControl>
</FormItem> </FormItem>
@@ -27,9 +27,9 @@ export const UrlBadge: React.FC<UrlBadgeProps> = ({
size="icon" size="icon"
type="button" type="button"
onClick={handleResetUrl} 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> </Button>
</Badge> </Badge>
</div> </div>
@@ -65,14 +65,14 @@ export const UrlInput: React.FC<UrlInputProps> = ({
maxLength={100} maxLength={100}
autoFocus autoFocus
placeholder="Paste a link or write a link" 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} onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)} onBlur={() => setIsFocused(false)}
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent align="center" side="top"> <TooltipContent align="center" side="top">
<TooltipArrow className="text-primary fill-current" /> <TooltipArrow className="fill-current text-primary" />
<span> <span>
Press <kbd className="px-1.5">Enter</kbd> to fetch metadata Press <kbd className="px-1.5">Enter</kbd> to fetch metadata
</span> </span>
@@ -60,7 +60,7 @@ const StepItem = ({
done: boolean done: boolean
}) => ( }) => (
<div className="flex items-start space-x-4 py-4"> <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} {number}
</div> </div>
<div className="flex-grow space-y-2"> <div className="flex-grow space-y-2">
@@ -23,7 +23,7 @@ export const DetailPageHeader: React.FC<DetailPageHeaderProps> = ({
return ( return (
<> <>
<ContentHeader className="lg:min-h-0"> <ContentHeader>
<div className="flex min-w-0 gap-2"> <div className="flex min-w-0 gap-2">
<SidebarToggleButton /> <SidebarToggleButton />
</div> </div>
@@ -45,7 +45,7 @@ export const DetailPageHeader: React.FC<DetailPageHeaderProps> = ({
)} )}
/> />
<Button size="sm" variant="outline" onClick={handleDelete}> <Button size="sm" variant="outline" onClick={handleDelete}>
<LaIcon name="Trash" className="mr-2 size-3.5" /> <LaIcon name="Trash" className="mr-2" />
Delete Delete
</Button> </Button>
</div> </div>
@@ -15,14 +15,14 @@ import { Button } from "@/components/ui/button"
import { LaIcon } from "@/components/custom/la-icon" import { LaIcon } from "@/components/custom/la-icon"
import { useConfirm } from "@omit/react-confirm-dialog" import { useConfirm } from "@omit/react-confirm-dialog"
import { usePageActions } from "~/hooks/actions/use-page-actions" import { usePageActions } from "~/hooks/actions/use-page-actions"
import { Paragraph } from "@shared/la-editor/extensions/paragraph" import { Paragraph } from "@shared/editor/extensions/paragraph"
import { StarterKit } from "@shared/la-editor/extensions/starter-kit" import { StarterKit } from "@shared/editor/extensions/starter-kit"
import { LAEditor, LAEditorRef } from "@shared/la-editor" import { LaEditor } from "@shared/editor"
export const Route = createFileRoute( export const Route = createFileRoute(
"/_layout/_pages/_protected/pages/$pageId/", "/_layout/_pages/_protected/pages/$pageId/",
)({ )({
component: () => <PageDetailComponent />, component: PageDetailComponent,
}) })
const TITLE_PLACEHOLDER = "Untitled" const TITLE_PLACEHOLDER = "Untitled"
@@ -73,20 +73,22 @@ function PageDetailComponent() {
) )
} }
const SidebarActions = ({ const SidebarActions = React.memo(
page, ({
handleDelete, page,
}: { handleDelete,
page: PersonalPage }: {
handleDelete: () => void page: PersonalPage
}) => ( handleDelete: () => void
<div className="relative min-w-56 max-w-72 border-l"> }) => (
<div className="flex"> <div className="relative min-w-56 max-w-72 border-l bg-[var(--body-background)]">
<div className="flex h-10 flex-auto flex-row items-center justify-between px-5"> <div className="flex">
<span className="text-left text-[13px] font-medium">Page actions</span> <div className="flex h-10 flex-auto flex-row items-center justify-between px-5">
</div> <span className="text-left text-[13px] font-medium text-muted-foreground">
<div className="absolute bottom-0 left-0 right-0 top-10 space-y-3 overflow-y-auto px-4 py-1.5"> Page actions
<div className="flex flex-row"> </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 <TopicSelector
value={page.topic?.name} value={page.topic?.name}
onTopicChange={(topic) => { onTopicChange={(topic) => {
@@ -101,52 +103,40 @@ const SidebarActions = ({
</span> </span>
)} )}
/> />
</div>
<div className="flex flex-row">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={handleDelete} onClick={handleDelete}
className="-ml-1.5" 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> <span className="text-sm">Delete</span>
</Button> </Button>
</div> </div>
</div> </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 titleEditorRef = React.useRef<Editor | null>(null)
const contentEditorRef = React.useRef<LAEditorRef>(null) const contentEditorRef = React.useRef<Editor | null>(null)
const isTitleInitialMount = React.useRef(true)
const isContentInitialMount = React.useRef(true)
const isInitialFocusApplied = React.useRef(false)
const updatePageContent = React.useCallback( const updatePageContent = React.useCallback(
(content: Content, model: PersonalPage) => { (content: Content) => {
if (isContentInitialMount.current) { page.content = content
isContentInitialMount.current = false page.updatedAt = new Date()
return
}
model.content = content
model.updatedAt = new Date()
}, },
[], [page],
) )
const handleUpdateTitle = React.useCallback( const handleUpdateTitle = React.useCallback(
(editor: Editor) => { (editor: Editor) => {
if (isTitleInitialMount.current) {
isTitleInitialMount.current = false
return
}
const newTitle = editor.getText() const newTitle = editor.getText()
if (newTitle !== page.title) { if (newTitle !== page.title) {
const slug = generateUniqueSlug(page.title?.toString() || "") const slug = generateUniqueSlug(newTitle || "")
page.title = newTitle page.title = newTitle
page.slug = slug page.slug = slug
page.updatedAt = new Date() page.updatedAt = new Date()
@@ -164,22 +154,18 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
const { selection } = state const { selection } = state
const { $anchor } = selection const { $anchor } = selection
switch (event.key) { if (
case "ArrowRight": (event.key === "ArrowRight" || event.key === "ArrowDown") &&
case "ArrowDown": $anchor.pos === state.doc.content.size - 1
if ($anchor.pos === state.doc.content.size - 1) { ) {
event.preventDefault() event.preventDefault()
contentEditorRef.current?.editor?.commands.focus("start") contentEditorRef.current?.commands.focus("start")
return true return true
} }
break if (event.key === "Enter" && !event.shiftKey) {
case "Enter": event.preventDefault()
if (!event.shiftKey) { contentEditorRef.current?.commands.focus("start")
event.preventDefault() return true
contentEditorRef.current?.editor?.commands.focus("start")
return true
}
break
} }
return false return false
}, },
@@ -188,7 +174,7 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
const handleContentKeyDown = React.useCallback( const handleContentKeyDown = React.useCallback(
(view: EditorView, event: KeyboardEvent) => { (view: EditorView, event: KeyboardEvent) => {
const editor = contentEditorRef.current?.editor const editor = contentEditorRef.current
if (!editor) return false if (!editor) return false
const { state } = editor const { state } = editor
@@ -239,34 +225,21 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
}, },
onCreate: ({ editor }) => { onCreate: ({ editor }) => {
if (page.title) editor.commands.setContent(`<p>${page.title}</p>`) if (page.title) editor.commands.setContent(`<p>${page.title}</p>`)
titleEditorRef.current = editor
}, },
onBlur: ({ editor }) => handleUpdateTitle(editor), onBlur: ({ editor }) => handleUpdateTitle(editor),
onUpdate: ({ editor }) => handleUpdateTitle(editor), onUpdate: ({ editor }) => handleUpdateTitle(editor),
}) })
React.useEffect(() => { const handleCreate = React.useCallback(
if (titleEditor) { ({ editor }: { editor: Editor }) => {
titleEditorRef.current = titleEditor if (page.content) {
} editor.commands.setContent(page.content as Content)
}, [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()
} }
} contentEditorRef.current = editor
}, [page.title, titleEditor]) },
[page.content],
)
return ( return (
<div className="relative flex grow flex-col overflow-y-auto [scrollbar-gutter:stable]"> <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"> <div className="mb-2 mt-8 py-1.5">
<EditorContent <EditorContent
editor={titleEditor} editor={titleEditor}
className="la-editor no-command grow cursor-text select-text text-2xl font-semibold leading-[calc(1.33333)] tracking-[-0.00625rem]" className="title-editor no-command grow cursor-text select-text text-2xl font-semibold leading-[calc(1.33333)] tracking-[-0.00625rem]"
/> />
</div> </div>
<div className="flex flex-auto flex-col"> <div className="flex flex-auto flex-col">
<div className="relative flex h-full max-w-full grow flex-col items-stretch p-0"> <div className="relative flex h-full max-w-full grow flex-col items-stretch p-0">
<LAEditor <LaEditor
ref={contentEditorRef}
editorClassName="-mx-3.5 px-3.5 py-2.5 flex-auto focus:outline-none" 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..." placeholder="Add content..."
output="json" output="json"
throttleDelay={3000} throttleDelay={3000}
onUpdate={(c) => updatePageContent(c, page)} editorProps={{ handleKeyDown: handleContentKeyDown }}
handleKeyDown={handleContentKeyDown} onCreate={handleCreate}
onBlur={(c) => updatePageContent(c, page)} onUpdate={updatePageContent}
onBlur={updatePageContent}
/> />
</div> </div>
</div> </div>
@@ -297,4 +270,6 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
</div> </div>
</div> </div>
) )
} })
DetailPageForm.displayName = "DetailPageForm"
@@ -24,7 +24,7 @@ export const PageHeader: React.FC<PageHeaderProps> = React.memo(() => {
} }
return ( return (
<ContentHeader className="px-6 py-4 max-lg:px-4"> <ContentHeader>
<HeaderTitle /> <HeaderTitle />
<div className="flex flex-auto" /> <div className="flex flex-auto" />
<NewPageButton onClick={handleNewPageClick} /> <NewPageButton onClick={handleNewPageClick} />
@@ -36,8 +36,8 @@ export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(
tabIndex={isActive ? 0 : -1} tabIndex={isActive ? 0 : -1}
className={cn( className={cn(
"relative block cursor-default outline-none", "relative block cursor-default outline-none",
"min-h-12 py-2 max-lg:px-4 sm:px-6", "min-h-12 py-2 sm:px-6 max-lg:px-4",
"data-[active='true']:bg-[var(--link-background-muted)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]", "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}`} to={`/pages/${page.id}`}
aria-selected={isActive} aria-selected={isActive}
@@ -47,7 +47,7 @@ export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(
> >
<div className="flex h-full items-center gap-4"> <div className="flex h-full items-center gap-4">
<Column.Wrapper style={columnStyles.title}> <Column.Wrapper style={columnStyles.title}>
<Column.Text className="truncate text-[13px] font-medium"> <Column.Text className="truncate text-sm font-medium">
{page.title || "Untitled"} {page.title || "Untitled"}
</Column.Text> </Column.Text>
</Column.Wrapper> </Column.Wrapper>
@@ -64,7 +64,7 @@ export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(
style={columnStyles.updated} style={columnStyles.updated}
className="flex justify-end" className="flex justify-end"
> >
<Column.Text className="text-[13px]"> <Column.Text className="text-sm">
{format(new Date(page.updatedAt), "d MMM yyyy")} {format(new Date(page.updatedAt), "d MMM yyyy")}
</Column.Text> </Column.Text>
</Column.Wrapper> </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"> <div className="flex h-full w-full flex-col overflow-hidden border-t">
{!isTablet && <ColumnHeader />} {!isTablet && <ColumnHeader />}
<Primitive.div <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} tabIndex={-1}
role="list" role="list"
> >
@@ -110,7 +110,7 @@ export const ColumnHeader: React.FC = () => {
const columnStyles = useColumnStyles() const columnStyles = useColumnStyles()
return ( 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.Wrapper style={columnStyles.title}>
<Column.Text>Title</Column.Text> <Column.Text>Title</Column.Text>
</Column.Wrapper> </Column.Wrapper>
@@ -137,7 +137,7 @@ function ProfileComponent() {
<Input <Input
value={newName} value={newName}
onChange={changeName} 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 && ( {error && (
<p className="text-red-500 text-opacity-70">{error}</p> <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"> <div className="flex w-full items-center">
<h2 className="text-md font-semibold">{title}</h2> <h2 className="text-md font-semibold">{title}</h2>
<div className="mx-4 flex-grow"> <div className="mx-4 flex-grow">
<div className="bg-result h-px"></div> <div className="h-px bg-result"></div>
</div> </div>
<span className="text-base font-light text-opacity-55">{count}</span> <span className="text-base font-light text-opacity-55">{count}</span>
</div> </div>
@@ -41,7 +41,7 @@ const SearchItem: React.FC<SearchItemProps> = ({
subtitle, subtitle,
topic, 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 <LaIcon
name={icon as "Square"} name={icon as "Square"}
className="size-4 flex-shrink-0 opacity-0 transition-opacity duration-200 group-hover:opacity-50" 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 <Link
to={href} to={href}
onClick={(e) => e.stopPropagation()} 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} {title}
</Link> </Link>
@@ -58,7 +58,7 @@ const SearchItem: React.FC<SearchItemProps> = ({
<Link <Link
to={href} to={href}
onClick={(e) => e.stopPropagation()} 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} {subtitle}
</Link> </Link>
@@ -138,7 +138,7 @@ const SearchComponent = () => {
<div className="relative my-5 flex w-full items-center space-x-2"> <div className="relative my-5 flex w-full items-center space-x-2">
<LaIcon <LaIcon
name="Search" 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 <input
autoFocus autoFocus
@@ -146,12 +146,12 @@ const SearchComponent = () => {
value={searchText} value={searchText}
onChange={handleSearch} onChange={handleSearch}
placeholder="Search topics, links, pages" 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 && ( {searchText && (
<LaIcon <LaIcon
name="X" 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} onClick={clearSearch}
/> />
)} )}
@@ -115,7 +115,7 @@ export const TaskForm: React.FC = () => {
<div <div
ref={formRef} ref={formRef}
onSubmit={handleSubmit} 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"> <div className="flex min-w-0 flex-1 items-center">
<Checkbox <Checkbox
@@ -58,7 +58,7 @@ export const TaskItem: React.FC<TaskItemProps> = ({
: "No due date" : "No due date"
return ( 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"> <div className="flex flex-grow flex-row items-center gap-3">
<Checkbox <Checkbox
checked={task.status === "done"} checked={task.status === "done"}
@@ -77,7 +77,7 @@ export const TaskItem: React.FC<TaskItemProps> = ({
<p <p
className={ className={
task.status === "done" task.status === "done"
? "text-foreground flex-grow line-through" ? "flex-grow text-foreground line-through"
: "flex-grow" : "flex-grow"
} }
onClick={clickTitle} onClick={clickTitle}
@@ -86,7 +86,7 @@ export const TaskItem: React.FC<TaskItemProps> = ({
</p> </p>
)} )}
</div> </div>
<span className="text-muted-foreground text-xs">{formattedDate}</span> <span className="text-xs text-muted-foreground">{formattedDate}</span>
</li> </li>
) )
} }
@@ -13,7 +13,7 @@ export const TopicHeader: React.FC<TopicHeaderProps> = React.memo(() => {
if (!me) return null if (!me) return null
return ( return (
<ContentHeader className="px-6 py-4 max-lg:px-4"> <ContentHeader>
<HeaderTitle /> <HeaderTitle />
<div className="flex flex-auto" /> <div className="flex flex-auto" />
</ContentHeader> </ContentHeader>
@@ -26,7 +26,9 @@ const HeaderTitle: React.FC = () => (
<div className="flex min-w-0 shrink-0 items-center gap-1.5"> <div className="flex min-w-0 shrink-0 items-center gap-1.5">
<SidebarToggleButton /> <SidebarToggleButton />
<div className="flex min-h-0 items-center"> <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>
</div> </div>
) )
@@ -142,8 +142,8 @@ export const TopicItem = React.forwardRef<HTMLAnchorElement, TopicItemProps>(
tabIndex={isActive ? 0 : -1} tabIndex={isActive ? 0 : -1}
className={cn( className={cn(
"relative block cursor-default outline-none", "relative block cursor-default outline-none",
"min-h-12 py-2 max-lg:px-4 sm:px-6", "min-h-12 py-2 sm:px-6 max-lg:px-4",
"data-[active='true']:bg-[var(--link-background-muted)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]", "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} aria-selected={isActive}
data-active={isActive} data-active={isActive}
@@ -155,7 +155,7 @@ export const TopicItem = React.forwardRef<HTMLAnchorElement, TopicItemProps>(
tabIndex={isActive ? 0 : -1} tabIndex={isActive ? 0 : -1}
> >
<Column.Wrapper style={columnStyles.title}> <Column.Wrapper style={columnStyles.title}>
<Column.Text className="truncate text-[13px] font-medium"> <Column.Text className="truncate text-sm font-medium">
{topic.prettyName} {topic.prettyName}
</Column.Text> </Column.Text>
</Column.Wrapper> </Column.Wrapper>
@@ -94,10 +94,10 @@ export const MainTopicList: React.FC<MainTopicListProps> = ({ me }) => {
}) })
return ( 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 />} {!isTablet && <ColumnHeader />}
<Primitive.div <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} tabIndex={-1}
role="list" role="list"
> >
@@ -144,7 +144,7 @@ export const ColumnHeader: React.FC = () => {
const columnStyles = useColumnStyles() const columnStyles = useColumnStyles()
return ( 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.Wrapper style={columnStyles.title}>
<Column.Text>Name</Column.Text> <Column.Text>Name</Column.Text>
</Column.Wrapper> </Column.Wrapper>
+3 -3
View File
@@ -43,7 +43,7 @@
--foreground: 0 0% 98%; --foreground: 0 0% 98%;
--card: 240 10% 3.9%; --card: 240 10% 3.9%;
--card-foreground: 0 0% 98%; --card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%; --popover: 220, 5.66%, 10.39%;
--popover-foreground: 0 0% 98%; --popover-foreground: 0 0% 98%;
--primary: 0 0% 98%; --primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%; --primary-foreground: 240 5.9% 10%;
@@ -55,7 +55,7 @@
--accent-foreground: 0 0% 98%; --accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%; --destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%; --border: 240 3.7% 20%;
--input: 220 9% 10%; --input: 220 9% 10%;
--result: 0 0% 7%; --result: 0 0% 7%;
--ring: 240 4.9% 83.9%; --ring: 240 4.9% 83.9%;
@@ -77,7 +77,7 @@
} }
body, body,
div#root { div#root {
@apply bg-background text-foreground size-full font-sans antialiased; @apply size-full bg-[var(--body-background)] font-sans text-foreground antialiased;
} }
} }
+2 -2
View File
@@ -116,11 +116,11 @@
.la [cmdk-group-heading] { .la [cmdk-group-heading] {
font-size: 13px; font-size: 13px;
height: 30px; height: 30px;
@apply text-muted-foreground flex items-center px-2; @apply flex items-center px-2 text-muted-foreground;
} }
.la [cmdk-empty] { .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] { .la [cmdk-item] {
+33 -2
View File
@@ -1,11 +1,42 @@
:root { :root {
--link-background-muted: hsl(0, 0%, 97.3%); --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%); --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 { .dark {
--link-background-muted: hsl(220, 6.7%, 8.8%); --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%); --link-shadow: hsl(234.9, 27.1%, 25.3%);
--less-foreground: #e5e7eb;
--item-active: rgb(53, 54, 57);
--item-hover: rgb(42, 43, 46);
--body-background: rgb(31, 32, 35);
--container-background: rgb(24, 25, 28);
}
.title-editor .ProseMirror .is-empty::before {
@apply pointer-events-none float-left h-0 w-full text-[var(--la-secondary)];
}
.title-editor:not(.no-command)
.ProseMirror.ProseMirror-focused
> p.has-focus.is-empty::before {
content: "Type / for commands...";
}
.title-editor .ProseMirror > p.is-editor-empty::before {
content: attr(data-placeholder);
@apply pointer-events-none float-left h-0 text-[var(--la-secondary)];
} }
+32 -23
View File
@@ -13,7 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@clerk/tanstack-start": "0.4.1", "@clerk/tanstack-start": "0.4.1",
"@clerk/themes": "^2.1.35", "@clerk/themes": "^2.1.37",
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/sortable": "^8.0.0",
@@ -39,12 +39,12 @@
"@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/react-query": "^5.59.0", "@tanstack/react-query": "^5.59.15",
"@tanstack/react-router": "^1.62.0",
"@tanstack/react-router-with-query": "^1.62.0",
"@tanstack/react-virtual": "^3.10.8", "@tanstack/react-virtual": "^3.10.8",
"@tanstack/router-zod-adapter": "^1.62.0", "@tanstack/react-router": "^1.70.1",
"@tanstack/start": "^1.62.0", "@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/core": "^2.8.0",
"@tiptap/extension-code-block-lowlight": "^2.8.0", "@tiptap/extension-code-block-lowlight": "^2.8.0",
"@tiptap/extension-color": "^2.8.0", "@tiptap/extension-color": "^2.8.0",
@@ -67,50 +67,59 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^16.4.5", "framer-motion": "^11.11.9",
"framer-motion": "^11.11.1", "jazz-browser-media-images": "^0.8.7",
"jazz-react": "^0.8.2", "jazz-react": "^0.8.7",
"jazz-react-auth-clerk": "^0.8.2", "jazz-react-auth-clerk": "^0.8.7",
"jazz-tools": "^0.8.2", "jazz-tools": "^0.8.5",
"jotai": "^2.10.0", "jotai": "^2.10.1",
"lowlight": "^3.1.0", "lowlight": "^3.1.0",
"lucide-react": "^0.446.0", "lucide-react": "^0.446.0",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"query-string": "^9.1.0", "query-string": "^9.1.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-day-picker": "8.10.1", "react-day-picker": "8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.53.0", "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", "ronin": "^4.3.1",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"sonner": "^1.5.0", "sonner": "^1.5.0",
"streaming-markdown": "^0.0.14", "streaming-markdown": "^0.0.14",
"tailwind-merge": "^2.5.3", "tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"uuid": "^10.0.0",
"vinxi": "0.4.3", "vinxi": "0.4.3",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@ronin/learn-anything": "^0.0.0-3456082797916", "@ronin/learn-anything": "^0.0.0-3457754034220",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@tanstack/react-query-devtools": "^5.59.0", "@tanstack/react-query-devtools": "^5.59.15",
"@tanstack/router-devtools": "^1.62.0", "@tanstack/router-devtools": "^1.70.1",
"@types/node": "^22.7.4", "@types/node": "^22.7.6",
"@types/react": "^18.3.11", "@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/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0", "@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.3.2", "@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"dotenv": "^16.4.5",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.14",
"typescript": "^5.6.2", "typescript": "^5.6.3",
"vite-tsconfig-paths": "^5.0.1" "vite-tsconfig-paths": "^5.0.1",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.5.14"
}, },
"prettier": { "prettier": {
"plugins": [
"prettier-plugin-tailwindcss"
],
"semi": false "semi": false
} }
} }
+37
View 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)
@@ -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 ( return (
<div <div
className={cn( className={cn(
"bg-popover text-popover-foreground rounded-lg border shadow-sm", "rounded-lg border bg-popover text-popover-foreground shadow-sm",
className, className,
)} )}
{...props} {...props}
@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { getShortcutKey } from "@/lib/utils" import { getShortcutKey } from "@shared/utils"
export interface ShortcutKeyWrapperProps export interface ShortcutKeyWrapperProps
extends React.HTMLAttributes<HTMLSpanElement> { extends React.HTMLAttributes<HTMLSpanElement> {
+43
View 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
@@ -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