Move to TanStack Start from Next.js (#184)

This commit is contained in:
Aslam
2024-10-07 16:44:17 +07:00
committed by GitHub
parent 3a89a1c07f
commit 950ebc3dad
514 changed files with 20021 additions and 15508 deletions

117
web/app/routes/__root.tsx Normal file
View File

@@ -0,0 +1,117 @@
/// <reference types="vite/client" />
import type { QueryClient } from "@tanstack/react-query"
import {
Outlet,
ScrollRestoration,
createRootRouteWithContext,
} from "@tanstack/react-router"
import { Body, Head, Html, Meta, Scripts } from "@tanstack/start"
import * as React from "react"
import { fetchClerkAuth } from "~/actions"
import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary.js"
import { NotFound } from "~/components/NotFound.js"
import appCss from "~/styles/app.css?url"
export const TanStackRouterDevtools =
process.env.NODE_ENV === "production"
? () => null
: React.lazy(() =>
import("@tanstack/router-devtools").then((res) => ({
default: res.TanStackRouterDevtools,
})),
)
export const ReactQueryDevtools =
process.env.NODE_ENV === "production"
? () => null
: React.lazy(() =>
import("@tanstack/react-query-devtools/production").then((d) => ({
default: d.ReactQueryDevtools,
})),
)
export const Route = createRootRouteWithContext<{
queryClient: QueryClient
}>()({
meta: () => [
{
charSet: "utf-8",
},
{
name: "viewport",
content: "width=device-width, initial-scale=1",
},
],
links: () => [
{ rel: "stylesheet", href: appCss },
{
rel: "apple-touch-icon",
sizes: "180x180",
href: "/apple-touch-icon.png",
},
{
rel: "icon",
type: "image/png",
sizes: "32x32",
href: "/favicon-32x32.png",
},
{
rel: "icon",
type: "image/png",
sizes: "16x16",
href: "/favicon-16x16.png",
},
{ rel: "manifest", href: "/site.webmanifest", color: "#fffff" },
{ rel: "icon", href: "/favicon.ico" },
],
beforeLoad: async ({ cause }) => {
if (cause !== "stay") {
const { user } = await fetchClerkAuth()
return {
user,
}
}
return {
user: null,
}
},
errorComponent: (props) => {
return (
<RootDocument>
<DefaultCatchBoundary {...props} />
</RootDocument>
)
},
notFoundComponent: () => <NotFound />,
component: RootComponent,
})
function RootComponent() {
return (
<RootDocument>
<Outlet />
</RootDocument>
)
}
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<Html>
<Head>
<Meta />
</Head>
<Body>
{children}
<React.Suspense>
<TanStackRouterDevtools position="bottom-right" />
<ReactQueryDevtools buttonPosition="bottom-left" />
</React.Suspense>
<ScrollRestoration />
<Scripts />
</Body>
</Html>
)
}

View File

@@ -0,0 +1,17 @@
import { Outlet, createFileRoute } from "@tanstack/react-router"
import { ThemeProvider } from "next-themes"
import { ClerkProvider } from "~/lib/providers/clerk-provider"
export const Route = createFileRoute("/_layout")({
component: LayoutComponent,
})
function LayoutComponent() {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ClerkProvider>
<Outlet />
</ClerkProvider>
</ThemeProvider>
)
}

View File

@@ -0,0 +1,21 @@
import { SignIn } from "@clerk/tanstack-start"
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/_layout/(auth)/_auth/sign-in/$")({
component: () => <SignInComponent />,
})
function SignInComponent() {
return (
<div className="flex justify-center py-24">
<SignIn
appearance={{
elements: {
formButtonPrimary: "bg-primary text-primary-foreground",
card: "shadow-none",
},
}}
/>
</div>
)
}

View File

@@ -0,0 +1,14 @@
import { SignUp } from "@clerk/tanstack-start"
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/_layout/(auth)/_auth/sign-up/$")({
component: () => <SignUpComponent />,
})
function SignUpComponent() {
return (
<div className="flex justify-center py-24">
<SignUp />
</div>
)
}

View File

@@ -0,0 +1,9 @@
import { createFileRoute, Outlet } from "@tanstack/react-router"
export const Route = createFileRoute("/_layout/(auth)/_auth")({
component: () => (
<main className="h-full">
<Outlet />
</main>
),
})

View File

