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