mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
Move to TanStack Start from Next.js (#184)
This commit is contained in:
@@ -1,20 +0,0 @@
|
||||
NEXT_PUBLIC_APP_NAME="Learn Anything"
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
NEXT_PUBLIC_JAZZ_GLOBAL_GROUP=""
|
||||
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
||||
CLERK_SECRET_KEY=
|
||||
|
||||
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
|
||||
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
|
||||
|
||||
NEXT_PUBLIC_JAZZ_PEER_URL="wss://"
|
||||
|
||||
RONIN_TOKEN=
|
||||
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
NEXT_PUBLIC_SENTRY_ORG=
|
||||
NEXT_PUBLIC_SENTRY_PROJECT=
|
||||
|
||||
# IGNORE_BUILD_ERRORS=true
|
||||
14
web/.eslintrc.cjs
Normal file
14
web/.eslintrc.cjs
Normal file
@@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
],
|
||||
ignorePatterns: ["dist", ".eslintrc.cjs"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
},
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
52
web/.gitignore
vendored
52
web/.gitignore
vendored
@@ -1,40 +1,24 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
node_modules
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.env
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.cache
|
||||
.vercel
|
||||
.output
|
||||
.vinxi
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/build/
|
||||
/api/
|
||||
/server/build
|
||||
/public/build
|
||||
.vinxi
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
.ronin
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
.ronin
|
||||
@@ -1,2 +0,0 @@
|
||||
[install.scopes]
|
||||
ronin = { url = "https://ronin.supply", token = "$RONIN_TOKEN" }
|
||||
4
web/.prettierignore
Normal file
4
web/.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
||||
**/build
|
||||
**/public
|
||||
pnpm-lock.yaml
|
||||
routeTree.gen.ts
|
||||
12
web/app.config.ts
Normal file
12
web/app.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "@tanstack/start/config"
|
||||
import tsConfigPaths from "vite-tsconfig-paths"
|
||||
|
||||
export default defineConfig({
|
||||
vite: {
|
||||
plugins: () => [
|
||||
tsConfigPaths({
|
||||
projects: ["./tsconfig.json"],
|
||||
}),
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -1,7 +0,0 @@
|
||||
export default function AuthLayout({
|
||||
children
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return <main className="h-full">{children}</main>
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { SignInClient } from "@/components/custom/clerk/sign-in-client"
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<div className="flex justify-center py-24">
|
||||
<SignInClient />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { SignUpClient } from "@/components/custom/clerk/sign-up-client"
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<div className="flex justify-center py-24">
|
||||
<SignUpClient />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { TopicDetailRoute } from "@/components/routes/topics/detail/TopicDetailRoute"
|
||||
|
||||
export default function DetailTopicPage({ params }: { params: { name: string } }) {
|
||||
return <TopicDetailRoute topicName={params.name} />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { CommunityTopicRoute } from "@/components/routes/community/CommunityTopicRoute"
|
||||
|
||||
export default function CommunityTopicPage({ params }: { params: { topicName: string } }) {
|
||||
return <CommunityTopicRoute topicName={params.topicName} />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import EditProfileRoute from "@/components/routes/EditProfileRoute"
|
||||
|
||||
export default function EditProfilePage() {
|
||||
return <EditProfileRoute />
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { JournalRoute } from "@/components/routes/journal/JournalRoute"
|
||||
import { currentUser } from "@clerk/nextjs/server"
|
||||
import { notFound } from "next/navigation"
|
||||
import { get } from "ronin"
|
||||
|
||||
export default async function JournalPage() {
|
||||
const user = await currentUser()
|
||||
const flag = await get.featureFlag.with.name("JOURNAL")
|
||||
|
||||
if (!user?.emailAddresses.some(email => flag?.emails.includes(email.emailAddress))) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <JournalRoute />
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { Viewport } from "next"
|
||||
import { Sidebar } from "@/components/custom/sidebar/sidebar"
|
||||
import { CommandPalette } from "@/components/custom/command-palette/command-palette"
|
||||
import { LearnAnythingOnboarding } from "@/components/custom/learn-anything-onboarding"
|
||||
import { Shortcut } from "@/components/custom/shortcut/shortcut"
|
||||
import { GlobalKeyboardHandler } from "@/components/custom/global-keyboard-handler"
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width, shrink-to-fit=no",
|
||||
maximumScale: 1,
|
||||
userScalable: false
|
||||
}
|
||||
|
||||
export default function PageLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-full w-full flex-row items-stretch overflow-hidden">
|
||||
<Sidebar />
|
||||
<LearnAnythingOnboarding />
|
||||
<GlobalKeyboardHandler />
|
||||
<CommandPalette />
|
||||
<Shortcut />
|
||||
|
||||
<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">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { LinkRoute } from "@/components/routes/link/LinkRoute"
|
||||
|
||||
export default function LinksPage() {
|
||||
return <LinkRoute />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import OnboardingRoute from "@/components/routes/OnboardingRoute"
|
||||
|
||||
export default function EditProfilePage() {
|
||||
return <OnboardingRoute />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { PageDetailRoute } from "@/components/routes/page/detail/PageDetailRoute"
|
||||
|
||||
export default function DetailPage({ params }: { params: { id: string } }) {
|
||||
return <PageDetailRoute pageId={params.id} />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { PageRoute } from "@/components/routes/page/PageRoute"
|
||||
|
||||
export default function Page() {
|
||||
return <PageRoute />
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { useUser } from "@clerk/nextjs"
|
||||
import { useState, useRef, useCallback } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import Link from "next/link"
|
||||
import { Avatar, AvatarImage } from "@/components/ui/avatar"
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProfileWrapper = () => {
|
||||
const account = useAccount()
|
||||
const params = useParams()
|
||||
const username = params.username as string
|
||||
const { user, isSignedIn } = useUser()
|
||||
const avatarInputRef = 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] = useState(false)
|
||||
const [newName, setNewName] = useState(account.me?.profile?.name || "")
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const editProfileClicked = () => {
|
||||
setIsEditing(true)
|
||||
setError("")
|
||||
}
|
||||
|
||||
const changeName = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNewName(e.target.value)
|
||||
setError("")
|
||||
}
|
||||
|
||||
const validateName = 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 href="/">
|
||||
<span className=""> homepage</span>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ProfileWrapper } from "./_components/wrapper"
|
||||
|
||||
export default function ProfilePage() {
|
||||
return <ProfileWrapper />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { SearchWrapper } from "@/components/routes/search/wrapper"
|
||||
|
||||
export default function ProfilePage() {
|
||||
return <SearchWrapper />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { SettingsRoute } from "@/components/routes/SettingsRoute"
|
||||
|
||||
export default function SettingsPage() {
|
||||
return <SettingsRoute />
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { TaskRoute } from "@/components/routes/task/TaskRoute"
|
||||
import { currentUser } from "@clerk/nextjs/server"
|
||||
import { notFound } from "next/navigation"
|
||||
import { get } from "ronin"
|
||||
|
||||
export default async function TaskPage() {
|
||||
const user = await currentUser()
|
||||
const flag = await get.featureFlag.with.name("TASK")
|
||||
|
||||
if (!user?.emailAddresses.some(email => flag?.emails.includes(email.emailAddress))) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <TaskRoute />
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { TaskRoute } from "@/components/routes/task/TaskRoute"
|
||||
import { currentUser } from "@clerk/nextjs/server"
|
||||
import { notFound } from "next/navigation"
|
||||
import { get } from "ronin"
|
||||
|
||||
export default async function TodayTasksPage() {
|
||||
const user = await currentUser()
|
||||
const flag = await get.featureFlag.with.name("TASK")
|
||||
|
||||
if (!user?.emailAddresses.some(email => flag?.emails.includes(email.emailAddress))) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <TaskRoute filter="today" />
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { TaskRoute } from "@/components/routes/task/TaskRoute"
|
||||
import { currentUser } from "@clerk/nextjs/server"
|
||||
import { notFound } from "next/navigation"
|
||||
import { get } from "ronin"
|
||||
|
||||
export default async function UpcomingTasksPage() {
|
||||
const user = await currentUser()
|
||||
const flag = await get.featureFlag.with.name("TASK")
|
||||
|
||||
if (!user?.emailAddresses.some(email => flag?.emails.includes(email.emailAddress))) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <TaskRoute filter="upcoming" />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import TauriRoute from "@/components/routes/tauri/TauriRoute"
|
||||
|
||||
export default function TauriPage() {
|
||||
return <TauriRoute />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { TopicRoute } from "@/components/routes/topics/TopicRoute"
|
||||
|
||||
export default function Page() {
|
||||
return <TopicRoute />
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export default function PublicLayout({
|
||||
children
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return <main className="h-full">{children}</main>
|
||||
}
|
||||
@@ -1,91 +1,140 @@
|
||||
"use server"
|
||||
import { clerkClient, getAuth } from "@clerk/tanstack-start/server"
|
||||
import { createServerFn } from "@tanstack/start"
|
||||
import { create, get } from "ronin"
|
||||
import * as cheerio from "cheerio"
|
||||
import { ensureUrlProtocol } from "@/lib/utils"
|
||||
import { urlSchema } from "@/lib/utils/schema"
|
||||
|
||||
import { authedProcedure } from "@/lib/utils/auth-procedure"
|
||||
import { currentUser } from "@clerk/nextjs/server"
|
||||
import { get } from "ronin"
|
||||
import { create } from "ronin"
|
||||
import { z } from "zod"
|
||||
import { ZSAError } from "zsa"
|
||||
|
||||
const MAX_FILE_SIZE = 1 * 1024 * 1024
|
||||
const ALLOWED_FILE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]
|
||||
|
||||
export const getFeatureFlag = authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string()
|
||||
})
|
||||
)
|
||||
.handler(async ({ input }) => {
|
||||
const { name } = input
|
||||
const flag = await get.featureFlag.with.name(name)
|
||||
|
||||
return { flag }
|
||||
})
|
||||
|
||||
export const sendFeedback = authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
content: z.string()
|
||||
})
|
||||
)
|
||||
.handler(async ({ input, ctx }) => {
|
||||
const { clerkUser } = ctx
|
||||
const { content } = input
|
||||
|
||||
try {
|
||||
await create.feedback.with({
|
||||
message: content,
|
||||
emailFrom: clerkUser?.emailAddresses[0].emailAddress
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw new ZSAError("ERROR", "Failed to send feedback")
|
||||
}
|
||||
})
|
||||
|
||||
export const storeImage = authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
file: z
|
||||
.any()
|
||||
.refine(file => file instanceof File, {
|
||||
message: "Not a file"
|
||||
})
|
||||
.refine(file => ALLOWED_FILE_TYPES.includes(file.type), {
|
||||
message: "Invalid file type. Only JPEG, PNG, GIF, and WebP images are allowed."
|
||||
})
|
||||
.refine(file => file.size <= MAX_FILE_SIZE, {
|
||||
message: "File size exceeds the maximum limit of 1 MB."
|
||||
})
|
||||
}),
|
||||
{ type: "formData" }
|
||||
)
|
||||
.handler(async ({ ctx, input }) => {
|
||||
const { file } = input
|
||||
const { clerkUser } = ctx
|
||||
|
||||
if (!clerkUser?.id) {
|
||||
throw new ZSAError("NOT_AUTHORIZED", "You are not authorized to upload files")
|
||||
}
|
||||
|
||||
try {
|
||||
const fileModel = await create.image.with({
|
||||
content: file,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size
|
||||
})
|
||||
|
||||
return { fileModel }
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw new ZSAError("ERROR", "Failed to store image")
|
||||
}
|
||||
})
|
||||
|
||||
export const isExistingUser = async () => {
|
||||
const clerkUser = await currentUser()
|
||||
const roninUser = await get.existingStripeSubscriber.with({ email: clerkUser?.emailAddresses[0].emailAddress })
|
||||
return clerkUser?.emailAddresses[0].emailAddress === roninUser?.email
|
||||
interface Metadata {
|
||||
title: string
|
||||
description: string
|
||||
icon: string | null
|
||||
url: string
|
||||
}
|
||||
|
||||
const DEFAULT_VALUES = {
|
||||
TITLE: "",
|
||||
DESCRIPTION: "",
|
||||
FAVICON: null,
|
||||
}
|
||||
|
||||
export const fetchClerkAuth = createServerFn("GET", async (_, ctx) => {
|
||||
const auth = await getAuth(ctx.request)
|
||||
|
||||
return {
|
||||
user: auth,
|
||||
}
|
||||
})
|
||||
|
||||
export const getFeatureFlag = createServerFn(
|
||||
"GET",
|
||||
async (data: { name: string }) => {
|
||||
const response = await get.featureFlag.with({
|
||||
name: data.name,
|
||||
})
|
||||
|
||||
return response
|
||||
},
|
||||
)
|
||||
|
||||
export const sendFeedbackFn = createServerFn(
|
||||
"POST",
|
||||
async (data: { content: string }, { request }) => {
|
||||
const auth = await getAuth(request)
|
||||
if (!auth.userId) {
|
||||
throw new Error("Unauthorized")
|
||||
}
|
||||
const user = await clerkClient({
|
||||
telemetry: { disabled: true },
|
||||
}).users.getUser(auth.userId)
|
||||
await create.feedback.with({
|
||||
message: data.content,
|
||||
emailFrom: user.emailAddresses[0].emailAddress,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
export const isExistingUserFn = createServerFn(
|
||||
"GET",
|
||||
async (_, { request }) => {
|
||||
const auth = await getAuth(request)
|
||||
|
||||
if (!auth.userId) {
|
||||
return false
|
||||
}
|
||||
|
||||
const user = await clerkClient({
|
||||
telemetry: { disabled: true },
|
||||
}).users.getUser(auth.userId)
|
||||
|
||||
const roninUser = await get.existingStripeSubscriber.with({
|
||||
email: user.emailAddresses[0].emailAddress,
|
||||
})
|
||||
|
||||
return user.emailAddresses[0].emailAddress === roninUser?.email
|
||||
},
|
||||
)
|
||||
|
||||
export const getMetadata = createServerFn("GET", async (url: string) => {
|
||||
if (!url) {
|
||||
return new Response('Missing "url" query parameter', {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const result = urlSchema.safeParse(url)
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
result.error.issues.map((issue) => issue.message).join(", "),
|
||||
)
|
||||
}
|
||||
|
||||
url = ensureUrlProtocol(url)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.text()
|
||||
const $ = cheerio.load(data)
|
||||
|
||||
const metadata: Metadata = {
|
||||
title:
|
||||
$("title").text() ||
|
||||
$('meta[property="og:title"]').attr("content") ||
|
||||
DEFAULT_VALUES.TITLE,
|
||||
description:
|
||||
$('meta[name="description"]').attr("content") ||
|
||||
$('meta[property="og:description"]').attr("content") ||
|
||||
DEFAULT_VALUES.DESCRIPTION,
|
||||
icon:
|
||||
$('link[rel="icon"]').attr("href") ||
|
||||
$('link[rel="shortcut icon"]').attr("href") ||
|
||||
DEFAULT_VALUES.FAVICON,
|
||||
url: url,
|
||||
}
|
||||
|
||||
if (metadata.icon && !metadata.icon.startsWith("http")) {
|
||||
metadata.icon = new URL(metadata.icon, url).toString()
|
||||
}
|
||||
|
||||
return metadata
|
||||
} catch (error) {
|
||||
console.error("Error fetching metadata:", error)
|
||||
const defaultMetadata: Metadata = {
|
||||
title: DEFAULT_VALUES.TITLE,
|
||||
description: DEFAULT_VALUES.DESCRIPTION,
|
||||
icon: DEFAULT_VALUES.FAVICON,
|
||||
url: url,
|
||||
}
|
||||
return defaultMetadata
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
import { NextRequest } from "next/server"
|
||||
import axios from "axios"
|
||||
import { GET } from "./route"
|
||||
|
||||
const DEFAULT_VALUES = {
|
||||
TITLE: "",
|
||||
DESCRIPTION: "",
|
||||
FAVICON: null
|
||||
}
|
||||
|
||||
jest.mock("axios")
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>
|
||||
|
||||
describe("Metadata Fetcher", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should return metadata when URL is valid", async () => {
|
||||
const mockHtml = `
|
||||
<html>
|
||||
<head>
|
||||
<title>Test Title</title>
|
||||
<meta name="description" content="Test Description">
|
||||
<link rel="icon" href="/icon.ico">
|
||||
</head>
|
||||
</html>
|
||||
`
|
||||
|
||||
mockedAxios.get.mockResolvedValue({ data: mockHtml })
|
||||
|
||||
const req = {
|
||||
url: process.env.NEXT_PUBLIC_APP_URL + "/api/metadata?url=https://example.com"
|
||||
} as unknown as NextRequest
|
||||
|
||||
const response = await GET(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toEqual({
|
||||
title: "Test Title",
|
||||
description: "Test Description",
|
||||
icon: "https://example.com/icon.ico",
|
||||
url: "https://example.com"
|
||||
})
|
||||
})
|
||||
|
||||
it("should return an error when URL is missing", async () => {
|
||||
const req = {
|
||||
url: process.env.NEXT_PUBLIC_APP_URL + "/api/metadata"
|
||||
} as unknown as NextRequest
|
||||
|
||||
const response = await GET(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toEqual({ error: "URL is required" })
|
||||
})
|
||||
|
||||
it("should return default values when fetching fails", async () => {
|
||||
mockedAxios.get.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
const req = {
|
||||
url: process.env.NEXT_PUBLIC_APP_URL + "/api/metadata?url=https://example.com"
|
||||
} as unknown as NextRequest
|
||||
|
||||
const response = await GET(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toEqual({
|
||||
title: DEFAULT_VALUES.TITLE,
|
||||
description: DEFAULT_VALUES.DESCRIPTION,
|
||||
icon: null,
|
||||
url: "https://example.com"
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle missing metadata gracefully", async () => {
|
||||
const mockHtml = `
|
||||
<html>
|
||||
<head>
|
||||
</head>
|
||||
</html>
|
||||
`
|
||||
|
||||
mockedAxios.get.mockResolvedValue({ data: mockHtml })
|
||||
|
||||
const req = {
|
||||
url: process.env.NEXT_PUBLIC_APP_URL + "/api/metadata?url=https://example.com"
|
||||
} as unknown as NextRequest
|
||||
|
||||
const response = await GET(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toEqual({
|
||||
title: DEFAULT_VALUES.TITLE,
|
||||
description: DEFAULT_VALUES.DESCRIPTION,
|
||||
icon: null,
|
||||
url: "https://example.com"
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,72 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import axios from "axios"
|
||||
import * as cheerio from "cheerio"
|
||||
import { ensureUrlProtocol } from "@/lib/utils"
|
||||
import { urlSchema } from "@/lib/utils/schema"
|
||||
|
||||
interface Metadata {
|
||||
title: string
|
||||
description: string
|
||||
icon: string | null
|
||||
url: string
|
||||
}
|
||||
|
||||
const DEFAULT_VALUES = {
|
||||
TITLE: "",
|
||||
DESCRIPTION: "",
|
||||
FAVICON: null
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
let url = searchParams.get("url")
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: "URL is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = urlSchema.safeParse(url)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.issues.map(issue => issue.message).join(", "))
|
||||
}
|
||||
|
||||
url = ensureUrlProtocol(url)
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(url, {
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
||||
}
|
||||
})
|
||||
|
||||
const $ = cheerio.load(data)
|
||||
|
||||
const metadata: Metadata = {
|
||||
title: $("title").text() || $('meta[property="og:title"]').attr("content") || DEFAULT_VALUES.TITLE,
|
||||
description:
|
||||
$('meta[name="description"]').attr("content") ||
|
||||
$('meta[property="og:description"]').attr("content") ||
|
||||
DEFAULT_VALUES.DESCRIPTION,
|
||||
icon: $('link[rel="icon"]').attr("href") || $('link[rel="shortcut icon"]').attr("href") || DEFAULT_VALUES.FAVICON,
|
||||
url: url
|
||||
}
|
||||
|
||||
if (metadata.icon && !metadata.icon.startsWith("http")) {
|
||||
metadata.icon = new URL(metadata.icon, url).toString()
|
||||
}
|
||||
|
||||
return NextResponse.json(metadata)
|
||||
} catch (error) {
|
||||
const defaultMetadata: Metadata = {
|
||||
title: DEFAULT_VALUES.TITLE,
|
||||
description: DEFAULT_VALUES.DESCRIPTION,
|
||||
icon: DEFAULT_VALUES.FAVICON,
|
||||
url: url
|
||||
}
|
||||
return NextResponse.json(defaultMetadata)
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let data: unknown
|
||||
try {
|
||||
data = (await request.json()) as unknown
|
||||
} catch (error) {
|
||||
return new NextResponse("Invalid JSON", { status: 400 })
|
||||
}
|
||||
|
||||
if (typeof data !== "object" || !data) {
|
||||
return new NextResponse("Missing request data", { status: 400 })
|
||||
}
|
||||
|
||||
if (!("question" in data) || typeof data.question !== "string") {
|
||||
return new NextResponse("Missing `question` data field.", { status: 400 })
|
||||
}
|
||||
|
||||
const chunks: string[] = [
|
||||
"# Hello",
|
||||
" from th",
|
||||
"e server",
|
||||
"\n\n your question",
|
||||
" was:\n\n",
|
||||
"> ",
|
||||
data.question,
|
||||
"\n\n",
|
||||
"**good bye!**"
|
||||
]
|
||||
|
||||
const stream = new ReadableStream<string>({
|
||||
async start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(chunk)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
}
|
||||
|
||||
controller.close()
|
||||
}
|
||||
})
|
||||
|
||||
return new NextResponse(stream)
|
||||
}
|
||||
8
web/app/client.tsx
Normal file
8
web/app/client.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
/// <reference types="vinxi/types/client" />
|
||||
import { hydrateRoot } from "react-dom/client"
|
||||
import { StartClient } from "@tanstack/start"
|
||||
import { createRouter } from "./router"
|
||||
|
||||
const router = createRouter()
|
||||
|
||||
hydrateRoot(document.getElementById("root")!, <StartClient router={router} />)
|
||||
@@ -1,127 +0,0 @@
|
||||
@keyframes scaleIn {
|
||||
0% {
|
||||
transform: scale(0.97) translateX(-50%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1) translateX(-50%);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleOut {
|
||||
0% {
|
||||
transform: scale(1) translateX(-50%);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: scale(0.97) translateX(-50%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
@keyframes fadeOut {
|
||||
0% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--cmdk-shadow: rgba(0, 0, 0, 0.12) 0px 4px 30px, rgba(0, 0, 0, 0.04) 0px 3px 17px, rgba(0, 0, 0, 0.04) 0px 2px 8px,
|
||||
rgba(0, 0, 0, 0.04) 0px 1px 1px;
|
||||
--cmdk-bg: rgb(255, 255, 255);
|
||||
--cmdk-border-color: rgb(216, 216, 216);
|
||||
|
||||
--cmdk-input-color: rgb(48, 48, 49);
|
||||
--cmdk-input-placeholder: hsl(0, 0%, 56.1%);
|
||||
|
||||
--cmdk-accent: rgb(243, 243, 243);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--cmdk-shadow: rgba(0, 0, 0, 0.15) 0px 4px 40px, rgba(0, 0, 0, 0.184) 0px 3px 20px, rgba(0, 0, 0, 0.184) 0px 3px 12px,
|
||||
rgba(0, 0, 0, 0.184) 0px 2px 8px, rgba(0, 0, 0, 0.184) 0px 1px 1px;
|
||||
--cmdk-bg: rgb(27, 28, 31);
|
||||
--cmdk-border-color: rgb(56, 59, 65);
|
||||
|
||||
--cmdk-input-color: rgb(228, 229, 233);
|
||||
--cmdk-input-placeholder: hsl(0, 0%, 43.9%);
|
||||
|
||||
--cmdk-accent: rgb(44, 48, 57);
|
||||
}
|
||||
|
||||
[la-overlay][cmdk-overlay] {
|
||||
animation: fadeIn 0.2s ease;
|
||||
@apply fixed inset-0 z-50 opacity-80;
|
||||
}
|
||||
|
||||
[la-dialog][cmdk-dialog] {
|
||||
top: 15%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 640px;
|
||||
background: var(--cmdk-bg);
|
||||
box-shadow: var(--cmdk-shadow);
|
||||
transform-origin: left;
|
||||
animation: scaleIn 0.2s ease;
|
||||
transition: transform 0.1s ease;
|
||||
border: 0.5px solid var(--cmdk-border-color);
|
||||
@apply fixed left-1/2 z-50 w-full overflow-hidden rounded-lg outline-none;
|
||||
}
|
||||
|
||||
[la-dialog][cmdk-dialog][data-state="closed"] {
|
||||
animation: scaleOut 0.2s ease;
|
||||
}
|
||||
|
||||
.la [cmdk-input-wrapper] {
|
||||
border-bottom: 1px solid var(--cmdk-border-color);
|
||||
height: 62px;
|
||||
font-size: 1.125rem;
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.la [cmdk-input] {
|
||||
font-size: inherit;
|
||||
height: 62px;
|
||||
color: var(--cmdk-input-color);
|
||||
caret-color: rgb(110, 94, 210);
|
||||
@apply m-0 w-full appearance-none border-none bg-transparent p-5 outline-none;
|
||||
}
|
||||
|
||||
.la [cmdk-input]::placeholder {
|
||||
color: var(--cmdk-input-placeholder);
|
||||
}
|
||||
|
||||
.la [cmdk-list] {
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
transition: 100ms ease;
|
||||
transition-property: height;
|
||||
@apply p-2;
|
||||
}
|
||||
|
||||
.la [cmdk-group-heading] {
|
||||
font-size: 13px;
|
||||
height: 30px;
|
||||
@apply text-muted-foreground flex items-center px-2;
|
||||
}
|
||||
|
||||
.la [cmdk-empty] {
|
||||
@apply text-muted-foreground flex h-16 items-center justify-center whitespace-pre-wrap text-sm;
|
||||
}
|
||||
|
||||
.la [cmdk-item] {
|
||||
scroll-margin: 8px 0;
|
||||
@apply flex min-h-10 cursor-pointer items-center gap-3 rounded-md px-2 text-sm aria-selected:bg-[var(--cmdk-accent)];
|
||||
}
|
||||
53
web/app/components/DefaultCatchBoundary.tsx
Normal file
53
web/app/components/DefaultCatchBoundary.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
ErrorComponent,
|
||||
Link,
|
||||
rootRouteId,
|
||||
useMatch,
|
||||
useRouter,
|
||||
} from "@tanstack/react-router"
|
||||
import type { ErrorComponentProps } from "@tanstack/react-router"
|
||||
|
||||
export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
const router = useRouter()
|
||||
const isRoot = useMatch({
|
||||
strict: false,
|
||||
select: (state) => state.id === rootRouteId,
|
||||
})
|
||||
|
||||
console.error(error)
|
||||
|
||||
return (
|
||||
<div className="min-w-0 flex-1 p-4 flex flex-col items-center justify-center gap-6">
|
||||
<ErrorComponent error={error} />
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
<button
|
||||
onClick={() => {
|
||||
router.invalidate()
|
||||
}}
|
||||
className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
{isRoot ? (
|
||||
<Link
|
||||
to="/"
|
||||
className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/"
|
||||
className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
window.history.back()
|
||||
}}
|
||||
>
|
||||
Go Back
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
web/app/components/GlobalKeyboardHandler.tsx
Normal file
141
web/app/components/GlobalKeyboardHandler.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import * as React from "react"
|
||||
import { useKeyDown, KeyFilter, Options } from "@/hooks/use-key-down"
|
||||
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
import { isModKey, isServer } from "@/lib/utils"
|
||||
import { useAtom } from "jotai"
|
||||
import { usePageActions } from "~/hooks/actions/use-page-actions"
|
||||
import { useAuth } from "@clerk/tanstack-start"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import queryString from "query-string"
|
||||
import { commandPaletteOpenAtom } from "~/store/any-store"
|
||||
|
||||
type RegisterKeyDownProps = {
|
||||
trigger: KeyFilter
|
||||
handler: (event: KeyboardEvent) => void
|
||||
options?: Options
|
||||
}
|
||||
|
||||
function RegisterKeyDown({ trigger, handler, options }: RegisterKeyDownProps) {
|
||||
useKeyDown(trigger, handler, options)
|
||||
return null
|
||||
}
|
||||
|
||||
type Sequence = {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
const SEQUENCES: Sequence = {
|
||||
GL: "/links",
|
||||
GP: "/pages",
|
||||
GT: "/topics",
|
||||
}
|
||||
|
||||
const MAX_SEQUENCE_TIME = 1000
|
||||
|
||||
export function GlobalKeyboardHandler() {
|
||||
if (isServer()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <KeyboardHandlerContent />
|
||||
}
|
||||
|
||||
export function KeyboardHandlerContent() {
|
||||
const [, setOpenCommandPalette] = useAtom(commandPaletteOpenAtom)
|
||||
const [sequence, setSequence] = React.useState<string[]>([])
|
||||
const { signOut } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const { me } = useAccountOrGuest()
|
||||
const { newPage } = usePageActions()
|
||||
|
||||
const resetSequence = React.useCallback(() => {
|
||||
setSequence([])
|
||||
}, [])
|
||||
|
||||
const checkSequence = React.useCallback(() => {
|
||||
const sequenceStr = sequence.join("")
|
||||
const route = SEQUENCES[sequenceStr]
|
||||
|
||||
if (route) {
|
||||
navigate({
|
||||
to: route,
|
||||
})
|
||||
resetSequence()
|
||||
}
|
||||
}, [sequence, navigate, resetSequence])
|
||||
|
||||
const goToNewLink = React.useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.metaKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
|
||||
navigate({
|
||||
to: `/links?${queryString.stringify({ create: true })}`,
|
||||
})
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
const goToNewPage = React.useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.metaKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!me || me._type === "Anonymous") {
|
||||
return
|
||||
}
|
||||
|
||||
const page = newPage(me)
|
||||
|
||||
navigate({
|
||||
to: `/pages/${page.id}`,
|
||||
})
|
||||
},
|
||||
[me, newPage, navigate],
|
||||
)
|
||||
|
||||
useKeyDown(
|
||||
(e) => e.altKey && e.shiftKey && e.code === "KeyQ",
|
||||
() => {
|
||||
signOut()
|
||||
},
|
||||
)
|
||||
|
||||
useKeyDown(
|
||||
() => true,
|
||||
(e) => {
|
||||
const key = e.key.toUpperCase()
|
||||
setSequence((prev) => [...prev, key])
|
||||
},
|
||||
)
|
||||
|
||||
useKeyDown(
|
||||
(e) => isModKey(e) && e.code === "KeyK",
|
||||
(e) => {
|
||||
e.preventDefault()
|
||||
setOpenCommandPalette((prev) => !prev)
|
||||
},
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
checkSequence()
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
resetSequence()
|
||||
}, MAX_SEQUENCE_TIME)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [sequence, checkSequence, resetSequence])
|
||||
|
||||
return (
|
||||
me &&
|
||||
me._type !== "Anonymous" && (
|
||||
<>
|
||||
<RegisterKeyDown trigger="c" handler={goToNewLink} />
|
||||
<RegisterKeyDown trigger="p" handler={goToNewPage} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
}
|
||||
25
web/app/components/NotFound.tsx
Normal file
25
web/app/components/NotFound.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Link } from "@tanstack/react-router"
|
||||
|
||||
export function NotFound({ children }: { children?: any }) {
|
||||
return (
|
||||
<div className="space-y-2 p-2">
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
{children || <p>The page you are looking for does not exist.</p>}
|
||||
</div>
|
||||
<p className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="bg-emerald-500 text-white px-2 py-1 rounded uppercase font-black text-sm"
|
||||
>
|
||||
Go back
|
||||
</button>
|
||||
<Link
|
||||
to="/"
|
||||
className="bg-cyan-600 text-white px-2 py-1 rounded uppercase font-black text-sm"
|
||||
>
|
||||
Start Over
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
web/app/components/Onboarding.tsx
Normal file
107
web/app/components/Onboarding.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { atom, useAtom } from "jotai"
|
||||
import { atomWithStorage } from "jotai/utils"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { isExistingUserFn } from "~/actions"
|
||||
import { useLocation } from "@tanstack/react-router"
|
||||
|
||||
const hasVisitedAtom = atomWithStorage("hasVisitedLearnAnything", false)
|
||||
const isDialogOpenAtom = atom(true)
|
||||
|
||||
export function Onboarding() {
|
||||
const { pathname } = useLocation()
|
||||
const [hasVisited, setHasVisited] = useAtom(hasVisitedAtom)
|
||||
const [isOpen, setIsOpen] = useAtom(isDialogOpenAtom)
|
||||
const [isFetching, setIsFetching] = useState(true)
|
||||
const [isExisting, setIsExisting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const loadUser = async () => {
|
||||
try {
|
||||
const existingUser = await isExistingUserFn()
|
||||
setIsExisting(existingUser)
|
||||
setIsOpen(true)
|
||||
} catch (error) {
|
||||
console.error("Error loading user:", error)
|
||||
} finally {
|
||||
setIsFetching(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasVisited && pathname !== "/") {
|
||||
loadUser()
|
||||
}
|
||||
}, [hasVisited, pathname, setIsOpen])
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false)
|
||||
setHasVisited(true)
|
||||
}
|
||||
|
||||
if (hasVisited || isFetching) return null
|
||||
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<AlertDialogContent className="max-w-xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<h1 className="text-2xl font-bold">Welcome to Learn Anything!</h1>
|
||||
</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogDescription className="text-foreground/70 space-y-4 text-base leading-5">
|
||||
{isExisting && (
|
||||
<>
|
||||
<p className="font-medium">Existing Customer Notice</p>
|
||||
<p>
|
||||
We noticed you are an existing Learn Anything customer. We
|
||||
sincerely apologize for any broken experience you may have
|
||||
encountered on the old website. We've been working hard on
|
||||
this new version, which addresses previous issues and offers
|
||||
more features. As an early customer, you're locked in at
|
||||
the <strong>$3</strong> price for our upcoming pro version.
|
||||
Thank you for your support!
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<p>
|
||||
Learn Anything is a learning platform that organizes knowledge in a
|
||||
social way. You can create pages, add links, track learning status
|
||||
of any topic, and more things in the future.
|
||||
</p>
|
||||
<p>
|
||||
Try do these quick onboarding steps to get a feel for the product:
|
||||
</p>
|
||||
<ul className="list-inside list-disc">
|
||||
<li>Create your first page</li>
|
||||
<li>Add a link to a resource</li>
|
||||
<li>Update your learning status on a topic</li>
|
||||
</ul>
|
||||
<p>
|
||||
If you have any questions, don't hesitate to reach out. Click
|
||||
on question mark button in the bottom right corner and enter your
|
||||
message.
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
|
||||
<AlertDialogFooter className="mt-4">
|
||||
<AlertDialogCancel onClick={handleClose}>Close</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleClose}>
|
||||
Get Started
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default Onboarding
|
||||
142
web/app/components/command-palette/command-data.ts
Normal file
142
web/app/components/command-palette/command-data.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { icons } from "lucide-react"
|
||||
import { LaAccount } from "@/lib/schema"
|
||||
import { HTMLLikeElement } from "@/lib/utils"
|
||||
import { useCommandActions } from "~/hooks/use-command-actions"
|
||||
|
||||
export type CommandAction = string | (() => void)
|
||||
|
||||
export interface CommandItemType {
|
||||
id?: string
|
||||
icon?: keyof typeof icons
|
||||
value: string
|
||||
label: HTMLLikeElement | string
|
||||
action: CommandAction
|
||||
payload?: any
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
export type CommandGroupType = Array<{
|
||||
heading?: string
|
||||
items: CommandItemType[]
|
||||
}>
|
||||
|
||||
const createNavigationItem = (
|
||||
icon: keyof typeof icons,
|
||||
value: string,
|
||||
path: string,
|
||||
actions: ReturnType<typeof useCommandActions>,
|
||||
): CommandItemType => ({
|
||||
icon,
|
||||
value: `Go to ${value}`,
|
||||
label: {
|
||||
tag: "span",
|
||||
children: [
|
||||
"Go to ",
|
||||
{
|
||||
tag: "span",
|
||||
attributes: { className: "font-semibold" },
|
||||
children: [value],
|
||||
},
|
||||
],
|
||||
},
|
||||
action: () => actions.navigateTo(path),
|
||||
})
|
||||
|
||||
export const createCommandGroups = (
|
||||
actions: ReturnType<typeof useCommandActions>,
|
||||
me: LaAccount,
|
||||
): Record<string, CommandGroupType> => ({
|
||||
home: [
|
||||
{
|
||||
heading: "General",
|
||||
items: [
|
||||
{
|
||||
icon: "SunMoon",
|
||||
value: "Change Theme...",
|
||||
label: "Change Theme...",
|
||||
action: "CHANGE_PAGE",
|
||||
payload: "changeTheme",
|
||||
},
|
||||
{
|
||||
icon: "Copy",
|
||||
value: "Copy Current URL",
|
||||
label: "Copy Current URL",
|
||||
action: actions.copyCurrentURL,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Personal Links",
|
||||
items: [
|
||||
{
|
||||
icon: "TextSearch",
|
||||
value: "Search Links...",
|
||||
label: "Search Links...",
|
||||
action: "CHANGE_PAGE",
|
||||
payload: "searchLinks",
|
||||
},
|
||||
{
|
||||
icon: "Plus",
|
||||
value: "Create New Link...",
|
||||
label: "Create New Link...",
|
||||
action: () => actions.navigateTo("/links?create=true"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Personal Pages",
|
||||
items: [
|
||||
{
|
||||
icon: "FileSearch",
|
||||
value: "Search Pages...",
|
||||
label: "Search Pages...",
|
||||
action: "CHANGE_PAGE",
|
||||
payload: "searchPages",
|
||||
},
|
||||
{
|
||||
icon: "Plus",
|
||||
value: "Create New Page...",
|
||||
label: "Create New Page...",
|
||||
action: () => actions.createNewPage(me),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Navigation",
|
||||
items: [
|
||||
createNavigationItem("ArrowRight", "Links", "/links", actions),
|
||||
createNavigationItem("ArrowRight", "Pages", "/pages", actions),
|
||||
createNavigationItem("ArrowRight", "Search", "/search", actions),
|
||||
createNavigationItem("ArrowRight", "Profile", "/profile", actions),
|
||||
createNavigationItem("ArrowRight", "Settings", "/settings", actions),
|
||||
],
|
||||
},
|
||||
],
|
||||
searchLinks: [],
|
||||
searchPages: [],
|
||||
topics: [],
|
||||
changeTheme: [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
icon: "Moon",
|
||||
value: "Change Theme to Dark",
|
||||
label: "Change Theme to Dark",
|
||||
action: () => actions.changeTheme("dark"),
|
||||
},
|
||||
{
|
||||
icon: "Sun",
|
||||
value: "Change Theme to Light",
|
||||
label: "Change Theme to Light",
|
||||
action: () => actions.changeTheme("light"),
|
||||
},
|
||||
{
|
||||
icon: "Monitor",
|
||||
value: "Change Theme to System",
|
||||
label: "Change Theme to System",
|
||||
action: () => actions.changeTheme("system"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
73
web/app/components/command-palette/command-group.tsx
Normal file
73
web/app/components/command-palette/command-group.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import * as React from "react"
|
||||
import { Command } from "cmdk"
|
||||
import { CommandSeparator, CommandShortcut } from "@/components/ui/command"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { CommandItemType, CommandAction } from "./command-data"
|
||||
import { HTMLLikeElement, renderHTMLLikeElement } from "@/lib/utils"
|
||||
|
||||
export interface CommandItemProps extends Omit<CommandItemType, "action"> {
|
||||
action: CommandAction
|
||||
handleAction: (action: CommandAction, payload?: any) => void
|
||||
}
|
||||
|
||||
const HTMLLikeRenderer: React.FC<{ content: HTMLLikeElement | string }> =
|
||||
React.memo(({ content }) => {
|
||||
return (
|
||||
<span className="line-clamp-1">{renderHTMLLikeElement(content)}</span>
|
||||
)
|
||||
})
|
||||
|
||||
HTMLLikeRenderer.displayName = "HTMLLikeRenderer"
|
||||
|
||||
export const CommandItem: React.FC<CommandItemProps> = React.memo(
|
||||
({ icon, label, action, payload, shortcut, handleAction, ...item }) => (
|
||||
<Command.Item
|
||||
value={`${item.id}-${item.value}`}
|
||||
onSelect={() => handleAction(action, payload)}
|
||||
>
|
||||
{icon && <LaIcon name={icon} />}
|
||||
<HTMLLikeRenderer content={label} />
|
||||
{shortcut && <CommandShortcut>{shortcut}</CommandShortcut>}
|
||||
</Command.Item>
|
||||
),
|
||||
)
|
||||
|
||||
CommandItem.displayName = "CommandItem"
|
||||
|
||||
export interface CommandGroupProps {
|
||||
heading?: string
|
||||
items: CommandItemType[]
|
||||
handleAction: (action: CommandAction, payload?: any) => void
|
||||
isLastGroup: boolean
|
||||
}
|
||||
|
||||
export const CommandGroup: React.FC<CommandGroupProps> = React.memo(
|
||||
({ heading, items, handleAction, isLastGroup }) => {
|
||||
return (
|
||||
<>
|
||||
{heading ? (
|
||||
<Command.Group heading={heading}>
|
||||
{items.map((item, index) => (
|
||||
<CommandItem
|
||||
key={`${heading}-${item.label}-${index}`}
|
||||
{...item}
|
||||
handleAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
</Command.Group>
|
||||
) : (
|
||||
items.map((item, index) => (
|
||||
<CommandItem
|
||||
key={`item-${item.label}-${index}`}
|
||||
{...item}
|
||||
handleAction={handleAction}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{!isLastGroup && <CommandSeparator className="my-1.5" />}
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
CommandGroup.displayName = "CommandGroup"
|
||||
214
web/app/components/command-palette/command-palette.tsx
Normal file
214
web/app/components/command-palette/command-palette.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { Command } from "cmdk"
|
||||
import {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog"
|
||||
import { CommandGroup } from "./command-group"
|
||||
import { CommandAction, createCommandGroups } from "./command-data"
|
||||
import { useAccount, useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
import { useAtom } from "jotai"
|
||||
import { useCommandActions } from "~/hooks/use-command-actions"
|
||||
import {
|
||||
filterItems,
|
||||
getTopics,
|
||||
getPersonalLinks,
|
||||
getPersonalPages,
|
||||
handleAction,
|
||||
} from "./utils"
|
||||
import { searchSafeRegExp } from "~/lib/utils"
|
||||
import { commandPaletteOpenAtom } from "~/store/any-store"
|
||||
|
||||
export function CommandPalette() {
|
||||
const { me } = useAccountOrGuest()
|
||||
|
||||
if (me._type === "Anonymous") return null
|
||||
|
||||
return <RealCommandPalette />
|
||||
}
|
||||
|
||||
export function RealCommandPalette() {
|
||||
const { me } = useAccount({ root: { personalLinks: [], personalPages: [] } })
|
||||
const dialogRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const [inputValue, setInputValue] = React.useState("")
|
||||
const [activePage, setActivePage] = React.useState("home")
|
||||
const [open, setOpen] = useAtom(commandPaletteOpenAtom)
|
||||
|
||||
const actions = useCommandActions()
|
||||
const commandGroups = React.useMemo(
|
||||
() => me && createCommandGroups(actions, me),
|
||||
[actions, me],
|
||||
)
|
||||
|
||||
const bounce = React.useCallback(() => {
|
||||
if (dialogRef.current) {
|
||||
dialogRef.current.style.transform = "scale(0.99) translateX(-50%)"
|
||||
setTimeout(() => {
|
||||
if (dialogRef.current) {
|
||||
dialogRef.current.style.transform = ""
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
bounce()
|
||||
}
|
||||
|
||||
if (activePage !== "home" && !inputValue && e.key === "Backspace") {
|
||||
e.preventDefault()
|
||||
setActivePage("home")
|
||||
setInputValue("")
|
||||
bounce()
|
||||
}
|
||||
},
|
||||
[activePage, inputValue, bounce],
|
||||
)
|
||||
|
||||
const topics = React.useMemo(() => getTopics(actions), [actions])
|
||||
const personalLinks = React.useMemo(
|
||||
() => getPersonalLinks(me?.root.personalLinks || [], actions),
|
||||
[me?.root.personalLinks, actions],
|
||||
)
|
||||
const personalPages = React.useMemo(
|
||||
() => getPersonalPages(me?.root.personalPages || [], actions),
|
||||
[me?.root.personalPages, actions],
|
||||
)
|
||||
|
||||
const getFilteredCommands = React.useCallback(() => {
|
||||
if (!commandGroups) return []
|
||||
|
||||
const searchRegex = searchSafeRegExp(inputValue)
|
||||
|
||||
if (activePage === "home") {
|
||||
if (!inputValue) {
|
||||
return commandGroups.home
|
||||
}
|
||||
|
||||
const allGroups = [
|
||||
...Object.values(commandGroups).flat(),
|
||||
personalLinks,
|
||||
personalPages,
|
||||
topics,
|
||||
]
|
||||
|
||||
return allGroups
|
||||
.map((group) => ({
|
||||
heading: group.heading,
|
||||
items: filterItems(group.items, searchRegex),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0)
|
||||
}
|
||||
|
||||
switch (activePage) {
|
||||
case "searchLinks":
|
||||
return [
|
||||
...commandGroups.searchLinks,
|
||||
{ items: filterItems(personalLinks.items, searchRegex) },
|
||||
]
|
||||
case "searchPages":
|
||||
return [
|
||||
...commandGroups.searchPages,
|
||||
{ items: filterItems(personalPages.items, searchRegex) },
|
||||
]
|
||||
default: {
|
||||
const pageCommands = commandGroups[activePage]
|
||||
if (!inputValue) return pageCommands
|
||||
return pageCommands
|
||||
.map((group) => ({
|
||||
heading: group.heading,
|
||||
items: filterItems(group.items, searchRegex),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
inputValue,
|
||||
activePage,
|
||||
commandGroups,
|
||||
personalLinks,
|
||||
personalPages,
|
||||
topics,
|
||||
])
|
||||
|
||||
const handleActionWrapper = React.useCallback(
|
||||
(action: CommandAction, payload?: any) => {
|
||||
handleAction(action, payload, {
|
||||
setActivePage,
|
||||
setInputValue,
|
||||
bounce,
|
||||
closeDialog: () => setOpen(false),
|
||||
})
|
||||
},
|
||||
[bounce, setOpen],
|
||||
)
|
||||
|
||||
const filteredCommands = React.useMemo(
|
||||
() => getFilteredCommands(),
|
||||
[getFilteredCommands],
|
||||
)
|
||||
|
||||
const commandKey = React.useMemo(() => {
|
||||
return filteredCommands
|
||||
.map((group) => {
|
||||
const itemsKey = group.items
|
||||
.map((item) => `${item.label}-${item.value}`)
|
||||
.join("|")
|
||||
return `${group.heading}:${itemsKey}`
|
||||
})
|
||||
.join("__")
|
||||
}, [filteredCommands])
|
||||
|
||||
if (!me) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogPortal>
|
||||
<DialogPrimitive.Overlay la-overlay="" cmdk-overlay="" />
|
||||
<DialogPrimitive.Content
|
||||
la-dialog=""
|
||||
cmdk-dialog=""
|
||||
className="la"
|
||||
ref={dialogRef}
|
||||
>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Command Palette</DialogTitle>
|
||||
<DialogDescription>
|
||||
Search for commands and actions
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Command key={commandKey} onKeyDown={handleKeyDown}>
|
||||
<div cmdk-input-wrapper="">
|
||||
<Command.Input
|
||||
autoFocus
|
||||
placeholder="Type a command or search..."
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Command.List>
|
||||
<Command.Empty>No results found.</Command.Empty>
|
||||
{filteredCommands.map((group, index, array) => (
|
||||
<CommandGroup
|
||||
key={`${group.heading}-${index}`}
|
||||
heading={group.heading}
|
||||
items={group.items}
|
||||
handleAction={handleActionWrapper}
|
||||
isLastGroup={index === array.length - 1}
|
||||
/>
|
||||
))}
|
||||
</Command.List>
|
||||
</Command>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
74
web/app/components/command-palette/utils.ts
Normal file
74
web/app/components/command-palette/utils.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { GraphData } from "~/lib/constants"
|
||||
import { CommandAction, CommandItemType } from "./command-data"
|
||||
|
||||
export const filterItems = (items: CommandItemType[], searchRegex: RegExp) =>
|
||||
items.filter((item) => searchRegex.test(item.value)).slice(0, 10)
|
||||
|
||||
export const getTopics = (actions: { navigateTo: (path: string) => void }) => ({
|
||||
heading: "Topics",
|
||||
items: GraphData.map((topic) => ({
|
||||
icon: "Circle" as const,
|
||||
value: topic?.prettyName || "",
|
||||
label: topic?.prettyName || "",
|
||||
action: () => actions.navigateTo(`/${topic?.name}`),
|
||||
})),
|
||||
})
|
||||
|
||||
export const getPersonalLinks = (
|
||||
personalLinks: any[],
|
||||
actions: { openLinkInNewTab: (url: string) => void },
|
||||
) => ({
|
||||
heading: "Personal Links",
|
||||
items: personalLinks.map((link) => ({
|
||||
id: link?.id,
|
||||
icon: "Link" as const,
|
||||
value: link?.title || "Untitled",
|
||||
label: link?.title || "Untitled",
|
||||
action: () => actions.openLinkInNewTab(link?.url || "#"),
|
||||
})),
|
||||
})
|
||||
|
||||
export const getPersonalPages = (
|
||||
personalPages: any[],
|
||||
actions: { navigateTo: (path: string) => void },
|
||||
) => ({
|
||||
heading: "Personal Pages",
|
||||
items: personalPages.map((page) => ({
|
||||
id: page?.id,
|
||||
icon: "FileText" as const,
|
||||
value: page?.title || "Untitled",
|
||||
label: page?.title || "Untitled",
|
||||
action: () => actions.navigateTo(`/pages/${page?.id}`),
|
||||
})),
|
||||
})
|
||||
|
||||
export const handleAction = (
|
||||
action: CommandAction,
|
||||
payload: any,
|
||||
callbacks: {
|
||||
setActivePage: (page: string) => void
|
||||
setInputValue: (value: string) => void
|
||||
bounce: () => void
|
||||
closeDialog: () => void
|
||||
},
|
||||
) => {
|
||||
const { setActivePage, setInputValue, bounce, closeDialog } = callbacks
|
||||
|
||||
if (typeof action === "function") {
|
||||
action()
|
||||
closeDialog()
|
||||
return
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case "CHANGE_PAGE":
|
||||
if (payload) {
|
||||
setActivePage(payload)
|
||||
setInputValue("")
|
||||
bounce()
|
||||
}
|
||||
break
|
||||
default:
|
||||
closeDialog()
|
||||
}
|
||||
}
|
||||
91
web/app/components/custom/ai-search.tsx
Normal file
91
web/app/components/custom/ai-search.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import * as React from "react"
|
||||
import * as smd from "streaming-markdown"
|
||||
|
||||
interface AiSearchProps {
|
||||
searchQuery: string
|
||||
}
|
||||
|
||||
const AiSearch: React.FC<AiSearchProps> = (props: { searchQuery: string }) => {
|
||||
const [error, setError] = React.useState<string>("")
|
||||
|
||||
const root_el = React.useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const [parser, md_el] = React.useMemo(() => {
|
||||
const md_el = document.createElement("div")
|
||||
const renderer = smd.default_renderer(md_el)
|
||||
const parser = smd.parser(renderer)
|
||||
return [parser, md_el]
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (root_el.current) {
|
||||
root_el.current.appendChild(md_el)
|
||||
}
|
||||
}, [md_el])
|
||||
|
||||
React.useEffect(() => {
|
||||
const question = props.searchQuery
|
||||
|
||||
fetchData()
|
||||
async function fetchData() {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch("/api/search-stream", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ question: question }),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error)
|
||||
setError("Error fetching data")
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
console.error("Response has no body")
|
||||
setError("Response has no body")
|
||||
return
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
let done = false
|
||||
while (!done) {
|
||||
const res = await reader.read()
|
||||
|
||||
if (res.value) {
|
||||
const text = decoder.decode(res.value)
|
||||
smd.parser_write(parser, text)
|
||||
}
|
||||
|
||||
if (res.done) {
|
||||
smd.parser_end(parser)
|
||||
done = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [props.searchQuery, parser])
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col items-center">
|
||||
<div className="w-full rounded-lg bg-inherit p-6 text-black dark:text-white">
|
||||
<div className="mb-6 rounded-lg bg-blue-700 p-4 text-white">
|
||||
<h2 className="text-lg font-medium">✨ This is what I have found:</h2>
|
||||
</div>
|
||||
<div
|
||||
className="rounded-xl bg-neutral-100 p-4 dark:bg-[#121212]"
|
||||
ref={root_el}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-md pb-5 font-semibold opacity-50">{error}</p>
|
||||
<button className="text-md rounded-2xl bg-neutral-300 px-6 py-3 font-semibold text-opacity-50 shadow-inner shadow-neutral-400/50 transition-colors hover:bg-neutral-700 dark:bg-neutral-800 dark:shadow-neutral-700/50">
|
||||
Ask Community
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AiSearch
|
||||
44
web/app/components/custom/column.tsx
Normal file
44
web/app/components/custom/column.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ColumnWrapperProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
style?: { [key: string]: string }
|
||||
}
|
||||
|
||||
interface ColumnTextProps extends React.HTMLAttributes<HTMLSpanElement> {}
|
||||
|
||||
const ColumnWrapper = React.forwardRef<HTMLDivElement, ColumnWrapperProps>(
|
||||
({ children, className, style, ...props }, ref) => (
|
||||
<div
|
||||
className={cn("flex grow flex-row items-center justify-start", className)}
|
||||
style={{
|
||||
width: "var(--width)",
|
||||
minWidth: "var(--min-width, min-content)",
|
||||
maxWidth: "min(var(--width), var(--max-width))",
|
||||
flexBasis: "var(--width)",
|
||||
...style,
|
||||
}}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
|
||||
ColumnWrapper.displayName = "ColumnWrapper"
|
||||
|
||||
const ColumnText = React.forwardRef<HTMLSpanElement, ColumnTextProps>(
|
||||
({ children, className, ...props }, ref) => (
|
||||
<span className={cn("text-left text-xs", className)} ref={ref} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
)
|
||||
|
||||
ColumnText.displayName = "ColumnText"
|
||||
|
||||
export const Column = {
|
||||
Wrapper: ColumnWrapper,
|
||||
Text: ColumnText,
|
||||
}
|
||||
58
web/app/components/custom/content-header.tsx
Normal file
58
web/app/components/custom/content-header.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as React from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useAtom } from "jotai"
|
||||
import { isCollapseAtom, toggleCollapseAtom } from "@/store/sidebar"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
|
||||
type ContentHeaderProps = Omit<React.HTMLAttributes<HTMLDivElement>, "title">
|
||||
|
||||
export const ContentHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ContentHeaderProps
|
||||
>(({ children, className, ...props }, ref) => {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex min-h-10 min-w-0 shrink-0 items-center gap-3 pl-8 pr-6 transition-opacity max-lg:px-4",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</header>
|
||||
)
|
||||
})
|
||||
|
||||
ContentHeader.displayName = "ContentHeader"
|
||||
|
||||
export const SidebarToggleButton: React.FC = () => {
|
||||
const [isCollapse] = useAtom(isCollapseAtom)
|
||||
const [, toggle] = useAtom(toggleCollapseAtom)
|
||||
const isTablet = useMedia("(max-width: 1024px)")
|
||||
|
||||
if (!isCollapse && !isTablet) return null
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
toggle()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label="Menu"
|
||||
className="text-primary/60"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<LaIcon name="PanelLeft" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
web/app/components/custom/date-picker.tsx
Normal file
57
web/app/components/custom/date-picker.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { format } from "date-fns"
|
||||
import { Calendar as CalendarIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
|
||||
interface DatePickerProps {
|
||||
date: Date | undefined
|
||||
onDateChange: (date: Date | undefined) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DatePicker({ date, onDateChange, className }: DatePickerProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
const selectDate = (selectedDate: Date | undefined) => {
|
||||
onDateChange(selectedDate)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-[240px] justify-start text-left font-normal",
|
||||
!date && "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{date ? format(date, "PPP") : <span>Pick a date</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto p-0"
|
||||
align="start"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={selectDate}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
30
web/app/components/custom/la-icon.tsx
Normal file
30
web/app/components/custom/la-icon.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { icons } from "lucide-react"
|
||||
|
||||
export type IconProps = {
|
||||
name: keyof typeof icons
|
||||
className?: string
|
||||
strokeWidth?: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export const LaIcon = React.memo(
|
||||
({ name, className, size, strokeWidth, ...props }: IconProps) => {
|
||||
const IconComponent = icons[name]
|
||||
|
||||
if (!IconComponent) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<IconComponent
|
||||
className={cn(!size ? "size-4" : size, className)}
|
||||
strokeWidth={strokeWidth || 2}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
LaIcon.displayName = "LaIcon"
|
||||
137
web/app/components/custom/learning-state-selector.tsx
Normal file
137
web/app/components/custom/learning-state-selector.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import * as React from "react"
|
||||
import { useAtom } from "jotai"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||
import { linkLearningStateSelectorAtom } from "@/store/link"
|
||||
import {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandItem,
|
||||
CommandGroup,
|
||||
} from "@/components/ui/command"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { icons } from "lucide-react"
|
||||
|
||||
interface LearningStateSelectorProps {
|
||||
showSearch?: boolean
|
||||
defaultLabel?: string
|
||||
searchPlaceholder?: string
|
||||
value?: string
|
||||
onChange: (value: LearningStateValue) => void
|
||||
className?: string
|
||||
defaultIcon?: keyof typeof icons
|
||||
}
|
||||
|
||||
export const LearningStateSelector: React.FC<LearningStateSelectorProps> = ({
|
||||
showSearch = true,
|
||||
defaultLabel = "State",
|
||||
searchPlaceholder = "Search state...",
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
defaultIcon,
|
||||
}) => {
|
||||
const [isLearningStateSelectorOpen, setIsLearningStateSelectorOpen] = useAtom(
|
||||
linkLearningStateSelectorAtom,
|
||||
)
|
||||
const selectedLearningState = React.useMemo(
|
||||
() => LEARNING_STATES.find((ls) => ls.value === value),
|
||||
[value],
|
||||
)
|
||||
|
||||
const handleSelect = (selectedValue: string) => {
|
||||
onChange(selectedValue as LearningStateValue)
|
||||
setIsLearningStateSelectorOpen(false)
|
||||
}
|
||||
|
||||
const iconName = selectedLearningState?.icon || defaultIcon
|
||||
const labelText = selectedLearningState?.label || defaultLabel
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={isLearningStateSelectorOpen}
|
||||
onOpenChange={setIsLearningStateSelectorOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
role="combobox"
|
||||
variant="secondary"
|
||||
className={cn("gap-x-2 text-sm", className)}
|
||||
>
|
||||
{iconName && (
|
||||
<LaIcon
|
||||
name={iconName}
|
||||
className={cn(selectedLearningState?.className)}
|
||||
/>
|
||||
)}
|
||||
{labelText && (
|
||||
<span
|
||||
className={cn("truncate", selectedLearningState?.className || "")}
|
||||
>
|
||||
{labelText}
|
||||
</span>
|
||||
)}
|
||||
<LaIcon name="ChevronDown" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 rounded-lg p-0" side="bottom" align="end">
|
||||
<LearningStateSelectorContent
|
||||
showSearch={showSearch}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
value={value}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
interface LearningStateSelectorContentProps {
|
||||
showSearch: boolean
|
||||
searchPlaceholder: string
|
||||
value?: string
|
||||
onSelect: (value: string) => void
|
||||
}
|
||||
|
||||
export const LearningStateSelectorContent: React.FC<
|
||||
LearningStateSelectorContentProps
|
||||
> = ({ showSearch, searchPlaceholder, value, onSelect }) => {
|
||||
return (
|
||||
<Command>
|
||||
{showSearch && (
|
||||
<CommandInput placeholder={searchPlaceholder} className="h-9" />
|
||||
)}
|
||||
<CommandList>
|
||||
<ScrollArea>
|
||||
<CommandGroup>
|
||||
{LEARNING_STATES.map((ls) => (
|
||||
<CommandItem key={ls.value} value={ls.value} onSelect={onSelect}>
|
||||
{ls.icon && (
|
||||
<LaIcon name={ls.icon} className={cn("mr-2", ls.className)} />
|
||||
)}
|
||||
<span className={ls.className}>{ls.label}</span>
|
||||
<LaIcon
|
||||
name="Check"
|
||||
className={cn(
|
||||
"absolute right-3",
|
||||
ls.value === value ? "text-primary" : "text-transparent",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</Command>
|
||||
)
|
||||
}
|
||||
32
web/app/components/custom/spinner.tsx
Normal file
32
web/app/components/custom/spinner.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SpinnerProps extends React.SVGAttributes<SVGElement> {}
|
||||
|
||||
export const Spinner = React.forwardRef<SVGSVGElement, SpinnerProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<svg
|
||||
ref={ref}
|
||||
className={cn("h-4 w-4 animate-spin", className)}
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<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>
|
||||
),
|
||||
)
|
||||
|
||||
Spinner.displayName = "Spinner"
|
||||
25
web/app/components/custom/textarea-autosize.tsx
Normal file
25
web/app/components/custom/textarea-autosize.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
import BaseTextareaAutosize from "react-textarea-autosize"
|
||||
import { TextareaAutosizeProps as BaseTextareaAutosizeProps } from "react-textarea-autosize"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps extends Omit<BaseTextareaAutosizeProps, "ref"> {}
|
||||
|
||||
const TextareaAutosize = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<BaseTextareaAutosize
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
TextareaAutosize.displayName = "TextareaAutosize"
|
||||
|
||||
export { TextareaAutosize }
|
||||
208
web/app/components/custom/topic-selector.tsx
Normal file
208
web/app/components/custom/topic-selector.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import * as React from "react"
|
||||
import { atom, useAtom } from "jotai"
|
||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandItem,
|
||||
CommandGroup,
|
||||
} from "@/components/ui/command"
|
||||
import { useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
|
||||
import { ListOfTopics, Topic } from "@/lib/schema"
|
||||
import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants"
|
||||
import { VariantProps } from "class-variance-authority"
|
||||
|
||||
interface TopicSelectorProps extends VariantProps<typeof buttonVariants> {
|
||||
showSearch?: boolean
|
||||
defaultLabel?: string
|
||||
searchPlaceholder?: string
|
||||
value?: string | null
|
||||
onChange?: (value: string) => void
|
||||
onTopicChange?: (value: Topic) => void
|
||||
className?: string
|
||||
renderSelectedText?: (value?: string | null) => React.ReactNode
|
||||
side?: "bottom" | "top" | "right" | "left"
|
||||
align?: "center" | "end" | "start"
|
||||
}
|
||||
|
||||
export const topicSelectorAtom = atom(false)
|
||||
|
||||
export const TopicSelector = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
TopicSelectorProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
showSearch = true,
|
||||
defaultLabel = "Select topic",
|
||||
searchPlaceholder = "Search topic...",
|
||||
value,
|
||||
onChange,
|
||||
onTopicChange,
|
||||
className,
|
||||
renderSelectedText,
|
||||
side = "bottom",
|
||||
align = "end",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [isTopicSelectorOpen, setIsTopicSelectorOpen] =
|
||||
useAtom(topicSelectorAtom)
|
||||
const group = useCoState(PublicGlobalGroup, JAZZ_GLOBAL_GROUP_ID, {
|
||||
root: { topics: [] },
|
||||
})
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
(selectedTopicName: string, topic: Topic) => {
|
||||
onChange?.(selectedTopicName)
|
||||
onTopicChange?.(topic)
|
||||
setIsTopicSelectorOpen(false)
|
||||
},
|
||||
[onChange, setIsTopicSelectorOpen, onTopicChange],
|
||||
)
|
||||
|
||||
const displaySelectedText = React.useMemo(() => {
|
||||
if (renderSelectedText) {
|
||||
return renderSelectedText(value)
|
||||
}
|
||||
return <span className="truncate">{value || defaultLabel}</span>
|
||||
}, [value, defaultLabel, renderSelectedText])
|
||||
|
||||
return (
|
||||
<Popover open={isTopicSelectorOpen} onOpenChange={setIsTopicSelectorOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
size="sm"
|
||||
type="button"
|
||||
role="combobox"
|
||||
variant="secondary"
|
||||
className={cn("gap-x-2 text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{displaySelectedText}
|
||||
<LaIcon name="ChevronDown" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-52 rounded-lg p-0"
|
||||
side={side}
|
||||
align={align}
|
||||
>
|
||||
{group?.root.topics && (
|
||||
<TopicSelectorContent
|
||||
showSearch={showSearch}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
value={value}
|
||||
onSelect={handleSelect}
|
||||
topics={group.root.topics}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
TopicSelector.displayName = "TopicSelector"
|
||||
|
||||
interface TopicSelectorContentProps
|
||||
extends Omit<TopicSelectorProps, "onChange" | "onTopicChange"> {
|
||||
onSelect: (value: string, topic: Topic) => void
|
||||
topics: ListOfTopics
|
||||
}
|
||||
|
||||
const TopicSelectorContent: React.FC<TopicSelectorContentProps> = React.memo(
|
||||
({ showSearch, searchPlaceholder, value, onSelect, topics }) => {
|
||||
const [search, setSearch] = React.useState("")
|
||||
const filteredTopics = React.useMemo(
|
||||
() =>
|
||||
topics.filter((topic) =>
|
||||
topic?.prettyName.toLowerCase().includes(search.toLowerCase()),
|
||||
),
|
||||
[topics, search],
|
||||
)
|
||||
|
||||
const parentRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: filteredTopics.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 35,
|
||||
overscan: 5,
|
||||
})
|
||||
|
||||
return (
|
||||
<Command>
|
||||
{showSearch && (
|
||||
<CommandInput
|
||||
placeholder={searchPlaceholder}
|
||||
className="h-9"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
)}
|
||||
<CommandList>
|
||||
<div ref={parentRef} style={{ height: "200px", overflow: "auto" }}>
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<CommandGroup>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const topic = filteredTopics[virtualRow.index]
|
||||
return (
|
||||
topic && (
|
||||
<CommandItem
|
||||
key={virtualRow.key}
|
||||
value={topic.name}
|
||||
onSelect={(value) => onSelect(value, topic)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<span>{topic.prettyName}</span>
|
||||
<LaIcon
|
||||
name="Check"
|
||||
className={cn(
|
||||
"absolute right-3",
|
||||
topic.name === value
|
||||
? "text-primary"
|
||||
: "text-transparent",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
)
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
</div>
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
TopicSelectorContent.displayName = "TopicSelectorContent"
|
||||
|
||||
export default TopicSelector
|
||||
29
web/app/components/icons/discord-icon.tsx
Normal file
29
web/app/components/icons/discord-icon.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
export const DiscordIcon = () => (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5.9143 7.38378L4.93679 14.6174C4.82454 15.448 5.24219 16.2606 5.983 16.6528L8.99995 18.25L9.99995 15.75C9.99995 15.75 10.6562 16.25 11.9999 16.25C13.3437 16.25 13.9999 15.75 13.9999 15.75L14.9999 18.25L18.0169 16.6528C18.7577 16.2606 19.1754 15.448 19.0631 14.6174L18.0856 7.38378C18.0334 6.99739 17.7613 6.67658 17.3887 6.56192L14.7499 5.75003V6.25003C14.7499 6.80232 14.3022 7.25003 13.7499 7.25003H10.2499C9.69766 7.25003 9.24995 6.80232 9.24995 6.25003V5.75003L6.61122 6.56192C6.23855 6.67658 5.96652 6.99739 5.9143 7.38378Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
></path>
|
||||
<path
|
||||
d="M10.5 12C10.5 12.2761 10.2761 12.5 10 12.5C9.72386 12.5 9.5 12.2761 9.5 12C9.5 11.7239 9.72386 11.5 10 11.5C10.2761 11.5 10.5 11.7239 10.5 12Z"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
></path>
|
||||
<path
|
||||
d="M14.5 12C14.5 12.2761 14.2761 12.5 14 12.5C13.7239 12.5 13.5 12.2761 13.5 12C13.5 11.7239 13.7239 11.5 14 11.5C14.2761 11.5 14.5 11.7239 14.5 12Z"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
64
web/app/components/icons/logo-icon.tsx
Normal file
64
web/app/components/icons/logo-icon.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
|
||||
interface LogoIconProps extends React.SVGProps<SVGSVGElement> {}
|
||||
|
||||
export const LogoIcon = ({ className, ...props }: LogoIconProps) => {
|
||||
return (
|
||||
<svg
|
||||
width="35"
|
||||
height="35"
|
||||
viewBox="0 0 30 30"
|
||||
fill="none"
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
<g clipPath="url(#clip0_7502_1806)">
|
||||
<g opacity="0.7">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M21.0784 28.966C22.2824 28.4786 23.4001 27.8248 24.4023 27.0309C23.3266 27.824 22.8358 28.1863 21.4672 28.855C21.1737 28.9845 20.7834 29.1589 20.4862 29.2776C20.7374 29.1817 20.9384 29.0775 21.0784 28.966ZM21.0784 28.966C21.9873 28.2464 20.0201 27.5006 16.6827 27.3016C13.3458 27.1024 9.90278 27.5248 8.99303 28.2455C8.53799 28.6055 8.80403 28.9727 9.60135 29.2735C8.65457 28.8758 8.5333 28.8244 7.8472 28.4597C6.75696 27.8168 6.58962 27.7185 5.73927 27.0742L4.92993 26.3942C4.52809 26.0366 4.52726 25.6534 5.00506 25.274C6.5144 24.0787 12.2291 23.3778 17.7679 23.708C23.2115 24.0331 26.4595 25.2334 25.1377 26.4094L24.4023 27.0309C23.4001 27.8248 22.2824 28.4786 21.0784 28.966ZM28.3512 22.3353C29.1155 20.9354 25.0453 19.584 18.5582 19.1967C11.4141 18.7709 4.0449 19.6752 2.09828 21.2168C1.63169 21.5863 1.51866 21.9584 1.71466 22.3174L1.24738 21.3808C0.661456 19.9547 0.637998 19.8993 0.411012 19.0759C0.290928 18.5604 0.132822 17.8708 0.0436785 17.3489C-0.00522774 17.0334 0.161581 16.7104 0.566459 16.3893C2.74386 14.6655 10.9842 13.6538 18.9722 14.1302C25.8065 14.5389 30.2415 15.9033 30.0181 17.3685C29.9229 17.8609 29.799 18.5172 29.6776 19.0027C29.2111 20.51 29.2018 20.5387 28.8566 21.3131L28.3512 22.3353ZM29.8832 11.9702C29.6058 10.6126 25.3295 9.38692 18.9372 9.00544C11.0164 8.53218 2.84438 9.53532 0.686174 11.2447C0.388347 11.4802 0.22062 11.7166 0.173528 11.951C0.310001 11.3893 0.502756 10.6417 0.675563 10.0903C1.23679 8.62642 1.24754 8.59884 1.64202 7.8504L2.07443 7.08959C2.15058 6.96518 2.26721 6.83897 2.42498 6.71374C4.32178 5.21178 11.5008 4.33054 18.4599 4.74618C23.6915 5.05808 27.3098 6.0137 27.9778 7.10736L28.4113 7.86864C29.076 9.24824 29.102 9.30198 29.3797 10.1094C29.5436 10.6635 29.7539 11.4062 29.8832 11.9702ZM24.5623 3.1821C23.6812 2.47343 21.1317 1.88047 17.6199 1.66987C12.3597 1.35668 6.93276 2.02235 5.49908 3.15763C5.49209 3.16281 5.48681 3.16755 5.48041 3.17257L5.65732 3.03037C6.60122 2.33439 7.22384 1.87498 8.5921 1.20633C9.52394 0.795491 9.62105 0.752916 10.3408 0.509223C11.6398 0.0845342 14.0986 -0.130655 16.4976 0.0123293C17.8074 0.0906479 18.8815 0.262207 19.6062 0.485844C20.3846 0.756101 20.569 0.819981 21.3403 1.16385C22.38 1.68628 22.5964 1.79488 23.5716 2.43791C23.8701 2.65971 24.2735 2.94884 24.5623 3.1821Z"
|
||||
fill="white"
|
||||
fillOpacity="0.5"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M21.0784 28.966C22.2824 28.4786 23.4001 27.8248 24.4023 27.0309C23.3266 27.824 22.8358 28.1863 21.4672 28.855C21.1737 28.9845 20.7834 29.1589 20.4862 29.2776C20.7374 29.1817 20.9384 29.0775 21.0784 28.966ZM21.0784 28.966C21.9873 28.2464 20.0201 27.5006 16.6827 27.3016C13.3458 27.1024 9.90278 27.5248 8.99303 28.2455C8.53799 28.6055 8.80403 28.9727 9.60135 29.2735C8.65457 28.8758 8.5333 28.8244 7.8472 28.4597C6.75696 27.8168 6.58962 27.7185 5.73927 27.0742L4.92993 26.3942C4.52809 26.0366 4.52726 25.6534 5.00506 25.274C6.5144 24.0787 12.2291 23.3778 17.7679 23.708C23.2115 24.0331 26.4595 25.2334 25.1377 26.4094L24.4023 27.0309C23.4001 27.8248 22.2824 28.4786 21.0784 28.966ZM28.3512 22.3353C29.1155 20.9354 25.0453 19.584 18.5582 19.1967C11.4141 18.7709 4.0449 19.6752 2.09828 21.2168C1.63169 21.5863 1.51866 21.9584 1.71466 22.3174L1.24738 21.3808C0.661456 19.9547 0.637998 19.8993 0.411012 19.0759C0.290928 18.5604 0.132822 17.8708 0.0436785 17.3489C-0.00522774 17.0334 0.161581 16.7104 0.566459 16.3893C2.74386 14.6655 10.9842 13.6538 18.9722 14.1302C25.8065 14.5389 30.2415 15.9033 30.0181 17.3685C29.9229 17.8609 29.799 18.5172 29.6776 19.0027C29.2111 20.51 29.2018 20.5387 28.8566 21.3131L28.3512 22.3353ZM29.8832 11.9702C29.6058 10.6126 25.3295 9.38692 18.9372 9.00544C11.0164 8.53218 2.84438 9.53532 0.686174 11.2447C0.388347 11.4802 0.22062 11.7166 0.173528 11.951C0.310001 11.3893 0.502756 10.6417 0.675563 10.0903C1.23679 8.62642 1.24754 8.59884 1.64202 7.8504L2.07443 7.08959C2.15058 6.96518 2.26721 6.83897 2.42498 6.71374C4.32178 5.21178 11.5008 4.33054 18.4599 4.74618C23.6915 5.05808 27.3098 6.0137 27.9778 7.10736L28.4113 7.86864C29.076 9.24824 29.102 9.30198 29.3797 10.1094C29.5436 10.6635 29.7539 11.4062 29.8832 11.9702ZM24.5623 3.1821C23.6812 2.47343 21.1317 1.88047 17.6199 1.66987C12.3597 1.35668 6.93276 2.02235 5.49908 3.15763C5.49209 3.16281 5.48681 3.16755 5.48041 3.17257L5.65732 3.03037C6.60122 2.33439 7.22384 1.87498 8.5921 1.20633C9.52394 0.795491 9.62105 0.752916 10.3408 0.509223C11.6398 0.0845342 14.0986 -0.130655 16.4976 0.0123293C17.8074 0.0906479 18.8815 0.262207 19.6062 0.485844C20.3846 0.756101 20.569 0.819981 21.3403 1.16385C22.38 1.68628 22.5964 1.79488 23.5716 2.43791C23.8701 2.65971 24.2735 2.94884 24.5623 3.1821Z"
|
||||
fill="#2358E0"
|
||||
fillOpacity="0.23"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M27.9987 7.21638L27.9694 7.16485C27.9799 7.18199 27.9897 7.19915 27.9987 7.21638ZM2.03707 7.19796C1.32209 8.55782 5.28261 9.86753 11.5833 10.243C18.5427 10.6589 25.7223 9.7775 27.6181 8.27546C28.0664 7.91991 28.1802 7.56156 27.9987 7.21638L28.4028 7.92612C28.7627 8.58433 28.844 8.79507 29.3713 10.1669C29.5443 10.7186 29.737 11.4658 29.8748 12.0277C29.9414 12.3524 29.779 12.6843 29.3622 13.0144C27.2039 14.7239 19.032 15.7269 11.1114 15.254C4.27975 14.8461 -0.133951 13.4745 0.165092 12.0085C0.292434 11.4448 0.502892 10.7026 0.667127 10.1478C0.942429 9.34203 0.955541 9.31502 1.63358 7.90789L2.03707 7.19796ZM2.03707 7.19796C2.04614 7.18077 2.0557 7.16395 2.066 7.14708L2.03707 7.19796ZM0.045561 17.4609C0.361533 18.8224 4.66336 20.0491 11.0801 20.4323C19.0685 20.9088 27.3093 19.8977 29.4861 18.1735C29.7914 17.932 29.9613 17.6896 30.0058 17.4492C29.9224 17.9404 29.7763 18.5793 29.6692 19.0601C29.495 19.766 29.3836 20.0424 28.8482 21.3706L28.3427 22.3928C28.2652 22.5344 28.1382 22.6762 27.9592 22.8181C26.0121 24.3604 18.6437 25.2641 11.4993 24.8381C6.06715 24.5138 2.32874 23.5136 1.70622 22.3749L1.23894 21.4383C0.887668 20.6653 0.878487 20.6365 0.402577 19.1333C0.276244 18.6383 0.144853 17.9746 0.045561 17.4609ZM0.045561 17.4609C0.0414181 17.4428 0.0379656 17.4246 0.0352439 17.4064C0.0385814 17.4245 0.0422712 17.4423 0.045561 17.4609ZM30.0058 17.4492C30.0071 17.4415 30.0084 17.4337 30.0097 17.426C30.0085 17.4337 30.0072 17.4415 30.0058 17.4492ZM4.99103 26.51C4.96674 26.4905 4.94348 26.4712 4.92149 26.4517L4.99103 26.51ZM4.99103 26.51C5.925 27.2536 8.60587 27.8751 12.2925 28.0956C17.8319 28.4256 23.5463 27.7251 25.0556 26.5286C25.0583 26.5265 25.061 26.5244 25.0636 26.5223L24.3938 27.0884C23.2187 28.0069 22.4421 28.4062 21.4587 28.9124C21.1678 29.0473 20.7729 29.2108 20.4778 29.3351C19.1007 29.8594 16.2024 30.1364 13.3806 29.9677C11.7136 29.8677 10.3882 29.632 9.59291 29.331C8.87794 29.0455 8.79946 29.0057 7.83876 28.5171C6.91988 27.9982 6.7843 27.8992 5.73083 27.1317L4.99103 26.51ZM25.0636 26.5223L25.1293 26.4669C25.109 26.4851 25.0867 26.5043 25.0636 26.5223ZM24.5539 3.23958C24.9932 3.59312 25.018 3.97504 24.5411 4.35241C23.1081 5.48738 17.6806 6.15388 12.4195 5.83999C7.18209 5.5271 4.08325 4.3611 5.47197 3.23005L5.64889 3.08786C6.87177 2.14553 7.51627 1.81302 8.58366 1.26382C9.22483 0.968483 9.40031 0.900301 10.1065 0.647417C9.89518 0.730196 9.72456 0.819108 9.605 0.915035C8.79062 1.55997 10.5522 2.22662 13.54 2.40516C16.5276 2.58319 19.6101 2.20556 20.4252 1.56034C20.8352 1.23533 20.589 0.904881 19.8634 0.633557C20.4348 0.830615 20.6321 0.916665 21.3318 1.22133C22.2102 1.62645 22.7484 1.97233 23.5631 2.49539C23.8679 2.70793 24.2581 3.01474 24.5539 3.23958Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M27.9987 7.21638L27.9694 7.16485C27.9799 7.18199 27.9897 7.19915 27.9987 7.21638ZM2.03707 7.19796C1.32209 8.55782 5.28261 9.86753 11.5833 10.243C18.5427 10.6589 25.7223 9.7775 27.6181 8.27546C28.0664 7.91991 28.1802 7.56156 27.9987 7.21638L28.4028 7.92612C28.7627 8.58433 28.844 8.79507 29.3713 10.1669C29.5443 10.7186 29.737 11.4658 29.8748 12.0277C29.9414 12.3524 29.779 12.6843 29.3622 13.0144C27.2039 14.7239 19.032 15.7269 11.1114 15.254C4.27975 14.8461 -0.133951 13.4745 0.165092 12.0085C0.292434 11.4448 0.502892 10.7026 0.667127 10.1478C0.942429 9.34203 0.955541 9.31502 1.63358 7.90789L2.03707 7.19796ZM2.03707 7.19796C2.04614 7.18077 2.0557 7.16395 2.066 7.14708L2.03707 7.19796ZM0.045561 17.4609C0.361533 18.8224 4.66336 20.0491 11.0801 20.4323C19.0685 20.9088 27.3093 19.8977 29.4861 18.1735C29.7914 17.932 29.9613 17.6896 30.0058 17.4492C29.9224 17.9404 29.7763 18.5793 29.6692 19.0601C29.495 19.766 29.3836 20.0424 28.8482 21.3706L28.3427 22.3928C28.2652 22.5344 28.1382 22.6762 27.9592 22.8181C26.0121 24.3604 18.6437 25.2641 11.4993 24.8381C6.06715 24.5138 2.32874 23.5136 1.70622 22.3749L1.23894 21.4383C0.887668 20.6653 0.878487 20.6365 0.402577 19.1333C0.276244 18.6383 0.144853 17.9746 0.045561 17.4609ZM0.045561 17.4609C0.0414181 17.4428 0.0379656 17.4246 0.0352439 17.4064C0.0385814 17.4245 0.0422712 17.4423 0.045561 17.4609ZM30.0058 17.4492C30.0071 17.4415 30.0084 17.4337 30.0097 17.426C30.0085 17.4337 30.0072 17.4415 30.0058 17.4492ZM4.99103 26.51C4.96674 26.4905 4.94348 26.4712 4.92149 26.4517L4.99103 26.51ZM4.99103 26.51C5.925 27.2536 8.60587 27.8751 12.2925 28.0956C17.8319 28.4256 23.5463 27.7251 25.0556 26.5286C25.0583 26.5265 25.061 26.5244 25.0636 26.5223L24.3938 27.0884C23.2187 28.0069 22.4421 28.4062 21.4587 28.9124C21.1678 29.0473 20.7729 29.2108 20.4778 29.3351C19.1007 29.8594 16.2024 30.1364 13.3806 29.9677C11.7136 29.8677 10.3882 29.632 9.59291 29.331C8.87794 29.0455 8.79946 29.0057 7.83876 28.5171C6.91988 27.9982 6.7843 27.8992 5.73083 27.1317L4.99103 26.51ZM25.0636 26.5223L25.1293 26.4669C25.109 26.4851 25.0867 26.5043 25.0636 26.5223ZM24.5539 3.23958C24.9932 3.59312 25.018 3.97504 24.5411 4.35241C23.1081 5.48738 17.6806 6.15388 12.4195 5.83999C7.18209 5.5271 4.08325 4.3611 5.47197 3.23005L5.64889 3.08786C6.87177 2.14553 7.51627 1.81302 8.58366 1.26382C9.22483 0.968483 9.40031 0.900301 10.1065 0.647417C9.89518 0.730196 9.72456 0.819108 9.605 0.915035C8.79062 1.55997 10.5522 2.22662 13.54 2.40516C16.5276 2.58319 19.6101 2.20556 20.4252 1.56034C20.8352 1.23533 20.589 0.904881 19.8634 0.633557C20.4348 0.830615 20.6321 0.916665 21.3318 1.22133C22.2102 1.62645 22.7484 1.97233 23.5631 2.49539C23.8679 2.70793 24.2581 3.01474 24.5539 3.23958Z"
|
||||
fill="url(#paint0_linear_7502_1806)"
|
||||
fillOpacity="0.32"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_7502_1806"
|
||||
x1="23.9069"
|
||||
y1="2.74376"
|
||||
x2="5.97898"
|
||||
y2="27.3127"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="white" stopOpacity="0" />
|
||||
<stop offset="1" stopColor="#2358E0" />
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_7502_1806">
|
||||
<rect width="30" height="30" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
190
web/app/components/shortcut/shortcut.tsx
Normal file
190
web/app/components/shortcut/shortcut.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { atom, useAtom } from "jotai"
|
||||
import {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTitle,
|
||||
sheetVariants,
|
||||
SheetDescription,
|
||||
} from "@/components/ui/sheet"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useKeyboardManager } from "@/hooks/use-keyboard-manager"
|
||||
|
||||
export const showShortcutAtom = atom(false)
|
||||
|
||||
type ShortcutItem = {
|
||||
label: string
|
||||
keys: string[]
|
||||
then?: string[]
|
||||
}
|
||||
|
||||
type ShortcutSection = {
|
||||
title: string
|
||||
shortcuts: ShortcutItem[]
|
||||
}
|
||||
|
||||
const SHORTCUTS: ShortcutSection[] = [
|
||||
{
|
||||
title: "General",
|
||||
shortcuts: [
|
||||
{ label: "Open command menu", keys: ["⌘", "k"] },
|
||||
{ label: "Log out", keys: ["⌥", "⇧", "q"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Navigation",
|
||||
shortcuts: [
|
||||
{ label: "Go to link", keys: ["G"], then: ["L"] },
|
||||
{ label: "Go to page", keys: ["G"], then: ["P"] },
|
||||
{ label: "Go to topic", keys: ["G"], then: ["T"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Links",
|
||||
shortcuts: [{ label: "Create new link", keys: ["c"] }],
|
||||
},
|
||||
{
|
||||
title: "Pages",
|
||||
shortcuts: [{ label: "Create new page", keys: ["p"] }],
|
||||
},
|
||||
]
|
||||
|
||||
const ShortcutKey: React.FC<{ keyChar: string }> = ({ keyChar }) => (
|
||||
<kbd
|
||||
aria-hidden="true"
|
||||
className="inline-flex size-5 items-center justify-center rounded border font-sans text-xs capitalize"
|
||||
>
|
||||
{keyChar}
|
||||
</kbd>
|
||||
)
|
||||
|
||||
const ShortcutItem: React.FC<ShortcutItem> = ({ label, keys, then }) => (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<dt className="flex grow items-center">
|
||||
<span className="text-muted-foreground text-left text-sm">{label}</span>
|
||||
</dt>
|
||||
<dd className="flex items-end">
|
||||
<span className="text-left">
|
||||
<span
|
||||
aria-label={keys.join(" ") + (then ? ` then ${then.join(" ")}` : "")}
|
||||
className="inline-flex items-center gap-1"
|
||||
>
|
||||
{keys.map((key, index) => (
|
||||
<ShortcutKey key={index} keyChar={key} />
|
||||
))}
|
||||
{then && (
|
||||
<>
|
||||
<span className="text-muted-foreground text-xs">then</span>
|
||||
{then.map((key, index) => (
|
||||
<ShortcutKey key={`then-${index}`} keyChar={key} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ShortcutSection: React.FC<ShortcutSection> = ({ title, shortcuts }) => (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="inline-flex gap-1.5 text-sm">{title}</h2>
|
||||
<dl className="m-0 flex flex-col gap-2">
|
||||
{shortcuts.map((shortcut, index) => (
|
||||
<ShortcutItem key={index} {...shortcut} />
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
)
|
||||
|
||||
export function Shortcut() {
|
||||
const [showShortcut, setShowShortcut] = useAtom(showShortcutAtom)
|
||||
const [searchQuery, setSearchQuery] = React.useState("")
|
||||
|
||||
const { disableKeydown } = useKeyboardManager("shortcutSection")
|
||||
|
||||
React.useEffect(() => {
|
||||
disableKeydown(showShortcut)
|
||||
}, [showShortcut, disableKeydown])
|
||||
|
||||
const filteredShortcuts = React.useMemo(() => {
|
||||
if (!searchQuery) return SHORTCUTS
|
||||
|
||||
return SHORTCUTS.map((section) => ({
|
||||
...section,
|
||||
shortcuts: section.shortcuts.filter((shortcut) =>
|
||||
shortcut.label.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
),
|
||||
})).filter((section) => section.shortcuts.length > 0)
|
||||
}, [searchQuery])
|
||||
|
||||
return (
|
||||
<Sheet open={showShortcut} onOpenChange={setShowShortcut}>
|
||||
<SheetPortal>
|
||||
<SheetOverlay className="bg-black/10" />
|
||||
<SheetPrimitive.Content
|
||||
className={cn(
|
||||
sheetVariants({ side: "right" }),
|
||||
"m-3 h-[calc(100vh-24px)] rounded-md p-0",
|
||||
)}
|
||||
>
|
||||
<header className="flex flex-[0_0_auto] items-center gap-3 px-5 pb-4 pt-5">
|
||||
<SheetTitle className="text-base font-medium">
|
||||
Keyboard Shortcuts
|
||||
</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
Quickly navigate around the app
|
||||
</SheetDescription>
|
||||
|
||||
<div className="flex-auto"></div>
|
||||
|
||||
<SheetPrimitive.Close
|
||||
className={cn(
|
||||
buttonVariants({ size: "icon", variant: "ghost" }),
|
||||
"size-6 p-0",
|
||||
)}
|
||||
>
|
||||
<LaIcon name="X" className="text-muted-foreground size-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-1 px-5 pb-6">
|
||||
<form className="relative flex items-center">
|
||||
<LaIcon
|
||||
name="Search"
|
||||
className="text-muted-foreground absolute left-3 size-4"
|
||||
/>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search shortcuts"
|
||||
className="border-muted-foreground/50 focus-visible:border-muted-foreground h-10 pl-10 focus-visible:ring-0"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<main className="flex-auto overflow-y-auto overflow-x-hidden [scrollbar-gutter:auto]">
|
||||
<div className="px-5 pb-5">
|
||||
<div
|
||||
role="region"
|
||||
aria-live="polite"
|
||||
className="flex flex-col gap-7"
|
||||
>
|
||||
{filteredShortcuts.map((section, index) => (
|
||||
<ShortcutSection key={index} {...section} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
158
web/app/components/sidebar/partials/feedback.tsx
Normal file
158
web/app/components/sidebar/partials/feedback.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as React from "react"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogPrimitive,
|
||||
} from "@/components/ui/dialog"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { MinimalTiptapEditor } from "@shared/minimal-tiptap"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { useRef, useState } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { z } from "zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { toast } from "sonner"
|
||||
import { Spinner } from "@/components/custom/spinner"
|
||||
import { Editor } from "@tiptap/react"
|
||||
import { sendFeedbackFn } from "~/actions"
|
||||
|
||||
const formSchema = z.object({
|
||||
content: z.string().min(1, {
|
||||
message: "Feedback cannot be empty",
|
||||
}),
|
||||
})
|
||||
|
||||
export function Feedback() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const editorRef = useRef<Editor | null>(null)
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
content: "",
|
||||
},
|
||||
})
|
||||
|
||||
const handleCreate = React.useCallback(
|
||||
({ editor }: { editor: Editor }) => {
|
||||
if (form.getValues("content") && editor.isEmpty) {
|
||||
editor.commands.setContent(form.getValues("content"))
|
||||
}
|
||||
editorRef.current = editor
|
||||
},
|
||||
[form],
|
||||
)
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
try {
|
||||
setIsPending(true)
|
||||
await sendFeedbackFn(values)
|
||||
|
||||
form.reset({ content: "" })
|
||||
editorRef.current?.commands.clearContent()
|
||||
|
||||
setOpen(false)
|
||||
toast.success("Feedback sent")
|
||||
} catch (error) {
|
||||
toast.error("Failed to send feedback")
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="icon" className="shrink-0" variant="ghost">
|
||||
<LaIcon name="CircleHelp" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
"flex flex-col p-4 sm:max-w-2xl",
|
||||
)}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogHeader className="mb-5">
|
||||
<DialogTitle>Share feedback</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Your feedback helps us improve. Please share your thoughts,
|
||||
ideas, and suggestions
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="sr-only">Content</FormLabel>
|
||||
<FormControl>
|
||||
<MinimalTiptapEditor
|
||||
{...field}
|
||||
throttleDelay={500}
|
||||
className={cn(
|
||||
"border-muted-foreground/40 focus-within:border-muted-foreground/80 min-h-52 rounded-lg",
|
||||
{
|
||||
"border-destructive focus-within:border-destructive":
|
||||
form.formState.errors.content,
|
||||
},
|
||||
)}
|
||||
editorContentClassName="p-4 overflow-auto flex grow"
|
||||
output="html"
|
||||
placeholder="Your feedback helps us improve. Please share your thoughts, ideas, and suggestions."
|
||||
autofocus={true}
|
||||
onCreate={handleCreate}
|
||||
editorClassName="focus:outline-none"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<DialogPrimitive.Close
|
||||
className={buttonVariants({ variant: "outline" })}
|
||||
>
|
||||
Cancel
|
||||
</DialogPrimitive.Close>
|
||||
<Button type="submit">
|
||||
{isPending ? (
|
||||
<>
|
||||
<Spinner className="mr-2" />
|
||||
<span>Sending feedback...</span>
|
||||
</>
|
||||
) : (
|
||||
"Send feedback"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
120
web/app/components/sidebar/partials/journal-section.tsx
Normal file
120
web/app/components/sidebar/partials/journal-section.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useAuth, useUser } from "@clerk/tanstack-start"
|
||||
import { LaIcon } from "~/components/custom/la-icon"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { getFeatureFlag } from "~/actions"
|
||||
|
||||
export const JournalSection: React.FC = () => {
|
||||
const { me } = useAccount()
|
||||
const journalEntries = me?.root?.journalEntries
|
||||
|
||||
const [, setIsFetching] = useState(false)
|
||||
const [isFeatureActive, setIsFeatureActive] = useState(false)
|
||||
const { isLoaded, isSignedIn } = useAuth()
|
||||
const { user } = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
async function checkFeatureFlag() {
|
||||
setIsFetching(true)
|
||||
|
||||
if (isLoaded && isSignedIn) {
|
||||
const response = await getFeatureFlag({ name: "JOURNAL" })
|
||||
|
||||
if (
|
||||
user?.emailAddresses.some((email) =>
|
||||
response?.emails.includes(email.emailAddress),
|
||||
)
|
||||
) {
|
||||
setIsFeatureActive(true)
|
||||
}
|
||||
setIsFetching(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkFeatureFlag()
|
||||
}, [isLoaded, isSignedIn, user])
|
||||
|
||||
if (!isLoaded || !isSignedIn) {
|
||||
return <div className="py-2 text-center text-gray-500">Loading...</div>
|
||||
}
|
||||
|
||||
if (!me) return null
|
||||
|
||||
if (!isFeatureActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/journal flex flex-col gap-px py-2">
|
||||
<JournalSectionHeader entriesCount={journalEntries?.length || 0} />
|
||||
{journalEntries && journalEntries.length > 0 && (
|
||||
<JournalEntryList entries={journalEntries} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface JournalHeaderProps {
|
||||
entriesCount: number
|
||||
}
|
||||
|
||||
const JournalSectionHeader: React.FC<JournalHeaderProps> = ({
|
||||
entriesCount,
|
||||
}) => (
|
||||
<Link
|
||||
to="/journals"
|
||||
className={cn(
|
||||
"flex h-9 items-center gap-px rounded-md px-2 py-1 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-0 sm:h-[30px] sm:text-xs",
|
||||
)}
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
<p className="text-xs">
|
||||
Journal
|
||||
{entriesCount > 0 && (
|
||||
<span className="text-muted-foreground ml-1">({entriesCount})</span>
|
||||
)}
|
||||
</p>
|
||||
</Link>
|
||||
)
|
||||
|
||||
interface JournalEntryListProps {
|
||||
entries: any[]
|
||||
}
|
||||
|
||||
const JournalEntryList: React.FC<JournalEntryListProps> = ({ entries }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
{entries.map((entry, index) => (
|
||||
<JournalEntryItem key={index} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface JournalEntryItemProps {
|
||||
entry: any
|
||||
}
|
||||
|
||||
const JournalEntryItem: React.FC<JournalEntryItemProps> = ({ entry }) => (
|
||||
<Link
|
||||
href={`/journal/${entry.id}`}
|
||||
className="group/journal-entry relative flex min-w-0 flex-1"
|
||||
>
|
||||
<div className="relative flex h-8 w-full items-center gap-2 rounded-md p-1.5 font-medium">
|
||||
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
|
||||
<LaIcon name="FileText" className="opacity-60" />
|
||||
<p
|
||||
className={cn(
|
||||
"truncate opacity-95 group-hover/journal-entry:opacity-100",
|
||||
)}
|
||||
>
|
||||
{entry.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
115
web/app/components/sidebar/partials/link-section.tsx
Normal file
115
web/app/components/sidebar/partials/link-section.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import * as React from "react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PersonalLinkLists } from "@/lib/schema/personal-link"
|
||||
import { LearningStateValue } from "~/lib/constants"
|
||||
|
||||
export const LinkSection: React.FC = () => {
|
||||
const { me } = useAccount({ root: { personalLinks: [] } })
|
||||
|
||||
if (!me) return null
|
||||
|
||||
const linkCount = me.root.personalLinks?.length || 0
|
||||
|
||||
return (
|
||||
<div className="group/pages flex flex-col gap-px py-2">
|
||||
<LinkSectionHeader linkCount={linkCount} />
|
||||
<LinkList personalLinks={me.root.personalLinks} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LinkSectionHeaderProps {
|
||||
linkCount: number
|
||||
}
|
||||
|
||||
const LinkSectionHeader: React.FC<LinkSectionHeaderProps> = ({ linkCount }) => (
|
||||
<Link
|
||||
to="/links"
|
||||
className={cn(
|
||||
"flex h-9 items-center gap-px rounded-md px-2 py-1 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-0 sm:h-[30px] sm:text-xs",
|
||||
)}
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
Links
|
||||
{linkCount > 0 && (
|
||||
<span className="text-muted-foreground ml-1">{linkCount}</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
|
||||
interface LinkListProps {
|
||||
personalLinks: PersonalLinkLists
|
||||
}
|
||||
|
||||
const LinkList: React.FC<LinkListProps> = ({ personalLinks }) => {
|
||||
const linkStates: LearningStateValue[] = [
|
||||
"wantToLearn",
|
||||
"learning",
|
||||
"learned",
|
||||
]
|
||||
const linkLabels: Record<LearningStateValue, string> = {
|
||||
wantToLearn: "To Learn",
|
||||
learning: "Learning",
|
||||
learned: "Learned",
|
||||
}
|
||||
|
||||
const linkCounts = linkStates.reduce(
|
||||
(acc, state) => ({
|
||||
...acc,
|
||||
[state]: personalLinks.filter((link) => link?.learningState === state)
|
||||
.length,
|
||||
}),
|
||||
{} as Record<LearningStateValue, number>,
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
{linkStates.map((state) => (
|
||||
<LinkListItem
|
||||
key={state}
|
||||
label={linkLabels[state]}
|
||||
state={state}
|
||||
count={linkCounts[state]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LinkListItemProps {
|
||||
label: string
|
||||
state: LearningStateValue
|
||||
count: number
|
||||
}
|
||||
|
||||
const LinkListItem: React.FC<LinkListItemProps> = ({ label, state, count }) => (
|
||||
<div className="group/reorder-page relative">
|
||||
<div className="group/topic-link relative flex min-w-0 flex-1">
|
||||
<Link
|
||||
to="/links"
|
||||
search={{ state }}
|
||||
className={cn(
|
||||
"relative flex h-9 w-full items-center gap-2 rounded-md p-1.5 font-medium hover:bg-accent hover:text-accent-foreground sm:h-8",
|
||||
)}
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
|
||||
<p className="truncate opacity-95 group-hover/topic-link:opacity-100">
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
{count > 0 && (
|
||||
<span className="absolute right-2 top-1/2 z-[1] -translate-y-1/2 rounded p-1 text-sm">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
284
web/app/components/sidebar/partials/page-section.tsx
Normal file
284
web/app/components/sidebar/partials/page-section.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import * as React from "react"
|
||||
import { useAtom } from "jotai"
|
||||
import { atomWithStorage } from "jotai/utils"
|
||||
import { Link, useNavigate } from "@tanstack/react-router"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { usePageActions } from "~/hooks/actions/use-page-actions"
|
||||
import { icons } from "lucide-react"
|
||||
|
||||
type SortOption = "title" | "recent"
|
||||
type ShowOption = 5 | 10 | 15 | 20 | 0
|
||||
|
||||
interface Option<T> {
|
||||
label: string
|
||||
value: T
|
||||
}
|
||||
|
||||
const SORTS: Option<SortOption>[] = [
|
||||
{ label: "Title", value: "title" },
|
||||
{ label: "Last edited", value: "recent" },
|
||||
]
|
||||
|
||||
const SHOWS: Option<ShowOption>[] = [
|
||||
{ label: "5 items", value: 5 },
|
||||
{ label: "10 items", value: 10 },
|
||||
{ label: "15 items", value: 15 },
|
||||
{ label: "20 items", value: 20 },
|
||||
{ label: "All", value: 0 },
|
||||
]
|
||||
|
||||
const pageSortAtom = atomWithStorage<SortOption>("pageSort", "title")
|
||||
const pageShowAtom = atomWithStorage<ShowOption>("pageShow", 5)
|
||||
|
||||
export const PageSection: React.FC = () => {
|
||||
const { me } = useAccount({
|
||||
root: {
|
||||
personalPages: [],
|
||||
},
|
||||
})
|
||||
const [sort] = useAtom(pageSortAtom)
|
||||
const [show] = useAtom(pageShowAtom)
|
||||
|
||||
if (!me) return null
|
||||
|
||||
const pageCount = me.root.personalPages?.length || 0
|
||||
|
||||
return (
|
||||
<div className="group/pages flex flex-col gap-px py-2">
|
||||
<PageSectionHeader pageCount={pageCount} />
|
||||
<PageList personalPages={me.root.personalPages} sort={sort} show={show} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PageSectionHeaderProps {
|
||||
pageCount: number
|
||||
}
|
||||
|
||||
const PageSectionHeader: React.FC<PageSectionHeaderProps> = ({ pageCount }) => (
|
||||
<Link
|
||||
to="/pages"
|
||||
className={cn(
|
||||
"flex h-9 flex-1 items-center justify-start gap-px rounded-md px-2 py-1",
|
||||
"hover:bg-accent hover:text-accent-foreground sm:h-[30px]",
|
||||
)}
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
<div className="flex grow items-center justify-between">
|
||||
<p className="text-sm sm:text-xs">
|
||||
Pages
|
||||
{pageCount > 0 && (
|
||||
<span className="text-muted-foreground ml-1">{pageCount}</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-px">
|
||||
<ShowAllForm />
|
||||
<NewPageButton />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
|
||||
const NewPageButton: React.FC = () => {
|
||||
const { me } = useAccount()
|
||||
const navigate = useNavigate()
|
||||
const { newPage } = usePageActions()
|
||||
|
||||
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const page = newPage(me)
|
||||
|
||||
if (page.id) {
|
||||
navigate({
|
||||
to: "/pages/$pageId",
|
||||
params: { pageId: page.id },
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-label="New Page"
|
||||
className={cn(
|
||||
"flex size-5 items-center justify-center p-0.5 shadow-none",
|
||||
"hover:bg-accent-foreground/10",
|
||||
"opacity-0 transition-opacity duration-200",
|
||||
"group-hover/pages:opacity-100 group-has-[[data-state='open']]/pages:opacity-100",
|
||||
"data-[state='open']:opacity-100 focus-visible:outline-none focus-visible:ring-0",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<LaIcon name="Plus" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
interface PageListProps {
|
||||
personalPages: PersonalPageLists
|
||||
sort: SortOption
|
||||
show: ShowOption
|
||||
}
|
||||
|
||||
const PageList: React.FC<PageListProps> = ({ personalPages, sort, show }) => {
|
||||
const sortedPages = React.useMemo(() => {
|
||||
return [...personalPages]
|
||||
.sort((a, b) => {
|
||||
if (sort === "title") {
|
||||
return (a?.title ?? "").localeCompare(b?.title ?? "")
|
||||
}
|
||||
return (b?.updatedAt?.getTime() ?? 0) - (a?.updatedAt?.getTime() ?? 0)
|
||||
})
|
||||
.slice(0, show === 0 ? personalPages.length : show)
|
||||
}, [personalPages, sort, show])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
{sortedPages.map(
|
||||
(page) => page?.id && <PageListItem key={page.id} page={page} />,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PageListItemProps {
|
||||
page: PersonalPage
|
||||
}
|
||||
|
||||
const PageListItem: React.FC<PageListItemProps> = ({ page }) => {
|
||||
return (
|
||||
<div className="group/reorder-page relative">
|
||||
<div className="group/sidebar-link relative flex min-w-0 flex-1">
|
||||
<Link
|
||||
to="/pages/$pageId"
|
||||
params={{ pageId: page.id }}
|
||||
className={cn(
|
||||
"relative flex h-9 w-full items-center gap-2 rounded-md p-1.5 font-medium sm:h-8",
|
||||
"group-hover/sidebar-link:bg-accent group-hover/sidebar-link:text-accent-foreground",
|
||||
)}
|
||||
activeOptions={{ exact: true }}
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
<div className="flex max-w-[calc(100%-1rem)] flex-1 items-center gap-1.5 truncate text-sm">
|
||||
<LaIcon name="FileText" className="flex-shrink-0 opacity-60" />
|
||||
<p className="truncate opacity-95 group-hover/sidebar-link:opacity-100">
|
||||
{page.title || "Untitled"}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SubMenuProps<T> {
|
||||
icon: keyof typeof icons
|
||||
label: string
|
||||
options: Option<T>[]
|
||||
currentValue: T
|
||||
onSelect: (value: T) => void
|
||||
}
|
||||
|
||||
const SubMenu = <T extends string | number>({
|
||||
icon,
|
||||
label,
|
||||
options,
|
||||
currentValue,
|
||||
onSelect,
|
||||
}: SubMenuProps<T>) => (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<span className="flex items-center gap-2">
|
||||
<LaIcon name={icon} />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{options.find((option) => option.value === currentValue)?.label}
|
||||
</span>
|
||||
<LaIcon name="ChevronRight" />
|
||||
</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
{options.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => onSelect(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
{currentValue === option.value && (
|
||||
<LaIcon name="Check" className="ml-auto" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
|
||||
const ShowAllForm: React.FC = () => {
|
||||
const [pagesSorted, setPagesSorted] = useAtom(pageSortAtom)
|
||||
const [pagesShow, setPagesShow] = useAtom(pageShowAtom)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"flex size-5 items-center justify-center p-0.5 shadow-none",
|
||||
"hover:bg-accent-foreground/10",
|
||||
"opacity-0 transition-opacity duration-200",
|
||||
"group-hover/pages:opacity-100 group-has-[[data-state='open']]/pages:opacity-100",
|
||||
"data-[state='open']:opacity-100 focus-visible:outline-none focus-visible:ring-0",
|
||||
)}
|
||||
>
|
||||
<LaIcon name="Ellipsis" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuGroup>
|
||||
<SubMenu
|
||||
icon="ArrowUpDown"
|
||||
label="Sort"
|
||||
options={SORTS}
|
||||
currentValue={pagesSorted}
|
||||
onSelect={setPagesSorted}
|
||||
/>
|
||||
<SubMenu
|
||||
icon="Hash"
|
||||
label="Show"
|
||||
options={SHOWS}
|
||||
currentValue={pagesShow}
|
||||
onSelect={setPagesShow}
|
||||
/>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
186
web/app/components/sidebar/partials/profile-section.tsx
Normal file
186
web/app/components/sidebar/partials/profile-section.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import * as React from "react"
|
||||
import { useAtom } from "jotai"
|
||||
import { icons } from "lucide-react"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { DiscordIcon } from "@/components/icons/discord-icon"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Avatar, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { showShortcutAtom } from "@/components/shortcut/shortcut"
|
||||
import { useKeyboardManager } from "@/hooks/use-keyboard-manager"
|
||||
import { SignInButton, useAuth, useUser } from "@clerk/tanstack-start"
|
||||
import { Link, useLocation } from "@tanstack/react-router"
|
||||
import { ShortcutKey } from "@shared/minimal-tiptap/components/shortcut-key"
|
||||
import { Feedback } from "./feedback"
|
||||
|
||||
export const ProfileSection: React.FC = () => {
|
||||
const { user, isSignedIn } = useUser()
|
||||
const { signOut } = useAuth()
|
||||
const [menuOpen, setMenuOpen] = React.useState(false)
|
||||
const { pathname } = useLocation()
|
||||
const [, setShowShortcut] = useAtom(showShortcutAtom)
|
||||
|
||||
const { disableKeydown } = useKeyboardManager("profileSection")
|
||||
|
||||
React.useEffect(() => {
|
||||
disableKeydown(menuOpen)
|
||||
}, [menuOpen, disableKeydown])
|
||||
|
||||
if (!isSignedIn) {
|
||||
return (
|
||||
<div className="flex flex-col gap-px border-t border-transparent px-3 py-2 pb-3 pt-1.5">
|
||||
<SignInButton mode="modal" forceRedirectUrl={pathname}>
|
||||
<Button variant="outline" className="flex w-full items-center gap-2">
|
||||
<LaIcon name="LogIn" />
|
||||
Sign in
|
||||
</Button>
|
||||
</SignInButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-px border-t border-transparent px-3 py-2 pb-3 pt-1.5">
|
||||
<div className="flex h-10 min-w-full items-center">
|
||||
<ProfileDropdown
|
||||
user={user}
|
||||
menuOpen={menuOpen}
|
||||
setMenuOpen={setMenuOpen}
|
||||
signOut={signOut}
|
||||
setShowShortcut={setShowShortcut}
|
||||
/>
|
||||
<Feedback />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProfileDropdownProps {
|
||||
user: any
|
||||
menuOpen: boolean
|
||||
setMenuOpen: (open: boolean) => void
|
||||
signOut: () => void
|
||||
setShowShortcut: (show: boolean) => void
|
||||
}
|
||||
|
||||
const ProfileDropdown: React.FC<ProfileDropdownProps> = ({
|
||||
user,
|
||||
menuOpen,
|
||||
setMenuOpen,
|
||||
signOut,
|
||||
setShowShortcut,
|
||||
}) => (
|
||||
<div className="flex min-w-0">
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label="Profile"
|
||||
className="hover:bg-accent focus-visible:ring-ring hover:text-accent-foreground flex h-auto items-center gap-1.5 truncate rounded py-1 pl-1 pr-1.5 focus-visible:outline-none focus-visible:ring-1"
|
||||
>
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={user.imageUrl} alt={user.fullName || ""} />
|
||||
</Avatar>
|
||||
<span className="truncate text-left text-sm font-medium -tracking-wider">
|
||||
{user.fullName}
|
||||
</span>
|
||||
<LaIcon
|
||||
name="ChevronDown"
|
||||
className={cn("size-4 shrink-0 transition-transform duration-300", {
|
||||
"rotate-180": menuOpen,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start" side="top">
|
||||
<DropdownMenuItems
|
||||
signOut={signOut}
|
||||
setShowShortcut={setShowShortcut}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface DropdownMenuItemsProps {
|
||||
signOut: () => void
|
||||
setShowShortcut: (show: boolean) => void
|
||||
}
|
||||
|
||||
const DropdownMenuItems: React.FC<DropdownMenuItemsProps> = ({
|
||||
signOut,
|
||||
setShowShortcut,
|
||||
}) => (
|
||||
<>
|
||||
<MenuLink href="/profile" icon="CircleUser" text="My profile" />
|
||||
<DropdownMenuItem className="gap-2" onClick={() => setShowShortcut(true)}>
|
||||
<LaIcon name="Keyboard" />
|
||||
<span>Shortcut</span>
|
||||
</DropdownMenuItem>
|
||||
<MenuLink href="/onboarding" icon="LayoutList" text="Onboarding" />
|
||||
<DropdownMenuSeparator />
|
||||
<MenuLink
|
||||
href="https://docs.learn-anything.xyz/"
|
||||
icon="Sticker"
|
||||
text="Docs"
|
||||
/>
|
||||
<MenuLink
|
||||
href="https://github.com/learn-anything/learn-anything"
|
||||
icon="Github"
|
||||
text="GitHub"
|
||||
/>
|
||||
<MenuLink
|
||||
href="https://discord.com/invite/bxtD8x6aNF"
|
||||
icon={DiscordIcon}
|
||||
text="Discord"
|
||||
iconClass="-ml-1"
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={signOut}>
|
||||
<div className="relative flex flex-1 cursor-pointer items-center gap-2">
|
||||
<LaIcon name="LogOut" />
|
||||
<span>Log out</span>
|
||||
<div className="absolute right-0">
|
||||
<ShortcutKey keys={["alt", "shift", "q"]} />
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
|
||||
interface MenuLinkProps {
|
||||
href: string
|
||||
icon: keyof typeof icons | React.FC
|
||||
text: string
|
||||
iconClass?: string
|
||||
}
|
||||
|
||||
const MenuLink: React.FC<MenuLinkProps> = ({
|
||||
href,
|
||||
icon,
|
||||
text,
|
||||
iconClass = "",
|
||||
}) => {
|
||||
const IconComponent = typeof icon === "string" ? icons[icon] : icon
|
||||
return (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link className="cursor-pointer" to={href}>
|
||||
<div
|
||||
className={cn("relative flex flex-1 items-center gap-2", iconClass)}
|
||||
>
|
||||
<IconComponent className="size-4" />
|
||||
<span className="line-clamp-1 flex-1">{text}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfileSection
|
||||
110
web/app/components/sidebar/partials/task-section.tsx
Normal file
110
web/app/components/sidebar/partials/task-section.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useEffect, useState } from "react"
|
||||
import { isToday, isFuture } from "date-fns"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { useAuth, useUser } from "@clerk/tanstack-start"
|
||||
import { getFeatureFlag } from "~/actions"
|
||||
import { LaIcon } from "~/components/custom/la-icon"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
|
||||
export const TaskSection: React.FC = () => {
|
||||
const { me } = useAccount({ root: { tasks: [] } })
|
||||
|
||||
const taskCount = me?.root?.tasks?.length || 0
|
||||
const todayTasks =
|
||||
me?.root?.tasks?.filter(
|
||||
(task) =>
|
||||
task?.status !== "done" && task?.dueDate && isToday(task.dueDate),
|
||||
) || []
|
||||
const upcomingTasks =
|
||||
me?.root?.tasks?.filter(
|
||||
(task) =>
|
||||
task?.status !== "done" && task?.dueDate && isFuture(task.dueDate),
|
||||
) || []
|
||||
|
||||
const [, setIsFetching] = useState(false)
|
||||
const [isFeatureActive, setIsFeatureActive] = useState(false)
|
||||
const { isLoaded, isSignedIn } = useAuth()
|
||||
const { user } = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
async function checkFeatureFlag() {
|
||||
setIsFetching(true)
|
||||
|
||||
if (isLoaded && isSignedIn) {
|
||||
const response = await getFeatureFlag({ name: "TASK" })
|
||||
|
||||
if (
|
||||
user?.emailAddresses.some((email) =>
|
||||
response?.emails.includes(email.emailAddress),
|
||||
)
|
||||
) {
|
||||
setIsFeatureActive(true)
|
||||
}
|
||||
setIsFetching(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkFeatureFlag()
|
||||
}, [isLoaded, isSignedIn, user])
|
||||
|
||||
if (!isLoaded || !isSignedIn) {
|
||||
return <div className="py-2 text-center text-gray-500">Loading...</div>
|
||||
}
|
||||
|
||||
if (!me) return null
|
||||
|
||||
if (!isFeatureActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/tasks flex flex-col gap-px py-2">
|
||||
<TaskSectionHeader title="Tasks" count={taskCount} />
|
||||
<TaskSectionHeader
|
||||
title="Today"
|
||||
iconName="BookOpenCheck"
|
||||
filter="today"
|
||||
count={todayTasks.length}
|
||||
/>
|
||||
<TaskSectionHeader
|
||||
title="Upcoming"
|
||||
iconName="History"
|
||||
filter="upcoming"
|
||||
count={upcomingTasks.length}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TaskSectionHeaderProps {
|
||||
title: string
|
||||
filter?: "today" | "upcoming"
|
||||
count: number
|
||||
iconName?: "BookOpenCheck" | "History"
|
||||
}
|
||||
|
||||
const TaskSectionHeader: React.FC<TaskSectionHeaderProps> = ({
|
||||
title,
|
||||
filter,
|
||||
count,
|
||||
iconName,
|
||||
}) => (
|
||||
<Link
|
||||
to="/tasks"
|
||||
className={cn(
|
||||
"flex flex-1 min-h-[30px] gap-px items-center justify-start hover:bg-accent hover:text-accent-foreground rounded-md px-2 py-1 focus-visible:outline-none focus-visible:ring-0",
|
||||
)}
|
||||
search={{ filter }}
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
{iconName && <LaIcon className="size-13 shrink-0 pr-2" name={iconName} />}
|
||||
|
||||
<p className="text-sm">
|
||||
{title}
|
||||
{count > 0 && <span className="text-muted-foreground ml-1">{count}</span>}
|
||||
</p>
|
||||
</Link>
|
||||
)
|
||||
142
web/app/components/sidebar/partials/topic-section.tsx
Normal file
142
web/app/components/sidebar/partials/topic-section.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import * as React from "react"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { ListOfTopics } from "@/lib/schema"
|
||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
|
||||
export const TopicSection: React.FC = () => {
|
||||
const { me } = useAccount({
|
||||
root: {
|
||||
topicsWantToLearn: [],
|
||||
topicsLearning: [],
|
||||
topicsLearned: [],
|
||||
},
|
||||
})
|
||||
|
||||
const topicCount =
|
||||
(me?.root.topicsWantToLearn?.length || 0) +
|
||||
(me?.root.topicsLearning?.length || 0) +
|
||||
(me?.root.topicsLearned?.length || 0)
|
||||
|
||||
if (!me) return null
|
||||
|
||||
return (
|
||||
<div className="group/topics flex flex-col gap-px py-2">
|
||||
<TopicSectionHeader topicCount={topicCount} />
|
||||
<List
|
||||
topicsWantToLearn={me.root.topicsWantToLearn}
|
||||
topicsLearning={me.root.topicsLearning}
|
||||
topicsLearned={me.root.topicsLearned}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TopicSectionHeaderProps {
|
||||
topicCount: number
|
||||
}
|
||||
|
||||
const TopicSectionHeader: React.FC<TopicSectionHeaderProps> = ({
|
||||
topicCount,
|
||||
}) => (
|
||||
<Link
|
||||
to="/topics"
|
||||
className="flex h-9 items-center gap-px rounded-md px-2 py-1 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-0 sm:h-[30px] sm:text-xs"
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
<p className="text-sm sm:text-xs">
|
||||
Topics
|
||||
{topicCount > 0 && (
|
||||
<span className="text-muted-foreground ml-1">{topicCount}</span>
|
||||
)}
|
||||
</p>
|
||||
</Link>
|
||||
)
|
||||
|
||||
interface ListProps {
|
||||
topicsWantToLearn: ListOfTopics
|
||||
topicsLearning: ListOfTopics
|
||||
topicsLearned: ListOfTopics
|
||||
}
|
||||
|
||||
const List: React.FC<ListProps> = ({
|
||||
topicsWantToLearn,
|
||||
topicsLearning,
|
||||
topicsLearned,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
<ListItem
|
||||
key={topicsWantToLearn.id}
|
||||
count={topicsWantToLearn.length}
|
||||
label="To Learn"
|
||||
value="wantToLearn"
|
||||
/>
|
||||
<ListItem
|
||||
key={topicsLearning.id}
|
||||
label="Learning"
|
||||
value="learning"
|
||||
count={topicsLearning.length}
|
||||
/>
|
||||
<ListItem
|
||||
key={topicsLearned.id}
|
||||
label="Learned"
|
||||
value="learned"
|
||||
count={topicsLearned.length}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ListItemProps {
|
||||
label: string
|
||||
value: LearningStateValue
|
||||
count: number
|
||||
}
|
||||
|
||||
const ListItem: React.FC<ListItemProps> = ({ label, value, count }) => {
|
||||
const le = LEARNING_STATES.find((l) => l.value === value)
|
||||
|
||||
if (!le) return null
|
||||
|
||||
return (
|
||||
<div className="group/reorder-page relative">
|
||||
<div className="group/topic-link relative flex min-w-0 flex-1">
|
||||
<Link
|
||||
to="/topics"
|
||||
search={{ learningState: value }}
|
||||
className={cn(
|
||||
"group-hover/topic-link:bg-accent relative flex h-9 w-full items-center gap-2 rounded-md p-1.5 font-medium sm:h-8",
|
||||
le.className,
|
||||
)}
|
||||
activeOptions={{ exact: true }}
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
|
||||
<LaIcon name={le.icon} className="flex-shrink-0 opacity-60" />
|
||||
<p
|
||||
className={cn(
|
||||
"truncate opacity-95 group-hover/topic-link:opacity-100",
|
||||
le.className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{count > 0 && (
|
||||
<span className="absolute right-2 top-1/2 z-[1] -translate-y-1/2 rounded p-1 text-sm">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
211
web/app/components/sidebar/sidebar.tsx
Normal file
211
web/app/components/sidebar/sidebar.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import * as React from "react"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { useAtom } from "jotai"
|
||||
import { LogoIcon } from "@/components/icons/logo-icon"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { isCollapseAtom } from "@/store/sidebar"
|
||||
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { Link, useLocation } from "@tanstack/react-router"
|
||||
|
||||
import { LinkSection } from "./partials/link-section"
|
||||
import { PageSection } from "./partials/page-section"
|
||||
import { TopicSection } from "./partials/topic-section"
|
||||
import { ProfileSection } from "./partials/profile-section"
|
||||
import { JournalSection } from "./partials/journal-section"
|
||||
import { TaskSection } from "./partials/task-section"
|
||||
|
||||
interface SidebarContextType {
|
||||
isCollapsed: boolean
|
||||
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextType>({
|
||||
isCollapsed: false,
|
||||
setIsCollapsed: () => {},
|
||||
})
|
||||
|
||||
const useSidebarCollapse = (
|
||||
isTablet: boolean,
|
||||
): [boolean, React.Dispatch<React.SetStateAction<boolean>>] => {
|
||||
const [isCollapsed, setIsCollapsed] = useAtom(isCollapseAtom)
|
||||
const location = useLocation()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isTablet) setIsCollapsed(true)
|
||||
}, [location.pathname, setIsCollapsed, isTablet])
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsCollapsed(isTablet)
|
||||
}, [isTablet, setIsCollapsed])
|
||||
|
||||
return [isCollapsed, setIsCollapsed]
|
||||
}
|
||||
|
||||
interface SidebarItemProps {
|
||||
label: string
|
||||
url: string
|
||||
icon?: React.ReactNode
|
||||
onClick?: () => void
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const SidebarItem: React.FC<SidebarItemProps> = React.memo(
|
||||
({ label, url, icon, onClick, children }) => {
|
||||
const { pathname } = useLocation()
|
||||
const isActive = pathname === url
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative my-0.5 rounded-md",
|
||||
isActive ? "bg-secondary/80" : "hover:bg-secondary/40",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
className="text-secondary-foreground flex h-8 grow items-center truncate rounded-md pl-1.5 pr-1 text-sm font-medium"
|
||||
to={url}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-primary/60 group-hover:text-primary mr-2 size-4",
|
||||
{ "text-primary": isActive },
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
SidebarItem.displayName = "SidebarItem"
|
||||
|
||||
const LogoAndSearch: React.FC = React.memo(() => {
|
||||
const { pathname } = useLocation()
|
||||
|
||||
return (
|
||||
<div className="px-3">
|
||||
<div className="mt-2 flex h-10 max-w-full items-center">
|
||||
<Link to="/" className="px-2">
|
||||
<LogoIcon className="size-7" />
|
||||
</Link>
|
||||
<div className="flex min-w-2 grow flex-row" />
|
||||
<Link
|
||||
to={pathname === "/search" ? "/" : "/search"}
|
||||
className={cn(
|
||||
buttonVariants({ size: "sm", variant: "secondary" }),
|
||||
"text-primary/60 flex w-20 items-center justify-start py-4 pl-2",
|
||||
)}
|
||||
activeProps={{
|
||||
className: "text-md font-medium",
|
||||
}}
|
||||
aria-label="Search"
|
||||
>
|
||||
{pathname === "/search" ? (
|
||||
"← Back"
|
||||
) : (
|
||||
<LaIcon name="Search" className="size-4" />
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
LogoAndSearch.displayName = "LogoAndSearch"
|
||||
|
||||
const SidebarContent: React.FC = React.memo(() => {
|
||||
const { me } = useAccountOrGuest()
|
||||
|
||||
return (
|
||||
<nav className="bg-background relative flex h-full w-full shrink-0 flex-col">
|
||||
<div>
|
||||
<LogoAndSearch />
|
||||
</div>
|
||||
<div className="relative mb-0.5 mt-1.5 flex grow flex-col overflow-y-auto rounded-md px-3 outline-none">
|
||||
<div className="h-2 shrink-0" />
|
||||
{me._type === "Account" && <LinkSection />}
|
||||
{me._type === "Account" && <TopicSection />}
|
||||
{me._type === "Account" && <JournalSection />}
|
||||
{me._type === "Account" && <TaskSection />}
|
||||
{me._type === "Account" && <PageSection />}
|
||||
</div>
|
||||
|
||||
<ProfileSection />
|
||||
</nav>
|
||||
)
|
||||
})
|
||||
|
||||
SidebarContent.displayName = "SidebarContent"
|
||||
|
||||
const Sidebar: React.FC = () => {
|
||||
const isTablet = useMedia("(max-width: 1024px)")
|
||||
const [isCollapsed, setIsCollapsed] = useSidebarCollapse(isTablet)
|
||||
|
||||
const sidebarClasses = cn(
|
||||
"h-full overflow-hidden transition-all duration-300 ease-in-out",
|
||||
isCollapsed ? "w-0" : "w-auto min-w-56",
|
||||
)
|
||||
|
||||
const sidebarInnerClasses = cn(
|
||||
"h-full w-56 min-w-56 transition-transform duration-300 ease-in-out",
|
||||
isCollapsed ? "-translate-x-full" : "translate-x-0",
|
||||
)
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({ isCollapsed, setIsCollapsed }),
|
||||
[isCollapsed, setIsCollapsed],
|
||||
)
|
||||
|
||||
if (isTablet) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-30 bg-black/40 transition-opacity duration-300",
|
||||
isCollapsed ? "pointer-events-none opacity-0" : "opacity-100",
|
||||
)}
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed left-0 top-0 z-40 h-full",
|
||||
sidebarClasses,
|
||||
!isCollapsed &&
|
||||
"shadow-[4px_0px_16px_rgba(0,0,0,0.1)] transition-all",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(sidebarInnerClasses, "border-r-primary/5 border-r")}
|
||||
>
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<SidebarContent />
|
||||
</SidebarContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={sidebarClasses}>
|
||||
<div className={sidebarInnerClasses}>
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<SidebarContent />
|
||||
</SidebarContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Sidebar.displayName = "Sidebar"
|
||||
|
||||
export { Sidebar, SidebarItem, SidebarContext }
|
||||
141
web/app/components/ui/alert-dialog.tsx
Normal file
141
web/app/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
@@ -13,7 +11,7 @@ const Avatar = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -40,7 +38,7 @@ const AvatarFallback = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
36
web/app/components/ui/badge.tsx
Normal file
36
web/app/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
57
web/app/components/ui/button.tsx
Normal file
57
web/app/components/ui/button.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
70
web/app/components/ui/calendar.tsx
Normal file
70
web/app/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import * as React from "react"
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||
month: "space-y-4",
|
||||
caption: "flex justify-center pt-1 relative items-center",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "space-x-1 flex items-center",
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-y-1",
|
||||
head_row: "flex",
|
||||
head_cell:
|
||||
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: cn(
|
||||
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
|
||||
props.mode === "range"
|
||||
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
|
||||
: "[&:has([aria-selected])]:rounded-md",
|
||||
),
|
||||
day: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-8 w-8 p-0 font-normal aria-selected:opacity-100",
|
||||
),
|
||||
day_range_start: "day-range-start",
|
||||
day_range_end: "day-range-end",
|
||||
day_selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside:
|
||||
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: () => <ChevronLeftIcon className="h-4 w-4" />,
|
||||
IconRight: () => <ChevronRightIcon className="h-4 w-4" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Calendar.displayName = "Calendar"
|
||||
|
||||
export { Calendar }
|
||||
28
web/app/components/ui/checkbox.tsx
Normal file
28
web/app/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "@radix-ui/react-icons"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
153
web/app/components/ui/command.tsx
Normal file
153
web/app/components/ui/command.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { MagnifyingGlassIcon } from "@radix-ui/react-icons"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
interface CommandDialogProps extends DialogProps {}
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
121
web/app/components/ui/dialog.tsx
Normal file
121
web/app/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogPrimitive,
|
||||
}
|
||||
198
web/app/components/ui/dropdown-menu.tsx
Normal file
198
web/app/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, DotFilledIcon } from "@radix-ui/react-icons"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<DotFilledIcon className="h-4 w-4 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
176
web/app/components/ui/form.tsx
Normal file
176
web/app/components/ui/form.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue,
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue,
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
25
web/app/components/ui/input.tsx
Normal file
25
web/app/components/ui/input.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
24
web/app/components/ui/label.tsx
Normal file
24
web/app/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
31
web/app/components/ui/popover.tsx
Normal file
31
web/app/components/ui/popover.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
46
web/app/components/ui/scroll-area.tsx
Normal file
46
web/app/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
162
web/app/components/ui/select.tsx
Normal file
162
web/app/components/ui/select.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
CaretSortIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
} from "@radix-ui/react-icons"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<CaretSortIcon className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
29
web/app/components/ui/separator.tsx
Normal file
29
web/app/components/ui/separator.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
138
web/app/components/ui/sheet.tsx
Normal file
138
web/app/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
export const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
@@ -12,14 +10,14 @@ const Switch = React.forwardRef<
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
24
web/app/components/ui/textarea.tsx
Normal file
24
web/app/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
@@ -47,7 +45,7 @@ const ToggleGroupItem = React.forwardRef<
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
43
web/app/components/ui/toggle.tsx
Normal file
43
web/app/components/ui/toggle.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-3",
|
||||
sm: "h-8 px-2",
|
||||
lg: "h-10 px-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
28
web/app/components/ui/tooltip.tsx
Normal file
28
web/app/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -1,11 +0,0 @@
|
||||
:root {
|
||||
--link-background-muted: hsl(0, 0%, 97.3%);
|
||||
--link-border-after: hsl(0, 0%, 91%);
|
||||
--link-shadow: hsl(240, 5.6%, 82.5%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--link-background-muted: hsl(220, 6.7%, 8.8%);
|
||||
--link-border-after: hsl(230, 10%, 11.8%);
|
||||
--link-shadow: hsl(234.9, 27.1%, 25.3%);
|
||||
}
|
||||
3129
web/app/data/graph.json
Normal file
3129
web/app/data/graph.json
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,7 +0,0 @@
|
||||
import { Raleway } from "next/font/google"
|
||||
export { GeistSans } from "geist/font/sans"
|
||||
export { GeistMono } from "geist/font/mono"
|
||||
// import { Inter } from "next/font/google"
|
||||
|
||||
// export const inter = Inter({ subsets: ["latin"] })
|
||||
export const raleway = Raleway({ subsets: ["latin"] })
|
||||
@@ -1,23 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as Sentry from "@sentry/nextjs"
|
||||
import NextError from "next/error"
|
||||
import { useEffect } from "react"
|
||||
|
||||
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
{/* `NextError` is the default Next.js error page component. Its type
|
||||
definition requires a `statusCode` prop. However, since the App Router
|
||||
does not expose status codes for errors, we simply pass 0 to render a
|
||||
generic error message. */}
|
||||
<NextError statusCode={0} />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 96%;
|
||||
--result: 240 5.9% 96%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--boxShadow: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 4.5%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 220 9% 10%;
|
||||
--result: 0 0% 7%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
--boxShadow: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@import "./command-palette.css";
|
||||
@import "./custom.css";
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
35
web/app/hooks/actions/use-link-actions.ts
Normal file
35
web/app/hooks/actions/use-link-actions.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import * as React from "react"
|
||||
import { toast } from "sonner"
|
||||
import { LaAccount, PersonalLink } from "@/lib/schema"
|
||||
|
||||
export const useLinkActions = () => {
|
||||
const deleteLink = React.useCallback((me: LaAccount, link: PersonalLink) => {
|
||||
if (!me.root?.personalLinks) return
|
||||
|
||||
try {
|
||||
const index = me.root.personalLinks.findIndex(
|
||||
(item) => item?.id === link.id,
|
||||
)
|
||||
if (index === -1) {
|
||||
throw new Error(`Link with id ${link.id} not found`)
|
||||
}
|
||||
|
||||
me.root.personalLinks.splice(index, 1)
|
||||
|
||||
toast.success("Link deleted.", {
|
||||
position: "bottom-right",
|
||||
description: `${link.title} has been deleted.`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to delete link:", error)
|
||||
toast.error("Failed to delete link", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "An unknown error occurred",
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
deleteLink,
|
||||
}
|
||||
}
|
||||
50
web/app/hooks/actions/use-page-actions.ts
Normal file
50
web/app/hooks/actions/use-page-actions.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as React from "react"
|
||||
import { toast } from "sonner"
|
||||
import { LaAccount, PersonalPage } from "@/lib/schema"
|
||||
import { ID } from "jazz-tools"
|
||||
|
||||
export const usePageActions = () => {
|
||||
const newPage = React.useCallback((me: LaAccount): PersonalPage => {
|
||||
const newPersonalPage = PersonalPage.create(
|
||||
{ public: false, createdAt: new Date(), updatedAt: new Date() },
|
||||
{ owner: me._owner },
|
||||
)
|
||||
me.root?.personalPages?.push(newPersonalPage)
|
||||
return newPersonalPage
|
||||
}, [])
|
||||
|
||||
const deletePage = React.useCallback(
|
||||
(me: LaAccount, pageId: ID<PersonalPage>): void => {
|
||||
if (!me.root?.personalPages) return
|
||||
|
||||
const index = me.root.personalPages.findIndex(
|
||||
(item) => item?.id === pageId,
|
||||
)
|
||||
if (index === -1) {
|
||||
toast.error("Page not found")
|
||||
return
|
||||
}
|
||||
|
||||
const page = me.root.personalPages[index]
|
||||
if (!page) {
|
||||
toast.error("Page data is invalid")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
me.root.personalPages.splice(index, 1)
|
||||
|
||||
toast.success("Page deleted", {
|
||||
position: "bottom-right",
|
||||
description: `${page.title} has been deleted.`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to delete page", error)
|
||||
toast.error("Failed to delete page")
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return { newPage, deletePage }
|
||||
}
|
||||
61
web/app/hooks/actions/use-task-actions.ts
Normal file
61
web/app/hooks/actions/use-task-actions.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useCallback } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { LaAccount } from "@/lib/schema"
|
||||
import { ID } from "jazz-tools"
|
||||
import { ListOfTasks, Task } from "~/lib/schema/task"
|
||||
|
||||
export const useTaskActions = () => {
|
||||
const newTask = useCallback((me: LaAccount): Task | null => {
|
||||
if (!me.root) {
|
||||
console.error("User root is not initialized")
|
||||
return null
|
||||
}
|
||||
|
||||
if (!me.root.tasks) {
|
||||
me.root.tasks = ListOfTasks.create([], { owner: me })
|
||||
}
|
||||
|
||||
const newTask = Task.create(
|
||||
{
|
||||
title: "",
|
||||
description: "",
|
||||
status: "todo",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{ owner: me._owner },
|
||||
)
|
||||
|
||||
me.root.tasks.push(newTask)
|
||||
return newTask
|
||||
}, [])
|
||||
|
||||
const deleteTask = useCallback((me: LaAccount, taskId: ID<Task>): void => {
|
||||
if (!me.root?.tasks) return
|
||||
|
||||
const index = me.root.tasks.findIndex((item) => item?.id === taskId)
|
||||
if (index === -1) {
|
||||
toast.error("Task not found")
|
||||
return
|
||||
}
|
||||
|
||||
const task = me.root.tasks[index]
|
||||
if (!task) {
|
||||
toast.error("Task data is invalid")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
me.root.tasks.splice(index, 1)
|
||||
|
||||
toast.success("Task completed", {
|
||||
position: "bottom-right",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to delete task", error)
|
||||
toast.error("Failed to delete task")
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { newTask, deleteTask }
|
||||
}
|
||||
36
web/app/hooks/use-active-item-scroll.ts
Normal file
36
web/app/hooks/use-active-item-scroll.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
|
||||
type ElementRef<T extends HTMLElement> = T | null
|
||||
type ElementRefs<T extends HTMLElement> = ElementRef<T>[]
|
||||
|
||||
interface ActiveItemScrollOptions {
|
||||
activeIndex: number | null
|
||||
}
|
||||
|
||||
export function useActiveItemScroll<T extends HTMLElement>(
|
||||
options: ActiveItemScrollOptions,
|
||||
) {
|
||||
const { activeIndex } = options
|
||||
const elementRefs = React.useRef<ElementRefs<T>>([])
|
||||
|
||||
const scrollActiveElementIntoView = React.useCallback((index: number) => {
|
||||
const activeElement = elementRefs.current[index]
|
||||
activeElement?.focus()
|
||||
// activeElement?.scrollIntoView({ block: "nearest" })
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeIndex !== null) {
|
||||
scrollActiveElementIntoView(activeIndex)
|
||||
}
|
||||
}, [activeIndex, scrollActiveElementIntoView])
|
||||
|
||||
const setElementRef = React.useCallback(
|
||||
(element: ElementRef<T>, index: number) => {
|
||||
elementRefs.current[index] = element
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return { setElementRef, scrollActiveElementIntoView }
|
||||
}
|
||||
29
web/app/hooks/use-awaitable-navigate.ts
Normal file
29
web/app/hooks/use-awaitable-navigate.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import type { NavigateOptions } from "@tanstack/react-router"
|
||||
import { useLocation, useNavigate } from "@tanstack/react-router"
|
||||
|
||||
type Resolve = (value?: unknown) => void
|
||||
|
||||
export const useAwaitableNavigate = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const resolveFunctionsRef = React.useRef<Resolve[]>([])
|
||||
const resolveAll = () => {
|
||||
resolveFunctionsRef.current.forEach((resolve) => resolve())
|
||||
resolveFunctionsRef.current.splice(0, resolveFunctionsRef.current.length)
|
||||
}
|
||||
const [, startTransition] = React.useTransition()
|
||||
|
||||
React.useEffect(() => {
|
||||
resolveAll()
|
||||
}, [location])
|
||||
|
||||
return (options: NavigateOptions) => {
|
||||
return new Promise((res) => {
|
||||
startTransition(() => {
|
||||
resolveFunctionsRef.current.push(res)
|
||||
res(navigate(options))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
53
web/app/hooks/use-command-actions.ts
Normal file
53
web/app/hooks/use-command-actions.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import { ensureUrlProtocol } from "@/lib/utils"
|
||||
import { useTheme } from "next-themes"
|
||||
import { toast } from "sonner"
|
||||
import { LaAccount } from "@/lib/schema"
|
||||
import { usePageActions } from "./actions/use-page-actions"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
|
||||
export const useCommandActions = () => {
|
||||
const { setTheme } = useTheme()
|
||||
const navigate = useNavigate()
|
||||
const { newPage } = usePageActions()
|
||||
|
||||
const changeTheme = React.useCallback(
|
||||
(theme: string) => {
|
||||
setTheme(theme)
|
||||
toast.success(`Theme changed to ${theme}.`, { position: "bottom-right" })
|
||||
},
|
||||
[setTheme],
|
||||
)
|
||||
|
||||
const navigateTo = React.useCallback(
|
||||
(path: string) => {
|
||||
navigate({ to: path })
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
const openLinkInNewTab = React.useCallback((url: string) => {
|
||||
window.open(ensureUrlProtocol(url), "_blank")
|
||||
}, [])
|
||||
|
||||
const copyCurrentURL = React.useCallback(() => {
|
||||
navigator.clipboard.writeText(window.location.href)
|
||||
toast.success("URL copied to clipboard.", { position: "bottom-right" })
|
||||
}, [])
|
||||
|
||||
const createNewPage = React.useCallback(
|
||||
(me: LaAccount) => {
|
||||
const page = newPage(me)
|
||||
navigate({ to: `/pages/${page.id}` })
|
||||
},
|
||||
[navigate, newPage],
|
||||
)
|
||||
|
||||
return {
|
||||
changeTheme,
|
||||
navigateTo,
|
||||
openLinkInNewTab,
|
||||
copyCurrentURL,
|
||||
createNewPage,
|
||||
}
|
||||
}
|
||||
37
web/app/hooks/use-event-listener.ts
Normal file
37
web/app/hooks/use-event-listener.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as React from "react"
|
||||
|
||||
type EventMap = WindowEventMap & HTMLElementEventMap & VisualViewportEventMap
|
||||
|
||||
export function useEventListener<
|
||||
K extends keyof EventMap,
|
||||
T extends Window | HTMLElement | VisualViewport | null = Window,
|
||||
>(
|
||||
eventName: K,
|
||||
handler: (event: EventMap[K]) => void,
|
||||
element: T = window as unknown as T, // Cast to `unknown` first, then `T`
|
||||
options: AddEventListenerOptions = {},
|
||||
) {
|
||||
const savedHandler = React.useRef<(event: EventMap[K]) => void>()
|
||||
const { capture, passive, once } = options
|
||||
|
||||
React.useEffect(() => {
|
||||
savedHandler.current = handler
|
||||
}, [handler])
|
||||
|
||||
React.useEffect(() => {
|
||||
const isSupported = element && element.addEventListener
|
||||
if (!isSupported) return
|
||||
|
||||
const eventListener = (event: EventMap[K]) => savedHandler.current?.(event)
|
||||
|
||||
const opts = { capture, passive, once }
|
||||
element.addEventListener(eventName, eventListener as EventListener, opts)
|
||||
return () => {
|
||||
element.removeEventListener(
|
||||
eventName,
|
||||
eventListener as EventListener,
|
||||
opts,
|
||||
)
|
||||
}
|
||||
}, [eventName, element, capture, passive, once])
|
||||
}
|
||||
19
web/app/hooks/use-is-mounted.ts
Normal file
19
web/app/hooks/use-is-mounted.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
/**
|
||||
* Hook to check if component is still mounted
|
||||
*
|
||||
* @returns {boolean} true if the component is mounted, false otherwise
|
||||
*/
|
||||
export function useIsMounted() {
|
||||
const isMounted = React.useRef(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
isMounted.current = true
|
||||
return () => {
|
||||
isMounted.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return React.useCallback(() => isMounted.current, [])
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user