@@ -0,0 +1,169 @@
import * as React from "react"
import {
Command,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { Command as CommandPrimitive } from "cmdk"
import { motion, AnimatePresence } from "framer-motion"
import { cn, searchSafeRegExp, shuffleArray } from "@/lib/utils"
import { useIsMounted } from "@/hooks/use-is-mounted"
interface GraphNode {
name: string
prettyName: string
connectedTopics: string[]
}
interface AutocompleteProps {
topics: GraphNode[]
onSelect: (topic: string) => void
onInputChange: (value: string) => void
}
export function Autocomplete({
topics = [],
onSelect,
onInputChange,
}: AutocompleteProps): JSX.Element {
const inputRef = React.useRef<HTMLInputElement>(null)
const [open, setOpen] = React.useState(false)
const isMounted = useIsMounted()
const [inputValue, setInputValue] = React.useState("")
const [hasInteracted, setHasInteracted] = React.useState(false)
const [initialTopics, setInitialTopics] = React.useState<GraphNode[]>([])
React.useEffect(() => {
setInitialTopics(shuffleArray(topics).slice(0, 5))
}, [topics])
const filteredTopics = React.useMemo(() => {
if (!inputValue) {
return initialTopics
}
const regex = searchSafeRegExp(inputValue)
return topics
.filter(
(topic) =>
regex.test(topic.name) ||
regex.test(topic.prettyName) ||
topic.connectedTopics.some((connectedTopic) =>
regex.test(connectedTopic),
),
)
.sort((a, b) => a.prettyName.localeCompare(b.prettyName))
.slice(0, 10)
}, [inputValue, topics, initialTopics])
const handleSelect = React.useCallback(
(topic: GraphNode) => {
setOpen(false)
onSelect(topic.name)
},
[onSelect],
)
const handleInputChange = React.useCallback(
(value: string) => {
setInputValue(value)
setOpen(true)
setHasInteracted(true)
onInputChange(value)
},
[onInputChange],
)
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
if ((event.key === "ArrowDown" || event.key === "ArrowUp") && !open) {
event.preventDefault()
setOpen(true)
setHasInteracted(true)
}
},
[open],
)
const commandKey = React.useMemo(() => {
return filteredTopics
.map(
(topic) =>
`${topic.name}:${topic.prettyName}:${topic.connectedTopics.join(",")}`,
)
.join("__")
}, [filteredTopics])
React.useEffect(() => {
if (inputRef.current && isMounted() && hasInteracted) {
inputRef.current.focus()
}
}, [commandKey, isMounted, hasInteracted])
return (
<Command
className={cn("relative mx-auto max-w-md overflow-visible shadow-md", {
"rounded-lg border": !open,
"rounded-none rounded-t-lg border-l border-r border-t": open,
})}
>
<div className={"relative flex items-center px-2 py-3"}>
<CommandPrimitive.Input
ref={inputRef}
value={inputValue}
onValueChange={handleInputChange}
onKeyDown={handleKeyDown}
onBlur={() => {
setTimeout(() => setOpen(false), 100)
}}
onFocus={() => setHasInteracted(true)}
onClick={() => {
setOpen(true)
setHasInteracted(true)
}}
placeholder={filteredTopics[0]?.prettyName}
className={cn(
"placeholder:text-muted-foreground flex-1 bg-transparent px-2 outline-none",
)}
autoFocus
/>
</div>
<div className="relative">
<AnimatePresence>
{open && hasInteracted && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.1 }}
className="bg-background absolute left-0 right-0 z-10 -mx-px rounded-b-lg border-b border-l border-r border-t shadow-lg"
>
<CommandList className="max-h-56">
<CommandGroup className="my-2">
{filteredTopics.map((topic, index) => (
<CommandItem
key={index}
value={topic.name}
onSelect={() => handleSelect(topic)}
className="min-h-10 rounded-none px-3 py-1.5"
>
<span>{topic.prettyName}</span>
<span className="text-muted-foreground/80 ml-auto text-xs">
{topic.connectedTopics.length > 0 &&
topic.connectedTopics.join(", ")}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</motion.div>
)}
</AnimatePresence>
</div>
</Command>
)
}
export default Autocomplete

View File

