mirror of
https://github.com/linsa-io/linsa.git
synced 2026-04-25 09:48:44 +02:00
Move to TanStack Start from Next.js (#184)
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { LaIcon } from "~/components/custom/la-icon"
|
||||
|
||||
interface Question {
|
||||
id: string
|
||||
title: string
|
||||
author: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
interface QuestionListProps {
|
||||
topicName: string
|
||||
onSelectQuestion: (question: Question) => void
|
||||
selectedQuestionId?: string
|
||||
}
|
||||
|
||||
export function QuestionList({
|
||||
topicName,
|
||||
onSelectQuestion,
|
||||
selectedQuestionId,
|
||||
}: QuestionListProps) {
|
||||
const [questions, setQuestions] = useState<Question[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const mockQuestions: Question[] = Array(10)
|
||||
.fill(null)
|
||||
.map((_, index) => ({
|
||||
id: (index + 1).toString(),
|
||||
title: "What can I do offline in Figma?",
|
||||
author: "Ana",
|
||||
timestamp: "13:35",
|
||||
}))
|
||||
setQuestions(mockQuestions)
|
||||
}, [topicName])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="scrollbar-hide flex-grow overflow-y-auto">
|
||||
{questions.map((question) => (
|
||||
<div
|
||||
key={question.id}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col gap-2 rounded p-4",
|
||||
selectedQuestionId === question.id && "bg-red-500",
|
||||
)}
|
||||
onClick={() => onSelectQuestion(question)}
|
||||
>
|
||||
<div className="flex flex-row justify-between opacity-50">
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<div className="h-6 w-6 rounded-full bg-slate-500" />
|
||||
<p className="text-sm font-medium">{question.author}</p>
|
||||
</div>
|
||||
<p>{question.timestamp}</p>
|
||||
</div>
|
||||
<h3 className="font-medium">{question.title}</h3>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative mt-4">
|
||||
<Input
|
||||
className="bg-input py-5 pr-10 focus:outline-none focus:ring-0"
|
||||
placeholder="Ask new question..."
|
||||
/>
|
||||
<button className="absolute right-2 top-1/2 -translate-y-1/2 transform opacity-60 hover:opacity-80">
|
||||
<LaIcon name="Send" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { LaIcon } from "~/components/custom/la-icon"
|
||||
interface Answer {
|
||||
id: string
|
||||
author: string
|
||||
content: string
|
||||
timestamp: string
|
||||
replies?: Answer[]
|
||||
}
|
||||
|
||||
interface QuestionThreadProps {
|
||||
question: {
|
||||
id: string
|
||||
title: string
|
||||
author: string
|
||||
timestamp: string
|
||||
}
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function QuestionThread({ question, onClose }: QuestionThreadProps) {
|
||||
const [answers, setAnswers] = useState<Answer[]>([])
|
||||
const [newAnswer, setNewAnswer] = useState("")
|
||||
const [replyTo, setReplyTo] = useState<Answer | null>(null)
|
||||
const [replyToAuthor, setReplyToAuthor] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const mockAnswers: Answer[] = [
|
||||
{
|
||||
id: "1",
|
||||
author: "Noone",
|
||||
content:
|
||||
"Just press Command + Just press Command + Just press Command + Just press Command + Just press Command +",
|
||||
timestamp: "14:40",
|
||||
},
|
||||
]
|
||||
setAnswers(mockAnswers)
|
||||
}, [question.id])
|
||||
|
||||
const sendReply = (answer: Answer) => {
|
||||
setReplyTo(answer)
|
||||
setReplyToAuthor(answer.author)
|
||||
setNewAnswer(`@${answer.author} `)
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
const length = inputRef.current.value.length
|
||||
inputRef.current.setSelectionRange(length, length)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const changeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value
|
||||
setNewAnswer(newValue)
|
||||
|
||||
if (replyToAuthor && !newValue.startsWith(`@${replyToAuthor}`)) {
|
||||
setReplyTo(null)
|
||||
setReplyToAuthor(null)
|
||||
}
|
||||
}
|
||||
|
||||
const sendAnswer = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (newAnswer.trim()) {
|
||||
const newReply: Answer = {
|
||||
id: Date.now().toString(),
|
||||
author: "Me",
|
||||
content: newAnswer,
|
||||
timestamp: new Date().toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
}
|
||||
|
||||
if (replyTo) {
|
||||
setAnswers((prevAnswers) =>
|
||||
prevAnswers.map((answer) =>
|
||||
answer.id === replyTo.id
|
||||
? { ...answer, replies: [...(answer.replies || []), newReply] }
|
||||
: answer,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
setAnswers((prevAnswers) => [...prevAnswers, newReply])
|
||||
}
|
||||
setNewAnswer("")
|
||||
setReplyTo(null)
|
||||
setReplyToAuthor(null)
|
||||
}
|
||||
}
|
||||
|
||||
const renderAnswers = (answers: Answer[], isReply = false) => (
|
||||
<div>
|
||||
{answers.map((answer) => (
|
||||
<div
|
||||
key={answer.id}
|
||||
className={`flex-grow overflow-y-auto p-4 ${isReply ? "ml-3 border-l" : ""}`}
|
||||
>
|
||||
<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>
|
||||
<span className="text-sm">{answer.author}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="focus:outline-none">
|
||||
<LaIcon
|
||||
name="Ellipsis"
|
||||
className="mr-2 size-4 shrink-0 opacity-30 hover:opacity-70"
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<div className="w-[15px]">
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onSelect={() => sendReply(answer)}>
|
||||
<div className="mx-auto flex flex-row items-center gap-3">
|
||||
<LaIcon name="Reply" className="size-4 shrink-0" />
|
||||
Reply
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
<span className="text-sm opacity-30">{answer.timestamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end justify-between">
|
||||
<p className="">{answer.content}</p>
|
||||
<LaIcon
|
||||
name="ThumbsUp"
|
||||
className="ml-2 size-4 shrink-0 opacity-70"
|
||||
/>
|
||||
</div>
|
||||
{answer.replies && renderAnswers(answer.replies, true)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
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="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>
|
||||
<h2 className="opacity-70">{question.author}</h2>
|
||||
</div>
|
||||
<button
|
||||
className="bg-accent rounded-full p-1.5 opacity-50 hover:opacity-80"
|
||||
onClick={onClose}
|
||||
>
|
||||
<LaIcon name="X" className="text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-md mb-1 font-semibold">{question.title}</p>
|
||||
<p className="text-sm opacity-70">{question.timestamp}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow overflow-y-auto">{renderAnswers(answers)}</div>
|
||||
<div className="border-accent border-t p-4">
|
||||
<form className="relative" onSubmit={sendAnswer}>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<button className="absolute right-2 top-1/2 -translate-y-1/2 transform opacity-50 hover:opacity-90">
|
||||
<LaIcon name="Send" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useLocation, useNavigate } from "@tanstack/react-router"
|
||||
|
||||
interface GuideCommunityToggleProps {
|
||||
topicName: string
|
||||
}
|
||||
|
||||
export const GuideCommunityToggle: React.FC<GuideCommunityToggleProps> = ({
|
||||
topicName,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const { pathname } = useLocation()
|
||||
const [view, setView] = useState<"guide" | "community">("guide")
|
||||
|
||||
useEffect(() => {
|
||||
setView(pathname.includes("/community/") ? "community" : "guide")
|
||||
}, [pathname])
|
||||
|
||||
const handleToggle = (newView: "guide" | "community") => {
|
||||
setView(newView)
|
||||
if (newView === "community") {
|
||||
navigate({ to: "/community/$topicName", params: { topicName } })
|
||||
} else {
|
||||
navigate({ to: "/$", params: { _splat: topicName } })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-accent/70 relative flex h-8 w-48 items-center rounded-md">
|
||||
<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)" }}
|
||||
/>
|
||||
<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",
|
||||
)}
|
||||
onClick={() => handleToggle("guide")}
|
||||
>
|
||||
Guide
|
||||
</button>
|
||||
<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",
|
||||
)}
|
||||
onClick={() => handleToggle("community")}
|
||||
>
|
||||
Community
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useAccountOrGuest, useCoState } from "@/lib/providers/jazz-provider"
|
||||
import {
|
||||
ContentHeader,
|
||||
SidebarToggleButton,
|
||||
} from "@/components/custom/content-header"
|
||||
import { Topic } from "@/lib/schema"
|
||||
import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants"
|
||||
import { GuideCommunityToggle } from "./-toggle"
|
||||
import { QuestionList } from "./-list"
|
||||
import { QuestionThread } from "./-thread"
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_layout/_pages/_protected/community/$topicName/",
|
||||
)({
|
||||
component: () => <CommunityTopicComponent />,
|
||||
})
|
||||
|
||||
interface Question {
|
||||
id: string
|
||||
title: string
|
||||
author: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
function CommunityTopicComponent() {
|
||||
const { topicName } = Route.useParams()
|
||||
const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
|
||||
const topicID = useMemo(
|
||||
() => me && Topic.findUnique({ topicName }, JAZZ_GLOBAL_GROUP_ID, me),
|
||||
[topicName, me],
|
||||
)
|
||||
const topic = useCoState(Topic, topicID, {
|
||||
latestGlobalGuide: { sections: [] },
|
||||
})
|
||||
|
||||
const [selectedQuestion, setSelectedQuestion] = useState<Question | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
if (!topic) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col">
|
||||
<ContentHeader className="px-6 py-4">
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 flex-col items-start">
|
||||
<p className="opacity-40">Topic</p>
|
||||
<span className="truncate text-left font-bold lg:text-xl">
|
||||
{topic.prettyName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow" />
|
||||
<GuideCommunityToggle topicName={topic.name} />
|
||||
</ContentHeader>
|
||||
<div className="relative flex flex-1 justify-center overflow-hidden">
|
||||
<div
|
||||
className={`w-1/2 overflow-y-auto p-3 transition-all duration-300 ${
|
||||
selectedQuestion ? "opacity-700 translate-x-[-50%]" : ""
|
||||
}`}
|
||||
>
|
||||
<QuestionList
|
||||
topicName={topic.name}
|
||||
onSelectQuestion={(question: Question) =>
|
||||
setSelectedQuestion(question)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{selectedQuestion && (
|
||||
<div className="absolute right-0 top-0 h-full w-1/2 overflow-y-auto">
|
||||
<QuestionThread
|
||||
question={{
|
||||
id: selectedQuestion.id,
|
||||
title: selectedQuestion.title,
|
||||
author: selectedQuestion.author,
|
||||
timestamp: selectedQuestion.timestamp,
|
||||
}}
|
||||
onClose={() => setSelectedQuestion(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
139
web/app/routes/_layout/_pages/_protected/journals/index.tsx
Normal file
139
web/app/routes/_layout/_pages/_protected/journals/index.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
import { JournalEntry, JournalEntryLists } from "@/lib/schema/journal"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { calendarFormatDate } from "@/lib/utils"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
// import { getFeatureFlag } from "~/actions"
|
||||
|
||||
export const Route = createFileRoute("/_layout/_pages/_protected/journals/")({
|
||||
// beforeLoad: async ({ context }) => {
|
||||
// if (!context.user.id) {
|
||||
// throw new Error("Unauthorized")
|
||||
// }
|
||||
|
||||
// const flag = await getFeatureFlag({ name: "JOURNAL" })
|
||||
// const canAccess = context.user?.emailAddresses.some((email) =>
|
||||
// flag?.emails.includes(email.emailAddress),
|
||||
// )
|
||||
// if (!canAccess) {
|
||||
// throw new Error("Unauthorized")
|
||||
// }
|
||||
// },
|
||||
component: () => <JournalComponent />,
|
||||
})
|
||||
|
||||
function JournalComponent() {
|
||||
const [date, setDate] = useState<Date>(new Date())
|
||||
const { me } = useAccount({ root: { journalEntries: [] } })
|
||||
const [newNote, setNewNote] = useState<JournalEntry | null>(null)
|
||||
|
||||
const notes =
|
||||
me?.root?.journalEntries ||
|
||||
(me ? JournalEntryLists.create([], { owner: me }) : [])
|
||||
|
||||
const selectDate = (selectedDate: Date | undefined) => {
|
||||
if (selectedDate) {
|
||||
setDate(selectedDate)
|
||||
}
|
||||
}
|
||||
|
||||
const createNewNote = () => {
|
||||
if (me) {
|
||||
const newEntry = JournalEntry.create(
|
||||
{
|
||||
title: "",
|
||||
content: "",
|
||||
date: date,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{ owner: me._owner },
|
||||
)
|
||||
setNewNote(newEntry)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewNoteChange = (field: keyof JournalEntry, value: string) => {
|
||||
if (newNote) {
|
||||
setNewNote((prevNote) => {
|
||||
if (prevNote) {
|
||||
return JournalEntry.create(
|
||||
{ ...prevNote, [field]: value },
|
||||
{ owner: me!._owner },
|
||||
)
|
||||
}
|
||||
return prevNote
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const saveNewNote = () => {
|
||||
if (newNote && me?.root?.journalEntries) {
|
||||
me.root.journalEntries.push(newNote)
|
||||
setNewNote(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col">
|
||||
<div className="relative flex flex-1 overflow-hidden">
|
||||
<div className="flex-grow overflow-y-auto p-6">
|
||||
{newNote ? (
|
||||
<div className="mb-6 rounded-lg border p-4 shadow-sm">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
value={newNote.title}
|
||||
onChange={(e) => handleNewNoteChange("title", e.target.value)}
|
||||
className="mb-2 w-full text-xl font-semibold"
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="Content"
|
||||
value={newNote.content as string}
|
||||
onChange={(e) => handleNewNoteChange("content", e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<Button onClick={saveNewNote} className="mt-2">
|
||||
Save Note
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{notes.map((entry, index) => (
|
||||
<div key={index} className="mb-6 rounded-lg border p-4 shadow-sm">
|
||||
<h2 className="mb-2 text-xl font-semibold">{entry?.title}</h2>
|
||||
<div className="prose prose-sm max-w-none">
|
||||
{entry?.content &&
|
||||
(typeof entry.content === "string" ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: entry.content }} />
|
||||
) : (
|
||||
<pre>{JSON.stringify(entry.content, null, 2)}</pre>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-sm opacity-70">
|
||||
{entry?.date && calendarFormatDate(new Date(entry.date))}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-[22%] border-l p-2">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={selectDate}
|
||||
className="rounded-md border"
|
||||
/>
|
||||
<Button onClick={createNewNote} className="mt-4 w-full">
|
||||
New Note
|
||||
</Button>
|
||||
<div className="p-2 text-sm opacity-50">
|
||||
<p>Total notes: {notes.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
219
web/app/routes/_layout/_pages/_protected/links/-bottom-bar.tsx
Normal file
219
web/app/routes/_layout/_pages/_protected/links/-bottom-bar.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import * as React from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import type { icons } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn, getShortcutKeys } from "@/lib/utils"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { useAtom } from "jotai"
|
||||
import { useConfirm } from "@omit/react-confirm-dialog"
|
||||
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { PersonalLink } from "@/lib/schema"
|
||||
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"
|
||||
|
||||
interface ToolbarButtonProps
|
||||
extends React.ComponentPropsWithoutRef<typeof Button> {
|
||||
icon: keyof typeof icons
|
||||
onClick?: (e: React.MouseEvent) => void
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonProps>(
|
||||
({ icon, onClick, tooltip, className, ...props }, ref) => {
|
||||
const button = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn("h-8 min-w-14 p-0", className)}
|
||||
onClick={onClick}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<LaIcon name={icon} />
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return button
|
||||
},
|
||||
)
|
||||
|
||||
ToolbarButton.displayName = "ToolbarButton"
|
||||
|
||||
export const LinkBottomBar: React.FC = () => {
|
||||
const { create: createMode, editId } = useSearch({
|
||||
from: "/_layout/_pages/_protected/links/",
|
||||
})
|
||||
const navigate = useNavigate()
|
||||
const [, setGlobalLinkFormExceptionRefsAtom] = useAtom(
|
||||
globalLinkFormExceptionRefsAtom,
|
||||
)
|
||||
const { me } = useAccount({ root: { personalLinks: [] } })
|
||||
const personalLink = useCoState(PersonalLink, editId as ID<PersonalLink>)
|
||||
|
||||
const cancelBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
const confirmBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
const overlayRef = React.useRef<HTMLDivElement>(null)
|
||||
const contentRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const deleteBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
const editMoreBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
const plusBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
const plusMoreBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
|
||||
const { deleteLink } = useLinkActions()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const handleCreateMode = React.useCallback(() => {
|
||||
navigate({
|
||||
to: "/links",
|
||||
search: { create: !createMode, editId: undefined },
|
||||
})
|
||||
}, [createMode, navigate])
|
||||
|
||||
const exceptionRefs = React.useMemo(
|
||||
() => [
|
||||
overlayRef,
|
||||
contentRef,
|
||||
deleteBtnRef,
|
||||
editMoreBtnRef,
|
||||
cancelBtnRef,
|
||||
confirmBtnRef,
|
||||
plusBtnRef,
|
||||
plusMoreBtnRef,
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
setGlobalLinkFormExceptionRefsAtom(exceptionRefs)
|
||||
}, [setGlobalLinkFormExceptionRefsAtom, exceptionRefs])
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (!personalLink || !me) return
|
||||
|
||||
const result = await confirm({
|
||||
title: `Delete "${personalLink.title}"?`,
|
||||
description: "This action cannot be undone.",
|
||||
alertDialogTitle: {
|
||||
className: "text-base",
|
||||
},
|
||||
alertDialogOverlay: {
|
||||
ref: overlayRef,
|
||||
},
|
||||
alertDialogContent: {
|
||||
ref: contentRef,
|
||||
},
|
||||
cancelButton: {
|
||||
variant: "outline",
|
||||
ref: cancelBtnRef,
|
||||
},
|
||||
confirmButton: {
|
||||
variant: "destructive",
|
||||
ref: confirmBtnRef,
|
||||
},
|
||||
})
|
||||
|
||||
if (result) {
|
||||
deleteLink(me, personalLink)
|
||||
|
||||
navigate({
|
||||
to: "/links",
|
||||
search: { create: undefined, editId: undefined },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const shortcutText = getShortcutKeys(["c"])
|
||||
|
||||
return (
|
||||
<div className="bg-background min-h-11 border-t">
|
||||
<AnimatePresence mode="wait">
|
||||
{editId && (
|
||||
<motion.div
|
||||
key="expanded"
|
||||
className="flex h-full items-center justify-center gap-1 border-t px-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
<ToolbarButton
|
||||
icon={"ArrowLeft"}
|
||||
onClick={() => {
|
||||
navigate({
|
||||
to: "/links",
|
||||
search: { create: undefined, editId: undefined },
|
||||
})
|
||||
}}
|
||||
aria-label="Go back"
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={"Trash"}
|
||||
onClick={handleDelete}
|
||||
className="text-destructive hover:text-destructive"
|
||||
ref={deleteBtnRef}
|
||||
aria-label="Delete link"
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={"Ellipsis"}
|
||||
ref={editMoreBtnRef}
|
||||
aria-label="More options"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!editId && (
|
||||
<motion.div
|
||||
key="collapsed"
|
||||
className="flex h-full items-center justify-center gap-1 px-2"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
{createMode && (
|
||||
<ToolbarButton
|
||||
icon={"ArrowLeft"}
|
||||
onClick={handleCreateMode}
|
||||
aria-label="Go back"
|
||||
/>
|
||||
)}
|
||||
{!createMode && (
|
||||
<ToolbarButton
|
||||
icon={"Plus"}
|
||||
onClick={handleCreateMode}
|
||||
tooltip={`New Link (${shortcutText.map((s) => s.symbol).join("")})`}
|
||||
ref={plusBtnRef}
|
||||
aria-label="New link"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
LinkBottomBar.displayName = "LinkBottomBar"
|
||||
|
||||
export default LinkBottomBar
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from "react"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
} from "@/components/ui/form"
|
||||
import { TextareaAutosize } from "@/components/custom/textarea-autosize"
|
||||
import { LinkFormValues } from "./-schema"
|
||||
|
||||
interface DescriptionInputProps {}
|
||||
|
||||
export const DescriptionInput: React.FC<DescriptionInputProps> = () => {
|
||||
const form = useFormContext<LinkFormValues>()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grow space-y-0">
|
||||
<FormLabel className="sr-only">Description</FormLabel>
|
||||
<FormControl>
|
||||
<TextareaAutosize
|
||||
{...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"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
159
web/app/routes/_layout/_pages/_protected/links/-header.tsx
Normal file
159
web/app/routes/_layout/_pages/_protected/links/-header.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import * as React from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
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 { 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 { LaIcon } from "@/components/custom/la-icon"
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
|
||||
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 navigate = useNavigate()
|
||||
const { state } = useSearch({
|
||||
from: "/_layout/_pages/_protected/links/",
|
||||
})
|
||||
|
||||
const handleTabChange = React.useCallback(
|
||||
async (value: string) => {
|
||||
if (value !== state) {
|
||||
navigate({
|
||||
to: "/links",
|
||||
search: { state: value as LearningStateValue },
|
||||
})
|
||||
}
|
||||
},
|
||||
[state, navigate],
|
||||
)
|
||||
|
||||
return (
|
||||
<FancySwitch
|
||||
value={state}
|
||||
onChange={(value) => {
|
||||
handleTabChange(value as string)
|
||||
}}
|
||||
options={ALL_STATES}
|
||||
className="bg-muted flex rounded-lg"
|
||||
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",
|
||||
)}
|
||||
highlighterIncludeMargin={true}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
LearningTab.displayName = "LearningTab"
|
||||
|
||||
const FilterAndSort = 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)
|
||||
setSortOpen(false)
|
||||
},
|
||||
[setSort],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex w-auto items-center justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover open={sortOpen} onOpenChange={setSortOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="min-w-8 gap-x-2 text-sm max-sm:p-0"
|
||||
>
|
||||
<LaIcon name="ListFilter" className="text-primary/60" />
|
||||
<span className="hidden md:block">Filter: {getFilterText()}</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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
FilterAndSort.displayName = "FilterAndSort"
|
||||
220
web/app/routes/_layout/_pages/_protected/links/-item.tsx
Normal file
220
web/app/routes/_layout/_pages/_protected/links/-item.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import * as React from "react"
|
||||
import { useAtom } from "jotai"
|
||||
import { useSortable } from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover"
|
||||
import { LearningStateSelectorContent } from "@/components/custom/learning-state-selector"
|
||||
import { PersonalLink } from "@/lib/schema/personal-link"
|
||||
import { cn, ensureUrlProtocol } from "@/lib/utils"
|
||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||
import { linkOpenPopoverForIdAtom } from "@/store/link"
|
||||
import { LinkForm } from "./-link-form"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
|
||||
interface LinkItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
personalLink: PersonalLink
|
||||
disabled?: boolean
|
||||
editId: string | null
|
||||
isActive: boolean
|
||||
onItemSelected?: (personalLink: PersonalLink) => void
|
||||
onFormClose?: () => void
|
||||
}
|
||||
|
||||
export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
(
|
||||
{
|
||||
personalLink,
|
||||
disabled,
|
||||
editId,
|
||||
isActive,
|
||||
onItemSelected,
|
||||
onFormClose,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [openPopoverForId, setOpenPopoverForId] = useAtom(
|
||||
linkOpenPopoverForIdAtom,
|
||||
)
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: personalLink.id, disabled })
|
||||
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}),
|
||||
[transform, transition],
|
||||
)
|
||||
|
||||
const selectedLearningState = React.useMemo(
|
||||
() =>
|
||||
LEARNING_STATES.find((ls) => ls.value === personalLink.learningState),
|
||||
[personalLink.learningState],
|
||||
)
|
||||
|
||||
const handleLearningStateSelect = React.useCallback(
|
||||
(value: string) => {
|
||||
const learningState = value as LearningStateValue
|
||||
personalLink.learningState =
|
||||
personalLink.learningState === learningState
|
||||
? undefined
|
||||
: learningState
|
||||
setOpenPopoverForId(null)
|
||||
},
|
||||
[personalLink, setOpenPopoverForId],
|
||||
)
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
onItemSelected?.(personalLink)
|
||||
}
|
||||
},
|
||||
[personalLink, onItemSelected],
|
||||
)
|
||||
|
||||
if (editId === personalLink.id) {
|
||||
return (
|
||||
<LinkForm
|
||||
onClose={onFormClose}
|
||||
personalLink={personalLink}
|
||||
onSuccess={onFormClose}
|
||||
onFail={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(node) => {
|
||||
setNodeRef(node)
|
||||
if (typeof ref === "function") {
|
||||
ref(node)
|
||||
} else if (ref) {
|
||||
ref.current = node
|
||||
}
|
||||
}}
|
||||
style={style as React.CSSProperties}
|
||||
{...props}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
tabIndex={0}
|
||||
onDoubleClick={() => onItemSelected?.(personalLink)}
|
||||
aria-disabled={disabled}
|
||||
aria-selected={isActive}
|
||||
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]",
|
||||
)}
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<Popover
|
||||
open={openPopoverForId === personalLink.id}
|
||||
onOpenChange={(open: boolean) =>
|
||||
setOpenPopoverForId(open ? personalLink.id : null)
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
role="combobox"
|
||||
variant="secondary"
|
||||
className="size-7 shrink-0 p-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{selectedLearningState?.icon ? (
|
||||
<LaIcon
|
||||
name={selectedLearningState.icon}
|
||||
className={cn(selectedLearningState.className)}
|
||||
/>
|
||||
) : (
|
||||
<LaIcon name="Circle" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-52 rounded-lg p-0"
|
||||
side="bottom"
|
||||
align="start"
|
||||
>
|
||||
<LearningStateSelectorContent
|
||||
showSearch={false}
|
||||
searchPlaceholder="Search state..."
|
||||
value={personalLink.learningState}
|
||||
onSelect={handleLearningStateSelect}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</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">
|
||||
{personalLink.icon && (
|
||||
<img
|
||||
src={personalLink.icon as string}
|
||||
alt={personalLink.title}
|
||||
className="size-5 shrink-0 rounded-full"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
)}
|
||||
<p className="text-primary hover:text-primary line-clamp-1 text-sm font-medium">
|
||||
{personalLink.title}
|
||||
</p>
|
||||
</div>
|
||||
{personalLink.url && (
|
||||
<div className="text-muted-foreground flex min-w-0 shrink items-center gap-x-1">
|
||||
<LaIcon
|
||||
name="Link"
|
||||
aria-hidden="true"
|
||||
className="size-3 flex-none"
|
||||
/>
|
||||
<Link
|
||||
to={ensureUrlProtocol(personalLink.url)}
|
||||
target="_blank"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="hover:text-primary mr-1 truncate text-xs"
|
||||
>
|
||||
{personalLink.url}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1"></div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-end">
|
||||
{personalLink.topic && (
|
||||
<Badge variant="secondary" className="border-muted-foreground/25">
|
||||
{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>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
LinkItem.displayName = "LinkItem"
|
||||
338
web/app/routes/_layout/_pages/_protected/links/-link-form.tsx
Normal file
338
web/app/routes/_layout/_pages/_protected/links/-link-form.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import * as React from "react"
|
||||
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { PersonalLink, Topic } from "@/lib/schema"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { toast } from "sonner"
|
||||
import { createLinkSchema, LinkFormValues } from "./-schema"
|
||||
import { cn, generateUniqueSlug } from "@/lib/utils"
|
||||
import { Form } from "@/components/ui/form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { atom, useAtom } from "jotai"
|
||||
import { linkLearningStateSelectorAtom } from "@/store/link"
|
||||
import { FormField, FormItem, FormLabel } from "@/components/ui/form"
|
||||
import { LearningStateSelector } from "@/components/custom/learning-state-selector"
|
||||
import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants"
|
||||
import { TitleInput } from "./-title-input"
|
||||
import { UrlInput } from "./-url-input"
|
||||
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, {
|
||||
topicSelectorAtom,
|
||||
} from "~/components/custom/topic-selector"
|
||||
import { getMetadata } from "~/actions"
|
||||
|
||||
export const globalLinkFormExceptionRefsAtom = atom<
|
||||
React.RefObject<HTMLElement>[]
|
||||
>([])
|
||||
|
||||
interface LinkFormProps extends React.ComponentPropsWithoutRef<"form"> {
|
||||
onClose?: () => void
|
||||
onSuccess?: () => void
|
||||
onFail?: () => void
|
||||
personalLink?: PersonalLink
|
||||
exceptionsRefs?: React.RefObject<HTMLElement>[]
|
||||
}
|
||||
|
||||
const defaultValues: Partial<LinkFormValues> = {
|
||||
url: "",
|
||||
icon: "",
|
||||
title: "",
|
||||
description: "",
|
||||
completed: false,
|
||||
notes: "",
|
||||
learningState: undefined,
|
||||
topic: null,
|
||||
}
|
||||
|
||||
export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
onSuccess,
|
||||
onFail,
|
||||
personalLink,
|
||||
onClose,
|
||||
exceptionsRefs = [],
|
||||
}) => {
|
||||
const [istopicSelectorOpen] = useAtom(topicSelectorAtom)
|
||||
const [islearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom)
|
||||
const [globalExceptionRefs] = useAtom(globalLinkFormExceptionRefsAtom)
|
||||
|
||||
const formRef = React.useRef<HTMLFormElement>(null)
|
||||
|
||||
const [isFetching, setIsFetching] = React.useState(false)
|
||||
const [urlFetched, setUrlFetched] = React.useState<string | null>(null)
|
||||
const { me } = useAccount()
|
||||
const selectedLink = useCoState(PersonalLink, personalLink?.id)
|
||||
|
||||
const form = useForm<LinkFormValues>({
|
||||
resolver: zodResolver(createLinkSchema),
|
||||
defaultValues,
|
||||
mode: "all",
|
||||
})
|
||||
|
||||
const topicName = form.watch("topic")
|
||||
const findTopic = React.useMemo(
|
||||
() => me && Topic.findUnique({ topicName }, JAZZ_GLOBAL_GROUP_ID, me),
|
||||
[topicName, me],
|
||||
)
|
||||
|
||||
const selectedTopic = useCoState(Topic, findTopic, {})
|
||||
|
||||
const allExceptionRefs = React.useMemo(
|
||||
() => [...exceptionsRefs, ...globalExceptionRefs],
|
||||
[exceptionsRefs, globalExceptionRefs],
|
||||
)
|
||||
|
||||
useOnClickOutside(formRef, (event) => {
|
||||
if (
|
||||
!istopicSelectorOpen &&
|
||||
!islearningStateSelectorOpen &&
|
||||
!allExceptionRefs.some((ref) =>
|
||||
ref.current?.contains(event.target as Node),
|
||||
)
|
||||
) {
|
||||
console.log("clicking outside")
|
||||
onClose?.()
|
||||
}
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedLink) {
|
||||
setUrlFetched(selectedLink.url)
|
||||
form.reset({
|
||||
url: selectedLink.url,
|
||||
icon: selectedLink.icon,
|
||||
title: selectedLink.title,
|
||||
description: selectedLink.description,
|
||||
completed: selectedLink.completed,
|
||||
notes: selectedLink.notes,
|
||||
learningState: selectedLink.learningState,
|
||||
topic: selectedLink.topic?.name,
|
||||
})
|
||||
}
|
||||
}, [selectedLink, selectedLink?.topic, form])
|
||||
|
||||
const fetchMetadata = async (url: string) => {
|
||||
setIsFetching(true)
|
||||
try {
|
||||
const data = await getMetadata(encodeURIComponent(url))
|
||||
setUrlFetched(data.url)
|
||||
form.setValue("url", data.url, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
form.setValue("icon", data.icon ?? "", {
|
||||
shouldValidate: true,
|
||||
})
|
||||
form.setValue("title", data.title, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
if (!form.getValues("description"))
|
||||
form.setValue("description", data.description, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
form.setFocus("title")
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch metadata", err)
|
||||
} finally {
|
||||
setIsFetching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = (values: LinkFormValues) => {
|
||||
if (isFetching || !me) return
|
||||
|
||||
try {
|
||||
const slug = generateUniqueSlug(values.title)
|
||||
|
||||
if (selectedLink) {
|
||||
if (!selectedTopic) {
|
||||
selectedLink.applyDiff({
|
||||
...values,
|
||||
slug,
|
||||
updatedAt: new Date(),
|
||||
topic: null,
|
||||
})
|
||||
} else {
|
||||
selectedLink.applyDiff({
|
||||
...values,
|
||||
slug,
|
||||
topic: selectedTopic,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const newPersonalLink = PersonalLink.create(
|
||||
{
|
||||
...values,
|
||||
slug,
|
||||
topic: selectedTopic,
|
||||
sequence: me.root?.personalLinks?.length || 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{ owner: me._owner },
|
||||
)
|
||||
me.root?.personalLinks?.push(newPersonalLink)
|
||||
}
|
||||
form.reset(defaultValues)
|
||||
onSuccess?.()
|
||||
} catch (error) {
|
||||
onFail?.()
|
||||
console.error("Failed to create/update link", error)
|
||||
toast.error(
|
||||
personalLink ? "Failed to update link" : "Failed to create link",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
form.reset(defaultValues)
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
const handleResetUrl = () => {
|
||||
setUrlFetched(null)
|
||||
form.setFocus("url")
|
||||
form.reset({ url: "", title: "", icon: "", description: "" })
|
||||
}
|
||||
|
||||
const canSubmit = form.formState.isValid && !form.formState.isSubmitting
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={-1}
|
||||
className="p-3 transition-all"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
handleCancel()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-muted/30 relative rounded-md border",
|
||||
isFetching && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="relative min-w-0 flex-1"
|
||||
>
|
||||
{isFetching && (
|
||||
<div
|
||||
className="absolute inset-0 z-10 bg-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5 p-3">
|
||||
<div className="flex flex-row items-start justify-between">
|
||||
<UrlInput
|
||||
urlFetched={urlFetched}
|
||||
fetchMetadata={fetchMetadata}
|
||||
isFetchingUrlMetadata={isFetching}
|
||||
/>
|
||||
{urlFetched && <TitleInput urlFetched={urlFetched} />}
|
||||
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="learningState"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-0">
|
||||
<FormLabel className="sr-only">Topic</FormLabel>
|
||||
<LearningStateSelector
|
||||
value={field.value}
|
||||
onChange={(value) => {
|
||||
form.setValue(
|
||||
"learningState",
|
||||
field.value === value ? undefined : value,
|
||||
)
|
||||
}}
|
||||
showSearch={false}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="topic"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-0">
|
||||
<FormLabel className="sr-only">Topic</FormLabel>
|
||||
<TopicSelector
|
||||
{...field}
|
||||
renderSelectedText={() => (
|
||||
<span className="truncate">
|
||||
{selectedTopic?.prettyName || "Topic"}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DescriptionInput />
|
||||
<UrlBadge
|
||||
urlFetched={urlFetched}
|
||||
handleResetUrl={handleResetUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between gap-2 rounded-b-md border-t px-3 py-2">
|
||||
<NotesSection />
|
||||
|
||||
{isFetching ? (
|
||||
<div className="flex w-auto items-center justify-end gap-x-2">
|
||||
<span className="text-muted-foreground flex items-center text-sm">
|
||||
<svg
|
||||
className="mr-2 h-4 w-4 animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
Fetching metadata...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-auto items-center justify-end gap-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" type="submit" disabled={!canSubmit}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
LinkForm.displayName = "LinkForm"
|
||||
327
web/app/routes/_layout/_pages/_protected/links/-list.tsx
Normal file
327
web/app/routes/_layout/_pages/_protected/links/-list.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
UniqueIdentifier,
|
||||
MeasuringStrategy,
|
||||
TouchSensor,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import type { MeasuringConfiguration } from "@dnd-kit/core"
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { PersonalLinkLists } from "@/lib/schema/personal-link"
|
||||
import { useAtom } from "jotai"
|
||||
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"
|
||||
|
||||
interface LinkListProps {}
|
||||
|
||||
const measuring: MeasuringConfiguration = {
|
||||
droppable: {
|
||||
strategy: MeasuringStrategy.Always,
|
||||
},
|
||||
}
|
||||
|
||||
const LinkList: React.FC<LinkListProps> = () => {
|
||||
const navigate = useNavigate()
|
||||
const isTouchDevice = useTouchSensor()
|
||||
const lastActiveIndexRef = React.useRef<number | null>(null)
|
||||
const [activeItemIndex, setActiveItemIndex] = React.useState<number | null>(
|
||||
null,
|
||||
)
|
||||
const [keyboardActiveIndex, setKeyboardActiveIndex] = React.useState<
|
||||
number | null
|
||||
>(null)
|
||||
const [, setIsDeleteConfirmShown] = useAtom(isDeleteConfirmShownAtom)
|
||||
const {
|
||||
create: createMode,
|
||||
editId,
|
||||
state,
|
||||
} = useSearch({
|
||||
from: "/_layout/_pages/_protected/links/",
|
||||
})
|
||||
const [draggingId, setDraggingId] = React.useState<UniqueIdentifier | null>(
|
||||
null,
|
||||
)
|
||||
const [sort] = useAtom(linkSortAtom)
|
||||
|
||||
const { deleteLink } = useLinkActions()
|
||||
const confirm = useConfirm()
|
||||
const { me } = useAccount({ root: { personalLinks: [] } })
|
||||
|
||||
const personalLinks = React.useMemo(
|
||||
() => me?.root?.personalLinks || [],
|
||||
[me?.root?.personalLinks],
|
||||
)
|
||||
|
||||
const filteredLinks = React.useMemo(
|
||||
() =>
|
||||
personalLinks.filter((link) => {
|
||||
if (state === "all") return true
|
||||
if (!link?.learningState) return false
|
||||
return link.learningState === state
|
||||
}),
|
||||
[personalLinks, state],
|
||||
)
|
||||
|
||||
const sortedLinks = React.useMemo(
|
||||
() =>
|
||||
sort === "title"
|
||||
? [...filteredLinks].sort((a, b) =>
|
||||
(a?.title || "").localeCompare(b?.title || ""),
|
||||
)
|
||||
: filteredLinks,
|
||||
[filteredLinks, sort],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (editId) {
|
||||
const index = sortedLinks.findIndex((link) => link?.id === editId)
|
||||
if (index !== -1) {
|
||||
lastActiveIndexRef.current = index
|
||||
setActiveItemIndex(index)
|
||||
setKeyboardActiveIndex(index)
|
||||
}
|
||||
}
|
||||
}, [editId, setActiveItemIndex, setKeyboardActiveIndex, sortedLinks])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(isTouchDevice ? TouchSensor : PointerSensor, {
|
||||
activationConstraint: {
|
||||
...(isTouchDevice ? { delay: 100, tolerance: 5 } : {}),
|
||||
distance: 5,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
)
|
||||
|
||||
const updateSequences = React.useCallback((links: PersonalLinkLists) => {
|
||||
links.forEach((link, index) => {
|
||||
if (link) {
|
||||
link.sequence = index
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleDeleteLink = React.useCallback(async () => {
|
||||
if (activeItemIndex === null) return
|
||||
setIsDeleteConfirmShown(true)
|
||||
const activeLink = sortedLinks[activeItemIndex]
|
||||
if (!activeLink || !me) return
|
||||
|
||||
const result = await confirm({
|
||||
title: `Delete "${activeLink.title}"?`,
|
||||
description: "This action cannot be undone.",
|
||||
alertDialogTitle: { className: "text-base" },
|
||||
cancelButton: { variant: "outline" },
|
||||
confirmButton: { variant: "destructive" },
|
||||
})
|
||||
|
||||
if (result) {
|
||||
deleteLink(me, activeLink)
|
||||
}
|
||||
setIsDeleteConfirmShown(false)
|
||||
}, [
|
||||
activeItemIndex,
|
||||
sortedLinks,
|
||||
me,
|
||||
confirm,
|
||||
deleteLink,
|
||||
setIsDeleteConfirmShown,
|
||||
])
|
||||
|
||||
useKeyDown((e) => isModKey(e) && e.key === "Backspace", handleDeleteLink)
|
||||
|
||||
const next = () =>
|
||||
Math.min((activeItemIndex ?? 0) + 1, sortedLinks.length - 1)
|
||||
|
||||
const prev = () => Math.max((activeItemIndex ?? 0) - 1, 0)
|
||||
|
||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||
switch (ev.key) {
|
||||
case "ArrowDown":
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
setActiveItemIndex(next())
|
||||
setKeyboardActiveIndex(next())
|
||||
break
|
||||
case "ArrowUp":
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
setActiveItemIndex(prev())
|
||||
setKeyboardActiveIndex(prev())
|
||||
}
|
||||
}
|
||||
|
||||
useKeyDown(() => true, handleKeyDown)
|
||||
|
||||
const handleDragStart = React.useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
if (sort !== "manual") return
|
||||
if (!me) return
|
||||
|
||||
const { active } = event
|
||||
const activeIndex = me?.root.personalLinks.findIndex(
|
||||
(item) => item?.id === active.id,
|
||||
)
|
||||
|
||||
if (activeIndex === -1) {
|
||||
console.error("Drag operation fail", {
|
||||
activeIndex,
|
||||
activeId: active.id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setActiveItemIndex(activeIndex)
|
||||
setDraggingId(active.id)
|
||||
},
|
||||
[sort, me, setActiveItemIndex],
|
||||
)
|
||||
|
||||
const handleDragCancel = React.useCallback(() => {
|
||||
setDraggingId(null)
|
||||
}, [])
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (!active || !over || !me?.root?.personalLinks) {
|
||||
console.error("Drag operation fail", { active, over })
|
||||
return
|
||||
}
|
||||
|
||||
const oldIndex = me.root.personalLinks.findIndex(
|
||||
(item) => item?.id === active.id,
|
||||
)
|
||||
const newIndex = me.root.personalLinks.findIndex(
|
||||
(item) => item?.id === over.id,
|
||||
)
|
||||
|
||||
if (oldIndex === -1 || newIndex === -1) {
|
||||
console.error("Drag operation fail", {
|
||||
oldIndex,
|
||||
newIndex,
|
||||
activeId: active.id,
|
||||
overId: over.id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (oldIndex !== newIndex) {
|
||||
try {
|
||||
const personalLinksArray = [...me.root.personalLinks]
|
||||
const updatedLinks = arrayMove(personalLinksArray, oldIndex, newIndex)
|
||||
|
||||
while (me.root.personalLinks.length > 0) {
|
||||
me.root.personalLinks.pop()
|
||||
}
|
||||
|
||||
updatedLinks.forEach((link) => {
|
||||
if (link) {
|
||||
me.root.personalLinks.push(link)
|
||||
}
|
||||
})
|
||||
|
||||
updateSequences(me.root.personalLinks)
|
||||
} catch (error) {
|
||||
console.error("Error during link reordering:", error)
|
||||
}
|
||||
}
|
||||
|
||||
setActiveItemIndex(null)
|
||||
setDraggingId(null)
|
||||
}
|
||||
|
||||
const { setElementRef } = useActiveItemScroll<HTMLDivElement>({
|
||||
activeIndex: keyboardActiveIndex,
|
||||
})
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
measuring={measuring}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
>
|
||||
<div
|
||||
className="relative flex h-full grow items-stretch overflow-hidden"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortedLinks.map((item) => item?.id || "") || []}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="relative flex h-full grow flex-col items-stretch overflow-hidden">
|
||||
<div className="flex h-full w-[calc(100%+0px)] flex-col overflow-hidden pr-0">
|
||||
<div className="relative overflow-y-auto overflow-x-hidden [scrollbar-gutter:auto]">
|
||||
{sortedLinks.map(
|
||||
(linkItem, index) =>
|
||||
linkItem && (
|
||||
<LinkItem
|
||||
key={linkItem.id}
|
||||
isActive={activeItemIndex === index}
|
||||
personalLink={linkItem}
|
||||
editId={editId}
|
||||
disabled={sort !== "manual" || !!editId}
|
||||
onPointerMove={() => {
|
||||
if (editId || draggingId || createMode) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
setKeyboardActiveIndex(null)
|
||||
setActiveItemIndex(index)
|
||||
}}
|
||||
onFormClose={async () => {
|
||||
navigate({ to: "/links" })
|
||||
setActiveItemIndex(lastActiveIndexRef.current)
|
||||
setKeyboardActiveIndex(lastActiveIndexRef.current)
|
||||
}}
|
||||
onItemSelected={(link) =>
|
||||
navigate({
|
||||
to: "/links",
|
||||
search: { editId: link.id },
|
||||
})
|
||||
}
|
||||
data-keyboard-active={keyboardActiveIndex === index}
|
||||
ref={(el) => setElementRef(el, index)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</div>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
LinkList.displayName = "LinkList"
|
||||
|
||||
export { LinkList }
|
||||
37
web/app/routes/_layout/_pages/_protected/links/-manage.tsx
Normal file
37
web/app/routes/_layout/_pages/_protected/links/-manage.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as React from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { LinkForm } from "./-link-form"
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
|
||||
interface LinkManageProps {}
|
||||
|
||||
const LinkManage: React.FC<LinkManageProps> = () => {
|
||||
const { create } = useSearch({ from: "/_layout/_pages/_protected/links/" })
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleFormClose = () => {
|
||||
navigate({
|
||||
to: "/links",
|
||||
search: { create: undefined },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{create && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
<LinkForm onClose={handleFormClose} onSuccess={handleFormClose} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
LinkManage.displayName = "LinkManage"
|
||||
|
||||
export { LinkManage }
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
} from "@/components/ui/form"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { LinkFormValues } from "./-schema"
|
||||
|
||||
export const NotesSection: React.FC = () => {
|
||||
const form = useFormContext<LinkFormValues>()
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative grow space-y-0">
|
||||
<FormLabel className="sr-only">Note</FormLabel>
|
||||
<FormControl>
|
||||
<>
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<LaIcon
|
||||
name="Pencil"
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground/70"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
{...field}
|
||||
autoComplete="off"
|
||||
placeholder="Notes"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground/70 border-none pl-8 shadow-none focus-visible:ring-0",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
15
web/app/routes/_layout/_pages/_protected/links/-schema.ts
Normal file
15
web/app/routes/_layout/_pages/_protected/links/-schema.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { z } from "zod"
|
||||
import { urlSchema } from "~/lib/utils/schema"
|
||||
|
||||
export const createLinkSchema = z.object({
|
||||
url: urlSchema,
|
||||
icon: z.string().optional(),
|
||||
title: z.string().min(1, { message: "Title can't be empty" }),
|
||||
description: z.string().optional(),
|
||||
completed: z.boolean().default(false),
|
||||
notes: z.string().optional(),
|
||||
learningState: z.enum(["wantToLearn", "learning", "learned"]).optional(),
|
||||
topic: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
export type LinkFormValues = z.infer<typeof createLinkSchema>
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from "react"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { LinkFormValues } from "./-schema"
|
||||
|
||||
interface TitleInputProps {
|
||||
urlFetched: string | null
|
||||
}
|
||||
|
||||
export const TitleInput: React.FC<TitleInputProps> = ({ urlFetched }) => {
|
||||
const form = useFormContext<LinkFormValues>()
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grow space-y-0">
|
||||
<FormLabel className="sr-only">Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type={urlFetched ? "text" : "hidden"}
|
||||
autoComplete="off"
|
||||
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"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from "react"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { LinkFormValues } from "./-schema"
|
||||
|
||||
interface UrlBadgeProps {
|
||||
urlFetched: string | null
|
||||
handleResetUrl: () => void
|
||||
}
|
||||
|
||||
export const UrlBadge: React.FC<UrlBadgeProps> = ({
|
||||
urlFetched,
|
||||
handleResetUrl,
|
||||
}) => {
|
||||
const form = useFormContext<LinkFormValues>()
|
||||
|
||||
if (!urlFetched) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 py-1.5">
|
||||
<div className="flex min-w-0 flex-row items-center gap-1.5">
|
||||
<Badge variant="secondary" className="relative truncate py-1 text-xs">
|
||||
{form.getValues("url")}
|
||||
<Button
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={handleResetUrl}
|
||||
className="text-muted-foreground hover:text-foreground ml-2 size-4 rounded-full bg-transparent hover:bg-transparent"
|
||||
>
|
||||
<LaIcon name="X" className="size-3.5" />
|
||||
</Button>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as React from "react"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LinkFormValues } from "./-schema"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { TooltipArrow } from "@radix-ui/react-tooltip"
|
||||
|
||||
interface UrlInputProps {
|
||||
urlFetched: string | null
|
||||
fetchMetadata: (url: string) => Promise<void>
|
||||
isFetchingUrlMetadata: boolean
|
||||
}
|
||||
|
||||
export const UrlInput: React.FC<UrlInputProps> = ({
|
||||
urlFetched,
|
||||
fetchMetadata,
|
||||
isFetchingUrlMetadata,
|
||||
}) => {
|
||||
const [isFocused, setIsFocused] = React.useState(false)
|
||||
const form = useFormContext<LinkFormValues>()
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && form.getValues("url")) {
|
||||
e.preventDefault()
|
||||
fetchMetadata(form.getValues("url"))
|
||||
}
|
||||
}
|
||||
|
||||
const shouldShowTooltip =
|
||||
isFocused &&
|
||||
!form.formState.errors.url &&
|
||||
!!form.getValues("url") &&
|
||||
!urlFetched
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
className={cn("grow space-y-0", {
|
||||
"hidden select-none": urlFetched,
|
||||
})}
|
||||
>
|
||||
<FormLabel className="sr-only">Url</FormLabel>
|
||||
<FormControl>
|
||||
<Tooltip open={shouldShowTooltip && !isFetchingUrlMetadata}>
|
||||
<TooltipTrigger asChild>
|
||||
<Input
|
||||
{...field}
|
||||
type={urlFetched ? "hidden" : "text"}
|
||||
autoComplete="off"
|
||||
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"
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="center" side="top">
|
||||
<TooltipArrow className="text-primary fill-current" />
|
||||
<span>
|
||||
Press <kbd className="px-1.5">Enter</kbd> to fetch metadata
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
<FormMessage className="px-1.5" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
35
web/app/routes/_layout/_pages/_protected/links/index.tsx
Normal file
35
web/app/routes/_layout/_pages/_protected/links/index.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { atom } from "jotai"
|
||||
import { LinkBottomBar } from "./-bottom-bar"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { LinkHeader } from "./-header"
|
||||
import { LinkManage } from "./-manage"
|
||||
import { LinkList } from "./-list"
|
||||
import { z } from "zod"
|
||||
import { fallback, zodSearchValidator } from "@tanstack/router-zod-adapter"
|
||||
|
||||
const linkSearchSchema = z.object({
|
||||
state: fallback(
|
||||
z.enum(["all", "wantToLearn", "learning", "learned"]),
|
||||
"all",
|
||||
).default("all"),
|
||||
create: fallback(z.boolean(), false).default(false),
|
||||
editId: fallback(z.string(), "").default(""),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/_layout/_pages/_protected/links/")({
|
||||
validateSearch: zodSearchValidator(linkSearchSchema),
|
||||
component: () => <LinkComponent />,
|
||||
})
|
||||
|
||||
export const isDeleteConfirmShownAtom = atom(false)
|
||||
|
||||
function LinkComponent() {
|
||||
return (
|
||||
<>
|
||||
<LinkHeader />
|
||||
<LinkManage />
|
||||
<LinkList />
|
||||
<LinkBottomBar />
|
||||
</>
|
||||
)
|
||||
}
|
||||
171
web/app/routes/_layout/_pages/_protected/onboarding/index.tsx
Normal file
171
web/app/routes/_layout/_pages/_protected/onboarding/index.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import * as React from "react"
|
||||
import { atomWithStorage } from "jotai/utils"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { useAtom } from "jotai"
|
||||
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { LaIcon } from "~/components/custom/la-icon"
|
||||
|
||||
export const Route = createFileRoute("/_layout/_pages/_protected/onboarding/")({
|
||||
component: () => <OnboardingComponent />,
|
||||
})
|
||||
|
||||
const isCreateLinkDoneAtom = atomWithStorage("isCreateLinkDone", false)
|
||||
const isCreatePageDoneAtom = atomWithStorage("isCreatePageDone", false)
|
||||
const isStartTrackingDoneAtom = atomWithStorage("isStartTrackingDone", false)
|
||||
const isAddLinkDoneAtom = atomWithStorage("isAddLinkDone", false)
|
||||
|
||||
const steps = [
|
||||
{
|
||||
number: 1,
|
||||
title: "Create Link",
|
||||
description:
|
||||
"Links are essentially bookmarks of things from internet. You can create a link by pressing Links button in left sidebar. Then pressing + button on the bottom.",
|
||||
task: "create any Link with any title or description (for example, you can add https://learn-anything.xyz as link)",
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: "Create Page",
|
||||
description:
|
||||
"Pages are things with content inside (images, text, anything). You can think of them as Notion pages. To create page, press the + button next to pages, then create title and put some content.",
|
||||
task: "create any Page with any content inside",
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: "Start tracking Learning status of some Topic",
|
||||
description:
|
||||
"What makes Learn Anything different from Notion and other tools is notion of topics. A topic is anything after learn-anything.xyz/<topic>, for example learn-anything.xyz/typescript. You can go to the page, then on top right corner where it says add to my profile, press it and change the state of the topic to I want to learn, Learning or Learned.",
|
||||
task: "go to any Topic, and mark it as I want to learn",
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
title: "Add a Link from a Topic into personal link collection",
|
||||
description:
|
||||
"If you noticed, there are links attached to topics as a list. This is the topic's study guide. It will be improved greatly in future and we will allow any user to edit these study guides too (Reddit style). You can click on the circle to left of the links and add a link to your personal collection with learning status too.",
|
||||
task: "add any Link from topic typescript into your personal collection",
|
||||
},
|
||||
]
|
||||
|
||||
const StepItem = ({
|
||||
number,
|
||||
title,
|
||||
description,
|
||||
task,
|
||||
done,
|
||||
}: {
|
||||
number: number
|
||||
title: string
|
||||
description: string
|
||||
task: string
|
||||
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">
|
||||
{number}
|
||||
</div>
|
||||
<div className="flex-grow space-y-2">
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
<p className="w-[90%] leading-relaxed opacity-70">{description}</p>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<LaIcon
|
||||
name={done ? "SquareCheck" : "Square"}
|
||||
className={`${done ? "text-green-500" : ""}`}
|
||||
/>
|
||||
<p className={`${done ? "opacity-35" : ""}`}>{task}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
function OnboardingComponent() {
|
||||
const { me } = useAccount({
|
||||
root: {
|
||||
personalPages: [],
|
||||
personalLinks: [],
|
||||
topicsWantToLearn: [],
|
||||
},
|
||||
})
|
||||
|
||||
const [isCreateLinkDone, setIsCreateLinkDone] = useAtom(isCreateLinkDoneAtom)
|
||||
const [isCreatePageDone, setIsCreatePageDone] = useAtom(isCreatePageDoneAtom)
|
||||
const [isStartTrackingDone, setIsStartTrackingDone] = useAtom(
|
||||
isStartTrackingDoneAtom,
|
||||
)
|
||||
const [isAddLinkDone, setIsAddLinkDone] = useAtom(isAddLinkDoneAtom)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!me) return
|
||||
|
||||
if (me.root.personalLinks.length > 0 && !isCreateLinkDone) {
|
||||
setIsCreateLinkDone(true)
|
||||
}
|
||||
|
||||
if (me.root.personalPages.length > 0 && !isCreatePageDone) {
|
||||
setIsCreatePageDone(true)
|
||||
}
|
||||
|
||||
if (me.root.topicsWantToLearn.length > 0 && !isStartTrackingDone) {
|
||||
setIsStartTrackingDone(true)
|
||||
}
|
||||
|
||||
if (
|
||||
me.root.personalLinks.some(
|
||||
(link) => link?.topic?.name === "typescript",
|
||||
) &&
|
||||
!isAddLinkDone
|
||||
) {
|
||||
setIsAddLinkDone(true)
|
||||
}
|
||||
}, [
|
||||
me,
|
||||
isCreateLinkDone,
|
||||
isCreatePageDone,
|
||||
setIsCreateLinkDone,
|
||||
setIsCreatePageDone,
|
||||
isAddLinkDone,
|
||||
setIsAddLinkDone,
|
||||
isStartTrackingDone,
|
||||
setIsStartTrackingDone,
|
||||
])
|
||||
|
||||
const completedSteps = [
|
||||
isCreateLinkDone,
|
||||
isCreatePageDone,
|
||||
isStartTrackingDone,
|
||||
isAddLinkDone,
|
||||
].filter(Boolean).length
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col space-y-4 text-sm text-black dark:text-white">
|
||||
<div className="ml-10 flex flex-col items-start border-b border-neutral-200 bg-inherit dark:border-neutral-900">
|
||||
<p className="h-[70px] p-[20px] text-2xl font-semibold opacity-60">
|
||||
Onboarding
|
||||
</p>
|
||||
</div>
|
||||
<div className="mx-auto w-[70%] rounded-lg border border-neutral-200 bg-inherit p-6 shadow dark:border-neutral-900">
|
||||
<h2 className="mb-4 text-lg font-semibold">
|
||||
Complete the steps below to get started
|
||||
</h2>
|
||||
<p className="mb-4">
|
||||
Completed {completedSteps} out of {steps.length} steps
|
||||
</p>
|
||||
<div className="divide-y">
|
||||
{steps.map((step, index) => (
|
||||
<StepItem
|
||||
key={step.number}
|
||||
{...step}
|
||||
done={
|
||||
[
|
||||
isCreateLinkDone,
|
||||
isCreatePageDone,
|
||||
isStartTrackingDone,
|
||||
isAddLinkDone,
|
||||
][index]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ContentHeader,
|
||||
SidebarToggleButton,
|
||||
} from "@/components/custom/content-header"
|
||||
import { PersonalPage } from "@/lib/schema/personal-page"
|
||||
import { TopicSelector } from "@/components/custom/topic-selector"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
|
||||
interface DetailPageHeaderProps {
|
||||
page: PersonalPage
|
||||
handleDelete: () => void
|
||||
isMobile: boolean
|
||||
}
|
||||
|
||||
export const DetailPageHeader: React.FC<DetailPageHeaderProps> = ({
|
||||
page,
|
||||
handleDelete,
|
||||
isMobile,
|
||||
}) => {
|
||||
if (!isMobile) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentHeader className="lg:min-h-0">
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<SidebarToggleButton />
|
||||
</div>
|
||||
</ContentHeader>
|
||||
|
||||
<div className="flex flex-row items-start gap-1.5 border-b px-6 py-2 max-lg:pl-4">
|
||||
<TopicSelector
|
||||
value={page.topic?.name}
|
||||
onTopicChange={(topic) => {
|
||||
page.topic = topic
|
||||
page.updatedAt = new Date()
|
||||
}}
|
||||
align="start"
|
||||
variant="outline"
|
||||
renderSelectedText={() => (
|
||||
<span className="truncate">
|
||||
{page.topic?.prettyName || "Select a topic"}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleDelete}>
|
||||
<LaIcon name="Trash" className="mr-2 size-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
301
web/app/routes/_layout/_pages/_protected/pages/$pageId/index.tsx
Normal file
301
web/app/routes/_layout/_pages/_protected/pages/$pageId/index.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import * as React from "react"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { ID } from "jazz-tools"
|
||||
import { PersonalPage } from "@/lib/schema"
|
||||
import { Content, EditorContent, useEditor } from "@tiptap/react"
|
||||
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { EditorView } from "@tiptap/pm/view"
|
||||
import { Editor } from "@tiptap/core"
|
||||
import { generateUniqueSlug } from "@/lib/utils"
|
||||
import { FocusClasses } from "@tiptap/extension-focus"
|
||||
import { DetailPageHeader } from "./-header"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { TopicSelector } from "@/components/custom/topic-selector"
|
||||
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"
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_layout/_pages/_protected/pages/$pageId/",
|
||||
)({
|
||||
component: () => <PageDetailComponent />,
|
||||
})
|
||||
|
||||
const TITLE_PLACEHOLDER = "Untitled"
|
||||
|
||||
function PageDetailComponent() {
|
||||
const { pageId } = Route.useParams()
|
||||
const { me } = useAccount({ root: { personalLinks: [] } })
|
||||
const isMobile = useMedia("(max-width: 770px)")
|
||||
const page = useCoState(PersonalPage, pageId as ID<PersonalPage>)
|
||||
const navigate = useNavigate()
|
||||
const { deletePage } = usePageActions()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const handleDelete = React.useCallback(async () => {
|
||||
const result = await confirm({
|
||||
title: "Delete page",
|
||||
description: "Are you sure you want to delete this page?",
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
cancelButton: { variant: "outline" },
|
||||
confirmButton: { variant: "destructive" },
|
||||
})
|
||||
|
||||
if (result && me?.root.personalPages) {
|
||||
deletePage(me, pageId as ID<PersonalPage>)
|
||||
navigate({ to: "/pages" })
|
||||
}
|
||||
}, [confirm, deletePage, me, pageId, navigate])
|
||||
|
||||
if (!page) return null
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-row overflow-hidden">
|
||||
<div className="flex h-full w-full">
|
||||
<div className="relative flex min-w-0 grow basis-[760px] flex-col">
|
||||
<DetailPageHeader
|
||||
page={page}
|
||||
handleDelete={handleDelete}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
<DetailPageForm key={pageId} page={page} />
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<SidebarActions page={page} handleDelete={handleDelete} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<TopicSelector
|
||||
value={page.topic?.name}
|
||||
onTopicChange={(topic) => {
|
||||
page.topic = topic
|
||||
page.updatedAt = new Date()
|
||||
}}
|
||||
variant="ghost"
|
||||
className="-ml-1.5"
|
||||
renderSelectedText={() => (
|
||||
<span className="truncate">
|
||||
{page.topic?.prettyName || "Select a topic"}
|
||||
</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" />
|
||||
<span className="text-sm">Delete</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const DetailPageForm = ({ 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 updatePageContent = React.useCallback(
|
||||
(content: Content, model: PersonalPage) => {
|
||||
if (isContentInitialMount.current) {
|
||||
isContentInitialMount.current = false
|
||||
return
|
||||
}
|
||||
model.content = content
|
||||
model.updatedAt = new Date()
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
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() || "")
|
||||
page.title = newTitle
|
||||
page.slug = slug
|
||||
page.updatedAt = new Date()
|
||||
}
|
||||
},
|
||||
[page],
|
||||
)
|
||||
|
||||
const handleTitleKeyDown = React.useCallback(
|
||||
(view: EditorView, event: KeyboardEvent) => {
|
||||
const editor = titleEditorRef.current
|
||||
if (!editor) return false
|
||||
|
||||
const { state } = editor
|
||||
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
|
||||
}
|
||||
return false
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleContentKeyDown = React.useCallback(
|
||||
(view: EditorView, event: KeyboardEvent) => {
|
||||
const editor = contentEditorRef.current?.editor
|
||||
if (!editor) return false
|
||||
|
||||
const { state } = editor
|
||||
const { selection } = state
|
||||
const { $anchor } = selection
|
||||
|
||||
if (
|
||||
(event.key === "ArrowLeft" || event.key === "ArrowUp") &&
|
||||
$anchor.pos - 1 === 0
|
||||
) {
|
||||
event.preventDefault()
|
||||
titleEditorRef.current?.commands.focus("end")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const titleEditor = useEditor({
|
||||
immediatelyRender: false,
|
||||
extensions: [
|
||||
FocusClasses,
|
||||
Paragraph,
|
||||
StarterKit.configure({
|
||||
bold: false,
|
||||
italic: false,
|
||||
typography: false,
|
||||
hardBreak: false,
|
||||
listItem: false,
|
||||
strike: false,
|
||||
focus: false,
|
||||
gapcursor: false,
|
||||
placeholder: { placeholder: TITLE_PLACEHOLDER },
|
||||
}),
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
spellcheck: "true",
|
||||
role: "textbox",
|
||||
"aria-readonly": "false",
|
||||
"aria-multiline": "false",
|
||||
"aria-label": TITLE_PLACEHOLDER,
|
||||
translate: "no",
|
||||
class: "focus:outline-none",
|
||||
},
|
||||
handleKeyDown: handleTitleKeyDown,
|
||||
},
|
||||
onCreate: ({ editor }) => {
|
||||
if (page.title) editor.commands.setContent(`<p>${page.title}</p>`)
|
||||
},
|
||||
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()
|
||||
}
|
||||
}
|
||||
}, [page.title, titleEditor])
|
||||
|
||||
return (
|
||||
<div className="relative flex grow flex-col overflow-y-auto [scrollbar-gutter:stable]">
|
||||
<div className="relative mx-auto flex h-full w-[calc(100%-80px)] shrink-0 grow flex-col max-lg:w-[calc(100%-40px)] max-lg:max-w-[unset]">
|
||||
<form className="flex shrink-0 flex-col">
|
||||
<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]"
|
||||
/>
|
||||
</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}
|
||||
editorClassName="-mx-3.5 px-3.5 py-2.5 flex-auto focus:outline-none"
|
||||
value={page.content}
|
||||
placeholder="Add content..."
|
||||
output="json"
|
||||
throttleDelay={3000}
|
||||
onUpdate={(c) => updatePageContent(c, page)}
|
||||
handleKeyDown={handleContentKeyDown}
|
||||
onBlur={(c) => updatePageContent(c, page)}
|
||||
onNewBlock={(c) => updatePageContent(c, page)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
web/app/routes/_layout/_pages/_protected/pages/-header.tsx
Normal file
65
web/app/routes/_layout/_pages/_protected/pages/-header.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import * as React from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
ContentHeader,
|
||||
SidebarToggleButton,
|
||||
} from "@/components/custom/content-header"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { usePageActions } from "~/hooks/actions/use-page-actions"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
|
||||
interface PageHeaderProps {}
|
||||
|
||||
export const PageHeader: React.FC<PageHeaderProps> = React.memo(() => {
|
||||
const { me } = useAccount()
|
||||
const navigate = useNavigate()
|
||||
const { newPage } = usePageActions()
|
||||
|
||||
if (!me) return null
|
||||
|
||||
const handleNewPageClick = () => {
|
||||
const page = newPage(me)
|
||||
navigate({ to: `/pages/${page.id}` })
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentHeader className="px-6 py-4 max-lg:px-4">
|
||||
<HeaderTitle />
|
||||
<div className="flex flex-auto" />
|
||||
<NewPageButton onClick={handleNewPageClick} />
|
||||
</ContentHeader>
|
||||
)
|
||||
})
|
||||
|
||||
PageHeader.displayName = "PageHeader"
|
||||
|
||||
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">Pages</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface NewPageButtonProps {
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const NewPageButton: React.FC<NewPageButtonProps> = ({ onClick }) => (
|
||||
<div className="flex w-auto items-center justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="gap-x-2"
|
||||
onClick={onClick}
|
||||
>
|
||||
<LaIcon name="Plus" />
|
||||
<span className="hidden md:block">New page</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
77
web/app/routes/_layout/_pages/_protected/pages/-item.tsx
Normal file
77
web/app/routes/_layout/_pages/_protected/pages/-item.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PersonalPage } from "@/lib/schema"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { format } from "date-fns"
|
||||
import { Column } from "~/components/custom/column"
|
||||
import { Link, useNavigate } from "@tanstack/react-router"
|
||||
import { useColumnStyles } from "./-list"
|
||||
|
||||
interface PageItemProps extends React.HTMLAttributes<HTMLAnchorElement> {
|
||||
page: PersonalPage
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(
|
||||
({ page, isActive, ...props }, ref) => {
|
||||
const isTablet = useMedia("(max-width: 640px)")
|
||||
const columnStyles = useColumnStyles()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLAnchorElement>) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
navigate({ to: `/pages/${page.id}` })
|
||||
}
|
||||
},
|
||||
[navigate, page.id],
|
||||
)
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
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]",
|
||||
)}
|
||||
to={`/pages/${page.id}`}
|
||||
aria-selected={isActive}
|
||||
data-active={isActive}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex h-full items-center gap-4">
|
||||
<Column.Wrapper style={columnStyles.title}>
|
||||
<Column.Text className="truncate text-[13px] font-medium">
|
||||
{page.title || "Untitled"}
|
||||
</Column.Text>
|
||||
</Column.Wrapper>
|
||||
|
||||
{!isTablet && (
|
||||
<Column.Wrapper style={columnStyles.topic}>
|
||||
{page.topic && (
|
||||
<Badge variant="secondary">{page.topic.prettyName}</Badge>
|
||||
)}
|
||||
</Column.Wrapper>
|
||||
)}
|
||||
|
||||
<Column.Wrapper
|
||||
style={columnStyles.updated}
|
||||
className="flex justify-end"
|
||||
>
|
||||
<Column.Text className="text-[13px]">
|
||||
{format(new Date(page.updatedAt), "d MMM yyyy")}
|
||||
</Column.Text>
|
||||
</Column.Wrapper>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
PageItem.displayName = "PageItem"
|
||||
125
web/app/routes/_layout/_pages/_protected/pages/-list.tsx
Normal file
125
web/app/routes/_layout/_pages/_protected/pages/-list.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import * as React from "react"
|
||||
import { Primitive } from "@radix-ui/react-primitive"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { PageItem } from "./-item"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
|
||||
import { useKeyDown } from "@/hooks/use-key-down"
|
||||
import { Column } from "~/components/custom/column"
|
||||
|
||||
interface PageListProps {}
|
||||
|
||||
export const PageList: React.FC<PageListProps> = () => {
|
||||
const isTablet = useMedia("(max-width: 640px)")
|
||||
const { me } = useAccount({ root: { personalPages: [] } })
|
||||
const [activeItemIndex, setActiveItemIndex] = React.useState<number | null>(
|
||||
null,
|
||||
)
|
||||
const [keyboardActiveIndex, setKeyboardActiveIndex] = React.useState<
|
||||
number | null
|
||||
>(null)
|
||||
const personalPages = React.useMemo(
|
||||
() => me?.root?.personalPages,
|
||||
[me?.root?.personalPages],
|
||||
)
|
||||
|
||||
const next = () =>
|
||||
Math.min((activeItemIndex ?? 0) + 1, (personalPages?.length ?? 0) - 1)
|
||||
|
||||
const prev = () => Math.max((activeItemIndex ?? 0) - 1, 0)
|
||||
|
||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||
switch (ev.key) {
|
||||
case "ArrowDown":
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
setActiveItemIndex(next())
|
||||
setKeyboardActiveIndex(next())
|
||||
break
|
||||
case "ArrowUp":
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
setActiveItemIndex(prev())
|
||||
setKeyboardActiveIndex(prev())
|
||||
}
|
||||
}
|
||||
|
||||
useKeyDown(() => true, handleKeyDown)
|
||||
|
||||
const { setElementRef } = useActiveItemScroll<HTMLAnchorElement>({
|
||||
activeIndex: keyboardActiveIndex,
|
||||
})
|
||||
|
||||
return (
|
||||
<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]"
|
||||
tabIndex={-1}
|
||||
role="list"
|
||||
>
|
||||
{personalPages?.map(
|
||||
(page, index) =>
|
||||
page?.id && (
|
||||
<PageItem
|
||||
key={page.id}
|
||||
ref={(el) => setElementRef(el, index)}
|
||||
page={page}
|
||||
isActive={index === activeItemIndex}
|
||||
onPointerMove={() => {
|
||||
setKeyboardActiveIndex(null)
|
||||
setActiveItemIndex(index)
|
||||
}}
|
||||
data-keyboard-active={keyboardActiveIndex === index}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</Primitive.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const useColumnStyles = () => {
|
||||
const isTablet = useMedia("(max-width: 640px)")
|
||||
|
||||
return {
|
||||
title: {
|
||||
"--width": "69px",
|
||||
"--min-width": "200px",
|
||||
"--max-width": isTablet ? "none" : "auto",
|
||||
},
|
||||
content: {
|
||||
"--width": "auto",
|
||||
"--min-width": "200px",
|
||||
"--max-width": "200px",
|
||||
},
|
||||
topic: {
|
||||
"--width": "65px",
|
||||
"--min-width": "120px",
|
||||
"--max-width": "120px",
|
||||
},
|
||||
updated: {
|
||||
"--width": "82px",
|
||||
"--min-width": "82px",
|
||||
"--max-width": "82px",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<Column.Wrapper style={columnStyles.title}>
|
||||
<Column.Text>Title</Column.Text>
|
||||
</Column.Wrapper>
|
||||
<Column.Wrapper style={columnStyles.topic}>
|
||||
<Column.Text>Topic</Column.Text>
|
||||
</Column.Wrapper>
|
||||
<Column.Wrapper style={columnStyles.updated}>
|
||||
<Column.Text>Updated</Column.Text>
|
||||
</Column.Wrapper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
web/app/routes/_layout/_pages/_protected/pages/index.tsx
Normal file
16
web/app/routes/_layout/_pages/_protected/pages/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { PageHeader } from "./-header"
|
||||
import { PageList } from "./-list"
|
||||
|
||||
export const Route = createFileRoute("/_layout/_pages/_protected/pages/")({
|
||||
component: () => <PageComponent />,
|
||||
})
|
||||
|
||||
export function PageComponent() {
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col overflow-hidden">
|
||||
<PageHeader />
|
||||
<PageList />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
192
web/app/routes/_layout/_pages/_protected/profile/index.tsx
Normal file
192
web/app/routes/_layout/_pages/_protected/profile/index.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import * as React from "react"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Avatar, AvatarImage } from "@/components/ui/avatar"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useUser } from "@clerk/tanstack-start"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/_layout/_pages/_protected/profile/")({
|
||||
component: () => <ProfileComponent />,
|
||||
})
|
||||
|
||||
interface ProfileStatsProps {
|
||||
number: number
|
||||
label: string
|
||||
}
|
||||
|
||||
const ProfileStats: React.FC<ProfileStatsProps> = ({ number, label }) => {
|
||||
return (
|
||||
<div className="text-center font-semibold text-black/60 dark:text-white">
|
||||
<p className="text-4xl">{number}</p>
|
||||
<p className="text-[#878787]">{label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileComponent() {
|
||||
const account = useAccount()
|
||||
const username = ""
|
||||
const { user } = useUser()
|
||||
const avatarInputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
const editAvatar = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) {
|
||||
const imageUrl = URL.createObjectURL(file)
|
||||
if (account.me && account.me.profile) {
|
||||
account.me.profile.avatarUrl = imageUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [isEditing, setIsEditing] = React.useState(false)
|
||||
const [newName, setNewName] = React.useState(account.me?.profile?.name || "")
|
||||
const [error, setError] = React.useState("")
|
||||
|
||||
const editProfileClicked = () => {
|
||||
setIsEditing(true)
|
||||
setError("")
|
||||
}
|
||||
|
||||
const changeName = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNewName(e.target.value)
|
||||
setError("")
|
||||
}
|
||||
|
||||
const validateName = React.useCallback((name: string) => {
|
||||
if (name.trim().length < 2) {
|
||||
return "Name must be at least 2 characters long"
|
||||
}
|
||||
if (name.trim().length > 40) {
|
||||
return "Name must not exceed 40 characters"
|
||||
}
|
||||
return ""
|
||||
}, [])
|
||||
|
||||
const saveProfile = () => {
|
||||
const validationError = validateName(newName)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
if (account.me && account.me.profile) {
|
||||
account.me.profile.name = newName.trim()
|
||||
}
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
setNewName(account.me?.profile?.name || "")
|
||||
setIsEditing(false)
|
||||
setError("")
|
||||
}
|
||||
|
||||
if (!account.me || !account.me.profile) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col py-3 text-black dark:text-white">
|
||||
<div className="flex flex-1 flex-col rounded-3xl border border-neutral-800">
|
||||
<p className="my-10 h-[74px] border-b border-neutral-900 text-center text-2xl font-semibold">
|
||||
Oops! This account doesn't exist.
|
||||
</p>
|
||||
<p className="mb-5 text-center text-lg font-semibold">
|
||||
Try searching for another.
|
||||
</p>
|
||||
<p className="mb-5 text-center text-lg font-semibold">
|
||||
The link you followed may be broken, or the page may have been
|
||||
removed. Go back to
|
||||
<Link to="/">homepage</Link>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col text-black dark:text-white">
|
||||
<div className="flex items-center justify-between p-[20px]">
|
||||
<p className="text-2xl font-semibold opacity-70">Profile</p>
|
||||
</div>
|
||||
<p className="text-2xl font-semibold">{username}</p>
|
||||
<div className="flex flex-col items-center border-b border-neutral-900 bg-inherit pb-5">
|
||||
<div className="flex w-full max-w-2xl align-top">
|
||||
<Button
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
variant="ghost"
|
||||
className="p-0 hover:bg-transparent"
|
||||
>
|
||||
<Avatar className="size-20">
|
||||
<AvatarImage
|
||||
src={account.me?.profile?.avatarUrl || user?.imageUrl}
|
||||
alt={user?.fullName || ""}
|
||||
/>
|
||||
</Avatar>
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
ref={avatarInputRef}
|
||||
onChange={editAvatar}
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<div className="ml-6 flex-1">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={changeName}
|
||||
className="border-result mb-3 mr-3 text-[25px] font-semibold"
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-red-500 text-opacity-70">{error}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="mb-3 text-[25px] font-semibold">
|
||||
{account.me?.profile?.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div>
|
||||
<Button onClick={saveProfile} className="mr-2">
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={cancelEditing} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={editProfileClicked}
|
||||
className="shadow-outer ml-auto flex h-[34px] cursor-pointer flex-row items-center justify-center space-x-2 rounded-lg bg-white px-3 text-center font-medium text-black shadow-[1px_1px_1px_1px_rgba(0,0,0,0.3)] hover:bg-black/10 dark:bg-[#222222] dark:text-white dark:hover:opacity-60"
|
||||
>
|
||||
Edit profile
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-10 flex justify-center">
|
||||
<div className="flex flex-row gap-20">
|
||||
<ProfileStats
|
||||
number={account.me.root?.topicsLearning?.length || 0}
|
||||
label="Learning"
|
||||
/>
|
||||
<ProfileStats
|
||||
number={account.me.root?.topicsWantToLearn?.length || 0}
|
||||
label="To Learn"
|
||||
/>
|
||||
<ProfileStats
|
||||
number={account.me.root?.topicsLearned?.length || 0}
|
||||
label="Learned"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto py-20">
|
||||
<p>Public profiles are coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
234
web/app/routes/_layout/_pages/_protected/search/index.tsx
Normal file
234
web/app/routes/_layout/_pages/_protected/search/index.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import * as React from "react"
|
||||
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { Topic, PersonalLink, PersonalPage } from "@/lib/schema"
|
||||
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
|
||||
import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import AiSearch from "~/components/custom/ai-search"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/_layout/_pages/_protected/search/")({
|
||||
component: () => <SearchComponent />,
|
||||
})
|
||||
|
||||
interface SearchTitleProps {
|
||||
title: string
|
||||
count: number
|
||||
}
|
||||
interface SearchItemProps {
|
||||
icon: string
|
||||
href: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
topic?: Topic
|
||||
}
|
||||
|
||||
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>
|
||||
<span className="text-base font-light text-opacity-55">{count}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
const SearchItem: React.FC<SearchItemProps> = ({
|
||||
icon,
|
||||
href,
|
||||
title,
|
||||
subtitle,
|
||||
topic,
|
||||
}) => (
|
||||
<div className="hover:bg-result group flex min-w-0 items-center gap-x-4 rounded-md p-2">
|
||||
<LaIcon
|
||||
name={icon as "Square"}
|
||||
className="size-4 flex-shrink-0 opacity-0 transition-opacity duration-200 group-hover:opacity-50"
|
||||
/>
|
||||
<div className="group flex items-center justify-between">
|
||||
<Link
|
||||
to={href}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="hover:text-primary text-sm font-medium hover:opacity-70"
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
{subtitle && (
|
||||
<Link
|
||||
to={href}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-muted-foreground ml-2 truncate text-xs hover:underline"
|
||||
>
|
||||
{subtitle}
|
||||
</Link>
|
||||
)}
|
||||
{topic && (
|
||||
<span className="ml-2 text-xs opacity-45">
|
||||
{topic.latestGlobalGuide?.sections?.reduce(
|
||||
(total, section) => total + (section?.links?.length || 0),
|
||||
0,
|
||||
) || 0}{" "}
|
||||
links
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const SearchComponent = () => {
|
||||
const [searchText, setSearchText] = React.useState("")
|
||||
const [showAiSearch, setShowAiSearch] = React.useState(false)
|
||||
const [searchResults, setSearchResults] = React.useState<{
|
||||
topics: Topic[]
|
||||
links: PersonalLink[]
|
||||
pages: PersonalPage[]
|
||||
}>({ topics: [], links: [], pages: [] })
|
||||
|
||||
const { me } = useAccount({
|
||||
root: { personalLinks: [], personalPages: [] },
|
||||
})
|
||||
|
||||
const globalGroup = useCoState(PublicGlobalGroup, JAZZ_GLOBAL_GROUP_ID, {
|
||||
root: {
|
||||
topics: [],
|
||||
},
|
||||
})
|
||||
|
||||
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value.toLowerCase()
|
||||
setSearchText(value)
|
||||
|
||||
if (!value) {
|
||||
setSearchResults({ topics: [], links: [], pages: [] })
|
||||
return
|
||||
}
|
||||
setSearchResults({
|
||||
topics:
|
||||
globalGroup?.root.topics?.filter(
|
||||
(topic: Topic | null): topic is Topic =>
|
||||
topic !== null && topic.prettyName.toLowerCase().startsWith(value),
|
||||
) || [],
|
||||
links:
|
||||
me?.root.personalLinks?.filter(
|
||||
(link: PersonalLink | null): link is PersonalLink =>
|
||||
link !== null && link.title.toLowerCase().startsWith(value),
|
||||
) || [],
|
||||
pages:
|
||||
me?.root.personalPages?.filter(
|
||||
(page): page is PersonalPage =>
|
||||
page !== null &&
|
||||
page.title !== undefined &&
|
||||
page.title.toLowerCase().startsWith(value),
|
||||
) || [],
|
||||
})
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchText("")
|
||||
setSearchResults({ topics: [], links: [], pages: [] })
|
||||
setShowAiSearch(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col overflow-hidden">
|
||||
<div className="flex h-full w-full justify-center overflow-y-auto">
|
||||
<div className="w-full max-w-[70%] sm:px-6 lg:px-8">
|
||||
<div className="relative mb-2 mt-5 flex w-full flex-row items-center transition-colors duration-300">
|
||||
<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"
|
||||
/>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
{searchText && (
|
||||
<LaIcon
|
||||
name="X"
|
||||
className="text-foreground/50 absolute right-3 size-4 flex-shrink-0 cursor-pointer"
|
||||
onClick={clearSearch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-full pb-5">
|
||||
{Object.values(searchResults).some((arr) => arr.length > 0) ? (
|
||||
<div className="space-y-1">
|
||||
{searchResults.links.length > 0 && (
|
||||
<>
|
||||
<SearchTitle
|
||||
title="Links"
|
||||
count={searchResults.links.length}
|
||||
/>
|
||||
{searchResults.links.map((link) => (
|
||||
<SearchItem
|
||||
key={link.id}
|
||||
icon="Square"
|
||||
href={link.url}
|
||||
title={link.title}
|
||||
subtitle={link.url}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{searchResults.pages.length > 0 && (
|
||||
<>
|
||||
<SearchTitle
|
||||
title="Pages"
|
||||
count={searchResults.pages.length}
|
||||
/>
|
||||
{searchResults.pages.map((page) => (
|
||||
<SearchItem
|
||||
key={page.id}
|
||||
icon="Square"
|
||||
href={`/pages/${page.id}`}
|
||||
title={page.title || ""}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{searchResults.topics.length > 0 && (
|
||||
<>
|
||||
<SearchTitle
|
||||
title="Topics"
|
||||
count={searchResults.topics.length}
|
||||
/>
|
||||
{searchResults.topics.map((topic) => (
|
||||
<SearchItem
|
||||
key={topic.id}
|
||||
icon="Square"
|
||||
href={`/${topic.name}`}
|
||||
title={topic.prettyName}
|
||||
topic={topic}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5">
|
||||
{/* {searchText && !showAiSearch && ( */}
|
||||
{searchText && (
|
||||
<div
|
||||
className="cursor-default rounded-lg bg-blue-700 p-4 font-semibold text-white"
|
||||
// onClick={() => setShowAiSearch(true)}
|
||||
>
|
||||
✨ Didn't find what you were looking for? Will soon
|
||||
have AI assistant builtin
|
||||
</div>
|
||||
)}
|
||||
{showAiSearch && <AiSearch searchQuery={searchText} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
132
web/app/routes/_layout/_pages/_protected/settings/index.tsx
Normal file
132
web/app/routes/_layout/_pages/_protected/settings/index.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useState, useCallback, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export const Route = createFileRoute("/_layout/_pages/_protected/settings/")({
|
||||
component: () => <SettingsComponent />,
|
||||
})
|
||||
|
||||
const MODIFIER_KEYS = ["Control", "Alt", "Shift", "Meta"]
|
||||
|
||||
const HotkeyInput = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}) => {
|
||||
const [recording, setRecording] = useState(false)
|
||||
const [currentKeys, setCurrentKeys] = useState<string[]>([])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
e.preventDefault()
|
||||
if (!recording) return
|
||||
const key = e.key === " " ? "Space" : e.key
|
||||
if (!currentKeys.includes(key)) {
|
||||
setCurrentKeys((prev) => {
|
||||
const newKeys = [...prev, key]
|
||||
return newKeys.slice(-3)
|
||||
})
|
||||
}
|
||||
},
|
||||
[recording, currentKeys],
|
||||
)
|
||||
|
||||
const handleKeyUp = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (!recording) return
|
||||
const key = e.key === " " ? "Space" : e.key
|
||||
if (MODIFIER_KEYS.includes(key)) return
|
||||
if (currentKeys.length > 0) {
|
||||
onChange(currentKeys.join("+"))
|
||||
setRecording(false)
|
||||
setCurrentKeys([])
|
||||
}
|
||||
},
|
||||
[recording, currentKeys, onChange],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (recording) {
|
||||
const handleKeyDownEvent = (e: KeyboardEvent) =>
|
||||
handleKeyDown(e as unknown as React.KeyboardEvent)
|
||||
const handleKeyUpEvent = (e: KeyboardEvent) =>
|
||||
handleKeyUp(e as unknown as React.KeyboardEvent)
|
||||
window.addEventListener("keydown", handleKeyDownEvent)
|
||||
window.addEventListener("keyup", handleKeyUpEvent)
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDownEvent)
|
||||
window.removeEventListener("keyup", handleKeyUpEvent)
|
||||
}
|
||||
}
|
||||
}, [recording, handleKeyDown, handleKeyUp])
|
||||
|
||||
return (
|
||||
<div className="mb-4 space-y-2">
|
||||
<label className="block text-sm font-medium">{label}</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={recording ? currentKeys.join("+") : value}
|
||||
placeholder="Click to set hotkey"
|
||||
className="flex-grow"
|
||||
readOnly
|
||||
onClick={() => setRecording(true)}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (recording) {
|
||||
setRecording(false)
|
||||
setCurrentKeys([])
|
||||
} else {
|
||||
setRecording(true)
|
||||
}
|
||||
}}
|
||||
variant={recording ? "destructive" : "secondary"}
|
||||
>
|
||||
{recording ? "Cancel" : "Set"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SettingsComponent = () => {
|
||||
// const { me } = useAccount()
|
||||
const [inboxHotkey, setInboxHotkey] = useState("")
|
||||
const [topInboxHotkey, setTopInboxHotkey] = useState("")
|
||||
|
||||
const saveSettings = () => {
|
||||
toast.success("Settings saved", {
|
||||
description: "Your hotkey settings have been updated.",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="border-b border-neutral-200 dark:border-neutral-800">
|
||||
<h1 className="p-6 text-2xl font-semibold">Settings</h1>
|
||||
</header>
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<section className="mb-8 max-w-md">
|
||||
<HotkeyInput
|
||||
label="Save to Inbox"
|
||||
value={inboxHotkey}
|
||||
onChange={setInboxHotkey}
|
||||
/>
|
||||
<HotkeyInput
|
||||
label="Save to Inbox (Top)"
|
||||
value={topInboxHotkey}
|
||||
onChange={setTopInboxHotkey}
|
||||
/>
|
||||
</section>
|
||||
<Button onClick={saveSettings}>Save Settings</Button>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
155
web/app/routes/_layout/_pages/_protected/tasks/-form.tsx
Normal file
155
web/app/routes/_layout/_pages/_protected/tasks/-form.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { ListOfTasks, Task } from "@/lib/schema/task"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { DatePicker } from "~/components/custom/date-picker"
|
||||
import { useSearch } from "@tanstack/react-router"
|
||||
|
||||
export const TaskForm: React.FC = () => {
|
||||
const { filter } = useSearch({ from: "/_layout/_pages/_protected/tasks/" })
|
||||
const [title, setTitle] = useState("")
|
||||
const [dueDate, setDueDate] = useState<Date | undefined>(
|
||||
filter === "today" ? new Date() : undefined,
|
||||
)
|
||||
const [inputVisible, setInputVisible] = useState(false)
|
||||
const { me } = useAccount({ root: {} })
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const formRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setTitle("")
|
||||
setDueDate(filter === "today" ? new Date() : undefined)
|
||||
setInputVisible(false)
|
||||
}, [filter])
|
||||
|
||||
const saveTask = useCallback(() => {
|
||||
if (title.trim() && (filter !== "upcoming" || dueDate)) {
|
||||
if (me?.root?.tasks === undefined) {
|
||||
if (!me) return
|
||||
me.root.tasks = ListOfTasks.create([], { owner: me })
|
||||
}
|
||||
|
||||
const newTask = Task.create(
|
||||
{
|
||||
title,
|
||||
description: "",
|
||||
status: "todo",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
dueDate:
|
||||
filter === "upcoming"
|
||||
? dueDate
|
||||
: filter === "today"
|
||||
? new Date()
|
||||
: null,
|
||||
},
|
||||
{ owner: me._owner },
|
||||
)
|
||||
me.root.tasks?.push(newTask)
|
||||
resetForm()
|
||||
}
|
||||
}, [title, dueDate, filter, me, resetForm])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
saveTask()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
resetForm()
|
||||
} else if (e.key === "Backspace" && title.trim() === "") {
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (inputVisible && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [inputVisible])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (formRef.current && !formRef.current.contains(event.target as Node)) {
|
||||
if (title.trim()) {
|
||||
saveTask()
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
}, [title, saveTask, resetForm])
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<AnimatePresence mode="wait">
|
||||
{filter ? (
|
||||
!inputVisible ? (
|
||||
<motion.div
|
||||
key="add-button"
|
||||
initial={{ opacity: 0, width: 0 }}
|
||||
animate={{ opacity: 1, width: "auto" }}
|
||||
exit={{ opacity: 0, width: 0 }}
|
||||
transition={{ duration: 0.01 }}
|
||||
>
|
||||
<Button
|
||||
className="flex flex-row items-center gap-1"
|
||||
onClick={() => setInputVisible(true)}
|
||||
variant="outline"
|
||||
>
|
||||
<LaIcon name="Plus" />
|
||||
Add task
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-result flex w-full items-center justify-between rounded-lg px-2 py-1"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<Checkbox
|
||||
checked={false}
|
||||
onCheckedChange={() => {}}
|
||||
className="mr-2"
|
||||
/>
|
||||
<Input
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
value={title}
|
||||
className="flex-grow border-none bg-transparent p-0 focus-visible:ring-0"
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Task title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="ml-2 flex items-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{filter === "upcoming" && (
|
||||
<DatePicker
|
||||
date={dueDate}
|
||||
onDateChange={(date: Date | undefined) => setDueDate(date)}
|
||||
className="z-50"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
92
web/app/routes/_layout/_pages/_protected/tasks/-item.tsx
Normal file
92
web/app/routes/_layout/_pages/_protected/tasks/-item.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { format } from "date-fns"
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import { Task } from "~/lib/schema/task"
|
||||
|
||||
interface TaskItemProps {
|
||||
task: Task
|
||||
onUpdateTask: (taskId: string, updates: Partial<Task>) => void
|
||||
onDeleteTask: (taskId: string) => void
|
||||
}
|
||||
|
||||
export const TaskItem: React.FC<TaskItemProps> = ({
|
||||
task,
|
||||
onUpdateTask,
|
||||
onDeleteTask,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editedTitle, setEditedTitle] = useState(task.title)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [isEditing])
|
||||
|
||||
const statusChange = (checked: boolean) => {
|
||||
if (checked) {
|
||||
onDeleteTask(task.id)
|
||||
} else {
|
||||
onUpdateTask(task.id, { status: "todo" })
|
||||
}
|
||||
}
|
||||
|
||||
const clickTitle = () => {
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const titleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEditedTitle(e.target.value)
|
||||
}
|
||||
|
||||
const titleBlur = () => {
|
||||
setIsEditing(false)
|
||||
if (editedTitle.trim() !== task.title) {
|
||||
onUpdateTask(task.id, { title: editedTitle.trim() })
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
titleBlur()
|
||||
}
|
||||
}
|
||||
|
||||
const formattedDate = task.dueDate
|
||||
? format(new Date(task.dueDate), "EEE, MMMM do, yyyy")
|
||||
: "No due date"
|
||||
|
||||
return (
|
||||
<li className="bg-result transitiion-opacity flex items-center justify-between rounded-lg p-2 px-3 hover:opacity-60">
|
||||
<div className="flex flex-grow flex-row items-center gap-3">
|
||||
<Checkbox
|
||||
checked={task.status === "done"}
|
||||
onCheckedChange={statusChange}
|
||||
/>
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={editedTitle}
|
||||
onChange={titleChange}
|
||||
onBlur={titleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-grow border-none bg-transparent p-0 shadow-none outline-none focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
className={
|
||||
task.status === "done"
|
||||
? "text-foreground flex-grow line-through"
|
||||
: "flex-grow"
|
||||
}
|
||||
onClick={clickTitle}
|
||||
>
|
||||
{task.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs">{formattedDate}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
31
web/app/routes/_layout/_pages/_protected/tasks/-list.tsx
Normal file
31
web/app/routes/_layout/_pages/_protected/tasks/-list.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Task } from "~/lib/schema/task"
|
||||
import { TaskItem } from "./-item"
|
||||
|
||||
interface TaskListProps {
|
||||
tasks: Task[]
|
||||
onUpdateTask: (taskId: string, updates: Partial<Task>) => void
|
||||
onDeleteTask: (taskId: string) => void
|
||||
}
|
||||
|
||||
export const TaskList: React.FC<TaskListProps> = ({
|
||||
tasks,
|
||||
onUpdateTask,
|
||||
onDeleteTask,
|
||||
}) => {
|
||||
return (
|
||||
<ul className="flex flex-col gap-y-2">
|
||||
{tasks?.map(
|
||||
(task) =>
|
||||
task?.id && (
|
||||
<li key={task.id}>
|
||||
<TaskItem
|
||||
task={task}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
/>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
98
web/app/routes/_layout/_pages/_protected/tasks/index.tsx
Normal file
98
web/app/routes/_layout/_pages/_protected/tasks/index.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { isToday, isFuture } from "date-fns"
|
||||
import { ID } from "jazz-tools"
|
||||
import { useTaskActions } from "~/hooks/actions/use-task-actions"
|
||||
import { TaskForm } from "./-form"
|
||||
import { TaskList } from "./-list"
|
||||
import { Task } from "~/lib/schema/task"
|
||||
import { z } from "zod"
|
||||
// import { getFeatureFlag } from "~/actions"
|
||||
|
||||
const taskSearchSchema = z.object({
|
||||
filter: z.enum(["today", "upcoming"]).optional(),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/_layout/_pages/_protected/tasks/")({
|
||||
// beforeLoad: async ({ context }) => {
|
||||
// if (!context.user.id) {
|
||||
// throw new Error("Unauthorized")
|
||||
// }
|
||||
|
||||
// const flag = await getFeatureFlag({ name: "TASK" })
|
||||
// const canAccess = context.user?.emailAddresses.some((email) =>
|
||||
// flag?.emails.includes(email.emailAddress),
|
||||
// )
|
||||
|
||||
// if (!canAccess) {
|
||||
// throw new Error("Unauthorized")
|
||||
// }
|
||||
// },
|
||||
validateSearch: taskSearchSchema,
|
||||
component: () => <TaskComponent />,
|
||||
})
|
||||
|
||||
function TaskComponent() {
|
||||
const { filter } = Route.useSearch()
|
||||
const { me } = useAccount({ root: { tasks: [] } })
|
||||
const tasks = me?.root.tasks
|
||||
const { deleteTask } = useTaskActions()
|
||||
|
||||
const filteredTasks = tasks?.filter((task) => {
|
||||
if (!task) return false
|
||||
if (filter === "today") {
|
||||
return task.status !== "done" && task.dueDate && isToday(task.dueDate)
|
||||
} else if (filter === "upcoming") {
|
||||
return task.status !== "done" && task.dueDate && isFuture(task.dueDate)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const updateTask = (taskId: string, updates: Partial<Task>) => {
|
||||
if (me?.root?.tasks) {
|
||||
const taskIndex = me.root.tasks.findIndex((task) => task?.id === taskId)
|
||||
if (taskIndex !== -1) {
|
||||
Object.assign(me.root.tasks[taskIndex]!, updates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onDeleteTask = (taskId: string) => {
|
||||
if (me) {
|
||||
deleteTask(me, taskId as ID<Task>)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4 p-4">
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<LaIcon
|
||||
name={
|
||||
filter === "today"
|
||||
? "BookOpenCheck"
|
||||
: filter === "upcoming"
|
||||
? "History"
|
||||
: "ListTodo"
|
||||
}
|
||||
className="size-6"
|
||||
/>
|
||||
<h1 className="text-xl font-bold">
|
||||
{filter === "today"
|
||||
? "Today's Tasks"
|
||||
: filter === "upcoming"
|
||||
? "Upcoming Tasks"
|
||||
: "All Tasks"}
|
||||
</h1>
|
||||
</div>
|
||||
<TaskForm />
|
||||
<TaskList
|
||||
tasks={
|
||||
filteredTasks?.filter((task): task is Task => task !== null) || []
|
||||
}
|
||||
onUpdateTask={updateTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
web/app/routes/_layout/_pages/_protected/topics/-header.tsx
Normal file
32
web/app/routes/_layout/_pages/_protected/topics/-header.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ContentHeader,
|
||||
SidebarToggleButton,
|
||||
} from "@/components/custom/content-header"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
|
||||
interface TopicHeaderProps {}
|
||||
|
||||
export const TopicHeader: React.FC<TopicHeaderProps> = React.memo(() => {
|
||||
const { me } = useAccount()
|
||||
|
||||
if (!me) return null
|
||||
|
||||
return (
|
||||
<ContentHeader className="px-6 py-4 max-lg:px-4">
|
||||
<HeaderTitle />
|
||||
<div className="flex flex-auto" />
|
||||
</ContentHeader>
|
||||
)
|
||||
})
|
||||
|
||||
TopicHeader.displayName = "TopicHeader"
|
||||
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
213
web/app/routes/_layout/_pages/_protected/topics/-item.tsx
Normal file
213
web/app/routes/_layout/_pages/_protected/topics/-item.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ListOfTopics, Topic } from "@/lib/schema"
|
||||
import { Column } from "@/components/custom/column"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover"
|
||||
import { LearningStateSelectorContent } from "@/components/custom/learning-state-selector"
|
||||
import { useAtom } from "jotai"
|
||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { Link, useNavigate } from "@tanstack/react-router"
|
||||
import { topicOpenPopoverForIdAtom, useColumnStyles } from "./-list"
|
||||
|
||||
interface TopicItemProps extends React.HTMLAttributes<HTMLAnchorElement> {
|
||||
topic: Topic
|
||||
learningState: LearningStateValue
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export const TopicItem = React.forwardRef<HTMLAnchorElement, TopicItemProps>(
|
||||
({ topic, learningState, isActive, ...props }, ref) => {
|
||||
const columnStyles = useColumnStyles()
|
||||
const [openPopoverForId, setOpenPopoverForId] = useAtom(
|
||||
topicOpenPopoverForIdAtom,
|
||||
)
|
||||
const navigate = useNavigate()
|
||||
const { me } = useAccount({
|
||||
root: { topicsWantToLearn: [], topicsLearning: [], topicsLearned: [] },
|
||||
})
|
||||
|
||||
let p: {
|
||||
index: number
|
||||
topic?: Topic | null
|
||||
learningState: LearningStateValue
|
||||
} | null = null
|
||||
|
||||
const wantToLearnIndex =
|
||||
me?.root.topicsWantToLearn.findIndex((t) => t?.id === topic.id) ?? -1
|
||||
if (wantToLearnIndex !== -1) {
|
||||
p = {
|
||||
index: wantToLearnIndex,
|
||||
topic: me?.root.topicsWantToLearn[wantToLearnIndex],
|
||||
learningState: "wantToLearn",
|
||||
}
|
||||
}
|
||||
|
||||
const learningIndex =
|
||||
me?.root.topicsLearning.findIndex((t) => t?.id === topic.id) ?? -1
|
||||
if (learningIndex !== -1) {
|
||||
p = {
|
||||
index: learningIndex,
|
||||
topic: me?.root.topicsLearning[learningIndex],
|
||||
learningState: "learning",
|
||||
}
|
||||
}
|
||||
|
||||
const learnedIndex =
|
||||
me?.root.topicsLearned.findIndex((t) => t?.id === topic.id) ?? -1
|
||||
if (learnedIndex !== -1) {
|
||||
p = {
|
||||
index: learnedIndex,
|
||||
topic: me?.root.topicsLearned[learnedIndex],
|
||||
learningState: "learned",
|
||||
}
|
||||
}
|
||||
|
||||
const selectedLearningState = React.useMemo(
|
||||
() => LEARNING_STATES.find((ls) => ls.value === learningState),
|
||||
[learningState],
|
||||
)
|
||||
|
||||
const handleLearningStateSelect = React.useCallback(
|
||||
(value: string) => {
|
||||
const newLearningState = value as LearningStateValue
|
||||
|
||||
const topicLists: Record<
|
||||
LearningStateValue,
|
||||
(ListOfTopics | null) | undefined
|
||||
> = {
|
||||
wantToLearn: me?.root.topicsWantToLearn,
|
||||
learning: me?.root.topicsLearning,
|
||||
learned: me?.root.topicsLearned,
|
||||
}
|
||||
|
||||
const removeFromList = (state: LearningStateValue, index: number) => {
|
||||
topicLists[state]?.splice(index, 1)
|
||||
}
|
||||
|
||||
if (p) {
|
||||
if (newLearningState === p.learningState) {
|
||||
removeFromList(p.learningState, p.index)
|
||||
return
|
||||
}
|
||||
removeFromList(p.learningState, p.index)
|
||||
}
|
||||
|
||||
topicLists[newLearningState]?.push(topic)
|
||||
|
||||
setOpenPopoverForId(null)
|
||||
},
|
||||
[
|
||||
setOpenPopoverForId,
|
||||
me?.root.topicsWantToLearn,
|
||||
me?.root.topicsLearning,
|
||||
me?.root.topicsLearned,
|
||||
p,
|
||||
topic,
|
||||
],
|
||||
)
|
||||
|
||||
const handlePopoverTriggerClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
setOpenPopoverForId(openPopoverForId === topic.id ? null : topic.id)
|
||||
}
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLAnchorElement>) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
navigate({
|
||||
to: "/$",
|
||||
params: { _splat: topic.name },
|
||||
})
|
||||
}
|
||||
},
|
||||
[navigate, topic.name],
|
||||
)
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
to="/$"
|
||||
params={{ _splat: topic.name }}
|
||||
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]",
|
||||
)}
|
||||
aria-selected={isActive}
|
||||
data-active={isActive}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="flex h-full cursor-default items-center gap-4 outline-none"
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<Column.Wrapper style={columnStyles.title}>
|
||||
<Column.Text className="truncate text-[13px] font-medium">
|
||||
{topic.prettyName}
|
||||
</Column.Text>
|
||||
</Column.Wrapper>
|
||||
|
||||
<Column.Wrapper
|
||||
style={columnStyles.topic}
|
||||
className="max-sm:justify-end"
|
||||
>
|
||||
<Popover
|
||||
open={openPopoverForId === topic.id}
|
||||
onOpenChange={(open: boolean) =>
|
||||
setOpenPopoverForId(open ? topic.id : null)
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
role="combobox"
|
||||
variant="secondary"
|
||||
className="size-7 shrink-0 p-0"
|
||||
onClick={handlePopoverTriggerClick}
|
||||
>
|
||||
{selectedLearningState?.icon ? (
|
||||
<LaIcon
|
||||
name={selectedLearningState.icon}
|
||||
className={cn(selectedLearningState.className)}
|
||||
/>
|
||||
) : (
|
||||
<LaIcon name="Circle" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-52 rounded-lg p-0"
|
||||
side="bottom"
|
||||
align="end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<LearningStateSelectorContent
|
||||
showSearch={false}
|
||||
searchPlaceholder="Search state..."
|
||||
value={learningState}
|
||||
onSelect={handleLearningStateSelect}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Column.Wrapper>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
TopicItem.displayName = "TopicItem"
|
||||
156
web/app/routes/_layout/_pages/_protected/topics/-list.tsx
Normal file
156
web/app/routes/_layout/_pages/_protected/topics/-list.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import * as React from "react"
|
||||
import { Primitive } from "@radix-ui/react-primitive"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { atom } from "jotai"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
|
||||
import { Column } from "@/components/custom/column"
|
||||
import { LaAccount, ListOfTopics, Topic, UserRoot } from "@/lib/schema"
|
||||
import { LearningStateValue } from "@/lib/constants"
|
||||
import { useKeyDown } from "@/hooks/use-key-down"
|
||||
import { TopicItem } from "./-item"
|
||||
|
||||
interface TopicListProps {}
|
||||
|
||||
interface MainTopicListProps extends TopicListProps {
|
||||
me: {
|
||||
root: {
|
||||
topicsWantToLearn: ListOfTopics
|
||||
topicsLearning: ListOfTopics
|
||||
topicsLearned: ListOfTopics
|
||||
} & UserRoot
|
||||
} & LaAccount
|
||||
}
|
||||
|
||||
export interface PersonalTopic {
|
||||
topic: Topic | null
|
||||
learningState: LearningStateValue
|
||||
}
|
||||
|
||||
export const topicOpenPopoverForIdAtom = atom<string | null>(null)
|
||||
|
||||
export const TopicList: React.FC<TopicListProps> = () => {
|
||||
const { me } = useAccount({
|
||||
root: { topicsWantToLearn: [], topicsLearning: [], topicsLearned: [] },
|
||||
})
|
||||
|
||||
if (!me) return null
|
||||
|
||||
return <MainTopicList me={me} />
|
||||
}
|
||||
|
||||
export const MainTopicList: React.FC<MainTopicListProps> = ({ me }) => {
|
||||
const isTablet = useMedia("(max-width: 640px)")
|
||||
const [activeItemIndex, setActiveItemIndex] = React.useState<number | null>(
|
||||
null,
|
||||
)
|
||||
const [keyboardActiveIndex, setKeyboardActiveIndex] = React.useState<
|
||||
number | null
|
||||
>(null)
|
||||
|
||||
const personalTopics = React.useMemo(
|
||||
() => [
|
||||
...me.root.topicsWantToLearn.map((topic) => ({
|
||||
topic,
|
||||
learningState: "wantToLearn" as const,
|
||||
})),
|
||||
...me.root.topicsLearning.map((topic) => ({
|
||||
topic,
|
||||
learningState: "learning" as const,
|
||||
})),
|
||||
...me.root.topicsLearned.map((topic) => ({
|
||||
topic,
|
||||
learningState: "learned" as const,
|
||||
})),
|
||||
],
|
||||
[me.root.topicsWantToLearn, me.root.topicsLearning, me.root.topicsLearned],
|
||||
)
|
||||
|
||||
const next = () =>
|
||||
Math.min((activeItemIndex ?? 0) + 1, (personalTopics?.length ?? 0) - 1)
|
||||
|
||||
const prev = () => Math.max((activeItemIndex ?? 0) - 1, 0)
|
||||
|
||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||
switch (ev.key) {
|
||||
case "ArrowDown":
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
setActiveItemIndex(next())
|
||||
setKeyboardActiveIndex(next())
|
||||
break
|
||||
case "ArrowUp":
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
setActiveItemIndex(prev())
|
||||
setKeyboardActiveIndex(prev())
|
||||
}
|
||||
}
|
||||
|
||||
useKeyDown(() => true, handleKeyDown)
|
||||
|
||||
const { setElementRef } = useActiveItemScroll<HTMLAnchorElement>({
|
||||
activeIndex: keyboardActiveIndex,
|
||||
})
|
||||
|
||||
return (
|
||||
<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]"
|
||||
tabIndex={-1}
|
||||
role="list"
|
||||
>
|
||||
{personalTopics?.map(
|
||||
(pt, index) =>
|
||||
pt.topic?.id && (
|
||||
<TopicItem
|
||||
key={pt.topic.id}
|
||||
ref={(el) => setElementRef(el, index)}
|
||||
topic={pt.topic}
|
||||
learningState={pt.learningState}
|
||||
isActive={index === activeItemIndex}
|
||||
onPointerMove={() => {
|
||||
setKeyboardActiveIndex(null)
|
||||
setActiveItemIndex(index)
|
||||
}}
|
||||
data-keyboard-active={keyboardActiveIndex === index}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</Primitive.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const useColumnStyles = () => {
|
||||
const isTablet = useMedia("(max-width: 640px)")
|
||||
|
||||
return {
|
||||
title: {
|
||||
"--width": "69px",
|
||||
"--min-width": "200px",
|
||||
"--max-width": isTablet ? "none" : "auto",
|
||||
},
|
||||
topic: {
|
||||
"--width": "65px",
|
||||
"--min-width": "120px",
|
||||
"--max-width": "120px",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<Column.Wrapper style={columnStyles.title}>
|
||||
<Column.Text>Name</Column.Text>
|
||||
</Column.Wrapper>
|
||||
<Column.Wrapper style={columnStyles.topic}>
|
||||
<Column.Text>State</Column.Text>
|
||||
</Column.Wrapper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
web/app/routes/_layout/_pages/_protected/topics/index.tsx
Normal file
16
web/app/routes/_layout/_pages/_protected/topics/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { TopicHeader } from "./-header"
|
||||
import { TopicList } from "./-list"
|
||||
|
||||
export const Route = createFileRoute("/_layout/_pages/_protected/topics/")({
|
||||
component: () => <TopicComponent />,
|
||||
})
|
||||
|
||||
function TopicComponent() {
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col overflow-hidden">
|
||||
<TopicHeader />
|
||||
<TopicList />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user