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

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

View File

@@ -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
View 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",
},
}

View File

@@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}

52
web/.gitignore vendored
View File

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

View File

@@ -1,2 +0,0 @@
[install.scopes]
ronin = { url = "https://ronin.supply", token = "$RONIN_TOKEN" }

4
web/.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
**/build
**/public
pnpm-lock.yaml
routeTree.gen.ts

12
web/app.config.ts Normal file
View 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"],
}),
],
},
})

View File

@@ -1,7 +0,0 @@
export default function AuthLayout({
children
}: Readonly<{
children: React.ReactNode
}>) {
return <main className="h-full">{children}</main>
}

View File

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

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
import { CommunityTopicRoute } from "@/components/routes/community/CommunityTopicRoute"
export default function CommunityTopicPage({ params }: { params: { topicName: string } }) {
return <CommunityTopicRoute topicName={params.topicName} />
}

View File

@@ -1,5 +0,0 @@
import EditProfileRoute from "@/components/routes/EditProfileRoute"
export default function EditProfilePage() {
return <EditProfileRoute />
}

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
import { LinkRoute } from "@/components/routes/link/LinkRoute"
export default function LinksPage() {
return <LinkRoute />
}

View File

@@ -1,5 +0,0 @@
import OnboardingRoute from "@/components/routes/OnboardingRoute"
export default function EditProfilePage() {
return <OnboardingRoute />
}

View File

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

View File

@@ -1,5 +0,0 @@
import { PageRoute } from "@/components/routes/page/PageRoute"
export default function Page() {
return <PageRoute />
}

View File

@@ -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&apos;t exist.
</p>
<p className="mb-5 text-center text-lg font-semibold">Try searching for another.</p>
<p className="mb-5 text-center text-lg font-semibold">
The link you followed may be broken, or the page may have been removed. Go back to
<Link 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>
)
}

View File

@@ -1,5 +0,0 @@
import { ProfileWrapper } from "./_components/wrapper"
export default function ProfilePage() {
return <ProfileWrapper />
}

View File

@@ -1,5 +0,0 @@
import { SearchWrapper } from "@/components/routes/search/wrapper"
export default function ProfilePage() {
return <SearchWrapper />
}

View File

@@ -1,5 +0,0 @@
import { SettingsRoute } from "@/components/routes/SettingsRoute"
export default function SettingsPage() {
return <SettingsRoute />
}

View File

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

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
import TauriRoute from "@/components/routes/tauri/TauriRoute"
export default function TauriPage() {
return <TauriRoute />
}

View File

@@ -1,5 +0,0 @@
import { TopicRoute } from "@/components/routes/topics/TopicRoute"
export default function Page() {
return <TopicRoute />
}

View File

@@ -1,7 +0,0 @@
export default function PublicLayout({
children
}: Readonly<{
children: React.ReactNode
}>) {
return <main className="h-full">{children}</main>
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View 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&apos;ve been working hard on
this new version, which addresses previous issues and offers
more features. As an early customer, you&apos;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&apos;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

View 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"),
},
],
},
],
})

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

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

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

View 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

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

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

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

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

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

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

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

View 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

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

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

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

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

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

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

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

View 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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

View File

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

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

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

View File

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

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

View 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