@@ -0,0 +1,381 @@
import * as react from "react"
import * as fg from "@nothing-but/force-graph"
import * as schedule from "@/lib/utils"
import * as canvas from "@/lib/utils"
import { searchSafeRegExp } from "@/lib/utils"
import { ease, trig, raf, color } from "@nothing-but/utils"
export type RawGraphNode = {
name: string
prettyName: string
connectedTopics: string[]
}
const COLORS: readonly color.HSL[] = [
[3, 86, 64],
[15, 87, 66],
[31, 90, 69],
[15, 87, 66],
[31, 90, 69],
[344, 87, 70],
]
type ColorMap = Record<string, color.HSL>
function generateColorMap(g: fg.graph.Graph): ColorMap {
const hsl_map: ColorMap = {}
for (let i = 0; i < g.nodes.length; i++) {
hsl_map[g.nodes[i].key as string] = COLORS[i % COLORS.length]
}
for (const { a, b } of g.edges) {
const a_hsl = hsl_map[a.key as string]
const b_hsl = hsl_map[b.key as string]
const am = a.mass - 1
const bm = b.mass - 1
hsl_map[a.key as string] = color.mix(a_hsl, b_hsl, am * am * am, bm)
hsl_map[b.key as string] = color.mix(a_hsl, b_hsl, am, bm * bm * bm)
}
return hsl_map
}
function generateNodesFromRawData(
g: fg.graph.Graph,
raw_data: RawGraphNode[],
): void {
const nodes_map = new Map<string, fg.graph.Node>()
/* create nodes */
for (const raw of raw_data) {
const node = fg.graph.make_node()
node.key = raw.name
node.label = raw.prettyName
fg.graph.add_node(g, node)
nodes_map.set(raw.name, node)
}
/* connections */
for (const raw of raw_data) {
const node_a = nodes_map.get(raw.name)!
for (const name_b of raw.connectedTopics) {
const node_b = nodes_map.get(name_b)!
fg.graph.connect(g, node_a, node_b)
}
}
/* calc mass from number of connections */
for (const node of g.nodes) {
const edges = fg.graph.get_node_edges(g, node)
node.mass = fg.graph.node_mass_from_edges(edges.length)
}
}
function filterNodes(s: State, filter: string): void {
fg.graph.clear_nodes(s.graph)
if (filter === "") {
fg.graph.add_nodes(s.graph, s.nodes)
fg.graph.add_edges(s.graph, s.edges)
} else {
// regex matching all letters of the filter (out of order)
const regex = searchSafeRegExp(filter)
fg.graph.add_nodes(
s.graph,
s.nodes.filter((node) => regex.test(node.label)),
)
fg.graph.add_edges(
s.graph,
s.edges.filter(
(edge) => regex.test(edge.a.label) && regex.test(edge.b.label),
),
)
}
}
const GRAPH_OPTIONS: fg.graph.Options = {
min_move: 0.001,
inertia_strength: 0.3,
origin_strength: 0.01,
repel_distance: 40,
repel_strength: 2,
link_strength: 0.03,
grid_size: 500,
}
const TITLE_SIZE_PX = 400
const simulateGraph = (
alpha: number,
gestures: fg.canvas.CanvasGestures,
vw: number,
vh: number,
): void => {
const c = gestures.canvas
const g = c.graph
alpha = alpha / 10 // slow things down a bit
fg.graph.simulate(g, alpha)
/*
Push nodes away from the center (the title)
*/
const grid_radius = g.options.grid_size / 2
const origin_x = grid_radius + c.translate.x
const origin_y = grid_radius + c.translate.y
const vmax = Math.max(vw, vh)
const push_radius =
(Math.min(TITLE_SIZE_PX, vw / 2, vh / 2) / vmax) *
(g.options.grid_size / c.scale) +
80 /* additional margin for when scrolled in */
for (const node of g.nodes) {
//
const dist_x = node.pos.x - origin_x
const dist_y = (node.pos.y - origin_y) * 2
const dist = Math.sqrt(dist_x * dist_x + dist_y * dist_y)
if (dist > push_radius) continue
const strength = ease.in_expo((push_radius - dist) / push_radius)
node.vel.x += strength * (node.pos.x - origin_x) * 10 * alpha
node.vel.y += strength * (node.pos.y - origin_y) * 10 * alpha
}
/*
When a node is being dragged
it will pull it's connections
*/
if (gestures.mode.type === fg.canvas.Mode.DraggingNode) {
//
const node = gestures.mode.node
for (const edge of fg.graph.each_node_edge(g, node)) {
const b = edge.b === node ? edge.a : edge.b
const dx =
(b.pos.x - node.pos.x) *
g.options.link_strength *
edge.strength *
alpha *
10
const dy =
(b.pos.y - node.pos.y) *
g.options.link_strength *
edge.strength *
alpha *
10
b.vel.x -= dx / b.mass
b.vel.y -= dy / b.mass
}
}
}
const drawGraph = (c: fg.canvas.CanvasState, color_map: ColorMap): void => {
fg.canvas.resetFrame(c)
fg.canvas.drawEdges(c)
/*
Draw text nodes
*/
const grid_size = c.graph.options.grid_size
const max_size = Math.max(c.ctx.canvas.width, c.ctx.canvas.height)
const clip_rect = fg.canvas.get_ctx_clip_rect(c.ctx, { x: 100, y: 20 })
c.ctx.textAlign = "center"
c.ctx.textBaseline = "middle"
for (const node of c.graph.nodes) {
const x = (node.pos.x / grid_size) * max_size
const y = (node.pos.y / grid_size) * max_size
if (fg.canvas.in_rect_xy(clip_rect, x, y)) {
const base_size = max_size / 220
const mass_boost_size = max_size / 140
const mass_boost = (node.mass - 1) / 8 / c.scale
c.ctx.font = `${base_size + mass_boost * mass_boost_size}px sans-serif`
const opacity = 0.6 + ((node.mass - 1) / 50) * 4
c.ctx.fillStyle =
node.anchor || c.hovered_node === node
? `rgba(129, 140, 248, ${opacity})`
: color.hsl_to_hsla_string(color_map[node.key as string], opacity)
c.ctx.fillText(node.label, x, y)
}
}
}
class State {
ctx: CanvasRenderingContext2D | null = null
/* copy of all nodes to filter them */
nodes: fg.graph.Node[] = []
edges: fg.graph.Edge[] = []
graph: fg.graph.Graph = fg.graph.make_graph(GRAPH_OPTIONS)
gestures: fg.canvas.CanvasGestures | null = null
raf_id: number = 0
bump_end = 0
alpha = 0
frame_iter_limit = raf.frameIterationsLimit(60)
schedule_filter = schedule.scheduleIdle(filterNodes)
ro: ResizeObserver | undefined
}
function init(
s: State,
props: {
onNodeClick: (name: string) => void
raw_nodes: RawGraphNode[]
canvas_el: HTMLCanvasElement | null
},
) {
const { canvas_el, raw_nodes } = props
if (canvas_el == null) return
s.ctx = canvas_el.getContext("2d")
if (s.ctx == null) return
generateNodesFromRawData(s.graph, raw_nodes)
fg.graph.set_positions_smart(s.graph)
s.nodes = s.graph.nodes.slice()
s.edges = s.graph.edges.slice()
const color_map = generateColorMap(s.graph)
const canvas_state = fg.canvas.canvasState({
ctx: s.ctx,
graph: s.graph,
max_scale: 3,
init_scale: 1.7,
init_grid_pos: trig.ZERO,
})
const gestures = (s.gestures = fg.canvas.canvasGestures({
canvas: canvas_state,
onGesture: (e) => {
switch (e.type) {
case fg.canvas.GestureEventType.Translate:
s.bump_end = raf.bump(s.bump_end)
break
case fg.canvas.GestureEventType.NodeClick:
props.onNodeClick(e.node.key as string)
break
case fg.canvas.GestureEventType.NodeDrag:
fg.graph.set_position(canvas_state.graph, e.node, e.pos)
break
}
},
}))
s.ro = new ResizeObserver(() => {
if (canvas.resizeCanvasToDisplaySize(canvas_el)) {
fg.canvas.updateTranslate(
canvas_state,
canvas_state.translate.x,
canvas_state.translate.y,
)
}
})
s.ro.observe(canvas_el)
// initial simulation is the most crazy
// so it's off-screen
simulateGraph(6, gestures, window.innerWidth, window.innerHeight)
function loop(time: number) {
const is_active = gestures.mode.type === fg.canvas.Mode.DraggingNode
const iterations = Math.min(2, raf.calcIterations(s.frame_iter_limit, time))
for (let i = iterations; i > 0; i--) {
s.alpha = raf.updateAlpha(s.alpha, is_active || time < s.bump_end)
simulateGraph(s.alpha, gestures, window.innerWidth, window.innerHeight)
}
if (iterations > 0) {
drawGraph(canvas_state, color_map)
}
s.raf_id = requestAnimationFrame(loop)
}
s.raf_id = requestAnimationFrame(loop)
}
function updateQuery(s: State, filter_query: string) {
s.schedule_filter.trigger(s, filter_query)
s.bump_end = raf.bump(s.bump_end)
}
function cleanup(s: State) {
cancelAnimationFrame(s.raf_id)
s.gestures && fg.canvas.cleanupCanvasGestures(s.gestures)
s.schedule_filter.clear()
s.ro?.disconnect()
}
export type ForceGraphProps = {
onNodeClick: (name: string) => void
/**
* Filter the displayed nodes by name.
*
* `""` means no filter
*/
filter_query: string
raw_nodes: RawGraphNode[]
}
export function ForceGraphClient(props: ForceGraphProps): react.JSX.Element {
const [canvas_el, setCanvasEl] = react.useState<HTMLCanvasElement | null>(
null,
)
const state = react.useRef(new State())
react.useEffect(() => {
init(state.current, {
canvas_el: canvas_el,
onNodeClick: props.onNodeClick,
raw_nodes: props.raw_nodes,
})
}, [canvas_el])
react.useEffect(() => {
updateQuery(state.current, props.filter_query)
}, [props.filter_query])
react.useEffect(() => {
return () => cleanup(state.current)
}, [])
return (
<div className="absolute inset-0 overflow-hidden">
<canvas
ref={setCanvasEl}
style={{
position: "absolute",
top: "-10%",
left: "-10%",
width: "120%",
height: "120%",
}}
/>
</div>
)
}
export default ForceGraphClient

