mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
chore: Enhancement + New Feature (#185)
* wip * wip page * chore: style * wip pages * wip pages * chore: toggle * chore: link * feat: topic search * chore: page section * refactor: apply tailwind class ordering * fix: handle loggedIn user for guest route * feat: folder & image schema * chore: move utils to shared * refactor: tailwind class ordering * feat: img ext for editor * refactor: remove qa * fix: tanstack start * fix: wrong import * chore: use toast * chore: schema
This commit is contained in:
@@ -124,7 +124,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
|
||||
<React.Suspense>
|
||||
<TanStackRouterDevtools position="bottom-right" />
|
||||
<ReactQueryDevtools buttonPosition="bottom-left" />
|
||||
<ReactQueryDevtools buttonPosition="bottom-right" />
|
||||
</React.Suspense>
|
||||
|
||||
<ScrollRestoration />
|
||||
|
||||
@@ -8,7 +8,7 @@ export const Route = createFileRoute("/_layout")({
|
||||
|
||||
function LayoutComponent() {
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
|
||||
<ClerkProvider>
|
||||
<Outlet />
|
||||
</ClerkProvider>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router"
|
||||
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/_layout/(auth)/_auth")({
|
||||
beforeLoad({ context }) {
|
||||
if (context.auth) {
|
||||
throw redirect({ to: "/links", replace: true })
|
||||
}
|
||||
},
|
||||
component: () => (
|
||||
<main className="h-full">
|
||||
<Outlet />
|
||||
|
||||
@@ -104,10 +104,13 @@ export function Autocomplete({
|
||||
|
||||
return (
|
||||
<Command
|
||||
className={cn("relative mx-auto max-w-md overflow-visible shadow-md", {
|
||||
"rounded-lg border": !open,
|
||||
"rounded-none rounded-t-lg border-l border-r border-t": open,
|
||||
})}
|
||||
className={cn(
|
||||
"relative mx-auto max-w-md overflow-visible bg-background shadow-md",
|
||||
{
|
||||
"rounded-lg border": !open,
|
||||
"rounded-none rounded-t-lg border-l border-r border-t": open,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="relative flex items-center">
|
||||
<CommandPrimitive.Input
|
||||
@@ -125,7 +128,7 @@ export function Autocomplete({
|
||||
}}
|
||||
placeholder={filteredTopics[0]?.prettyName}
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex-1 bg-transparent min-h-10 px-3 py-1 sm:py-3 sm:px-4 outline-none",
|
||||
"min-h-10 flex-1 bg-transparent px-3 py-1 outline-none placeholder:text-muted-foreground sm:px-4 sm:py-3",
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
@@ -138,7 +141,7 @@ export function Autocomplete({
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
className="bg-background absolute left-0 right-0 z-10 -mx-px rounded-b-lg border-b border-l border-r border-t shadow-lg"
|
||||
className="absolute left-0 right-0 z-10 -mx-px rounded-b-lg border-b border-l border-r border-t bg-background shadow-lg"
|
||||
>
|
||||
<CommandList className="max-h-56">
|
||||
<CommandGroup className="my-2">
|
||||
@@ -150,7 +153,7 @@ export function Autocomplete({
|
||||
className="min-h-10 rounded-none px-3 py-1.5"
|
||||
>
|
||||
<span>{topic.prettyName}</span>
|
||||
<span className="text-muted-foreground/80 ml-auto text-xs">
|
||||
<span className="ml-auto text-xs text-muted-foreground/80">
|
||||
{topic.connectedTopics.length > 0 &&
|
||||
topic.connectedTopics.join(", ")}
|
||||
</span>
|
||||
|
||||
@@ -25,7 +25,7 @@ function LandingComponent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-screen">
|
||||
<div className="relative h-full w-screen bg-background">
|
||||
<ForceGraphClient
|
||||
raw_nodes={GraphData}
|
||||
onNodeClick={handleTopicSelect}
|
||||
@@ -40,7 +40,7 @@ function LandingComponent() {
|
||||
>
|
||||
<motion.h1
|
||||
className={cn(
|
||||
"mb-2 text-center text-5xl font-bold tracking-tight sm:mb-4 md:text-7xl font-raleway",
|
||||
"mb-2 text-center font-raleway text-5xl font-bold tracking-tight sm:mb-4 md:text-7xl",
|
||||
)}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
|
||||
@@ -33,7 +33,7 @@ function LayoutContent() {
|
||||
return (
|
||||
<>
|
||||
<Toaster expand={false} />
|
||||
<div className="flex min-h-full size-full flex-row items-stretch overflow-hidden">
|
||||
<div className="flex size-full min-h-full flex-row items-stretch overflow-hidden">
|
||||
<Sidebar />
|
||||
<Shortcut />
|
||||
<GlobalKeyboardHandler />
|
||||
@@ -49,7 +49,7 @@ function LayoutContent() {
|
||||
function MainContent() {
|
||||
return (
|
||||
<div className="relative flex min-w-0 flex-1 flex-col">
|
||||
<main className="relative flex flex-auto flex-col place-items-stretch overflow-auto lg:my-2 lg:mr-2 lg:rounded-md lg:border">
|
||||
<main className="relative flex flex-auto flex-col place-items-stretch overflow-auto bg-[var(--container-background)] lg:my-2 lg:mr-2 lg:rounded-md lg:border">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,6 @@ export const Route = createFileRoute("/_layout/_pages/(topic)/$")({
|
||||
export const openPopoverForIdAtom = atom<string | null>(null)
|
||||
|
||||
export function TopicDetailComponent() {
|
||||
console.log("TopicDetailComponent")
|
||||
const params = useParams({ from: "/_layout/_pages/$" })
|
||||
const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
|
||||
|
||||
@@ -30,34 +29,65 @@ export function TopicDetailComponent() {
|
||||
latestGlobalGuide: { sections: [] },
|
||||
})
|
||||
const [activeIndex, setActiveIndex] = React.useState(-1)
|
||||
const [searchQuery, setSearchQuery] = React.useState("")
|
||||
|
||||
const topicExists = GraphData.find((node) => {
|
||||
return node.name === params._splat
|
||||
})
|
||||
const topicExists = React.useMemo(
|
||||
() => GraphData.find((node) => node.name === params._splat),
|
||||
[params._splat],
|
||||
)
|
||||
|
||||
const latestGlobalGuide = React.useMemo(
|
||||
() => topic?.latestGlobalGuide,
|
||||
[topic?.latestGlobalGuide],
|
||||
)
|
||||
|
||||
const flattenedItems = React.useMemo(
|
||||
() =>
|
||||
latestGlobalGuide?.sections.flatMap((section) => [
|
||||
{ type: "section" as const, data: section },
|
||||
...(section?.links?.map((link) => ({
|
||||
type: "link" as const,
|
||||
data: link,
|
||||
})) || []),
|
||||
]) || [],
|
||||
[latestGlobalGuide],
|
||||
)
|
||||
|
||||
const filteredItems = React.useMemo(() => {
|
||||
if (!searchQuery) return flattenedItems
|
||||
return flattenedItems.filter((item) => {
|
||||
if (item.type === "section") {
|
||||
return item.data?.title
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())
|
||||
}
|
||||
if (item.type === "link") {
|
||||
return (
|
||||
item.data?.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.data?.url.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
}
|
||||
return false
|
||||
})
|
||||
}, [flattenedItems, searchQuery])
|
||||
|
||||
if (!topicExists) {
|
||||
return <NotFoundPlaceholder />
|
||||
}
|
||||
|
||||
const flattenedItems = topic?.latestGlobalGuide?.sections.flatMap(
|
||||
(section) => [
|
||||
{ type: "section" as const, data: section },
|
||||
...(section?.links?.map((link) => ({
|
||||
type: "link" as const,
|
||||
data: link,
|
||||
})) || []),
|
||||
],
|
||||
)
|
||||
|
||||
if (!topic || !me || !flattenedItems) {
|
||||
if (!topic || !me) {
|
||||
return <TopicDetailSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopicDetailHeader topic={topic} />
|
||||
<TopicDetailHeader
|
||||
topic={topic}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
/>
|
||||
<TopicDetailList
|
||||
items={flattenedItems}
|
||||
items={filteredItems}
|
||||
topic={topic}
|
||||
activeIndex={activeIndex}
|
||||
setActiveIndex={setActiveIndex}
|
||||
@@ -88,7 +118,7 @@ function TopicDetailSkeleton() {
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<Skeleton className="h-6 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-36" />
|
||||
<Skeleton className="h-7 w-28" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-6 max-lg:px-4">
|
||||
|
||||
@@ -10,13 +10,19 @@ import { LearningStateValue } from "@/lib/constants"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { useClerk } from "@clerk/tanstack-start"
|
||||
import { useLocation } from "@tanstack/react-router"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { LaIcon } from "~/components/custom/la-icon"
|
||||
|
||||
interface TopicDetailHeaderProps {
|
||||
topic: Topic
|
||||
searchQuery: string
|
||||
setSearchQuery: (query: string) => void
|
||||
}
|
||||
|
||||
export const TopicDetailHeader = React.memo(function TopicDetailHeader({
|
||||
topic,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
}: TopicDetailHeaderProps) {
|
||||
const clerk = useClerk()
|
||||
const { pathname } = useLocation()
|
||||
@@ -111,28 +117,51 @@ export const TopicDetailHeader = React.memo(function TopicDetailHeader({
|
||||
topicLists[learningState]?.push(topic)
|
||||
}
|
||||
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(event.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentHeader className="px-6 py-5 max-lg:px-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 min-w-0 flex-1 items-center">
|
||||
<h1 className="truncate text-left font-bold lg:text-xl">
|
||||
{topic.prettyName}
|
||||
</h1>
|
||||
<>
|
||||
<ContentHeader>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 min-w-0 flex-1 items-center">
|
||||
<h1 className="truncate text-left font-semibold lg:text-lg">
|
||||
{topic.prettyName}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-auto"></div>
|
||||
{/* <GuideCommunityToggle topicName={topic.name} /> */}
|
||||
|
||||
<LearningStateSelector
|
||||
showSearch={false}
|
||||
value={p?.learningState || ""}
|
||||
onChange={handleAddToProfile}
|
||||
defaultLabel={isMobile ? "" : "Add to profile"}
|
||||
defaultIcon="Circle"
|
||||
/>
|
||||
</ContentHeader>
|
||||
<div className="flex min-h-10 flex-row items-center justify-between border-b border-b-[var(--la-border-new)] px-6 py-2 max-lg:px-4">
|
||||
<div className="flex flex-1 flex-row items-center gap-2">
|
||||
<span className="text-tertiary flex h-5 w-5 items-center justify-center">
|
||||
<LaIcon name="Search" className="text-muted-foreground" />
|
||||
</span>
|
||||
<Input
|
||||
className="h-6 flex-1 border-none bg-transparent p-0 focus-visible:ring-0"
|
||||
placeholder="Search..."
|
||||
role="searchbox"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-auto"></div>
|
||||
{/* <GuideCommunityToggle topicName={topic.name} /> */}
|
||||
|
||||
<LearningStateSelector
|
||||
showSearch={false}
|
||||
value={p?.learningState || ""}
|
||||
onChange={handleAddToProfile}
|
||||
defaultLabel={isMobile ? "" : "Add to profile"}
|
||||
defaultIcon="Circle"
|
||||
/>
|
||||
</ContentHeader>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -178,11 +178,8 @@ export const LinkItem = React.memo(
|
||||
size="sm"
|
||||
type="button"
|
||||
role="combobox"
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"size-7 shrink-0 p-0",
|
||||
"hover:bg-accent-foreground/10",
|
||||
)}
|
||||
variant="ghost"
|
||||
className="h-auto shrink-0 cursor-default p-0 text-muted-foreground/75 hover:bg-inherit hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{selectedLearningState?.icon ? (
|
||||
@@ -215,7 +212,7 @@ export const LinkItem = React.memo(
|
||||
<div className="gap-x-2 space-y-0.5 xl:flex xl:flex-row">
|
||||
<p
|
||||
className={cn(
|
||||
"text-primary hover:text-primary line-clamp-1 text-sm font-medium",
|
||||
"line-clamp-1 text-sm font-medium text-primary hover:text-primary",
|
||||
isActive && "font-bold",
|
||||
)}
|
||||
>
|
||||
@@ -226,14 +223,14 @@ export const LinkItem = React.memo(
|
||||
<LaIcon
|
||||
name="Link"
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground group-hover:text-primary size-3.5 flex-none"
|
||||
className="flex-none text-muted-foreground group-hover:text-primary"
|
||||
/>
|
||||
|
||||
<Link
|
||||
to={ensureUrlProtocol(link.url)}
|
||||
target="_blank"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-muted-foreground hover:text-primary text-xs"
|
||||
className="text-xs text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<span className="line-clamp-1">{link.url}</span>
|
||||
</Link>
|
||||
|
||||
@@ -51,10 +51,10 @@ export function TopicDetailList({
|
||||
className="flex flex-col"
|
||||
>
|
||||
<div className="flex items-center gap-4 px-6 py-2 max-lg:px-4">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
<p className="text-[13px] font-medium text-muted-foreground">
|
||||
{item.data?.title}
|
||||
</p>
|
||||
<div className="flex-1 border-b" />
|
||||
<div className="flex-1 border-b border-[var(--la-border-new)]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -82,7 +82,7 @@ export function TopicDetailList({
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="flex-1 overflow-auto">
|
||||
<div ref={parentRef} className="flex-1 overflow-auto py-4">
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/_layout/_pages/_protected")({
|
||||
beforeLoad: async ({ context, location, cause }) => {
|
||||
beforeLoad: async ({ context, location }) => {
|
||||
if (!context?.auth?.userId) {
|
||||
throw redirect({
|
||||
to: "/sign-in/$",
|
||||
|
||||
@@ -106,7 +106,7 @@ export function QuestionThread({ question, onClose }: QuestionThreadProps) {
|
||||
>
|
||||
<div className="flex items-center justify-between pb-1">
|
||||
<div className="flex items-center">
|
||||
<div className="bg-accent mr-2 h-6 w-6 rounded-full"></div>
|
||||
<div className="mr-2 h-6 w-6 rounded-full bg-accent"></div>
|
||||
<span className="text-sm">{answer.author}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
@@ -147,16 +147,16 @@ export function QuestionThread({ question, onClose }: QuestionThreadProps) {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="border-accent bg-background fixed bottom-0 right-0 top-0 z-50 flex h-full w-[40%] flex-col border-l">
|
||||
<div className="border-accent flex w-full justify-between border-b p-4">
|
||||
<div className="fixed bottom-0 right-0 top-0 z-50 flex h-full w-[40%] flex-col border-l border-accent bg-background">
|
||||
<div className="flex w-full justify-between border-b border-accent p-4">
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="mb-2 flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-accent h-8 w-8 rounded-full"></div>
|
||||
<div className="h-8 w-8 rounded-full bg-accent"></div>
|
||||
<h2 className="opacity-70">{question.author}</h2>
|
||||
</div>
|
||||
<button
|
||||
className="bg-accent rounded-full p-1.5 opacity-50 hover:opacity-80"
|
||||
className="rounded-full bg-accent p-1.5 opacity-50 hover:opacity-80"
|
||||
onClick={onClose}
|
||||
>
|
||||
<LaIcon name="X" className="text-primary" />
|
||||
@@ -167,7 +167,7 @@ export function QuestionThread({ question, onClose }: QuestionThreadProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow overflow-y-auto">{renderAnswers(answers)}</div>
|
||||
<div className="border-accent border-t p-4">
|
||||
<div className="border-t border-accent p-4">
|
||||
<form className="relative" onSubmit={sendAnswer}>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
@@ -176,7 +176,7 @@ export function QuestionThread({ question, onClose }: QuestionThreadProps) {
|
||||
value={newAnswer}
|
||||
onChange={changeInput}
|
||||
placeholder="Answer the question..."
|
||||
className="bg-input w-full rounded p-2 text-opacity-70 placeholder:text-opacity-50 focus:outline-none focus:ring-0"
|
||||
className="w-full rounded bg-input p-2 text-opacity-70 placeholder:text-opacity-50 focus:outline-none focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<button className="absolute right-2 top-1/2 -translate-y-1/2 transform opacity-50 hover:opacity-90">
|
||||
|
||||
@@ -28,7 +28,7 @@ export const GuideCommunityToggle: React.FC<GuideCommunityToggleProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-accent/70 relative flex h-8 w-48 items-center rounded-md">
|
||||
<div className="relative flex h-8 w-48 items-center rounded-md bg-accent/70">
|
||||
<div
|
||||
className="absolute h-8 w-[calc(50%-4px)] rounded-md transition-all duration-300 ease-in-out"
|
||||
style={{ left: view === "guide" ? "2px" : "calc(50% + 2px)" }}
|
||||
@@ -36,7 +36,7 @@ export const GuideCommunityToggle: React.FC<GuideCommunityToggleProps> = ({
|
||||
<button
|
||||
className={cn(
|
||||
"relative z-10 h-full flex-1 rounded-md text-sm font-medium transition-colors",
|
||||
view === "guide" ? "text-primary bg-accent" : "text-primary/50",
|
||||
view === "guide" ? "bg-accent text-primary" : "text-primary/50",
|
||||
)}
|
||||
onClick={() => handleToggle("guide")}
|
||||
>
|
||||
@@ -45,7 +45,7 @@ export const GuideCommunityToggle: React.FC<GuideCommunityToggleProps> = ({
|
||||
<button
|
||||
className={cn(
|
||||
"relative z-10 h-full flex-1 rounded-md text-sm font-medium transition-colors",
|
||||
view === "community" ? "text-primary bg-accent" : "text-primary/50",
|
||||
view === "community" ? "bg-accent text-primary" : "text-primary/50",
|
||||
)}
|
||||
onClick={() => handleToggle("community")}
|
||||
>
|
||||
|
||||
@@ -45,7 +45,7 @@ function CommunityTopicComponent() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col">
|
||||
<ContentHeader className="px-6 py-4">
|
||||
<ContentHeader>
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 flex-col items-start">
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn, getShortcutKeys } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { useAtom } from "jotai"
|
||||
import { useConfirm } from "@omit/react-confirm-dialog"
|
||||
@@ -17,6 +17,7 @@ import { ID } from "jazz-tools"
|
||||
import { globalLinkFormExceptionRefsAtom } from "./-link-form"
|
||||
import { useLinkActions } from "~/hooks/actions/use-link-actions"
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
import { getShortcutKeys } from "@shared/utils"
|
||||
|
||||
interface ToolbarButtonProps
|
||||
extends React.ComponentPropsWithoutRef<typeof Button> {
|
||||
@@ -146,7 +147,7 @@ export const LinkBottomBar: React.FC = () => {
|
||||
const shortcutText = getShortcutKeys(["c"])
|
||||
|
||||
return (
|
||||
<div className="bg-background min-h-11 border-t">
|
||||
<div className="min-h-11 border-t">
|
||||
<AnimatePresence mode="wait">
|
||||
{editId && (
|
||||
<motion.div
|
||||
|
||||
@@ -27,7 +27,7 @@ export const DescriptionInput: React.FC<DescriptionInputProps> = () => {
|
||||
{...field}
|
||||
autoComplete="off"
|
||||
placeholder="Description"
|
||||
className="placeholder:text-muted-foreground/70 resize-none overflow-y-auto border-none p-1.5 text-[13px] font-medium shadow-none focus-visible:ring-0"
|
||||
className="resize-none overflow-y-auto border-none p-1.5 text-sm font-medium shadow-none placeholder:text-muted-foreground/70 focus-visible:ring-0"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
|
||||
@@ -1,77 +1,37 @@
|
||||
import * as React from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import {
|
||||
ContentHeader,
|
||||
SidebarToggleButton,
|
||||
} from "@/components/custom/content-header"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
import { FancySwitch, OptionValue } from "@omit/react-fancy-switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||
import { useAtom } from "jotai"
|
||||
import { linkSortAtom } from "@/store/link"
|
||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||
import { FancySwitch } from "@omit/react-fancy-switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
const ALL_STATES = [
|
||||
{ label: "All", value: "all", icon: "List", className: "text-foreground" },
|
||||
...LEARNING_STATES,
|
||||
]
|
||||
|
||||
export const LinkHeader = React.memo(() => {
|
||||
const isTablet = useMedia("(max-width: 1024px)")
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentHeader className="px-6 max-lg:px-4 lg:py-4">
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 items-center">
|
||||
<span className="truncate text-left font-bold lg:text-xl">
|
||||
Links
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isTablet && <LearningTab />}
|
||||
|
||||
<div className="flex flex-auto"></div>
|
||||
|
||||
<FilterAndSort />
|
||||
</ContentHeader>
|
||||
|
||||
{isTablet && (
|
||||
<div className="flex flex-row items-start justify-between border-b px-6 pb-4 pt-2 max-lg:pl-4">
|
||||
<LearningTab />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
LinkHeader.displayName = "LinkHeader"
|
||||
|
||||
const LearningTab = React.memo(() => {
|
||||
const LearningTab: React.FC = React.memo(() => {
|
||||
const navigate = useNavigate()
|
||||
const { state } = useSearch({
|
||||
from: "/_layout/_pages/_protected/links/",
|
||||
})
|
||||
const { state } = useSearch({ from: "/_layout/_pages/_protected/links/" })
|
||||
|
||||
const handleTabChange = React.useCallback(
|
||||
async (value: string) => {
|
||||
(value: OptionValue) => {
|
||||
if (value !== state) {
|
||||
navigate({
|
||||
to: "/links",
|
||||
@@ -85,30 +45,22 @@ const LearningTab = React.memo(() => {
|
||||
return (
|
||||
<FancySwitch
|
||||
value={state}
|
||||
onChange={(value) => {
|
||||
handleTabChange(value as string)
|
||||
}}
|
||||
onChange={handleTabChange}
|
||||
options={ALL_STATES}
|
||||
className="bg-muted flex rounded-lg"
|
||||
className="flex rounded-md"
|
||||
highlighterClassName="bg-muted-foreground/10 rounded-md"
|
||||
radioClassName={cn(
|
||||
"relative mx-2 flex h-8 cursor-pointer items-center justify-center rounded-full px-1 text-sm text-secondary-foreground/60 data-[checked]:text-secondary-foreground font-medium transition-colors focus:outline-none",
|
||||
"relative mx-2 flex h-6 cursor-pointer items-center justify-center rounded-full px-1 text-sm text-muted-foreground data-[checked]:text-foreground data-[checked]:font-medium transition-colors focus:outline-none",
|
||||
)}
|
||||
highlighterIncludeMargin={true}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
LearningTab.displayName = "LearningTab"
|
||||
|
||||
const FilterAndSort = React.memo(() => {
|
||||
const FilterAndSort: React.FC = React.memo(() => {
|
||||
const [sort, setSort] = useAtom(linkSortAtom)
|
||||
const [sortOpen, setSortOpen] = React.useState(false)
|
||||
|
||||
const getFilterText = React.useCallback(() => {
|
||||
return sort.charAt(0).toUpperCase() + sort.slice(1)
|
||||
}, [sort])
|
||||
|
||||
const handleSortChange = React.useCallback(
|
||||
(value: string) => {
|
||||
setSort(value)
|
||||
@@ -120,40 +72,80 @@ const FilterAndSort = React.memo(() => {
|
||||
return (
|
||||
<div className="flex w-auto items-center justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover open={sortOpen} onOpenChange={setSortOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<DropdownMenu open={sortOpen} onOpenChange={setSortOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="min-w-8 gap-x-2 text-sm max-sm:p-0"
|
||||
variant="ghost"
|
||||
className="h-7 gap-x-2 text-sm max-sm:p-0"
|
||||
>
|
||||
<LaIcon name="ListFilter" className="text-primary/60" />
|
||||
<span className="hidden md:block">Filter: {getFilterText()}</span>
|
||||
<LaIcon name="ChevronDown" />
|
||||
<span className="hidden md:block">Display</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="end">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex min-w-8 flex-row items-center">
|
||||
<Label>Sort by</Label>
|
||||
<div className="flex flex-auto flex-row items-center justify-end">
|
||||
<Select value={sort} onValueChange={handleSortChange}>
|
||||
<SelectTrigger className="h-6 w-auto">
|
||||
<SelectValue placeholder="Select"></SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="title">Title</SelectItem>
|
||||
<SelectItem value="manual">Manual</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end" side="top">
|
||||
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">
|
||||
Display
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem>
|
||||
<LaIcon name="List" className="mr-2 h-4 w-4" />
|
||||
<span>List</span>
|
||||
<LaIcon name="Check" className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">
|
||||
Ordering
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => handleSortChange("title")}>
|
||||
<span>Title</span>
|
||||
{sort === "title" && (
|
||||
<LaIcon name="Check" className="ml-auto h-4 w-4" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleSortChange("manual")}>
|
||||
<span>Manual</span>
|
||||
{sort === "manual" && (
|
||||
<LaIcon name="Check" className="ml-auto h-4 w-4" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const LinkHeader: React.FC = React.memo(() => {
|
||||
const isTablet = useMedia("(max-width: 1024px)")
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentHeader>
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 items-center">
|
||||
<span className="truncate text-left font-semibold lg:text-lg">
|
||||
Links
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isTablet && <LearningTab />}
|
||||
|
||||
<div className="flex flex-auto" />
|
||||
|
||||
<FilterAndSort />
|
||||
</ContentHeader>
|
||||
|
||||
{isTablet && (
|
||||
<div className="flex flex-row items-start justify-between border-b px-6 py-2 max-lg:pl-4">
|
||||
<LearningTab />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
LinkHeader.displayName = "LinkHeader"
|
||||
LearningTab.displayName = "LearningTab"
|
||||
FilterAndSort.displayName = "FilterAndSort"
|
||||
|
||||
@@ -115,15 +115,15 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
data-disabled={disabled}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"w-full overflow-visible border-b-[0.5px] border-transparent outline-none",
|
||||
"data-[active='true']:bg-[var(--link-background-muted)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]",
|
||||
"w-full cursor-default overflow-visible border-b-[0.5px] border-transparent outline-none",
|
||||
"data-[active='true']:bg-[var(--link-background-muted-new)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]",
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full grow overflow-visible outline-none",
|
||||
"flex items-center gap-x-2 py-2 max-lg:px-4 sm:px-5 sm:py-2",
|
||||
"flex items-center gap-x-2 py-2 sm:px-5 sm:py-2 max-lg:px-4",
|
||||
)}
|
||||
>
|
||||
<Popover
|
||||
@@ -137,8 +137,8 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
size="sm"
|
||||
type="button"
|
||||
role="combobox"
|
||||
variant="secondary"
|
||||
className="size-7 shrink-0 p-0"
|
||||
variant="ghost"
|
||||
className="size-7 shrink-0 cursor-default p-0 text-muted-foreground/75 hover:bg-inherit hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -148,7 +148,7 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
className={cn(selectedLearningState.className)}
|
||||
/>
|
||||
) : (
|
||||
<LaIcon name="Circle" />
|
||||
<LaIcon name="Circle" strokeWidth={2.5} />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -167,22 +167,22 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
</Popover>
|
||||
|
||||
<div className="flex min-w-0 flex-col items-start gap-y-1.5 overflow-hidden md:flex-row md:items-center md:gap-x-2">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{personalLink.icon && (
|
||||
<img
|
||||
src={personalLink.icon as string}
|
||||
alt={personalLink.title}
|
||||
className="size-5 shrink-0 rounded-full"
|
||||
className="size-4 shrink-0 rounded-full"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
)}
|
||||
<p className="text-primary hover:text-primary line-clamp-1 text-sm font-medium">
|
||||
<p className="line-clamp-1 text-sm font-medium text-primary hover:text-primary">
|
||||
{personalLink.title}
|
||||
</p>
|
||||
</div>
|
||||
{personalLink.url && (
|
||||
<div className="text-muted-foreground flex min-w-0 shrink items-center gap-x-1">
|
||||
<div className="flex min-w-0 shrink items-center gap-x-1 text-muted-foreground">
|
||||
<LaIcon
|
||||
name="Link"
|
||||
aria-hidden="true"
|
||||
@@ -192,7 +192,7 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
to={ensureUrlProtocol(personalLink.url)}
|
||||
target="_blank"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="hover:text-primary mr-1 truncate text-xs"
|
||||
className="mr-1 truncate text-xs hover:text-primary"
|
||||
>
|
||||
{personalLink.url}
|
||||
</Link>
|
||||
@@ -204,14 +204,17 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
|
||||
<div className="flex shrink-0 items-center justify-end">
|
||||
{personalLink.topic && (
|
||||
<Badge variant="secondary" className="border-muted-foreground/25">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="border-muted-foreground/25 font-medium"
|
||||
>
|
||||
{personalLink.topic.prettyName}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-[0.5px] w-full after:absolute after:left-0 after:right-0 after:block after:h-full after:bg-[var(--link-border-after)]"></div>
|
||||
<div className="relative h-[0.5px] w-full after:absolute after:left-0 after:right-0 after:block after:h-full after:bg-[var(--la-border-new)]"></div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -19,7 +19,8 @@ import { DescriptionInput } from "./-description-input"
|
||||
import { UrlBadge } from "./-url-badge"
|
||||
import { NotesSection } from "./-notes-section"
|
||||
import { useOnClickOutside } from "~/hooks/use-on-click-outside"
|
||||
import TopicSelector, {
|
||||
import {
|
||||
TopicSelector,
|
||||
topicSelectorAtom,
|
||||
} from "~/components/custom/topic-selector"
|
||||
import { createServerFn } from "@tanstack/start"
|
||||
@@ -291,7 +292,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-muted/30 relative rounded-md border",
|
||||
"relative rounded-md border bg-muted/30",
|
||||
isFetching && "opacity-50",
|
||||
)}
|
||||
>
|
||||
@@ -369,7 +370,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
|
||||
{isFetching ? (
|
||||
<div className="flex w-auto items-center justify-end gap-x-2">
|
||||
<span className="text-muted-foreground flex items-center text-sm">
|
||||
<span className="flex items-center text-sm text-muted-foreground">
|
||||
<svg
|
||||
className="mr-2 h-4 w-4 animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -27,12 +27,12 @@ import { linkSortAtom } from "@/store/link"
|
||||
import { LinkItem } from "./-item"
|
||||
import { useConfirm } from "@omit/react-confirm-dialog"
|
||||
import { useKeyDown } from "@/hooks/use-key-down"
|
||||
import { isModKey } from "@/lib/utils"
|
||||
import { useTouchSensor } from "~/hooks/use-touch-sensor"
|
||||
import { useActiveItemScroll } from "~/hooks/use-active-item-scroll"
|
||||
import { isDeleteConfirmShownAtom } from "."
|
||||
import { useLinkActions } from "~/hooks/actions/use-link-actions"
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
import { isModKey } from "@shared/utils"
|
||||
|
||||
interface LinkListProps {}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export const NotesSection: React.FC = () => {
|
||||
autoComplete="off"
|
||||
placeholder="Notes"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground/70 border-none pl-8 shadow-none focus-visible:ring-0",
|
||||
"border-none pl-8 shadow-none placeholder:text-muted-foreground/70 focus-visible:ring-0",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -31,7 +31,7 @@ export const TitleInput: React.FC<TitleInputProps> = ({ urlFetched }) => {
|
||||
maxLength={100}
|
||||
autoFocus
|
||||
placeholder="Title"
|
||||
className="placeholder:text-muted-foreground/70 h-8 border-none p-1.5 text-[15px] font-semibold shadow-none focus-visible:ring-0"
|
||||
className="h-8 border-none p-1.5 text-[15px] font-semibold shadow-none placeholder:text-muted-foreground/70 focus-visible:ring-0"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
|
||||
@@ -27,9 +27,9 @@ export const UrlBadge: React.FC<UrlBadgeProps> = ({
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={handleResetUrl}
|
||||
className="text-muted-foreground hover:text-foreground ml-2 size-4 rounded-full bg-transparent hover:bg-transparent"
|
||||
className="ml-2 size-4 rounded-full bg-transparent text-muted-foreground hover:bg-transparent hover:text-foreground"
|
||||
>
|
||||
<LaIcon name="X" className="size-3.5" />
|
||||
<LaIcon name="X" className="" />
|
||||
</Button>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -65,14 +65,14 @@ export const UrlInput: React.FC<UrlInputProps> = ({
|
||||
maxLength={100}
|
||||
autoFocus
|
||||
placeholder="Paste a link or write a link"
|
||||
className="placeholder:text-muted-foreground/70 h-8 border-none p-1.5 text-[15px] font-semibold shadow-none focus-visible:ring-0"
|
||||
className="h-8 border-none p-1.5 text-[15px] font-semibold shadow-none placeholder:text-muted-foreground/70 focus-visible:ring-0"
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="center" side="top">
|
||||
<TooltipArrow className="text-primary fill-current" />
|
||||
<TooltipArrow className="fill-current text-primary" />
|
||||
<span>
|
||||
Press <kbd className="px-1.5">Enter</kbd> to fetch metadata
|
||||
</span>
|
||||
|
||||
@@ -60,7 +60,7 @@ const StepItem = ({
|
||||
done: boolean
|
||||
}) => (
|
||||
<div className="flex items-start space-x-4 py-4">
|
||||
<div className="border-foreground/20 w-6 flex-shrink-0 items-center justify-center rounded-3xl border text-center opacity-70">
|
||||
<div className="w-6 flex-shrink-0 items-center justify-center rounded-3xl border border-foreground/20 text-center opacity-70">
|
||||
{number}
|
||||
</div>
|
||||
<div className="flex-grow space-y-2">
|
||||
|
||||
@@ -23,7 +23,7 @@ export const DetailPageHeader: React.FC<DetailPageHeaderProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentHeader className="lg:min-h-0">
|
||||
<ContentHeader>
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<SidebarToggleButton />
|
||||
</div>
|
||||
@@ -45,7 +45,7 @@ export const DetailPageHeader: React.FC<DetailPageHeaderProps> = ({
|
||||
)}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleDelete}>
|
||||
<LaIcon name="Trash" className="mr-2 size-3.5" />
|
||||
<LaIcon name="Trash" className="mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -15,14 +15,14 @@ import { Button } from "@/components/ui/button"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { useConfirm } from "@omit/react-confirm-dialog"
|
||||
import { usePageActions } from "~/hooks/actions/use-page-actions"
|
||||
import { Paragraph } from "@shared/la-editor/extensions/paragraph"
|
||||
import { StarterKit } from "@shared/la-editor/extensions/starter-kit"
|
||||
import { LAEditor, LAEditorRef } from "@shared/la-editor"
|
||||
import { Paragraph } from "@shared/editor/extensions/paragraph"
|
||||
import { StarterKit } from "@shared/editor/extensions/starter-kit"
|
||||
import { LaEditor } from "@shared/editor"
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_layout/_pages/_protected/pages/$pageId/",
|
||||
)({
|
||||
component: () => <PageDetailComponent />,
|
||||
component: PageDetailComponent,
|
||||
})
|
||||
|
||||
const TITLE_PLACEHOLDER = "Untitled"
|
||||
@@ -73,20 +73,22 @@ function PageDetailComponent() {
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarActions = ({
|
||||
page,
|
||||
handleDelete,
|
||||
}: {
|
||||
page: PersonalPage
|
||||
handleDelete: () => void
|
||||
}) => (
|
||||
<div className="relative min-w-56 max-w-72 border-l">
|
||||
<div className="flex">
|
||||
<div className="flex h-10 flex-auto flex-row items-center justify-between px-5">
|
||||
<span className="text-left text-[13px] font-medium">Page actions</span>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 top-10 space-y-3 overflow-y-auto px-4 py-1.5">
|
||||
<div className="flex flex-row">
|
||||
const SidebarActions = React.memo(
|
||||
({
|
||||
page,
|
||||
handleDelete,
|
||||
}: {
|
||||
page: PersonalPage
|
||||
handleDelete: () => void
|
||||
}) => (
|
||||
<div className="relative min-w-56 max-w-72 border-l bg-[var(--body-background)]">
|
||||
<div className="flex">
|
||||
<div className="flex h-10 flex-auto flex-row items-center justify-between px-5">
|
||||
<span className="text-left text-[13px] font-medium text-muted-foreground">
|
||||
Page actions
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 top-10 space-y-3 overflow-y-auto px-4 py-1.5">
|
||||
<TopicSelector
|
||||
value={page.topic?.name}
|
||||
onTopicChange={(topic) => {
|
||||
@@ -101,52 +103,40 @@ const SidebarActions = ({
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleDelete}
|
||||
className="-ml-1.5"
|
||||
>
|
||||
<LaIcon name="Trash" className="mr-2 size-3.5" />
|
||||
<LaIcon name="Trash" className="mr-2" />
|
||||
<span className="text-sm">Delete</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
|
||||
const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
SidebarActions.displayName = "SidebarActions"
|
||||
|
||||
const DetailPageForm = React.memo(({ page }: { page: PersonalPage }) => {
|
||||
const titleEditorRef = React.useRef<Editor | null>(null)
|
||||
const contentEditorRef = React.useRef<LAEditorRef>(null)
|
||||
const isTitleInitialMount = React.useRef(true)
|
||||
const isContentInitialMount = React.useRef(true)
|
||||
const isInitialFocusApplied = React.useRef(false)
|
||||
const contentEditorRef = React.useRef<Editor | null>(null)
|
||||
|
||||
const updatePageContent = React.useCallback(
|
||||
(content: Content, model: PersonalPage) => {
|
||||
if (isContentInitialMount.current) {
|
||||
isContentInitialMount.current = false
|
||||
return
|
||||
}
|
||||
model.content = content
|
||||
model.updatedAt = new Date()
|
||||
(content: Content) => {
|
||||
page.content = content
|
||||
page.updatedAt = new Date()
|
||||
},
|
||||
[],
|
||||
[page],
|
||||
)
|
||||
|
||||
const handleUpdateTitle = React.useCallback(
|
||||
(editor: Editor) => {
|
||||
if (isTitleInitialMount.current) {
|
||||
isTitleInitialMount.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const newTitle = editor.getText()
|
||||
if (newTitle !== page.title) {
|
||||
const slug = generateUniqueSlug(page.title?.toString() || "")
|
||||
const slug = generateUniqueSlug(newTitle || "")
|
||||
page.title = newTitle
|
||||
page.slug = slug
|
||||
page.updatedAt = new Date()
|
||||
@@ -164,22 +154,18 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
const { selection } = state
|
||||
const { $anchor } = selection
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowRight":
|
||||
case "ArrowDown":
|
||||
if ($anchor.pos === state.doc.content.size - 1) {
|
||||
event.preventDefault()
|
||||
contentEditorRef.current?.editor?.commands.focus("start")
|
||||
return true
|
||||
}
|
||||
break
|
||||
case "Enter":
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault()
|
||||
contentEditorRef.current?.editor?.commands.focus("start")
|
||||
return true
|
||||
}
|
||||
break
|
||||
if (
|
||||
(event.key === "ArrowRight" || event.key === "ArrowDown") &&
|
||||
$anchor.pos === state.doc.content.size - 1
|
||||
) {
|
||||
event.preventDefault()
|
||||
contentEditorRef.current?.commands.focus("start")
|
||||
return true
|
||||
}
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
contentEditorRef.current?.commands.focus("start")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -188,7 +174,7 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
|
||||
const handleContentKeyDown = React.useCallback(
|
||||
(view: EditorView, event: KeyboardEvent) => {
|
||||
const editor = contentEditorRef.current?.editor
|
||||
const editor = contentEditorRef.current
|
||||
if (!editor) return false
|
||||
|
||||
const { state } = editor
|
||||
@@ -239,34 +225,21 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
},
|
||||
onCreate: ({ editor }) => {
|
||||
if (page.title) editor.commands.setContent(`<p>${page.title}</p>`)
|
||||
titleEditorRef.current = editor
|
||||
},
|
||||
onBlur: ({ editor }) => handleUpdateTitle(editor),
|
||||
onUpdate: ({ editor }) => handleUpdateTitle(editor),
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (titleEditor) {
|
||||
titleEditorRef.current = titleEditor
|
||||
}
|
||||
}, [titleEditor])
|
||||
|
||||
React.useEffect(() => {
|
||||
isTitleInitialMount.current = true
|
||||
isContentInitialMount.current = true
|
||||
|
||||
if (
|
||||
!isInitialFocusApplied.current &&
|
||||
titleEditor &&
|
||||
contentEditorRef.current?.editor
|
||||
) {
|
||||
isInitialFocusApplied.current = true
|
||||
if (!page.title) {
|
||||
titleEditor?.commands.focus()
|
||||
} else {
|
||||
contentEditorRef.current.editor.commands.focus()
|
||||
const handleCreate = React.useCallback(
|
||||
({ editor }: { editor: Editor }) => {
|
||||
if (page.content) {
|
||||
editor.commands.setContent(page.content as Content)
|
||||
}
|
||||
}
|
||||
}, [page.title, titleEditor])
|
||||
contentEditorRef.current = editor
|
||||
},
|
||||
[page.content],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative flex grow flex-col overflow-y-auto [scrollbar-gutter:stable]">
|
||||
@@ -275,21 +248,21 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
<div className="mb-2 mt-8 py-1.5">
|
||||
<EditorContent
|
||||
editor={titleEditor}
|
||||
className="la-editor no-command grow cursor-text select-text text-2xl font-semibold leading-[calc(1.33333)] tracking-[-0.00625rem]"
|
||||
className="title-editor no-command grow cursor-text select-text text-2xl font-semibold leading-[calc(1.33333)] tracking-[-0.00625rem]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-auto flex-col">
|
||||
<div className="relative flex h-full max-w-full grow flex-col items-stretch p-0">
|
||||
<LAEditor
|
||||
ref={contentEditorRef}
|
||||
<LaEditor
|
||||
editorClassName="-mx-3.5 px-3.5 py-2.5 flex-auto focus:outline-none"
|
||||
value={page.content}
|
||||
value={page.content as Content}
|
||||
placeholder="Add content..."
|
||||
output="json"
|
||||
throttleDelay={3000}
|
||||
onUpdate={(c) => updatePageContent(c, page)}
|
||||
handleKeyDown={handleContentKeyDown}
|
||||
onBlur={(c) => updatePageContent(c, page)}
|
||||
editorProps={{ handleKeyDown: handleContentKeyDown }}
|
||||
onCreate={handleCreate}
|
||||
onUpdate={updatePageContent}
|
||||
onBlur={updatePageContent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -297,4 +270,6 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
DetailPageForm.displayName = "DetailPageForm"
|
||||
|
||||
@@ -24,7 +24,7 @@ export const PageHeader: React.FC<PageHeaderProps> = React.memo(() => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentHeader className="px-6 py-4 max-lg:px-4">
|
||||
<ContentHeader>
|
||||
<HeaderTitle />
|
||||
<div className="flex flex-auto" />
|
||||
<NewPageButton onClick={handleNewPageClick} />
|
||||
|
||||
@@ -36,8 +36,8 @@ export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
className={cn(
|
||||
"relative block cursor-default outline-none",
|
||||
"min-h-12 py-2 max-lg:px-4 sm:px-6",
|
||||
"data-[active='true']:bg-[var(--link-background-muted)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]",
|
||||
"min-h-12 py-2 sm:px-6 max-lg:px-4",
|
||||
"data-[active='true']:bg-[var(--link-background-muted-new)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]",
|
||||
)}
|
||||
to={`/pages/${page.id}`}
|
||||
aria-selected={isActive}
|
||||
@@ -47,7 +47,7 @@ export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(
|
||||
>
|
||||
<div className="flex h-full items-center gap-4">
|
||||
<Column.Wrapper style={columnStyles.title}>
|
||||
<Column.Text className="truncate text-[13px] font-medium">
|
||||
<Column.Text className="truncate text-sm font-medium">
|
||||
{page.title || "Untitled"}
|
||||
</Column.Text>
|
||||
</Column.Wrapper>
|
||||
@@ -64,7 +64,7 @@ export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(
|
||||
style={columnStyles.updated}
|
||||
className="flex justify-end"
|
||||
>
|
||||
<Column.Text className="text-[13px]">
|
||||
<Column.Text className="text-sm">
|
||||
{format(new Date(page.updatedAt), "d MMM yyyy")}
|
||||
</Column.Text>
|
||||
</Column.Wrapper>
|
||||
|
||||
@@ -54,7 +54,7 @@ export const PageList: React.FC<PageListProps> = () => {
|
||||
<div className="flex h-full w-full flex-col overflow-hidden border-t">
|
||||
{!isTablet && <ColumnHeader />}
|
||||
<Primitive.div
|
||||
className="divide-primary/5 flex flex-1 flex-col divide-y overflow-y-auto outline-none [scrollbar-gutter:stable]"
|
||||
className="flex flex-1 flex-col divide-y divide-primary/5 overflow-y-auto outline-none [scrollbar-gutter:stable]"
|
||||
tabIndex={-1}
|
||||
role="list"
|
||||
>
|
||||
@@ -110,7 +110,7 @@ export const ColumnHeader: React.FC = () => {
|
||||
const columnStyles = useColumnStyles()
|
||||
|
||||
return (
|
||||
<div className="flex h-8 shrink-0 grow-0 flex-row gap-4 border-b max-lg:px-4 sm:px-6">
|
||||
<div className="flex h-8 shrink-0 grow-0 flex-row gap-4 border-b sm:px-6 max-lg:px-4">
|
||||
<Column.Wrapper style={columnStyles.title}>
|
||||
<Column.Text>Title</Column.Text>
|
||||
</Column.Wrapper>
|
||||
|
||||
@@ -137,7 +137,7 @@ function ProfileComponent() {
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={changeName}
|
||||
className="border-result mb-3 mr-3 text-[25px] font-semibold"
|
||||
className="mb-3 mr-3 border-result text-[25px] font-semibold"
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-red-500 text-opacity-70">{error}</p>
|
||||
|
||||
@@ -28,7 +28,7 @@ const SearchTitle: React.FC<SearchTitleProps> = ({ title, count }) => (
|
||||
<div className="flex w-full items-center">
|
||||
<h2 className="text-md font-semibold">{title}</h2>
|
||||
<div className="mx-4 flex-grow">
|
||||
<div className="bg-result h-px"></div>
|
||||
<div className="h-px bg-result"></div>
|
||||
</div>
|
||||
<span className="text-base font-light text-opacity-55">{count}</span>
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@ const SearchItem: React.FC<SearchItemProps> = ({
|
||||
subtitle,
|
||||
topic,
|
||||
}) => (
|
||||
<div className="hover:bg-result group flex min-w-0 items-center gap-x-4 rounded-md p-2">
|
||||
<div className="group flex min-w-0 items-center gap-x-4 rounded-md p-2 hover:bg-result">
|
||||
<LaIcon
|
||||
name={icon as "Square"}
|
||||
className="size-4 flex-shrink-0 opacity-0 transition-opacity duration-200 group-hover:opacity-50"
|
||||
@@ -50,7 +50,7 @@ const SearchItem: React.FC<SearchItemProps> = ({
|
||||
<Link
|
||||
to={href}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="hover:text-primary text-sm font-medium hover:opacity-70"
|
||||
className="text-sm font-medium hover:text-primary hover:opacity-70"
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
@@ -58,7 +58,7 @@ const SearchItem: React.FC<SearchItemProps> = ({
|
||||
<Link
|
||||
to={href}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-muted-foreground ml-2 truncate text-xs hover:underline"
|
||||
className="ml-2 truncate text-xs text-muted-foreground hover:underline"
|
||||
>
|
||||
{subtitle}
|
||||
</Link>
|
||||
@@ -138,7 +138,7 @@ const SearchComponent = () => {
|
||||
<div className="relative my-5 flex w-full items-center space-x-2">
|
||||
<LaIcon
|
||||
name="Search"
|
||||
className="text-foreground absolute left-4 size-4 flex-shrink-0"
|
||||
className="absolute left-4 size-4 flex-shrink-0 text-foreground"
|
||||
/>
|
||||
<input
|
||||
autoFocus
|
||||
@@ -146,12 +146,12 @@ const SearchComponent = () => {
|
||||
value={searchText}
|
||||
onChange={handleSearch}
|
||||
placeholder="Search topics, links, pages"
|
||||
className="dark:bg-input w-full rounded-lg border border-neutral-300 p-2 pl-8 focus:outline-none dark:border-neutral-600"
|
||||
className="w-full rounded-lg border border-neutral-300 p-2 pl-8 focus:outline-none dark:border-neutral-600 dark:bg-input"
|
||||
/>
|
||||
{searchText && (
|
||||
<LaIcon
|
||||
name="X"
|
||||
className="text-foreground/50 absolute right-3 size-4 flex-shrink-0 cursor-pointer"
|
||||
className="absolute right-3 size-4 flex-shrink-0 cursor-pointer text-foreground/50"
|
||||
onClick={clearSearch}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -115,7 +115,7 @@ export const TaskForm: React.FC = () => {
|
||||
<div
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-result flex w-full items-center justify-between rounded-lg px-2 py-1"
|
||||
className="flex w-full items-center justify-between rounded-lg bg-result px-2 py-1"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<Checkbox
|
||||
|
||||
@@ -58,7 +58,7 @@ export const TaskItem: React.FC<TaskItemProps> = ({
|
||||
: "No due date"
|
||||
|
||||
return (
|
||||
<li className="bg-result transitiion-opacity flex items-center justify-between rounded-lg p-2 px-3 hover:opacity-60">
|
||||
<li className="transitiion-opacity flex items-center justify-between rounded-lg bg-result p-2 px-3 hover:opacity-60">
|
||||
<div className="flex flex-grow flex-row items-center gap-3">
|
||||
<Checkbox
|
||||
checked={task.status === "done"}
|
||||
@@ -77,7 +77,7 @@ export const TaskItem: React.FC<TaskItemProps> = ({
|
||||
<p
|
||||
className={
|
||||
task.status === "done"
|
||||
? "text-foreground flex-grow line-through"
|
||||
? "flex-grow text-foreground line-through"
|
||||
: "flex-grow"
|
||||
}
|
||||
onClick={clickTitle}
|
||||
@@ -86,7 +86,7 @@ export const TaskItem: React.FC<TaskItemProps> = ({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs">{formattedDate}</span>
|
||||
<span className="text-xs text-muted-foreground">{formattedDate}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export const TopicHeader: React.FC<TopicHeaderProps> = React.memo(() => {
|
||||
if (!me) return null
|
||||
|
||||
return (
|
||||
<ContentHeader className="px-6 py-4 max-lg:px-4">
|
||||
<ContentHeader>
|
||||
<HeaderTitle />
|
||||
<div className="flex flex-auto" />
|
||||
</ContentHeader>
|
||||
@@ -26,7 +26,9 @@ const HeaderTitle: React.FC = () => (
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 items-center">
|
||||
<span className="truncate text-left font-bold lg:text-xl">Topics</span>
|
||||
<span className="truncate text-left font-semibold lg:text-lg">
|
||||
Topics
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -142,8 +142,8 @@ export const TopicItem = React.forwardRef<HTMLAnchorElement, TopicItemProps>(
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
className={cn(
|
||||
"relative block cursor-default outline-none",
|
||||
"min-h-12 py-2 max-lg:px-4 sm:px-6",
|
||||
"data-[active='true']:bg-[var(--link-background-muted)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]",
|
||||
"min-h-12 py-2 sm:px-6 max-lg:px-4",
|
||||
"data-[active='true']:bg-[var(--link-background-muted-new)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]",
|
||||
)}
|
||||
aria-selected={isActive}
|
||||
data-active={isActive}
|
||||
@@ -155,7 +155,7 @@ export const TopicItem = React.forwardRef<HTMLAnchorElement, TopicItemProps>(
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<Column.Wrapper style={columnStyles.title}>
|
||||
<Column.Text className="truncate text-[13px] font-medium">
|
||||
<Column.Text className="truncate text-sm font-medium">
|
||||
{topic.prettyName}
|
||||
</Column.Text>
|
||||
</Column.Wrapper>
|
||||
|
||||
@@ -94,10 +94,10 @@ export const MainTopicList: React.FC<MainTopicListProps> = ({ me }) => {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden border-t">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
{!isTablet && <ColumnHeader />}
|
||||
<Primitive.div
|
||||
className="divide-primary/5 flex flex-1 flex-col divide-y overflow-y-auto outline-none [scrollbar-gutter:stable]"
|
||||
className="flex flex-1 flex-col divide-y divide-primary/5 overflow-y-auto outline-none [scrollbar-gutter:stable]"
|
||||
tabIndex={-1}
|
||||
role="list"
|
||||
>
|
||||
@@ -144,7 +144,7 @@ export const ColumnHeader: React.FC = () => {
|
||||
const columnStyles = useColumnStyles()
|
||||
|
||||
return (
|
||||
<div className="flex h-8 shrink-0 grow-0 flex-row gap-4 border-b max-lg:px-4 sm:px-6">
|
||||
<div className="flex h-8 shrink-0 grow-0 flex-row gap-4 border-b sm:px-6 max-lg:px-4">
|
||||
<Column.Wrapper style={columnStyles.title}>
|
||||
<Column.Text>Name</Column.Text>
|
||||
</Column.Wrapper>
|
||||
|
||||
Reference in New Issue
Block a user