View File

@@ -0,0 +1,60 @@
import * as React from "react"
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { motion } from "framer-motion"
import { cn } from "@/lib/utils"
import { GraphData } from "~/lib/constants"
import { ForceGraphClient } from "./-components/force-graph-client"
import { Autocomplete } from "./-components/autocomplete"
export const Route = createFileRoute("/_layout/(landing)/")({
component: LandingComponent,
})
function LandingComponent() {
const navigate = useNavigate()
const [filterQuery, setFilterQuery] = React.useState<string>("")
const handleTopicSelect = (topic: string) => {
navigate({
to: topic,
})
}
const handleInputChange = (value: string) => {
setFilterQuery(value)
}
return (
<div className="relative h-full w-screen">
<ForceGraphClient
raw_nodes={GraphData}
onNodeClick={handleTopicSelect}
filter_query={filterQuery}
/>
<div className="absolute left-1/2 top-1/2 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 transform max-sm:px-5">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<motion.h1
className={cn(
"mb-2 text-center text-5xl font-bold tracking-tight sm:mb-4 md:text-7xl font-raleway",
)}
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
I want to learn
</motion.h1>
<Autocomplete
topics={GraphData}
onSelect={handleTopicSelect}
onInputChange={handleInputChange}
/>
</motion.div>
</div>
</div>
)
}

View File

@@ -0,0 +1,57 @@
import { Outlet, createFileRoute } from "@tanstack/react-router"
import { Provider as JotaiProvider } from "jotai"
import { Toaster } from "sonner"
import { ConfirmDialogProvider } from "@omit/react-confirm-dialog"
import { Sidebar } from "~/components/sidebar/sidebar"
import { TooltipProvider } from "~/components/ui/tooltip"
import { JazzAndAuth } from "~/lib/providers/jazz-provider"
import { Shortcut } from "~/components/shortcut/shortcut"
import { Onboarding } from "~/components/Onboarding"
import { GlobalKeyboardHandler } from "~/components/GlobalKeyboardHandler"
import { CommandPalette } from "~/components/command-palette/command-palette"
export const Route = createFileRoute("/_layout/_pages")({
component: PagesLayout,
})
function PagesLayout() {
return (
<JotaiProvider>
<TooltipProvider>
<ConfirmDialogProvider>
<JazzAndAuth>
<LayoutContent />
</JazzAndAuth>
</ConfirmDialogProvider>
</TooltipProvider>
</JotaiProvider>
)
}
function LayoutContent() {
return (
<>
<Toaster expand={false} />
<div className="flex min-h-full size-full flex-row items-stretch overflow-hidden">
<Sidebar />
<Shortcut />
<GlobalKeyboardHandler />
<CommandPalette />
<Onboarding />
<MainContent />
</div>
</>
)
}
function MainContent() {
return (
<div className="relative flex min-w-0 flex-1 flex-col">
<main className="relative flex flex-auto flex-col place-items-stretch overflow-auto lg:my-2 lg:mr-2 lg:rounded-md lg:border">
<Outlet />
</main>
</div>
)
}

View File

@@ -0,0 +1,107 @@
import * as React from "react"
import { createFileRoute, useParams } from "@tanstack/react-router"
import { useAccountOrGuest, useCoState } from "@/lib/providers/jazz-provider"
import { GraphData, JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants"
import { Topic } from "@/lib/schema"
import { atom } from "jotai"
import { Skeleton } from "@/components/ui/skeleton"
import { LaIcon } from "@/components/custom/la-icon"
import { TopicDetailHeader } from "./-header"
import { TopicDetailList } from "./-list"
export const Route = createFileRoute("/_layout/_pages/(topic)/$")({
component: TopicDetailComponent,
})
export const openPopoverForIdAtom = atom<string | null>(null)
export function TopicDetailComponent() {
console.log("TopicDetailComponent")
const params = useParams({ from: "/_layout/_pages/$" })
const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
const topicID = React.useMemo(
() =>
me &&
Topic.findUnique({ topicName: params._splat }, JAZZ_GLOBAL_GROUP_ID, me),
[params._splat, me],
)
const topic = useCoState(Topic, topicID, {
latestGlobalGuide: { sections: [] },
})
const [activeIndex, setActiveIndex] = React.useState(-1)
const topicExists = GraphData.find((node) => {
return node.name === params._splat
})
if (!topicExists) {
return <NotFoundPlaceholder />
}
const flattenedItems = topic?.latestGlobalGuide?.sections.flatMap(
(section) => [
{ type: "section" as const, data: section },
...(section?.links?.map((link) => ({
type: "link" as const,
data: link,
})) || []),
],
)
if (!topic || !me || !flattenedItems) {
return <TopicDetailSkeleton />
}
return (
<>
<TopicDetailHeader topic={topic} />
<TopicDetailList
items={flattenedItems}
topic={topic}
activeIndex={activeIndex}
setActiveIndex={setActiveIndex}
/>
</>
)
}
function NotFoundPlaceholder() {
return (
<div className="flex h-full grow flex-col items-center justify-center gap-3">
<div className="flex flex-row items-center gap-1.5">
<LaIcon name="CircleAlert" />
<span className="text-left font-medium">Topic not found</span>
</div>
<span className="max-w-sm text-left text-sm">
There is no topic with the given identifier.
</span>
</div>
)
}
function TopicDetailSkeleton() {
return (
<>
<div className="flex items-center justify-between px-6 py-5 max-lg:px-4">
<div className="flex items-center space-x-4">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-6 w-48" />
</div>
<Skeleton className="h-9 w-36" />
</div>
<div className="space-y-4 p-6 max-lg:px-4">
{[...Array(10)].map((_, index) => (
<div key={index} className="flex items-center space-x-4">
<Skeleton className="h-7 w-7 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
</>
)
}

View File

@@ -0,0 +1,139 @@
import * as React from "react"
import {
ContentHeader,
SidebarToggleButton,
} from "@/components/custom/content-header"
import { ListOfTopics, Topic } from "@/lib/schema"
import { LearningStateSelector } from "@/components/custom/learning-state-selector"
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
import { LearningStateValue } from "@/lib/constants"
import { useMedia } from "@/hooks/use-media"
import { useClerk } from "@clerk/tanstack-start"
import { useLocation } from "@tanstack/react-router"
interface TopicDetailHeaderProps {
topic: Topic
}
export const TopicDetailHeader = React.memo(function TopicDetailHeader({
topic,
}: TopicDetailHeaderProps) {
const clerk = useClerk()
const { pathname } = useLocation()
const isMobile = useMedia("(max-width: 770px)")
const { me } = useAccountOrGuest({
root: {
topicsWantToLearn: [],
topicsLearning: [],
topicsLearned: [],
},
})
let p: {
index: number
topic?: Topic | null
learningState: LearningStateValue
} | null = null
const wantToLearnIndex =
me?._type === "Anonymous"
? -1
: (me?.root.topicsWantToLearn.findIndex((t) => t?.id === topic.id) ?? -1)
if (wantToLearnIndex !== -1) {
p = {
index: wantToLearnIndex,
topic:
me && me._type !== "Anonymous"
? me.root.topicsWantToLearn[wantToLearnIndex]
: undefined,
learningState: "wantToLearn",
}
}
const learningIndex =
me?._type === "Anonymous"
? -1
: (me?.root.topicsLearning.findIndex((t) => t?.id === topic.id) ?? -1)
if (learningIndex !== -1) {
p = {
index: learningIndex,
topic:
me && me._type !== "Anonymous"
? me?.root.topicsLearning[learningIndex]
: undefined,
learningState: "learning",
}
}
const learnedIndex =
me?._type === "Anonymous"
? -1
: (me?.root.topicsLearned.findIndex((t) => t?.id === topic.id) ?? -1)
if (learnedIndex !== -1) {
p = {
index: learnedIndex,
topic:
me && me._type !== "Anonymous"
? me?.root.topicsLearned[learnedIndex]
: undefined,
learningState: "learned",
}
}
const handleAddToProfile = (learningState: LearningStateValue) => {
if (me?._type === "Anonymous") {
return clerk.redirectToSignIn({
redirectUrl: pathname,
})
}
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 (learningState === p.learningState) {
removeFromList(p.learningState, p.index)
return
}
removeFromList(p.learningState, p.index)
}
topicLists[learningState]?.push(topic)
}
return (
<ContentHeader className="px-6 py-5 max-lg:px-4">
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<SidebarToggleButton />
<div className="flex min-h-0 min-w-0 flex-1 items-center">
<h1 className="truncate text-left font-bold lg:text-xl">
{topic.prettyName}
</h1>
</div>
</div>
<div className="flex flex-auto"></div>
{/* <GuideCommunityToggle topicName={topic.name} /> */}
<LearningStateSelector
showSearch={false}
value={p?.learningState || ""}
onChange={handleAddToProfile}
defaultLabel={isMobile ? "" : "Add to profile"}
defaultIcon="Circle"
/>
</ContentHeader>
)
})
TopicDetailHeader.displayName = "TopicDetailHeader"

View File

@@ -0,0 +1,251 @@
import * as React from "react"
import { useAtom } from "jotai"
import { toast } from "sonner"
import { LaIcon } from "@/components/custom/la-icon"
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover"
import { Button } from "@/components/ui/button"
import { LearningStateSelectorContent } from "@/components/custom/learning-state-selector"
import { cn, ensureUrlProtocol, generateUniqueSlug } from "@/lib/utils"
import {
Link as LinkSchema,
PersonalLink,
PersonalLinkLists,
Topic,
} from "@/lib/schema"
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
import { useClerk } from "@clerk/tanstack-start"
import { Link, useLocation, useNavigate } from "@tanstack/react-router"
import { openPopoverForIdAtom } from "./$"
interface LinkItemProps extends React.ComponentPropsWithoutRef<"div"> {
topic: Topic
link: LinkSchema
isActive: boolean
index: number
setActiveIndex: (index: number) => void
personalLinks?: PersonalLinkLists
}
export const LinkItem = React.memo(
React.forwardRef<HTMLDivElement, LinkItemProps>(
(
{
topic,
link,
isActive,
index,
setActiveIndex,
className,
personalLinks,
...props
},
ref,
) => {
const clerk = useClerk()
const { pathname } = useLocation()
const navigate = useNavigate()
const [, setOpenPopoverForId] = useAtom(openPopoverForIdAtom)
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false)
const { me } = useAccountOrGuest()
const personalLink = React.useMemo(() => {
return personalLinks?.find((pl) => pl?.link?.id === link.id)
}, [personalLinks, link.id])
const selectedLearningState = React.useMemo(() => {
return LEARNING_STATES.find(
(ls) => ls.value === personalLink?.learningState,
)
}, [personalLink?.learningState])
const handleClick = React.useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
setActiveIndex(index)
},
[index, setActiveIndex],
)
const handleSelectLearningState = React.useCallback(
(learningState: LearningStateValue) => {
if (!personalLinks || !me || me?._type === "Anonymous") {
return clerk.redirectToSignIn({
redirectUrl: pathname,
})
}
const defaultToast = {
duration: 5000,
position: "bottom-right" as const,
closeButton: true,
action: {
label: "Go to list",
onClick: () =>
navigate({
to: "/links",
}),
},
}
if (personalLink) {
if (personalLink.learningState === learningState) {
personalLink.learningState = undefined
toast.error("Link learning state removed", defaultToast)
} else {
personalLink.learningState = learningState
toast.success("Link learning state updated", defaultToast)
}
} else {
const slug = generateUniqueSlug(link.title)
const newPersonalLink = PersonalLink.create(
{
url: link.url,
title: link.title,
slug,
link,
learningState,
sequence: personalLinks.length + 1,
completed: false,
topic,
createdAt: new Date(),
updatedAt: new Date(),
},
{ owner: me },
)
personalLinks.push(newPersonalLink)
toast.success("Link added.", {
...defaultToast,
description: `${link.title} has been added to your personal link.`,
})
}
setOpenPopoverForId(null)
setIsPopoverOpen(false)
},
[
personalLink,
personalLinks,
me,
link,
navigate,
topic,
setOpenPopoverForId,
clerk,
pathname,
],
)
const handlePopoverOpenChange = React.useCallback(
(open: boolean) => {
setIsPopoverOpen(open)
setOpenPopoverForId(open ? link.id : null)
},
[link.id, setOpenPopoverForId],
)
return (
<div
ref={ref}
tabIndex={0}
onClick={handleClick}
className={cn(
"relative flex h-14 cursor-pointer items-center outline-none xl:h-11",
{
"bg-muted-foreground/10": isActive,
"hover:bg-muted/50": !isActive,
},
className,
)}
{...props}
>
<div className="flex grow justify-between gap-x-6 px-6 max-lg:px-4">
<div className="flex min-w-0 items-center gap-x-4">
<Popover
open={isPopoverOpen}
onOpenChange={handlePopoverOpenChange}
>
<PopoverTrigger asChild>
<Button
size="sm"
type="button"
role="combobox"
variant="secondary"
className={cn(
"size-7 shrink-0 p-0",
"hover:bg-accent-foreground/10",
)}
onClick={(e) => e.stopPropagation()}
>
{selectedLearningState?.icon ? (
<LaIcon
name={selectedLearningState.icon}
className={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={(value: string) =>
handleSelectLearningState(value as LearningStateValue)
}
/>
</PopoverContent>
</Popover>
<div className="w-full min-w-0 flex-auto">
<div className="gap-x-2 space-y-0.5 xl:flex xl:flex-row">
<p
className={cn(
"text-primary hover:text-primary line-clamp-1 text-sm font-medium",
isActive && "font-bold",
)}
>
{link.title}
</p>
<div className="group flex items-center gap-x-1">
<LaIcon
name="Link"
aria-hidden="true"
className="text-muted-foreground group-hover:text-primary size-3.5 flex-none"
/>
<Link
to={ensureUrlProtocol(link.url)}
target="_blank"
onClick={(e) => e.stopPropagation()}
className="text-muted-foreground hover:text-primary text-xs"
>
<span className="line-clamp-1">{link.url}</span>
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
)
},
),
)
LinkItem.displayName = "LinkItem"

View File

@@ -0,0 +1,107 @@
import * as React from "react"
import { useVirtualizer, VirtualItem } from "@tanstack/react-virtual"
import {
Link as LinkSchema,
Section as SectionSchema,
Topic,
} from "@/lib/schema"
import { LinkItem } from "./-item"
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
export type FlattenedItem =
| { type: "link"; data: LinkSchema | null }
| { type: "section"; data: SectionSchema | null }
interface TopicDetailListProps {
items: FlattenedItem[]
topic: Topic
activeIndex: number
setActiveIndex: (index: number) => void
}
export function TopicDetailList({
items,
topic,
activeIndex,
setActiveIndex,
}: TopicDetailListProps) {
const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
const personalLinks =
!me || me._type === "Anonymous" ? undefined : me.root.personalLinks
const parentRef = React.useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 44,
overscan: 5,
})
const renderItem = React.useCallback(
(virtualRow: VirtualItem) => {
const item = items[virtualRow.index]
if (item.type === "section") {
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className="flex flex-col"
>
<div className="flex items-center gap-4 px-6 py-2 max-lg:px-4">
<p className="text-foreground text-sm font-medium">
{item.data?.title}
</p>
<div className="flex-1 border-b" />
</div>
</div>
)
}
if (item.data?.id) {
return (
<LinkItem
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
topic={topic}
link={item.data as LinkSchema}
isActive={activeIndex === virtualRow.index}
index={virtualRow.index}
setActiveIndex={setActiveIndex}
personalLinks={personalLinks}
/>
)
}
return null
},
[items, topic, activeIndex, setActiveIndex, virtualizer, personalLinks],
)
return (
<div ref={parentRef} className="flex-1 overflow-auto">
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualizer.getVirtualItems()[0]?.start ?? 0}px)`,
}}
>
{virtualizer.getVirtualItems().map(renderItem)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,15 @@
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
export const Route = createFileRoute("/_layout/_pages/_protected")({
beforeLoad: ({ context, location, cause }) => {
if (cause !== "stay") {
if (!context?.user?.userId) {
throw redirect({
to: "/sign-in/$",
search: { redirect_url: location.pathname },
})
}
}
},
component: () => <Outlet />,
})

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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

View File

@@ -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>
)
}

View 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"

View 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"

View 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"

View 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 }

View 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 }

View File

@@ -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>
)}
/>
)
}

View 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>

View File

@@ -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>
)}
/>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)}
/>
)
}

View 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 />
</>
)
}

View 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>
)
}

View File

@@ -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>
</>
)
}

View 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>
)
}

View 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>
)

View 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"

View 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>
)
}

View 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>
)
}

View 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&apos;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>
)
}

View 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&apos;t find what you were looking for? Will soon
have AI assistant builtin
</div>
)}
{showAiSearch && <AiSearch searchQuery={searchText} />}
</div>
)}
</div>
</div>
</div>
</div>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)

View 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"

View 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>
)
}

View 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>
)
}