├── .gitignore ├── LICENSE ├── README.md ├── actions ├── add-website-reference.ts ├── docs.ts └── websites.ts ├── app ├── (auth) │ └── sign-in │ │ └── [[...sign-in]] │ │ └── page.tsx ├── (protected) │ ├── components │ │ ├── data-table.tsx │ │ ├── file-card.tsx │ │ ├── files-grid.tsx │ │ └── table-columns.tsx │ ├── docs │ │ └── [doc_id] │ │ │ ├── loading.tsx │ │ │ ├── page.tsx │ │ │ ├── text-editor │ │ │ ├── ai-chat-sidebar.tsx │ │ │ ├── ai-chat-trigger.tsx │ │ │ ├── index.tsx │ │ │ ├── model-selector.tsx │ │ │ ├── reference-selector.tsx │ │ │ ├── text-to-speech.tsx │ │ │ └── voice-transcription.tsx │ │ │ └── tour.tsx │ ├── home │ │ ├── new-doc.tsx │ │ ├── page.tsx │ │ ├── search-command.tsx │ │ └── tour.tsx │ ├── integrations │ │ ├── discord │ │ │ └── page.tsx │ │ ├── github │ │ │ └── page.tsx │ │ ├── linear │ │ │ └── page.tsx │ │ ├── notion │ │ │ └── page.tsx │ │ └── page.tsx │ ├── layout.tsx │ └── references │ │ └── page.tsx ├── actions │ ├── textToSpeech.ts │ └── transcribe.ts ├── api │ ├── chat │ │ └── route.ts │ ├── completion │ │ └── route.ts │ ├── discord │ │ └── data │ │ │ └── route.ts │ ├── docs │ │ └── [doc_id] │ │ │ └── route.ts │ ├── github │ │ ├── data │ │ │ └── route.ts │ │ └── sync │ │ │ └── route.ts │ ├── linear │ │ ├── data │ │ │ └── route.ts │ │ ├── sync.ts │ │ └── sync │ │ │ └── route.ts │ ├── notion │ │ ├── data │ │ │ └── route.ts │ │ └── sync │ │ │ └── route.ts │ ├── references │ │ └── route.ts │ ├── text-modification │ │ └── route.ts │ ├── transcribe │ │ └── route.ts │ └── uploadthing │ │ ├── core.ts │ │ └── route.ts ├── components │ ├── AnnouncementBar.tsx │ └── PleaseStarUsOnGitHub.tsx ├── favicon.ico ├── globals.css ├── layout.tsx ├── not-found.tsx ├── page.tsx └── providers.tsx ├── biome.json ├── bun.lock ├── components.json ├── components ├── add-reference.tsx ├── animated-prompt.tsx ├── command-menu.tsx ├── editable-document-name.tsx ├── home │ ├── app-header.tsx │ ├── quick-action-button.tsx │ ├── recent-files-card.tsx │ └── status-bar.tsx ├── inline-diff-view.tsx ├── integration-sidebar.tsx ├── new-document-button.tsx ├── t0-keycap.tsx ├── text-diff-view.tsx ├── text-scramble.tsx ├── text-selection-menu.tsx ├── tour.tsx └── ui │ ├── alert-dialog.tsx │ ├── alert.tsx │ ├── anthropic-logo.tsx │ ├── badge.tsx │ ├── bounce-spinner.tsx │ ├── button.tsx │ ├── card.tsx │ ├── checkbox.tsx │ ├── collapsible.tsx │ ├── command.tsx │ ├── dialog.tsx │ ├── drawer.tsx │ ├── dropdown-menu.tsx │ ├── file-upload.tsx │ ├── google-logo.tsx │ ├── icons │ ├── discord.tsx │ ├── github.tsx │ ├── gmail.tsx │ ├── google-calendar.tsx │ ├── google-docs.tsx │ ├── index.ts │ ├── linear.tsx │ ├── ms-teams.tsx │ ├── notion.tsx │ ├── slack.tsx │ ├── spinner.tsx │ ├── t0-logo.tsx │ ├── vercel.tsx │ └── x-icon.tsx │ ├── input.tsx │ ├── label.tsx │ ├── llama-logo.tsx │ ├── menubar.tsx │ ├── openai-logo.tsx │ ├── pagination.tsx │ ├── popover.tsx │ ├── progress.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── sidebar.tsx │ ├── skeleton.tsx │ ├── sonner.tsx │ ├── switch.tsx │ ├── table.tsx │ ├── tabs.tsx │ ├── textarea.tsx │ ├── toggle.tsx │ ├── tooltip.tsx │ └── xai-logo.tsx ├── hooks ├── use-debounced-callback.ts ├── use-document-references.ts ├── use-media-query.ts ├── use-mobile.ts ├── use-model.ts ├── use-reference-processing.ts ├── use-references.ts └── use-selected-references.ts ├── lib ├── auth │ └── server.ts ├── firecrawl.ts ├── nanoid.ts ├── redis.ts ├── tour-constants.ts ├── trigger-hooks.ts ├── uploadthing.ts ├── utils.ts └── vector.ts ├── middleware.ts ├── next.config.ts ├── package.json ├── postcss.config.mjs ├── public ├── autocomplete.png ├── bghero.webp ├── default-content.json ├── file.svg ├── globe.svg ├── home.png ├── keytype.mp3 ├── landing.png ├── next.svg ├── t0-logo.svg ├── vercel.svg └── window.svg ├── trigger.config.ts ├── trigger └── process-document.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | .env*.local 43 | 44 | .trigger 45 | .cursor -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Railly Hugo and Anthony Cueva. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Text0 2 | 3 | A text editor with absurdly smart autocomplete. Text0 serves as a copilot for writing, accessing user-provided references to offer context and help users write faster. 4 | 5 | ![Text0 Screenshot](./public/autocomplete.png) 6 | 7 | ## Features 8 | 9 | - Smart autocomplete powered by AI 10 | - Chat with your content 11 | - Reference-aware suggestions 12 | - Github, Linear and Notion integrations 13 | - Seamless writing experience 14 | - Voice input and output 15 | - Zen mode 16 | 17 | ## Tech Stack 18 | 19 | - React 20 | - Next.js 21 | - Tailwind CSS 22 | - RadixUI 23 | - Upstash Redis 24 | - Upstash Vector 25 | - AI SDK 26 | - UploadThing 27 | - Cursor 28 | - Vercel 29 | - Clerk (Authentication) 30 | 31 | ## Getting Started 32 | 33 | ### Prerequisites 34 | 35 | - Node.js 36 | - Bun (preferred) 37 | 38 | ### Installation 39 | 40 | ```bash 41 | # Clone the repository 42 | git clone https://github.com/crafter-station/text0.git 43 | cd text0 44 | 45 | # Install dependencies 46 | bun install 47 | 48 | # Start the development server 49 | bun dev 50 | ``` 51 | 52 | ## Usage 53 | 54 | Visit [www.text0.dev](https://www.text0.dev) to access the hosted version. 55 | 56 | To use the local development version, navigate to `http://localhost:3000` after starting the development server. 57 | 58 | ## Screenshots 59 | 60 | ![Landing](./public/landing.png) 61 | ![Autocomplete](./public/autocomplete.png) 62 | ![File navigation](./public/home.png) 63 | 64 | ## Contributing 65 | 66 | Contributions are welcome! Please feel free to: 67 | 68 | 1. Open issues for bugs or feature requests 69 | 2. Submit PRs for new functionality 70 | 3. Fork the project for your own use 71 | 72 | ## License 73 | 74 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 75 | 76 | ## Contact 77 | 78 | - [Railly Hugo](https://github.com/Railly) 79 | - [Anthony Cueva](https://github.com/cuevaio) 80 | -------------------------------------------------------------------------------- /actions/add-website-reference.ts: -------------------------------------------------------------------------------- 1 | export { 2 | addWebsiteReference, 3 | type AddWebsiteReferenceActionState, 4 | } from "./websites"; 5 | -------------------------------------------------------------------------------- /actions/docs.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { getSecureSession } from "@/lib/auth/server"; 4 | import { nanoid } from "@/lib/nanoid"; 5 | import { 6 | DOCUMENT_KEY, 7 | type Document, 8 | USER_DOCUMENTS_KEY, 9 | redis, 10 | } from "@/lib/redis"; 11 | import type { ActionState } from "@/lib/utils"; 12 | import { revalidatePath } from "next/cache"; 13 | import { z } from "zod"; 14 | 15 | export type CreateDocumentActionState = ActionState< 16 | { name: string }, 17 | { documentId: string } 18 | >; 19 | 20 | export type UpdateDocumentNameActionState = ActionState<{ 21 | name: string; 22 | documentId: string; 23 | }>; 24 | 25 | export async function createDocument( 26 | prevState: CreateDocumentActionState | undefined, 27 | formData: FormData, 28 | ): Promise { 29 | const rawFormData = Object.fromEntries(formData.entries()) as { 30 | name: string; 31 | pathname: string; 32 | }; 33 | 34 | try { 35 | const session = await getSecureSession(); 36 | if (!session.userId) { 37 | throw new Error("Unauthorized"); 38 | } 39 | 40 | const form = z.object({ 41 | name: z.string().min(1, "Document name is required"), 42 | pathname: z.string(), 43 | }); 44 | 45 | const parsed = form.safeParse(rawFormData); 46 | 47 | if (!parsed.success) { 48 | return { success: false, error: parsed.error.message }; 49 | } 50 | 51 | const id = nanoid(); 52 | const document: Document = { 53 | id, 54 | userId: session.userId, 55 | name: parsed.data.name, 56 | content: "", 57 | createdAt: new Date().toISOString(), 58 | updatedAt: new Date().toISOString(), 59 | }; 60 | 61 | // Store the document 62 | await redis.hset(DOCUMENT_KEY(id), document); 63 | 64 | // Add document ID to user's documents list 65 | await redis.zadd(USER_DOCUMENTS_KEY(session.userId), { 66 | score: Date.now(), 67 | member: id, 68 | }); 69 | revalidatePath(parsed.data.pathname, "layout"); 70 | 71 | return { success: true, data: { documentId: id } }; 72 | } catch (error) { 73 | console.error(error); 74 | return { 75 | success: false, 76 | error: error instanceof Error ? error.message : "Unknown error occurred", 77 | form: rawFormData, 78 | }; 79 | } 80 | } 81 | 82 | export async function updateDocumentName( 83 | prevState: UpdateDocumentNameActionState | undefined, 84 | formData: FormData, 85 | ): Promise { 86 | const rawFormData = Object.fromEntries(formData.entries()) as { 87 | name: string; 88 | documentId: string; 89 | }; 90 | 91 | try { 92 | const session = await getSecureSession(); 93 | if (!session.userId) { 94 | throw new Error("Unauthorized"); 95 | } 96 | 97 | const form = z.object({ 98 | name: z.string().min(1, "Document name is required"), 99 | documentId: z.string().min(1, "Document ID is required"), 100 | }); 101 | 102 | const parsed = form.safeParse(rawFormData); 103 | 104 | if (!parsed.success) { 105 | return { success: false, error: parsed.error.message }; 106 | } 107 | 108 | // Verify the document belongs to the user 109 | const document = await redis.hgetall( 110 | DOCUMENT_KEY(parsed.data.documentId), 111 | ); 112 | if (!document || document.userId !== session.userId) { 113 | throw new Error("Document not found or unauthorized"); 114 | } 115 | 116 | // Update the document name 117 | await redis.hset(DOCUMENT_KEY(parsed.data.documentId), { 118 | ...document, 119 | name: parsed.data.name, 120 | updatedAt: new Date().toISOString(), 121 | } satisfies Document); 122 | 123 | revalidatePath(`/docs/${parsed.data.documentId}`); 124 | 125 | return { success: true }; 126 | } catch (error) { 127 | console.error(error); 128 | return { 129 | success: false, 130 | error: error instanceof Error ? error.message : "Unknown error occurred", 131 | form: rawFormData, 132 | }; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /actions/websites.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { getSecureSession } from "@/lib/auth/server"; 4 | import { redis } from "@/lib/redis"; 5 | import type { Reference } from "@/lib/redis"; 6 | import type { ActionState } from "@/lib/utils"; 7 | import type { processReferenceTask } from "@/trigger/process-document"; 8 | import { tasks } from "@trigger.dev/sdk/v3"; 9 | import { nanoid } from "@/lib/nanoid"; 10 | import { revalidatePath } from "next/cache"; 11 | import { z } from "zod"; 12 | import { openai } from "@ai-sdk/openai"; 13 | import { generateText } from "ai"; 14 | 15 | const USER_REFERENCES_KEY = (userId: string) => `user:${userId}:references`; 16 | const REFERENCE_KEY = (referenceId: string) => `reference:${referenceId}`; 17 | 18 | export type AddWebsiteReferenceActionState = ActionState< 19 | { url: string }, 20 | { referenceId: string } 21 | >; 22 | 23 | export async function addWebsiteReference( 24 | prevState: AddWebsiteReferenceActionState | undefined, 25 | formData: FormData, 26 | ): Promise { 27 | try { 28 | const session = await getSecureSession(); 29 | if (!session.userId) { 30 | throw new Error("Unauthorized"); 31 | } 32 | 33 | const url = formData.get("url") as string; 34 | if (!url) { 35 | throw new Error("URL is required"); 36 | } 37 | 38 | const urlSchema = z.string().url(); 39 | const result = urlSchema.safeParse(url); 40 | if (!result.success) { 41 | throw new Error("Invalid URL"); 42 | } 43 | 44 | // Content moderation check 45 | const model = openai("gpt-4o-mini"); 46 | const { text } = await generateText({ 47 | model, 48 | prompt: ` 49 | As a content moderator, analyze the file name or the URL for any inappropriate, NSFW. 50 | Focus on: 51 | 1. NSFW or adult content 52 | 53 | If you don't have enough information to make a decision, just allow it. 54 | 55 | Content to analyze: 56 | URL: ${url} 57 | 58 | Respond with a JSON object containing: 59 | { 60 | "isAllowed": boolean, 61 | "reason": string if rejected, null if allowed 62 | } 63 | 64 | Only respond with the JSON object, nothing else. 65 | `.trim(), 66 | temperature: 0, 67 | maxTokens: 150, 68 | }); 69 | 70 | const moderationResult = JSON.parse(text); 71 | if (!moderationResult.isAllowed) { 72 | return { 73 | success: false, 74 | error: moderationResult.reason || "Content not allowed", 75 | }; 76 | } 77 | 78 | const referenceId = nanoid(); 79 | 80 | await redis.sadd(USER_REFERENCES_KEY(session.userId), referenceId); 81 | await redis.hset(REFERENCE_KEY(referenceId), { 82 | id: referenceId, 83 | userId: session.userId, 84 | url, 85 | uploadedAt: new Date().toISOString(), 86 | chunksCount: 0, 87 | processed: false, 88 | name: url, 89 | } satisfies Reference); 90 | 91 | // Trigger the document processing task 92 | await tasks.trigger("process-reference", { 93 | userId: session.userId, 94 | referenceId, 95 | }); 96 | 97 | revalidatePath("/references"); 98 | 99 | return { success: true, data: { referenceId } }; 100 | } catch (error) { 101 | console.error("Website reference error:", error); 102 | return { 103 | success: false, 104 | error: 105 | error instanceof Error 106 | ? error.message 107 | : "Failed to add website reference", 108 | }; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /app/(auth)/sign-in/[[...sign-in]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { InvertedT0Logo } from "@/components/ui/icons/t0-logo"; 2 | import { SignIn } from "@clerk/nextjs"; 3 | 4 | export default function Page() { 5 | return ( 6 |
7 | {/* Left side - Sign In */} 8 |
9 |
10 |
11 | 12 |
13 | 14 |
15 |
16 | 17 | {/* Right side - Gradient */} 18 |
19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /app/(protected)/components/file-card.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Badge } from "@/components/ui/badge"; 4 | import { Checkbox } from "@/components/ui/checkbox"; 5 | import type { Document, Reference } from "@/lib/redis"; 6 | import { cn } from "@/lib/utils"; 7 | import { CheckCircle2, ExternalLink, XCircle } from "lucide-react"; 8 | import Link from "next/link"; 9 | 10 | interface FileCardProps { 11 | file: Document | Reference; 12 | type: "document" | "reference"; 13 | isSelected?: boolean; 14 | onSelect?: (selected: boolean) => void; 15 | } 16 | 17 | export function FileCard({ file, type, isSelected, onSelect }: FileCardProps) { 18 | const isReference = type === "reference"; 19 | const ref = file as Reference; 20 | const doc = file as Document; 21 | 22 | return ( 23 |
29 | 34 | 35 |
36 | {isReference ? ( 37 | <> 38 |
39 |
40 | 41 | {ref.name || ref.filename || "Untitled"} 42 | 43 | {ref.processed ? ( 44 | 48 | 49 | Processed 50 | 51 | ) : ( 52 | 56 | 57 | Processing 58 | 59 | )} 60 |
61 | 67 | 68 | 69 |
70 |
71 | {ref.chunksCount} chunks 72 | {new Date(ref.uploadedAt).toLocaleDateString()} 73 |
74 | 75 | ) : ( 76 | <> 77 |
78 | 82 | {doc.name} 83 | 84 | 85 | {new Date(doc.createdAt).toLocaleDateString()} 86 | 87 |
88 |
89 | {doc.content} 90 |
91 | 92 | )} 93 |
94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /app/(protected)/components/files-grid.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { Document, Reference } from "@/lib/redis"; 4 | import { useState } from "react"; 5 | import { FileCard } from "./file-card"; 6 | 7 | interface FilesGridProps { 8 | files: (Document | Reference)[]; 9 | type: "document" | "reference"; 10 | } 11 | 12 | export function FilesGrid({ files, type }: FilesGridProps) { 13 | const [selectedFiles, setSelectedFiles] = useState>(new Set()); 14 | 15 | const toggleFile = (id: string) => { 16 | const newSelected = new Set(selectedFiles); 17 | if (newSelected.has(id)) { 18 | newSelected.delete(id); 19 | } else { 20 | newSelected.add(id); 21 | } 22 | setSelectedFiles(newSelected); 23 | }; 24 | 25 | return ( 26 |
27 | {files.map((file) => ( 28 | toggleFile(file.id)} 34 | /> 35 | ))} 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/(protected)/docs/[doc_id]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | import { cn } from "@/lib/utils"; 3 | 4 | export default function Loading() { 5 | return ( 6 |
7 |
8 | 9 | 10 |
11 | {Array.from({ length: 6 }).map((_, index) => ( 12 | // biome-ignore lint/suspicious/noArrayIndexKey: 13 | 14 | ))} 15 |
16 |
17 | {Array.from({ length: 4 }).map((_, index) => ( 18 | // biome-ignore lint/suspicious/noArrayIndexKey: 19 | 20 | ))} 21 |
22 |
23 |
24 |
25 | 26 | 27 |
28 |
29 | 30 |
31 | {Array.from({ length: 4 }).map((_, index) => ( 32 |
33 | key={index} 34 | className={cn( 35 | "flex h-12 items-center gap-2 px-2", 36 | index !== 3 && "border-b", 37 | )} 38 | > 39 | 40 | 41 |
42 | ))} 43 |
44 |
45 |
46 |
47 | 48 |
49 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /app/(protected)/docs/[doc_id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { getSecureSession } from "@/lib/auth/server"; 2 | import { DOCUMENT_KEY, type Document } from "@/lib/redis"; 3 | import { Redis } from "@upstash/redis"; 4 | import { TextEditor } from "./text-editor"; 5 | 6 | if ( 7 | !process.env.UPSTASH_REDIS_REST_URL || 8 | !process.env.UPSTASH_REDIS_REST_TOKEN 9 | ) { 10 | throw new Error("Missing Redis environment variables"); 11 | } 12 | 13 | const redis = new Redis({ 14 | url: process.env.UPSTASH_REDIS_REST_URL, 15 | token: process.env.UPSTASH_REDIS_REST_TOKEN, 16 | }); 17 | 18 | export default async function DocumentPage({ 19 | params, 20 | }: Readonly<{ 21 | params: Promise<{ doc_id: string }>; 22 | }>) { 23 | const { doc_id } = await params; 24 | const session = await getSecureSession(); 25 | const document: Document | null = await redis.hgetall(DOCUMENT_KEY(doc_id)); 26 | 27 | if (!document) { 28 | return
Document not found
; 29 | } 30 | 31 | if (session.userId !== document.userId) { 32 | return
Document not found
; 33 | } 34 | 35 | return ( 36 |
37 | 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /app/(protected)/docs/[doc_id]/text-editor/ai-chat-trigger.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { useSidebar } from "@/components/ui/sidebar"; 5 | import { 6 | Tooltip, 7 | TooltipContent, 8 | TooltipTrigger, 9 | } from "@/components/ui/tooltip"; 10 | import { MessageSquare } from "lucide-react"; 11 | 12 | export function AIChatTrigger() { 13 | const { toggleSidebar } = useSidebar(); 14 | 15 | return ( 16 | 17 | 18 | 30 | 31 | Toggle AI Assistant 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /app/(protected)/docs/[doc_id]/text-editor/model-selector.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AnthropicLogo } from "@/components/ui/anthropic-logo"; 4 | import { GoogleLogo } from "@/components/ui/google-logo"; 5 | import { LlamaLogo } from "@/components/ui/llama-logo"; 6 | import { OpenAILogo } from "@/components/ui/openai-logo"; 7 | import { 8 | Select, 9 | SelectContent, 10 | SelectItem, 11 | SelectTrigger, 12 | } from "@/components/ui/select"; 13 | import { 14 | Tooltip, 15 | TooltipContent, 16 | TooltipTrigger, 17 | } from "@/components/ui/tooltip"; 18 | import { XAILogo } from "@/components/ui/xai-logo"; 19 | import { useModel } from "@/hooks/use-model"; 20 | 21 | const models = [ 22 | { 23 | id: "gpt-4o-mini", 24 | name: "GPT-4o Mini", 25 | description: "Fast and efficient model for quick completions", 26 | component: "openai", 27 | }, 28 | { 29 | id: "grok-3-fast-beta", 30 | name: "Grok 3 Fast Beta", 31 | description: "High-speed model with balanced performance", 32 | component: "xai", 33 | }, 34 | { 35 | id: "claude-3-5-sonnet-latest", 36 | name: "Claude 3.5 Sonnet", 37 | description: "Advanced model for nuanced and detailed writing", 38 | component: "anthropic", 39 | }, 40 | { 41 | id: "claude-3-5-haiku-latest", 42 | name: "Claude 3.5 Haiku", 43 | description: "Lightweight model for quick and creative writing", 44 | component: "anthropic", 45 | }, 46 | { 47 | id: "llama-3.1-8b-instant", 48 | name: "Llama 3.1 8B", 49 | description: "Fast and efficient model for instant completions", 50 | component: "llama", 51 | }, 52 | { 53 | id: "llama-3.3-70b-versatile", 54 | name: "Llama 3.3 70B", 55 | description: "Powerful model for complex and versatile writing", 56 | component: "llama", 57 | }, 58 | { 59 | id: "gemini-2.0-flash-001", 60 | name: "Gemini 2.0 Flash", 61 | description: "Quick and accurate model for fast responses", 62 | component: "google", 63 | }, 64 | { 65 | id: "gemini-2.0-flash-lite-preview-02-05", 66 | name: "Gemini 2.0 Flash Lite", 67 | description: "Lightweight version for efficient completions", 68 | component: "google", 69 | }, 70 | ]; 71 | 72 | function ModelLogo({ model }: Readonly<{ model: (typeof models)[0] }>) { 73 | const logoProps = { className: "size-4" }; 74 | 75 | switch (model.component) { 76 | case "anthropic": 77 | return ; 78 | case "google": 79 | return ; 80 | case "openai": 81 | return ; 82 | case "xai": 83 | return ; 84 | case "llama": 85 | return ; 86 | default: 87 | return null; 88 | } 89 | } 90 | 91 | export function ModelSelector() { 92 | const [model, setModel] = useModel(); 93 | const selectedModel = models.find((m) => m.id === model); 94 | 95 | return ( 96 |
97 | 98 | 99 | 133 | 134 | 138 |
139 | {selectedModel?.name} 140 | 141 | {selectedModel?.description} 142 | 143 |
144 |
145 |
146 |
147 | ); 148 | } 149 | -------------------------------------------------------------------------------- /app/(protected)/docs/[doc_id]/text-editor/text-to-speech.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Volume2, Loader2 } from "lucide-react"; 4 | import { useState, useEffect } from "react"; 5 | import { Button } from "@/components/ui/button"; 6 | import { textToSpeech } from "@/app/actions/textToSpeech"; 7 | import { 8 | Tooltip, 9 | TooltipTrigger, 10 | TooltipContent, 11 | } from "@/components/ui/tooltip"; 12 | 13 | interface TextToSpeechProps { 14 | selectedText: string; 15 | } 16 | 17 | export function TextToSpeech({ selectedText }: TextToSpeechProps) { 18 | const [isConverting, setIsConverting] = useState(false); 19 | const [audioUrl, setAudioUrl] = useState(null); 20 | const [audioElement, setAudioElement] = useState( 21 | null, 22 | ); 23 | 24 | // Listen for the custom event 25 | useEffect(() => { 26 | const handleTextToSpeechEvent = (event: CustomEvent<{ text: string }>) => { 27 | if (event.detail.text) { 28 | convertTextToSpeech(event.detail.text); 29 | } 30 | }; 31 | 32 | window.addEventListener( 33 | "text0:text-to-speech", 34 | handleTextToSpeechEvent as EventListener, 35 | ); 36 | return () => { 37 | window.removeEventListener( 38 | "text0:text-to-speech", 39 | handleTextToSpeechEvent as EventListener, 40 | ); 41 | }; 42 | }, []); 43 | 44 | // Cleanup audio URL when component unmounts 45 | useEffect(() => { 46 | return () => { 47 | if (audioUrl) { 48 | URL.revokeObjectURL(audioUrl); 49 | } 50 | }; 51 | }, [audioUrl]); 52 | 53 | const convertTextToSpeech = async (text: string) => { 54 | if (!text || isConverting) return; 55 | 56 | setIsConverting(true); 57 | try { 58 | const response = await textToSpeech(text); 59 | 60 | if (response.success && response.audioData) { 61 | // Create a blob from the base64 audio data 62 | const binaryData = atob(response.audioData); 63 | const bytes = new Uint8Array(binaryData.length); 64 | for (let i = 0; i < binaryData.length; i++) { 65 | bytes[i] = binaryData.charCodeAt(i); 66 | } 67 | 68 | // Create a blob and URL 69 | const blob = new Blob([bytes], { 70 | type: response.contentType || "audio/mpeg", 71 | }); 72 | const url = URL.createObjectURL(blob); 73 | 74 | // Clean up previous URL if exists 75 | if (audioUrl) { 76 | URL.revokeObjectURL(audioUrl); 77 | } 78 | 79 | // Set the audio URL and create an audio element 80 | setAudioUrl(url); 81 | 82 | // Create new audio element if it doesn't exist 83 | if (!audioElement) { 84 | const audio = new Audio(url); 85 | setAudioElement(audio); 86 | audio.play(); 87 | } else { 88 | // Update and play existing audio element 89 | audioElement.src = url; 90 | audioElement.play(); 91 | } 92 | } else { 93 | console.error("Failed to convert text to speech:", response.error); 94 | } 95 | } catch (error) { 96 | console.error("Error converting text to speech:", error); 97 | } finally { 98 | setIsConverting(false); 99 | } 100 | }; 101 | 102 | const handleTextToSpeech = () => { 103 | convertTextToSpeech(selectedText); 104 | }; 105 | 106 | return ( 107 | 108 | 109 | 122 | 123 | 124 |

Text to Speech

125 |
126 |
127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /app/(protected)/home/new-doc.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createDocument } from "@/actions/docs"; 4 | import { QuickActionButton } from "@/components/home/quick-action-button"; 5 | import { usePathname, useRouter } from "next/navigation"; 6 | import React from "react"; 7 | 8 | export const NewDoc = () => { 9 | const [state, formAction, isPending] = React.useActionState( 10 | createDocument, 11 | undefined, 12 | ); 13 | const router = useRouter(); 14 | const pathname = usePathname(); 15 | 16 | React.useEffect(() => { 17 | if (state?.success) { 18 | router.push( 19 | `/docs/${state.data?.documentId}?from=${encodeURIComponent(pathname)}`, 20 | ); 21 | } 22 | }, [state, router, pathname]); 23 | 24 | return ( 25 |
26 | 27 | 28 | 33 | New Document 34 | New Doc 35 | 36 |
37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /app/(protected)/home/page.tsx: -------------------------------------------------------------------------------- 1 | import { AppHeader } from "@/components/home/app-header"; 2 | import { QuickActionButton } from "@/components/home/quick-action-button"; 3 | import { RecentFilesCard } from "@/components/home/recent-files-card"; 4 | import { StatusBar } from "@/components/home/status-bar"; 5 | import { getSecureUser } from "@/lib/auth/server"; 6 | import { 7 | DOCUMENT_KEY, 8 | type Document, 9 | REFERENCE_KEY, 10 | type Reference, 11 | USER_DOCUMENTS_KEY, 12 | USER_REFERENCES_KEY, 13 | redis, 14 | } from "@/lib/redis"; 15 | import { NewDoc } from "./new-doc"; 16 | import { SearchCommand } from "./search-command"; 17 | import { AddReference } from "@/components/add-reference"; 18 | import { TOUR_STEP_IDS } from "@/lib/tour-constants"; 19 | import { HomeTour } from "./tour"; 20 | 21 | export default async function HomePage() { 22 | const user = await getSecureUser(); 23 | 24 | // Fetch documents and references 25 | const documentsWithIds = await redis.zrange( 26 | USER_DOCUMENTS_KEY(user.id), 27 | 0, 28 | -1, 29 | ); 30 | const referencesWithIds = await redis.smembers(USER_REFERENCES_KEY(user.id)); 31 | 32 | const [documents, references] = await Promise.all([ 33 | Promise.all( 34 | documentsWithIds.map((id) => redis.hgetall(DOCUMENT_KEY(id))), 35 | ), 36 | Promise.all( 37 | referencesWithIds.map((id) => 38 | redis.hgetall(REFERENCE_KEY(id)), 39 | ), 40 | ), 41 | ]); 42 | 43 | const validDocuments = documents.filter( 44 | (doc): doc is Document => doc !== null, 45 | ); 46 | const validReferences = references.filter( 47 | (ref): ref is Reference => ref !== null, 48 | ); 49 | 50 | // Combine and sort by date 51 | const allFiles = [ 52 | ...validDocuments.map((doc) => ({ ...doc, type: "document" as const })), 53 | ...validReferences.map((ref) => ({ ...ref, type: "reference" as const })), 54 | ] 55 | .sort((a, b) => { 56 | const dateA = new Date( 57 | a.type === "document" ? a.updatedAt : a.uploadedAt, 58 | ); 59 | const dateB = new Date( 60 | b.type === "document" ? b.updatedAt : b.uploadedAt, 61 | ); 62 | return dateA.getTime() - dateB.getTime(); 63 | }) 64 | .slice(0, 5); // Limit to 5 most recent items 65 | 66 | return ( 67 |
68 | 69 | {/* Main Content */} 70 |
71 |
72 |
73 | 74 |
75 | 76 | {/* Quick Actions */} 77 |
78 |
79 | 80 |
81 |
82 | 83 | 84 | New Memory 85 | New Mem 86 | 87 | 88 |
89 |
90 | 91 |
92 |
93 | 94 |
95 | 96 |
97 |
98 |
99 | 100 |
101 | 106 |
107 |
108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /app/(protected)/home/tour.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { TourAlertDialog, useTour } from "@/components/tour"; 4 | import { TOUR_STEP_IDS } from "@/lib/tour-constants"; 5 | import { usePathname, useSearchParams } from "next/navigation"; 6 | import { useEffect, useState } from "react"; 7 | import type { TourStep } from "@/components/tour"; 8 | 9 | const HOME_TOUR_STEPS: TourStep[] = [ 10 | { 11 | content: ( 12 |
13 |

Create New Documents

14 |

15 | Create a new document to start writing and organizing your thoughts. 16 |

17 |
18 | ), 19 | selectorId: TOUR_STEP_IDS.NEW_DOC, 20 | position: "bottom", 21 | }, 22 | { 23 | content: ( 24 |
25 |

Add Memories

26 |

Upload memories that can be used by your AI assistant.

27 |
28 | ), 29 | selectorId: TOUR_STEP_IDS.NEW_MEMORY, 30 | position: "bottom", 31 | }, 32 | { 33 | content: ( 34 |
35 |

Search

36 |

Quickly search for documents and other content in your workspace.

37 |
38 | ), 39 | selectorId: TOUR_STEP_IDS.SEARCH_COMMAND, 40 | position: "bottom", 41 | }, 42 | { 43 | content: ( 44 |
45 |

Recent Files

46 |

Access your most recently modified documents and references here.

47 |
48 | ), 49 | selectorId: TOUR_STEP_IDS.RECENT_FILES, 50 | position: "top", 51 | }, 52 | { 53 | content: ( 54 |
55 |

Status Bar

56 |

View statistics about your workspace and account information.

57 |
58 | ), 59 | selectorId: TOUR_STEP_IDS.STATUS_BAR, 60 | position: "top", 61 | }, 62 | { 63 | content: ( 64 |
65 |

Toggle Sidebar

66 |

67 | Expand or collapse the sidebar to create more space for your content. 68 |

69 |
70 | ), 71 | selectorId: TOUR_STEP_IDS.SIDEBAR_TOGGLE, 72 | position: "right", 73 | }, 74 | { 75 | content: ( 76 |
77 |

Command Menu

78 |

Use the command menu to quickly navigate and find functionality.

79 |
80 | ), 81 | selectorId: TOUR_STEP_IDS.COMMAND_MENU, 82 | position: "right", 83 | }, 84 | { 85 | content: ( 86 |
87 |

Document Management

88 |

Access all your documents here. Click to open a document.

89 |
90 | ), 91 | selectorId: TOUR_STEP_IDS.MY_DOCUMENTS, 92 | position: "right", 93 | }, 94 | { 95 | content: ( 96 |
97 |

Integrations

98 |

99 | Connect with other tools and services to add more context to your AI 100 | assistant. 101 |

102 |
103 | ), 104 | selectorId: TOUR_STEP_IDS.INTEGRATIONS, 105 | position: "right", 106 | }, 107 | ]; 108 | 109 | export function HomeTour() { 110 | const { setSteps, isTourCompleted } = useTour(); 111 | const [openTour, setOpenTour] = useState(false); 112 | const pathname = usePathname(); 113 | const searchParams = useSearchParams(); 114 | 115 | // Get navigation source if available 116 | const from = searchParams.get("from"); 117 | const isNavigated = !!from; 118 | 119 | useEffect(() => { 120 | // Set tour steps 121 | setSteps(HOME_TOUR_STEPS); 122 | 123 | // Check localStorage first 124 | let isCompleted = false; 125 | try { 126 | const completedStatus = localStorage.getItem("tour-completed-home"); 127 | isCompleted = completedStatus === "true"; 128 | } catch (error) { 129 | console.error("Error reading from localStorage:", error); 130 | } 131 | 132 | // Only show tour if not completed and either: 133 | // 1. We're navigating from another page (isNavigated is true) 134 | // 2. This is a direct load/refresh (no from parameter) 135 | if (!isCompleted && !isTourCompleted) { 136 | // Show tour dialog after a short delay 137 | const timer = setTimeout(() => { 138 | setOpenTour(true); 139 | }, 1000); 140 | 141 | return () => clearTimeout(timer); 142 | } 143 | }, [setSteps, isTourCompleted]); 144 | 145 | return ( 146 | 152 | ); 153 | } 154 | -------------------------------------------------------------------------------- /app/(protected)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { MinimalIntegrationSidebar } from "@/components/integration-sidebar"; 2 | import { SidebarProvider } from "@/components/ui/sidebar"; 3 | import { getSecureUser } from "@/lib/auth/server"; 4 | import { 5 | DOCUMENT_KEY, 6 | type Document, 7 | USER_DOCUMENTS_KEY, 8 | redis, 9 | } from "@/lib/redis"; 10 | import { redirect } from "next/navigation"; 11 | export default async function ProtectedLayout({ 12 | children, 13 | }: Readonly<{ 14 | children: React.ReactNode; 15 | }>) { 16 | const user = await getSecureUser(); 17 | 18 | if (!user) { 19 | redirect("/sign-in"); 20 | } 21 | 22 | const documentsWithIds = await redis.zrange( 23 | USER_DOCUMENTS_KEY(user.id), 24 | 0, 25 | -1, 26 | ); 27 | const _documents = await Promise.all( 28 | documentsWithIds.map((documentId) => 29 | redis.hgetall(DOCUMENT_KEY(documentId)), 30 | ), 31 | ); 32 | const documents = _documents 33 | .map((document) => document as Document) 34 | .sort( 35 | (a, b) => 36 | new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), 37 | ); 38 | 39 | return ( 40 | 41 |
42 | 43 |
{children}
44 |
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /app/(protected)/references/page.tsx: -------------------------------------------------------------------------------- 1 | import { AddReference } from "@/components/add-reference"; 2 | import { Button } from "@/components/ui/button"; 3 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 4 | import { getSecureSession } from "@/lib/auth/server"; 5 | import { 6 | REFERENCE_KEY, 7 | type Reference, 8 | USER_REFERENCES_KEY, 9 | redis, 10 | } from "@/lib/redis"; 11 | import { ArrowRight, BrainIcon, FileText } from "lucide-react"; 12 | 13 | async function getReferences(userId: string): Promise { 14 | // Get all document IDs for the user 15 | const referenceIds = await redis.smembers(USER_REFERENCES_KEY(userId)); 16 | 17 | // Fetch details for each document 18 | const documents = await Promise.all( 19 | referenceIds.map(async (referenceId) => { 20 | const referenceInfo = await redis.hgetall(REFERENCE_KEY(referenceId)); 21 | return { 22 | id: referenceId, 23 | ...referenceInfo, 24 | } as Reference; 25 | }), 26 | ); 27 | 28 | // Sort documents by upload date (newest first) 29 | documents.sort( 30 | (a, b) => 31 | new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime(), 32 | ); 33 | 34 | return documents; 35 | } 36 | 37 | export default async function FilesPage() { 38 | const session = await getSecureSession(); 39 | if (!session.userId) { 40 | return null; 41 | } 42 | 43 | const references = await getReferences(session.userId); 44 | 45 | return ( 46 |
47 |
48 |
49 |
50 | 51 |

My Memories

52 |
53 | 54 | 57 | 58 |
59 | 60 | 90 | 91 | {references.length === 0 && ( 92 | 93 | 94 | No Memories Yet 95 | 96 | 97 |

98 | Upload your first memory to get started. We support various file 99 | formats including PDF, DOCX, and more. 100 |

101 |
102 | 103 |
104 |
105 |
106 | )} 107 |
108 |
109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /app/actions/textToSpeech.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { ElevenLabsClient } from "elevenlabs"; 4 | 5 | export async function textToSpeech( 6 | text: string, 7 | voiceId = "21m00Tcm4TlvDq8ikWAM", 8 | ) { 9 | try { 10 | const client = new ElevenLabsClient({ 11 | apiKey: process.env.ELEVENLABS_API_KEY, 12 | }); 13 | 14 | // Convert text to speech using ElevenLabs 15 | const audioStream = await client.textToSpeech.convert(voiceId, { 16 | text, 17 | model_id: "eleven_multilingual_v2", 18 | output_format: "mp3_44100_128", 19 | }); 20 | 21 | // Collect chunks from the readable stream 22 | const chunks: Buffer[] = []; 23 | for await (const chunk of audioStream) { 24 | chunks.push(Buffer.from(chunk)); 25 | } 26 | 27 | // Combine chunks into a single buffer and convert to base64 28 | const audioBuffer = Buffer.concat(chunks); 29 | const base64Audio = audioBuffer.toString("base64"); 30 | 31 | return { 32 | success: true, 33 | audioData: base64Audio, 34 | contentType: "audio/mpeg", 35 | }; 36 | } catch (error) { 37 | console.error("Text-to-speech error:", error); 38 | return { 39 | success: false, 40 | error: "Failed to convert text to speech", 41 | }; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/actions/transcribe.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { experimental_transcribe as transcribe } from "ai"; 4 | import { elevenlabs } from "@ai-sdk/elevenlabs"; 5 | 6 | export async function transcribeAudio(audioData: ArrayBuffer) { 7 | try { 8 | // Use ElevenLabs Scribe v1 for transcription 9 | const transcript = await transcribe({ 10 | model: elevenlabs.transcription("scribe_v1"), 11 | audio: new Uint8Array(audioData), 12 | }); 13 | 14 | return { 15 | success: true, 16 | text: transcript.text, 17 | segments: transcript.segments, 18 | language: transcript.language, 19 | durationInSeconds: transcript.durationInSeconds, 20 | }; 21 | } catch (error) { 22 | console.error("Transcription error:", error); 23 | return { 24 | success: false, 25 | error: "Failed to transcribe audio", 26 | }; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/api/completion/route.ts: -------------------------------------------------------------------------------- 1 | import { vector } from "@/lib/vector"; 2 | import { type LanguageModelV1, streamText } from "ai"; 3 | 4 | import { anthropic } from "@ai-sdk/anthropic"; 5 | import { google } from "@ai-sdk/google"; 6 | import { groq } from "@ai-sdk/groq"; 7 | import { openai } from "@ai-sdk/openai"; 8 | import { xai } from "@ai-sdk/xai"; 9 | 10 | import { getSecureSession } from "@/lib/auth/server"; 11 | import type { NextRequest } from "next/server"; 12 | 13 | export const maxDuration = 30; 14 | 15 | export async function POST(request: NextRequest) { 16 | try { 17 | const body = await request.json(); 18 | const searchParams = request.nextUrl.searchParams; 19 | const _model = searchParams.get("model"); 20 | 21 | const session = await getSecureSession(); 22 | 23 | let model: LanguageModelV1; 24 | 25 | if (_model === "gpt-4o-mini") { 26 | model = openai("gpt-4o-mini"); 27 | } else if (_model === "grok-3-fast-beta") { 28 | model = xai("grok-3-fast-beta"); 29 | } else if (_model === "claude-3-5-sonnet-latest") { 30 | model = anthropic("claude-3-5-sonnet-latest"); 31 | } else if (_model === "claude-3-5-haiku-latest") { 32 | model = anthropic("claude-3-5-haiku-latest"); 33 | } else if (_model === "llama-3.1-8b-instant") { 34 | model = groq("llama-3.1-8b-instant"); 35 | } else if (_model === "llama-3.3-70b-versatile") { 36 | model = groq("llama-3.3-70b-versatile"); 37 | } else if (_model === "gemini-2.0-flash-001") { 38 | model = google("gemini-2.0-flash-001"); 39 | } else if (_model === "gemini-2.0-flash-lite-preview-02-05") { 40 | model = google("gemini-2.0-flash-lite-preview-02-05"); 41 | } else { 42 | model = openai("gpt-4o-mini"); 43 | } 44 | 45 | const andFilter = 46 | body.references.length > 0 47 | ? ` AND referenceId IN ('${body.references.join("','")}')` 48 | : ""; 49 | 50 | const userFilter = `userId = '${session.userId}'`; 51 | 52 | let context = await vector.query({ 53 | data: body.prompt, 54 | topK: 5, 55 | includeData: true, 56 | filter: `${userFilter}${andFilter}`, 57 | }); 58 | 59 | if (andFilter && !context.some((c) => c.score > 0.875)) { 60 | context = await vector.query({ 61 | data: body.prompt, 62 | topK: 5, 63 | includeData: true, 64 | filter: userFilter, 65 | }); 66 | } 67 | 68 | context = context.filter((c) => c.score > 0.8); 69 | 70 | const contextData = context.map((c) => c.data).join("\n"); 71 | 72 | const result = streamText({ 73 | model, 74 | prompt: ` 75 | 76 | You are an autocompletion system that suggests text completions. 77 | Your name is text0. 78 | 79 | Rules: 80 | - USE the provided context in tags 81 | - Read CAREFULLY the input text in tags 82 | - Suggest up to 10 words maximum 83 | - Ensure suggestions maintain semantic meaning 84 | - Wrap completion in tags 85 | - Return only the completion text 86 | - Periods at the end of the completion are OPTIONAL, not fully required 87 | 88 | 89 | 90 | Math Academy is a challenging but rewarding platform for learning math. 91 | Math Academy teaches 92 | math in a fun and engaging way. 93 | 94 | 95 | 96 | ${contextData} 97 | 98 | 99 | ${body.prompt} 100 | 101 | 102 | Your completion: 103 | `, 104 | temperature: 0.75, 105 | maxTokens: 50, 106 | }); 107 | 108 | return result.toDataStreamResponse(); 109 | } catch (error) { 110 | return new Response("Internal Server Error", { status: 500 }); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /app/api/discord/data/route.ts: -------------------------------------------------------------------------------- 1 | import { getSecureSession } from "@/lib/auth/server"; 2 | import { type OauthAccessToken, createClerkClient } from "@clerk/backend"; 3 | import { ClerkAPIResponseError } from "@clerk/shared"; 4 | import { NextResponse } from "next/server"; 5 | 6 | export async function GET() { 7 | try { 8 | const session = await getSecureSession(); 9 | if (!session.userId) { 10 | return NextResponse.json( 11 | { error: "Unauthorized: No user ID found" }, 12 | { status: 401 }, 13 | ); 14 | } 15 | 16 | const clerkClient = createClerkClient({ 17 | secretKey: process.env.CLERK_SECRET_KEY, 18 | }); 19 | 20 | let oauthTokens: OauthAccessToken[] | undefined; 21 | try { 22 | const response = await clerkClient.users.getUserOauthAccessToken( 23 | session.userId, 24 | "discord", 25 | ); 26 | oauthTokens = response.data; 27 | } catch (error) { 28 | if (error instanceof ClerkAPIResponseError) { 29 | return NextResponse.json( 30 | { 31 | error: "Failed to retrieve Discord OAuth token", 32 | details: error.errors, 33 | }, 34 | { status: 400 }, 35 | ); 36 | } 37 | throw error; 38 | } 39 | 40 | if (!oauthTokens || oauthTokens.length === 0) { 41 | return NextResponse.json( 42 | { error: "Discord OAuth token not found for this user" }, 43 | { status: 400 }, 44 | ); 45 | } 46 | 47 | const token = oauthTokens[0].token; 48 | 49 | // Fetch user data from Discord 50 | const userResponse = await fetch("https://discord.com/api/users/@me", { 51 | headers: { 52 | Authorization: `Bearer ${token}`, 53 | }, 54 | }); 55 | if (!userResponse.ok) { 56 | const errorData = await userResponse.json(); 57 | return NextResponse.json( 58 | { 59 | error: "Failed to fetch user data from Discord", 60 | details: errorData, 61 | status: userResponse.status, 62 | }, 63 | { status: userResponse.status }, 64 | ); 65 | } 66 | const userData = await userResponse.json(); 67 | 68 | // Fetch user's guilds (servers) 69 | const guildsResponse = await fetch( 70 | "https://discord.com/api/users/@me/guilds", 71 | { 72 | headers: { 73 | Authorization: `Bearer ${token}`, 74 | }, 75 | }, 76 | ); 77 | if (!guildsResponse.ok) { 78 | const errorData = await guildsResponse.json(); 79 | return NextResponse.json( 80 | { 81 | error: "Failed to fetch guilds from Discord", 82 | details: errorData, 83 | status: guildsResponse.status, 84 | }, 85 | { status: guildsResponse.status }, 86 | ); 87 | } 88 | const guildsData = await guildsResponse.json(); 89 | 90 | return NextResponse.json({ 91 | user: userData, 92 | guilds: guildsData, 93 | }); 94 | } catch (error) { 95 | console.error("Error in Discord API route:", error); 96 | if (error instanceof ClerkAPIResponseError) { 97 | return NextResponse.json( 98 | { 99 | error: "Clerk API error", 100 | details: error.errors, 101 | }, 102 | { status: 500 }, 103 | ); 104 | } 105 | return NextResponse.json( 106 | { error: "Internal server error", details: String(error) }, 107 | { status: 500 }, 108 | ); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /app/api/docs/[doc_id]/route.ts: -------------------------------------------------------------------------------- 1 | import { getSecureSession } from "@/lib/auth/server"; 2 | import { DOCUMENT_KEY, redis, type Document } from "@/lib/redis"; 3 | import { revalidatePath } from "next/cache"; 4 | import { NextResponse } from "next/server"; 5 | import { z } from "zod"; 6 | 7 | const updateDocumentSchema = z.object({ 8 | content: z.string(), 9 | }); 10 | 11 | export async function PATCH( 12 | request: Request, 13 | { params }: { params: Promise<{ doc_id: string }> }, 14 | ) { 15 | try { 16 | const session = await getSecureSession(); 17 | const { doc_id } = await params; 18 | if (!session.userId) { 19 | return new NextResponse("Unauthorized", { status: 401 }); 20 | } 21 | 22 | const body = await request.json(); 23 | const parsed = updateDocumentSchema.safeParse(body); 24 | 25 | if (!parsed.success) { 26 | return new NextResponse("Invalid request data", { status: 400 }); 27 | } 28 | 29 | // Verify the document belongs to the user 30 | const document = await redis.hgetall(DOCUMENT_KEY(doc_id)); 31 | if (!document || document.userId !== session.userId) { 32 | return new NextResponse("Document not found or unauthorized", { 33 | status: 404, 34 | }); 35 | } 36 | 37 | // Update the document content 38 | await redis.hset(DOCUMENT_KEY(doc_id), { 39 | ...document, 40 | content: parsed.data.content, 41 | updatedAt: new Date().toISOString(), 42 | } satisfies Document); 43 | 44 | revalidatePath(`/docs/${doc_id}`, "page"); 45 | 46 | return NextResponse.json({ success: true }); 47 | } catch (error) { 48 | console.error("[DOCUMENT_UPDATE]", error); 49 | return new NextResponse("Internal error", { status: 500 }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/api/github/data/route.ts: -------------------------------------------------------------------------------- 1 | import { getSecureSession } from "@/lib/auth/server"; 2 | import { type OauthAccessToken, createClerkClient } from "@clerk/backend"; 3 | import { ClerkAPIResponseError } from "@clerk/shared"; 4 | import { NextResponse } from "next/server"; 5 | 6 | export async function GET() { 7 | try { 8 | const session = await getSecureSession(); 9 | if (!session.userId) { 10 | return NextResponse.json( 11 | { error: "Unauthorized: No user ID found" }, 12 | { status: 401 }, 13 | ); 14 | } 15 | 16 | const clerkClient = createClerkClient({ 17 | secretKey: process.env.CLERK_SECRET_KEY, 18 | }); 19 | 20 | let oauthTokens: OauthAccessToken[] | undefined; 21 | try { 22 | const oauthTokensResponse = 23 | await clerkClient.users.getUserOauthAccessToken( 24 | session.userId, 25 | "github", 26 | ); 27 | oauthTokens = oauthTokensResponse.data; 28 | } catch (error) { 29 | if (error instanceof ClerkAPIResponseError) { 30 | return NextResponse.json( 31 | { 32 | error: "Failed to retrieve GitHub OAuth token", 33 | details: error.errors, 34 | }, 35 | { status: 400 }, 36 | ); 37 | } 38 | throw error; 39 | } 40 | 41 | if (!oauthTokens || oauthTokens.length === 0) { 42 | return NextResponse.json( 43 | { error: "GitHub OAuth token not found for this user" }, 44 | { status: 400 }, 45 | ); 46 | } 47 | 48 | const token = oauthTokens[0].token; 49 | 50 | const userResponse = await fetch("https://api.github.com/user", { 51 | headers: { 52 | Authorization: `Bearer ${token}`, 53 | Accept: "application/vnd.github+json", 54 | }, 55 | }); 56 | if (!userResponse.ok) { 57 | const errorData = await userResponse.json(); 58 | return NextResponse.json( 59 | { 60 | error: "Failed to fetch user data from GitHub", 61 | details: errorData, 62 | status: userResponse.status, 63 | }, 64 | { status: userResponse.status }, 65 | ); 66 | } 67 | const userData = await userResponse.json(); 68 | 69 | const reposResponse = await fetch( 70 | "https://api.github.com/user/repos?sort=updated&per_page=10", 71 | { 72 | headers: { 73 | Authorization: `Bearer ${token}`, 74 | Accept: "application/vnd.github+json", 75 | }, 76 | }, 77 | ); 78 | if (!reposResponse.ok) { 79 | const errorData = await reposResponse.json(); 80 | return NextResponse.json( 81 | { 82 | error: "Failed to fetch repositories from GitHub", 83 | details: errorData, 84 | status: reposResponse.status, 85 | }, 86 | { status: reposResponse.status }, 87 | ); 88 | } 89 | const reposData = await reposResponse.json(); 90 | 91 | const notificationsResponse = await fetch( 92 | "https://api.github.com/notifications?per_page=10", 93 | { 94 | headers: { 95 | Authorization: `Bearer ${token}`, 96 | Accept: "application/vnd.github+json", 97 | }, 98 | }, 99 | ); 100 | if (!notificationsResponse.ok) { 101 | const errorData = await notificationsResponse.json(); 102 | return NextResponse.json( 103 | { 104 | error: "Failed to fetch notifications from GitHub", 105 | details: errorData, 106 | status: notificationsResponse.status, 107 | }, 108 | { status: notificationsResponse.status }, 109 | ); 110 | } 111 | const notificationsData = await notificationsResponse.json(); 112 | 113 | return NextResponse.json({ 114 | user: userData, 115 | repos: reposData, 116 | notifications: notificationsData, 117 | }); 118 | } catch (error) { 119 | console.error("Error in GitHub API route:", error); 120 | if (error instanceof ClerkAPIResponseError) { 121 | return NextResponse.json( 122 | { 123 | error: "Clerk API error", 124 | details: error.errors, 125 | }, 126 | { status: 500 }, 127 | ); 128 | } 129 | return NextResponse.json( 130 | { error: "Internal server error", details: String(error) }, 131 | { status: 500 }, 132 | ); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /app/api/linear/data/route.ts: -------------------------------------------------------------------------------- 1 | import { getSecureSession } from "@/lib/auth/server"; 2 | import { type OauthAccessToken, createClerkClient } from "@clerk/backend"; 3 | import { ClerkAPIResponseError } from "@clerk/shared"; 4 | import { LinearClient } from "@linear/sdk"; 5 | import { NextResponse } from "next/server"; 6 | 7 | export async function GET() { 8 | try { 9 | const session = await getSecureSession(); 10 | if (!session.userId) { 11 | return NextResponse.json( 12 | { error: "Unauthorized: No user ID found" }, 13 | { status: 401 }, 14 | ); 15 | } 16 | 17 | const clerkClient = createClerkClient({ 18 | secretKey: process.env.CLERK_SECRET_KEY, 19 | }); 20 | 21 | let oauthTokens: OauthAccessToken[] | undefined; 22 | try { 23 | const oauthTokensResponse = 24 | await clerkClient.users.getUserOauthAccessToken( 25 | session.userId, 26 | "linear", 27 | ); 28 | oauthTokens = oauthTokensResponse.data; 29 | } catch (error) { 30 | if (error instanceof ClerkAPIResponseError) { 31 | return NextResponse.json( 32 | { 33 | error: "Failed to retrieve Linear OAuth token", 34 | details: error.errors, 35 | }, 36 | { status: 400 }, 37 | ); 38 | } 39 | throw error; 40 | } 41 | 42 | if (!oauthTokens || oauthTokens.length === 0) { 43 | return NextResponse.json( 44 | { error: "Linear OAuth token not found for this user" }, 45 | { status: 400 }, 46 | ); 47 | } 48 | 49 | const token = oauthTokens[0].token; 50 | 51 | // Initialize Linear Client with the access token 52 | const linearClient = new LinearClient({ 53 | accessToken: token, 54 | }); 55 | 56 | // Fetch the current user (viewer) 57 | const user = await linearClient.viewer; 58 | if (!user) { 59 | return NextResponse.json( 60 | { error: "Failed to fetch user data from Linear" }, 61 | { status: 400 }, 62 | ); 63 | } 64 | 65 | // Fetch the user's assigned issues 66 | const assignedIssues = await user.assignedIssues(); 67 | const issues = assignedIssues.nodes.map((issue) => ({ 68 | id: issue.id, 69 | title: issue.title, 70 | description: issue.description ?? "No description", 71 | url: issue.url, 72 | createdAt: issue.createdAt, 73 | updatedAt: issue.updatedAt, 74 | })); 75 | 76 | // Fetch the user's teams 77 | const teams = await linearClient.teams(); 78 | const teamData = teams.nodes.map((team) => ({ 79 | id: team.id, 80 | name: team.name, 81 | description: team.description ?? "No description", 82 | createdAt: team.createdAt, 83 | })); 84 | 85 | return NextResponse.json({ 86 | user: { 87 | id: user.id, 88 | name: user.name, 89 | email: user.email, 90 | displayName: user.displayName, 91 | }, 92 | issues, 93 | teams: teamData, 94 | }); 95 | } catch (error) { 96 | console.error("Error in Linear API route:", error); 97 | if (error instanceof ClerkAPIResponseError) { 98 | return NextResponse.json( 99 | { 100 | error: "Clerk API error", 101 | details: error.errors, 102 | }, 103 | { status: 500 }, 104 | ); 105 | } 106 | return NextResponse.json( 107 | { error: "Internal server error", details: String(error) }, 108 | { status: 500 }, 109 | ); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /app/api/notion/data/route.ts: -------------------------------------------------------------------------------- 1 | import { getSecureSession } from "@/lib/auth/server"; 2 | import { type OauthAccessToken, createClerkClient } from "@clerk/backend"; 3 | import { ClerkAPIResponseError } from "@clerk/shared"; 4 | import { NextResponse } from "next/server"; 5 | 6 | export async function GET() { 7 | try { 8 | const session = await getSecureSession(); 9 | if (!session.userId) { 10 | return NextResponse.json( 11 | { error: "Unauthorized: No user ID found" }, 12 | { status: 401 }, 13 | ); 14 | } 15 | 16 | const clerkClient = createClerkClient({ 17 | secretKey: process.env.CLERK_SECRET_KEY, 18 | }); 19 | 20 | let oauthTokens: OauthAccessToken[] | undefined; 21 | try { 22 | const oauthTokensResponse = 23 | await clerkClient.users.getUserOauthAccessToken( 24 | session.userId, 25 | "notion", 26 | ); 27 | oauthTokens = oauthTokensResponse.data; 28 | } catch (error) { 29 | if (error instanceof ClerkAPIResponseError) { 30 | return NextResponse.json( 31 | { 32 | error: "Failed to retrieve Notion OAuth token", 33 | details: error.errors, 34 | }, 35 | { status: 400 }, 36 | ); 37 | } 38 | throw error; 39 | } 40 | 41 | if (!oauthTokens || oauthTokens.length === 0) { 42 | return NextResponse.json( 43 | { error: "Notion OAuth token not found for this user" }, 44 | { status: 400 }, 45 | ); 46 | } 47 | 48 | const token = oauthTokens[0].token; 49 | 50 | // Fetch user data from Notion 51 | const userResponse = await fetch("https://api.notion.com/v1/users/me", { 52 | headers: { 53 | Authorization: `Bearer ${token}`, 54 | "Notion-Version": "2022-06-28", 55 | }, 56 | }); 57 | if (!userResponse.ok) { 58 | const errorData = await userResponse.json(); 59 | return NextResponse.json( 60 | { 61 | error: "Failed to fetch user data from Notion", 62 | details: errorData, 63 | status: userResponse.status, 64 | }, 65 | { status: userResponse.status }, 66 | ); 67 | } 68 | const userData = await userResponse.json(); 69 | 70 | // Fetch user's pages (search for accessible pages) 71 | const pagesResponse = await fetch("https://api.notion.com/v1/search", { 72 | method: "POST", 73 | headers: { 74 | Authorization: `Bearer ${token}`, 75 | "Content-Type": "application/json", 76 | "Notion-Version": "2022-06-28", 77 | }, 78 | body: JSON.stringify({ 79 | filter: { property: "object", value: "page" }, 80 | sort: { direction: "descending", timestamp: "last_edited_time" }, 81 | page_size: 10, 82 | }), 83 | }); 84 | if (!pagesResponse.ok) { 85 | const errorData = await pagesResponse.json(); 86 | return NextResponse.json( 87 | { 88 | error: "Failed to fetch pages from Notion", 89 | details: errorData, 90 | status: pagesResponse.status, 91 | }, 92 | { status: pagesResponse.status }, 93 | ); 94 | } 95 | const pagesData = await pagesResponse.json(); 96 | 97 | // Fetch user's databases (search for accessible databases) 98 | const databasesResponse = await fetch("https://api.notion.com/v1/search", { 99 | method: "POST", 100 | headers: { 101 | Authorization: `Bearer ${token}`, 102 | "Content-Type": "application/json", 103 | "Notion-Version": "2022-06-28", 104 | }, 105 | body: JSON.stringify({ 106 | filter: { property: "object", value: "database" }, 107 | sort: { direction: "descending", timestamp: "last_edited_time" }, 108 | page_size: 10, 109 | }), 110 | }); 111 | if (!databasesResponse.ok) { 112 | const errorData = await databasesResponse.json(); 113 | return NextResponse.json( 114 | { 115 | error: "Failed to fetch databases from Notion", 116 | details: errorData, 117 | status: databasesResponse.status, 118 | }, 119 | { status: databasesResponse.status }, 120 | ); 121 | } 122 | const databasesData = await databasesResponse.json(); 123 | 124 | return NextResponse.json({ 125 | user: userData, 126 | pages: pagesData.results, 127 | databases: databasesData.results, 128 | }); 129 | } catch (error) { 130 | console.error("Error in Notion API route:", error); 131 | if (error instanceof ClerkAPIResponseError) { 132 | return NextResponse.json( 133 | { 134 | error: "Clerk API error", 135 | details: error.errors, 136 | }, 137 | { status: 500 }, 138 | ); 139 | } 140 | return NextResponse.json( 141 | { error: "Internal server error", details: String(error) }, 142 | { status: 500 }, 143 | ); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /app/api/references/route.ts: -------------------------------------------------------------------------------- 1 | import { getSecureSession } from "@/lib/auth/server"; 2 | import { 3 | REFERENCE_KEY, 4 | type Reference, 5 | USER_REFERENCES_KEY, 6 | redis, 7 | } from "@/lib/redis"; 8 | import { NextResponse } from "next/server"; 9 | 10 | export async function GET() { 11 | try { 12 | const session = await getSecureSession(); 13 | if (!session.userId) { 14 | return new NextResponse("Unauthorized", { status: 401 }); 15 | } 16 | 17 | // Get all reference IDs for the user 18 | const referenceIds = await redis.smembers( 19 | USER_REFERENCES_KEY(session.userId), 20 | ); 21 | 22 | // Get all references in parallel 23 | const references = await Promise.all( 24 | referenceIds.map(async (id) => { 25 | const reference = await redis.hgetall(REFERENCE_KEY(id)); 26 | return reference as Reference | null; 27 | }), 28 | ); 29 | 30 | // Filter out any null references and sort by uploadedAt 31 | const validReferences = references 32 | .filter( 33 | (ref): ref is Reference => ref !== null && Object.keys(ref).length > 0, 34 | ) 35 | .sort( 36 | (a, b) => 37 | new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime(), 38 | ); 39 | 40 | return NextResponse.json(validReferences); 41 | } catch (error) { 42 | console.error("Error fetching references:", error); 43 | return new NextResponse("Internal Server Error", { status: 500 }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/api/text-modification/route.ts: -------------------------------------------------------------------------------- 1 | import { openai } from "@ai-sdk/openai"; 2 | import { streamText } from "ai"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export const runtime = "edge"; 6 | 7 | export async function POST(req: Request) { 8 | try { 9 | const { prompt, model } = await req.json(); 10 | 11 | if (!prompt || !model) { 12 | return new NextResponse( 13 | JSON.stringify({ 14 | error: "Missing required fields: prompt or model", 15 | }), 16 | { status: 400 }, 17 | ); 18 | } 19 | 20 | const result = streamText({ 21 | model: openai(model), 22 | system: `You are a highly skilled writing assistant focused on precise text modifications. Follow these guidelines strictly: 23 | 24 | 1. ONLY return the modified text - no explanations, no prefixes, no comments 25 | 2. Maintain the original meaning and intent while improving the text 26 | 3. Keep the same tone and style unless explicitly asked to change it 27 | 4. Preserve important technical terms and proper nouns 28 | 5. Ensure modifications are contextually appropriate 29 | 6. When improving clarity: 30 | - Remove redundancies 31 | - Use active voice 32 | - Make sentences more concise 33 | - Improve flow between ideas 34 | 7. When fixing grammar: 35 | - Correct spelling errors 36 | - Fix punctuation 37 | - Ensure proper sentence structure 38 | 8. Format output exactly as received (e.g., if input has line breaks, preserve them) 39 | 40 | Remember: Your output should contain ONLY the modified text, exactly as it should appear.`, 41 | prompt, 42 | temperature: 0.3, // Lower temperature for more precise output 43 | }); 44 | 45 | return result.toDataStreamResponse(); 46 | } catch (error) { 47 | console.error("Error in text modification API:", error); 48 | return new NextResponse( 49 | JSON.stringify({ 50 | error: 51 | "An error occurred while processing your request. Please try again.", 52 | }), 53 | { status: 500 }, 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/api/transcribe/route.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest, NextResponse } from "next/server"; 2 | import { experimental_transcribe as transcribe } from "ai"; 3 | import { elevenlabs } from "@ai-sdk/elevenlabs"; 4 | 5 | export async function POST(req: NextRequest) { 6 | try { 7 | const formData = await req.formData(); 8 | const audioFile = formData.get("audio") as File; 9 | 10 | if (!audioFile) { 11 | return NextResponse.json( 12 | { error: "Audio file is required" }, 13 | { status: 400 }, 14 | ); 15 | } 16 | 17 | const audioBytes = await audioFile.arrayBuffer(); 18 | 19 | // Use ElevenLabs Scribe v1 for transcription 20 | const transcript = await transcribe({ 21 | model: elevenlabs.transcription("scribe_v1"), 22 | audio: new Uint8Array(audioBytes), 23 | }); 24 | 25 | return NextResponse.json({ 26 | text: transcript.text, 27 | segments: transcript.segments, 28 | language: transcript.language, 29 | durationInSeconds: transcript.durationInSeconds, 30 | }); 31 | } catch (error) { 32 | console.error("Transcription error:", error); 33 | return NextResponse.json( 34 | { error: "Failed to transcribe audio" }, 35 | { status: 500 }, 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/api/uploadthing/core.ts: -------------------------------------------------------------------------------- 1 | import { getSecureSession } from "@/lib/auth/server"; 2 | import { nanoid } from "@/lib/nanoid"; 3 | import { 4 | REFERENCE_KEY, 5 | type Reference, 6 | USER_REFERENCES_KEY, 7 | redis, 8 | } from "@/lib/redis"; 9 | import type { processReferenceTask } from "@/trigger/process-document"; 10 | import { tasks } from "@trigger.dev/sdk/v3"; 11 | import { type FileRouter, createUploadthing } from "uploadthing/next"; 12 | import { UploadThingError } from "uploadthing/server"; 13 | 14 | const f = createUploadthing(); 15 | // FileRouter for your app, can contain multiple FileRoutes 16 | export const ourFileRouter = { 17 | // Define as many FileRoutes as you like, each with a unique routeSlug 18 | documentUploader: f({ 19 | pdf: { 20 | maxFileSize: "16MB", 21 | maxFileCount: 1, 22 | }, 23 | text: { 24 | maxFileSize: "16MB", 25 | maxFileCount: 1, 26 | }, 27 | "text/markdown": { 28 | maxFileSize: "16MB", 29 | maxFileCount: 1, 30 | }, 31 | "application/vnd.openxmlformats-officedocument.wordprocessingml.document": { 32 | maxFileSize: "16MB", 33 | maxFileCount: 1, 34 | }, 35 | "text/plain": { 36 | maxFileSize: "16MB", 37 | maxFileCount: 1, 38 | }, 39 | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { 40 | maxFileSize: "16MB", 41 | maxFileCount: 1, 42 | }, 43 | "application/vnd.openxmlformats-officedocument.presentationml.presentation": 44 | { 45 | maxFileSize: "16MB", 46 | maxFileCount: 1, 47 | }, 48 | }) 49 | .middleware(async () => { 50 | // This code runs on your server before upload 51 | const session = await getSecureSession(); 52 | 53 | // If you throw, the user will not be able to upload 54 | if (!session.userId) throw new UploadThingError("Unauthorized"); 55 | 56 | // Whatever is returned here is accessible in onUploadComplete as `metadata` 57 | return { userId: session.userId }; 58 | }) 59 | .onUploadComplete(async ({ metadata, file }) => { 60 | console.log("Upload complete", { metadata, file }); 61 | const referenceId = nanoid(); 62 | 63 | await redis.sadd(USER_REFERENCES_KEY(metadata.userId), referenceId); 64 | await redis.hset(REFERENCE_KEY(referenceId), { 65 | id: referenceId, 66 | userId: metadata.userId, 67 | url: file.ufsUrl, 68 | name: file.name, 69 | uploadedAt: new Date().toISOString(), 70 | chunksCount: 0, 71 | processed: false, 72 | filename: file.name, 73 | } satisfies Reference); 74 | // Trigger the document processing task 75 | await tasks.trigger("process-reference", { 76 | userId: metadata.userId, 77 | referenceId, 78 | }); 79 | 80 | // Return immediately to the client 81 | return { 82 | uploadedBy: metadata.userId, 83 | referenceId, 84 | }; 85 | }), 86 | } satisfies FileRouter; 87 | 88 | export type OurFileRouter = typeof ourFileRouter; 89 | -------------------------------------------------------------------------------- /app/api/uploadthing/route.ts: -------------------------------------------------------------------------------- 1 | import { createRouteHandler } from "uploadthing/next"; 2 | 3 | import { ourFileRouter } from "./core"; 4 | 5 | export const dynamic = "force-dynamic"; 6 | export const runtime = "nodejs"; 7 | 8 | // Export routes for Next App Router 9 | export const { GET, POST } = createRouteHandler({ 10 | router: ourFileRouter, 11 | config: { 12 | callbackUrl: `${process.env.NEXT_PUBLIC_APP_URL}/api/uploadthing`, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /app/components/AnnouncementBar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { XIcon } from "lucide-react"; 5 | import { useState } from "react"; 6 | 7 | export function AnnouncementBar() { 8 | const [showAnnouncement, setShowAnnouncement] = useState(true); 9 | 10 | if (!showAnnouncement) return null; 11 | 12 | return ( 13 |
14 |
15 | 🏆 We won the first global ▲ Next.js Hackathon! 16 | 25 |
26 |
27 | ); 28 | } -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crafter-station/text0/125ec86d0a6ec83e0b20a9197ec713e6768da38c/app/favicon.ico -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import { Toaster } from "@/components/ui/sonner"; 3 | import type { Metadata } from "next"; 4 | import { Geist, Geist_Mono } from "next/font/google"; 5 | import { Providers } from "./providers"; 6 | import { TourProvider } from "@/components/tour"; 7 | import { Analytics } from "@vercel/analytics/react"; 8 | import { AnnouncementBar } from "./components/AnnouncementBar"; 9 | import { PleaseStarUsOnGitHub } from "./components/PleaseStarUsOnGitHub"; 10 | 11 | const geistSans = Geist({ 12 | variable: "--font-geist-sans", 13 | subsets: ["latin"], 14 | }); 15 | 16 | const geistMono = Geist_Mono({ 17 | variable: "--font-geist-mono", 18 | subsets: ["latin"], 19 | }); 20 | 21 | export const metadata: Metadata = { 22 | title: "Text0 – The AI-Native Personal Text Editor", 23 | description: 24 | "Text0 is your personal thinking partner. It completes your thoughts, remembers your context, and adapts the interface as you write. Built for developers, powered by AI, and integrated with your tools. This isn't just a text editor — it's your second brain.", 25 | keywords: [ 26 | "Text0", 27 | "AI text editor", 28 | "Generative UI", 29 | "Second brain", 30 | "Memory-aware writing", 31 | "GPT-4o text editor", 32 | "AI-native interface", 33 | "Personal AI workspace", 34 | "Developer tools", 35 | "Obsidian alternative", 36 | "Next.js text editor", 37 | "Context-aware autocomplete", 38 | "Adaptive UI", 39 | "Railly Hugo", 40 | "Anthony Cueva", 41 | ], 42 | applicationName: "Text0", 43 | authors: [ 44 | { 45 | name: "Railly Hugo", 46 | url: "https://railly.dev", 47 | }, 48 | { 49 | name: "Anthony Cueva", 50 | url: "https://cueva.io", 51 | }, 52 | ], 53 | creator: "Railly Hugo", 54 | publisher: "Crafter Station", 55 | metadataBase: new URL("https://text0.dev"), 56 | openGraph: { 57 | type: "website", 58 | title: "Text0 – The AI-Native Personal Text Editor", 59 | description: 60 | "A text editor that listens, completes, remembers. AI-native, voice-aware, memory-driven — built to match your flow.", 61 | url: "https://text0.dev", 62 | images: [ 63 | { 64 | url: "https://text0.dev/og.png", 65 | width: 1200, 66 | height: 630, 67 | alt: "Text0 – Built by Railly Hugo and Anthony Cueva", 68 | }, 69 | ], 70 | }, 71 | twitter: { 72 | card: "summary_large_image", 73 | title: "Text0 – The AI-Native Text Editor That Thinks With You", 74 | description: 75 | "More than autocomplete. Text0 remembers your context, adapts the interface, and integrates your tools into your flow. Write with AI that feels personal.", 76 | creator: "@raillyhugo, @cuevaio", 77 | images: ["https://text0.dev/og.png"], 78 | }, 79 | themeColor: "#000000", 80 | icons: { 81 | icon: "/favicon.ico", 82 | apple: "/apple-touch-icon.png", 83 | }, 84 | category: "technology", 85 | }; 86 | 87 | export default function RootLayout({ 88 | children, 89 | }: { 90 | children: React.ReactNode; 91 | }) { 92 | return ( 93 | 94 | 97 | 98 | 99 | 100 | {children} 101 | 102 | 103 | 104 | 105 | 106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowLeft, Ship, Timer } from "lucide-react"; 2 | import Link from "next/link"; 3 | 4 | export default function NotFound() { 5 | return ( 6 |
7 | {/* Floating gradients */} 8 |
17 |
26 | 27 | {/* Content */} 28 |
29 | {/* Fun 404 animation */} 30 |
31 |
32 | 33 | 4 34 | 35 |
36 |
37 | 38 |
39 |
40 | 41 | 4 42 | 43 |
44 |
45 | 46 |
47 |

48 | If you designed your 404 page,{" "} 49 | 50 | you shipped too late 51 | 52 | ✨ 53 | 54 | 55 |

56 |

57 | — Guillermo Rauch 58 |

59 |
60 | 61 |
62 | 66 | 67 | Back to shipping 68 | 69 |
70 | 71 | Time to ship something else 72 |
73 |
74 |
75 |
76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { AnimatedPrompt } from "@/components/animated-prompt"; 2 | import { T0Keycap } from "@/components/t0-keycap"; 3 | import { TextScramble } from "@/components/text-scramble"; 4 | import { GithubIcon } from "@/components/ui/icons/github"; 5 | import { VercelIcon } from "@/components/ui/icons/vercel"; 6 | import { XIcon } from "@/components/ui/icons/x-icon"; 7 | import Image from "next/image"; 8 | 9 | export default async function LandingPage() { 10 | return ( 11 |
12 | {/* Background Image */} 13 | Light ray background 21 | 22 | {/* Main Content */} 23 |
24 |
25 | {/* Hackathon Badge */} 26 |
27 | 28 | 29 | Built for Vercel Hackathon 30 | 31 |
32 | 33 | {/* App Title */} 34 |
35 |
36 | 53 | text0 54 | 55 |

56 | Your AI-native personal text editor 57 |

58 |
59 |
60 | 61 | {/* Press T to Start Prompt */} 62 | 63 | 64 | {/* Keyboard */} 65 |
66 | 67 |
68 |
69 |
70 | 71 | {/* Status Bar */} 72 | 114 |
115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /app/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { ClerkProvider } from "@clerk/nextjs"; 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 4 | import { ThemeProvider } from "next-themes"; 5 | import { NuqsAdapter } from "nuqs/adapters/next/app"; 6 | import { useTheme } from "next-themes"; 7 | import { useEffect, useState } from "react"; 8 | import { TooltipProvider } from "@/components/ui/tooltip"; 9 | 10 | export const Providers = ({ children }: { children: React.ReactNode }) => { 11 | const queryClient = new QueryClient(); 12 | const { resolvedTheme } = useTheme(); 13 | const [mounted, setMounted] = useState(false); 14 | 15 | useEffect(() => { 16 | setMounted(true); 17 | }, []); 18 | 19 | return ( 20 | 21 | 63 | 64 | 69 | {children} 70 | 71 | 72 | 73 | 74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": [] 11 | }, 12 | "organizeImports": { 13 | "enabled": true, 14 | "ignore": ["node_modules", ".next"] 15 | }, 16 | "formatter": { 17 | "ignore": ["node_modules", ".next"], 18 | "enabled": true, 19 | "indentStyle": "tab", 20 | "indentWidth": 2, 21 | "lineEnding": "lf" 22 | }, 23 | "linter": { 24 | "ignore": ["node_modules", ".next"], 25 | "enabled": true, 26 | "rules": { 27 | "recommended": true, 28 | "nursery": { 29 | "useSortedClasses": "warn" 30 | }, 31 | "correctness": { 32 | "noUnusedImports": "warn" 33 | } 34 | } 35 | }, 36 | "javascript": { 37 | "formatter": { 38 | "quoteStyle": "double" 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "app/globals.css", 9 | "baseColor": "gray", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /components/animated-prompt.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { motion } from "motion/react"; 4 | 5 | export const AnimatedPrompt = () => { 6 | return ( 7 | 13 |

14 | Press the button to get started 15 | 16 | Press{" "} 17 | 18 | T 19 | {" "} 20 | to get started 21 | 22 |

23 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /components/editable-document-name.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { updateDocumentName } from "@/actions/docs"; 4 | import { Input } from "@/components/ui/input"; 5 | import { useEffect, useRef } from "react"; 6 | import * as React from "react"; 7 | import { toast } from "sonner"; 8 | import { Button } from "./ui/button"; 9 | 10 | interface EditableDocumentNameProps { 11 | documentId: string; 12 | initialName: string; 13 | setUpdatedAt: (val: string) => void; 14 | } 15 | 16 | export function EditableDocumentName({ 17 | documentId, 18 | initialName, 19 | setUpdatedAt, 20 | }: Readonly) { 21 | const [state, formAction, isPending] = React.useActionState( 22 | updateDocumentName, 23 | undefined, 24 | ); 25 | const inputRef = useRef(null); 26 | const buttonRef = useRef(null); 27 | 28 | useEffect(() => { 29 | if (state?.success) { 30 | toast.success("Document name updated"); 31 | if (inputRef.current) { 32 | inputRef.current.blur(); 33 | } 34 | setUpdatedAt(new Date().toISOString()); 35 | } 36 | }, [state, setUpdatedAt]); 37 | 38 | useEffect(() => { 39 | if (!state?.success && state?.error) { 40 | toast.error(state.error); 41 | } 42 | }, [state]); 43 | 44 | const handleKeyDown = (e: React.KeyboardEvent) => { 45 | if (e.key === "Enter") { 46 | e.preventDefault(); 47 | buttonRef.current?.click(); 48 | } 49 | }; 50 | 51 | return ( 52 |
57 | 58 | 68 | 77 |
78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /components/home/app-header.tsx: -------------------------------------------------------------------------------- 1 | import { T0Logo } from "@/components/ui/icons/t0-logo"; 2 | import { TextScramble } from "../text-scramble"; 3 | 4 | export function AppHeader() { 5 | return ( 6 |
7 |
8 | 9 |
10 |
11 | 17 | text0 18 | 19 |

20 | Your documents and memories in one place 21 |

22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /components/home/quick-action-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { cn } from "@/lib/utils"; 5 | import { AnimatePresence, motion } from "framer-motion"; 6 | import { Loader2, icons } from "lucide-react"; 7 | import { useState } from "react"; 8 | 9 | interface QuickActionButtonProps { 10 | iconName: keyof typeof icons; 11 | label: string; 12 | onClick?: () => void; 13 | variant?: "default" | "primary" | "secondary"; 14 | size?: "sm" | "md" | "lg"; 15 | disabled?: boolean; 16 | loading?: boolean; 17 | className?: string; 18 | children?: React.ReactNode; 19 | } 20 | 21 | export function QuickActionButton({ 22 | iconName, 23 | label, 24 | onClick, 25 | variant = "default", 26 | size = "md", 27 | disabled = false, 28 | loading = false, 29 | className, 30 | children, 31 | }: Readonly) { 32 | const [isHovered, setIsHovered] = useState(false); 33 | 34 | const Icon = icons[iconName] || Loader2; 35 | 36 | const sizeStyles = { 37 | sm: "h-[60px] px-4 gap-2 text-xs", 38 | md: "h-[80px] p-2 md:px-6 gap-3 text-sm", 39 | lg: "h-[100px] px-8 gap-4 text-base", 40 | }; 41 | 42 | const variantStyles = { 43 | default: 44 | "border-border bg-muted dark:bg-card hover:bg-muted/50 hover:border-primary/50", 45 | primary: 46 | "border-primary/20 bg-primary/10 hover:bg-primary/20 hover:border-primary", 47 | secondary: 48 | "border-secondary/20 bg-secondary/10 hover:bg-secondary/20 hover:border-secondary", 49 | }; 50 | 51 | const iconVariants = { 52 | initial: { scale: 1, rotate: 0 }, 53 | hover: { scale: 1.2, rotate: 5, transition: { duration: 0.2 } }, 54 | }; 55 | 56 | const buttonVariants = { 57 | initial: { scale: 1, boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)" }, 58 | hover: { 59 | scale: 1.015, 60 | boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", 61 | transition: { duration: 0.2 }, 62 | }, 63 | tap: { scale: 0.995, transition: { duration: 0.1 } }, 64 | }; 65 | 66 | return ( 67 | setIsHovered(true)} 73 | onMouseLeave={() => setIsHovered(false)} 74 | className={cn( 75 | "relative overflow-hidden rounded-md", 76 | disabled && "cursor-not-allowed opacity-50", 77 | className, 78 | )} 79 | > 80 | 131 | 132 | ); 133 | } 134 | -------------------------------------------------------------------------------- /components/home/recent-files-card.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { Document, Reference } from "@/lib/redis"; 3 | import { BrainIcon, ExternalLink, FileText } from "lucide-react"; 4 | import Link from "next/link"; 5 | import { motion } from "motion/react"; 6 | 7 | type FileItem = 8 | | (Document & { type: "document" }) 9 | | (Reference & { type: "reference" }); 10 | 11 | interface RecentFilesCardProps { 12 | files: FileItem[]; 13 | } 14 | 15 | export function RecentFilesCard({ files }: RecentFilesCardProps) { 16 | const iconVariants = { 17 | initial: { scale: 1, rotate: 0 }, 18 | hover: { scale: 1.2, rotate: 5, transition: { duration: 0.2 } }, 19 | }; 20 | 21 | return ( 22 |
23 |
24 | {files.map((file) => 25 | file.type === "document" ? ( 26 | 31 |
32 | 39 | 40 | 41 |
42 |
43 |
44 |

45 | {file.name} 46 |

47 |

48 | {file.content.slice(0, 200)}... 49 |

50 |
51 |
52 |

53 | {new Date(file.createdAt).toLocaleDateString()} 54 |

55 |
56 |
57 | 58 | ) : ( 59 | 66 |
67 | 74 | 75 | 76 |
77 |
78 |
79 |

80 | {file.name ?? file.filename ?? "Untitled"} 81 | 82 |

83 |
84 |
85 |

86 | {new Date(file.uploadedAt).toLocaleDateString()} 87 |

88 |
89 |
90 |
91 | ), 92 | )} 93 |
94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /components/home/status-bar.tsx: -------------------------------------------------------------------------------- 1 | interface StatusBarProps { 2 | documentsCount: number; 3 | referencesCount: number; 4 | userName: string | null; 5 | } 6 | 7 | export function StatusBar({ 8 | documentsCount, 9 | referencesCount, 10 | userName, 11 | }: StatusBarProps) { 12 | return ( 13 |
14 |
15 |
16 | {referencesCount} references 17 | {documentsCount} documents 18 |
19 |
{userName} • text0
20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /components/inline-diff-view.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { cn } from "@/lib/utils"; 3 | import { diffWords } from "diff"; 4 | import { Check, X } from "lucide-react"; 5 | 6 | interface InlineDiffViewProps { 7 | originalText: string; 8 | newText: string; 9 | isLoading?: boolean; 10 | className?: string; 11 | onAccept: () => void; 12 | onReject: () => void; 13 | isZenMode?: boolean; 14 | } 15 | 16 | export function InlineDiffView({ 17 | originalText, 18 | newText, 19 | isLoading, 20 | className, 21 | onAccept, 22 | onReject, 23 | isZenMode, 24 | }: Readonly) { 25 | // Use streaming text while loading, otherwise use final newText 26 | const diff = diffWords(originalText, newText); 27 | 28 | return ( 29 |
30 |
36 | {diff.map((part, i) => ( 37 | 46 | {part.value} 47 | 48 | ))} 49 | {isLoading && ( 50 | 51 | ▋ 52 | 53 | )} 54 |
55 |
56 | 66 | 76 |
77 |
78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /components/new-document-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createDocument } from "@/actions/docs"; 4 | import { Button } from "@/components/ui/button"; 5 | import { 6 | Dialog, 7 | DialogContent, 8 | DialogHeader, 9 | DialogTitle, 10 | DialogTrigger, 11 | } from "@/components/ui/dialog"; 12 | import { Input } from "@/components/ui/input"; 13 | import { cn } from "@/lib/utils"; 14 | import { Plus } from "lucide-react"; 15 | import { useRouter } from "next/navigation"; 16 | import { useEffect } from "react"; 17 | import * as React from "react"; 18 | import { toast } from "sonner"; 19 | 20 | interface NewDocumentButtonProps { 21 | className?: string; 22 | } 23 | 24 | export function NewDocumentButton({ 25 | className, 26 | }: Readonly) { 27 | const [state, formAction, isPending] = React.useActionState( 28 | createDocument, 29 | undefined, 30 | ); 31 | 32 | const router = useRouter(); 33 | 34 | useEffect(() => { 35 | if (state?.success && state.data?.documentId) { 36 | toast.success("Document created successfully"); 37 | router.push(`/docs/${state.data.documentId}`); 38 | } 39 | }, [state, router]); 40 | 41 | useEffect(() => { 42 | if (!state?.success && state?.error) { 43 | toast.error(state.error); 44 | } 45 | }, [state]); 46 | 47 | return ( 48 | 49 | 50 | 62 | 63 | 64 | 65 | Create New Document 66 | 67 |
68 | 69 | 72 |
73 |
74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /components/text-scramble.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { AnimatePresence, motion } from "motion/react"; 5 | import type { MotionProps } from "motion/react"; 6 | import { useEffect, useId, useRef, useState } from "react"; 7 | 8 | type CharacterSet = string[] | readonly string[]; 9 | 10 | interface HyperTextProps extends MotionProps { 11 | /** The text content to be animated */ 12 | children: string; 13 | /** Optional className for styling */ 14 | className?: string; 15 | /** Duration of the animation in milliseconds */ 16 | duration?: number; 17 | /** Delay before animation starts in milliseconds */ 18 | delay?: number; 19 | /** Component to render as - defaults to div */ 20 | as?: React.ElementType; 21 | /** Whether to start animation when element comes into view */ 22 | startOnView?: boolean; 23 | /** Whether to trigger animation on hover */ 24 | animateOnHover?: boolean; 25 | /** Custom character set for scramble effect. Defaults to uppercase alphabet */ 26 | characterSet?: CharacterSet; 27 | } 28 | 29 | const DEFAULT_CHARACTER_SET = Object.freeze( 30 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split(""), 31 | ) as readonly string[]; 32 | 33 | const getRandomInt = (max: number): number => Math.floor(Math.random() * max); 34 | 35 | export function TextScramble({ 36 | children, 37 | className, 38 | duration = 800, 39 | delay = 0, 40 | as: Component = "div", 41 | startOnView = false, 42 | animateOnHover = true, 43 | characterSet = DEFAULT_CHARACTER_SET, 44 | ...props 45 | }: HyperTextProps) { 46 | const id = useId(); 47 | const MotionComponent = motion.create(Component, { 48 | forwardMotionProps: true, 49 | }); 50 | 51 | const [displayText, setDisplayText] = useState(() => 52 | children.split(""), 53 | ); 54 | const [isAnimating, setIsAnimating] = useState(false); 55 | const iterationCount = useRef(0); 56 | const elementRef = useRef(null); 57 | 58 | const handleAnimationTrigger = () => { 59 | if (animateOnHover && !isAnimating) { 60 | iterationCount.current = 0; 61 | setIsAnimating(true); 62 | } 63 | }; 64 | 65 | // Handle animation start based on view or delay 66 | useEffect(() => { 67 | if (!startOnView) { 68 | const startTimeout = setTimeout(() => { 69 | setIsAnimating(true); 70 | }, delay); 71 | return () => clearTimeout(startTimeout); 72 | } 73 | 74 | const observer = new IntersectionObserver( 75 | ([entry]) => { 76 | if (entry.isIntersecting) { 77 | setTimeout(() => { 78 | setIsAnimating(true); 79 | }, delay); 80 | observer.disconnect(); 81 | } 82 | }, 83 | { threshold: 0.1, rootMargin: "-30% 0px -30% 0px" }, 84 | ); 85 | 86 | if (elementRef.current) { 87 | observer.observe(elementRef.current); 88 | } 89 | 90 | return () => observer.disconnect(); 91 | }, [delay, startOnView]); 92 | 93 | // Handle scramble animation 94 | useEffect(() => { 95 | if (!isAnimating) return; 96 | 97 | const intervalDuration = duration / (children.length * 10); 98 | const maxIterations = children.length; 99 | 100 | const interval = setInterval(() => { 101 | if (iterationCount.current < maxIterations) { 102 | setDisplayText((currentText) => 103 | currentText.map((letter, index) => 104 | letter === " " 105 | ? letter 106 | : index <= iterationCount.current 107 | ? children[index] 108 | : characterSet[getRandomInt(characterSet.length)], 109 | ), 110 | ); 111 | iterationCount.current = iterationCount.current + 0.1; 112 | } else { 113 | setIsAnimating(false); 114 | clearInterval(interval); 115 | } 116 | }, intervalDuration); 117 | 118 | return () => clearInterval(interval); 119 | }, [children, duration, isAnimating, characterSet]); 120 | 121 | return ( 122 | 128 | 129 | {displayText.map((letter, index) => ( 130 | 132 | key={letter + index} 133 | className={cn("font-mono", letter === " " ? "w-3" : "")} 134 | > 135 | {letter.toUpperCase()} 136 | 137 | ))} 138 | 139 | 140 | ); 141 | } 142 | -------------------------------------------------------------------------------- /components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | 9 | function AlertDialog({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function AlertDialogTrigger({ 16 | ...props 17 | }: React.ComponentProps) { 18 | return ( 19 | 20 | ) 21 | } 22 | 23 | function AlertDialogPortal({ 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 28 | ) 29 | } 30 | 31 | function AlertDialogOverlay({ 32 | className, 33 | ...props 34 | }: React.ComponentProps) { 35 | return ( 36 | 44 | ) 45 | } 46 | 47 | function AlertDialogContent({ 48 | className, 49 | ...props 50 | }: React.ComponentProps) { 51 | return ( 52 | 53 | 54 | 62 | 63 | ) 64 | } 65 | 66 | function AlertDialogHeader({ 67 | className, 68 | ...props 69 | }: React.ComponentProps<"div">) { 70 | return ( 71 |
76 | ) 77 | } 78 | 79 | function AlertDialogFooter({ 80 | className, 81 | ...props 82 | }: React.ComponentProps<"div">) { 83 | return ( 84 |
92 | ) 93 | } 94 | 95 | function AlertDialogTitle({ 96 | className, 97 | ...props 98 | }: React.ComponentProps) { 99 | return ( 100 | 105 | ) 106 | } 107 | 108 | function AlertDialogDescription({ 109 | className, 110 | ...props 111 | }: React.ComponentProps) { 112 | return ( 113 | 118 | ) 119 | } 120 | 121 | function AlertDialogAction({ 122 | className, 123 | ...props 124 | }: React.ComponentProps) { 125 | return ( 126 | 130 | ) 131 | } 132 | 133 | function AlertDialogCancel({ 134 | className, 135 | ...props 136 | }: React.ComponentProps) { 137 | return ( 138 | 142 | ) 143 | } 144 | 145 | export { 146 | AlertDialog, 147 | AlertDialogPortal, 148 | AlertDialogOverlay, 149 | AlertDialogTrigger, 150 | AlertDialogContent, 151 | AlertDialogHeader, 152 | AlertDialogFooter, 153 | AlertDialogTitle, 154 | AlertDialogDescription, 155 | AlertDialogAction, 156 | AlertDialogCancel, 157 | } 158 | -------------------------------------------------------------------------------- /components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { type VariantProps, cva } from "class-variance-authority"; 3 | 4 | const alertVariants = cva("rounded p-3 border", { 5 | variants: { 6 | variant: { 7 | primary: "bg-primary/15 border-primary/15 text-primary", 8 | success: "bg-success/15 border-success/15 text-success", 9 | destructive: 10 | "bg-destructive/20 border-destructive/15 text-destructive/95", 11 | // default: "bg-default/40 border-default/40 text-gray-600", 12 | }, 13 | }, 14 | defaultVariants: { 15 | variant: "primary", 16 | }, 17 | }); 18 | 19 | interface AlertProps 20 | extends React.HTMLAttributes, 21 | VariantProps {} 22 | 23 | function Alert({ variant, className, ...props }: AlertProps) { 24 | return ( 25 |
30 | ); 31 | } 32 | 33 | export { Alert }; 34 | -------------------------------------------------------------------------------- /components/ui/anthropic-logo.tsx: -------------------------------------------------------------------------------- 1 | import type { SVGProps } from "react"; 2 | 3 | export function AnthropicLogo(props: Readonly>) { 4 | return ( 5 | 14 | Anthropic Logo 15 | 16 | 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import { type VariantProps, cva } from "class-variance-authority"; 2 | import type * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full 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", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | }, 24 | ); 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ); 34 | } 35 | 36 | export { Badge, badgeVariants }; 37 | -------------------------------------------------------------------------------- /components/ui/bounce-spinner.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | const BounceSpinner = ({ className }: { className?: string }) => { 4 | return ( 5 |
6 |
7 |
8 |
9 |
10 | ); 11 | }; 12 | 13 | export { BounceSpinner }; 14 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "@radix-ui/react-slot"; 2 | import { type VariantProps, cva } from "class-variance-authority"; 3 | import * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-muted hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | success: "bg-success/70 text-success-foreground hover:bg-success/80", 22 | }, 23 | size: { 24 | default: "h-10 px-4 py-2", 25 | sm: "h-9 rounded-md px-3", 26 | lg: "h-11 rounded-md px-8", 27 | icon: "h-10 w-10", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | }, 35 | ); 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean; 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button"; 46 | return ( 47 | 52 | ); 53 | }, 54 | ); 55 | Button.displayName = "Button"; 56 | 57 | export { Button, buttonVariants }; 58 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )); 18 | Card.displayName = "Card"; 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )); 30 | CardHeader.displayName = "CardHeader"; 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )); 45 | CardTitle.displayName = "CardTitle"; 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )); 57 | CardDescription.displayName = "CardDescription"; 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )); 65 | CardContent.displayName = "CardContent"; 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )); 77 | CardFooter.displayName = "CardFooter"; 78 | 79 | export { 80 | Card, 81 | CardHeader, 82 | CardFooter, 83 | CardTitle, 84 | CardDescription, 85 | CardContent, 86 | }; 87 | -------------------------------------------------------------------------------- /components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; 4 | import { CheckIcon } from "lucide-react"; 5 | import type * as React from "react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | function Checkbox({ 10 | className, 11 | ...props 12 | }: React.ComponentProps) { 13 | return ( 14 | 22 | 26 | 27 | 28 | 29 | ); 30 | } 31 | 32 | export { Checkbox }; 33 | -------------------------------------------------------------------------------- /components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; 4 | 5 | function Collapsible({ 6 | ...props 7 | }: React.ComponentProps) { 8 | return ; 9 | } 10 | 11 | function CollapsibleTrigger({ 12 | ...props 13 | }: React.ComponentProps) { 14 | return ( 15 | 19 | ); 20 | } 21 | 22 | function CollapsibleContent({ 23 | ...props 24 | }: React.ComponentProps) { 25 | return ( 26 | 30 | ); 31 | } 32 | 33 | export { Collapsible, CollapsibleTrigger, CollapsibleContent }; 34 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as DialogPrimitive from "@radix-ui/react-dialog"; 2 | import { X } from "lucide-react"; 3 | import * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const Dialog = DialogPrimitive.Root; 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger; 10 | 11 | const DialogPortal = DialogPrimitive.Portal; 12 | 13 | const DialogClose = DialogPrimitive.Close; 14 | 15 | const DialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )); 28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 29 | 30 | const DialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef & { 33 | title: string; 34 | } 35 | >(({ className, children, title, ...props }, ref) => ( 36 | 37 | 38 | 51 | {title} 52 | {children} 53 | 54 | 55 | Close 56 | 57 | 58 | 59 | )); 60 | DialogContent.displayName = DialogPrimitive.Content.displayName; 61 | 62 | const DialogHeader = ({ 63 | className, 64 | ...props 65 | }: React.HTMLAttributes) => ( 66 |
73 | ); 74 | DialogHeader.displayName = "DialogHeader"; 75 | 76 | const DialogFooter = ({ 77 | className, 78 | ...props 79 | }: React.HTMLAttributes) => ( 80 |
87 | ); 88 | DialogFooter.displayName = "DialogFooter"; 89 | 90 | const DialogTitle = React.forwardRef< 91 | React.ElementRef, 92 | React.ComponentPropsWithoutRef 93 | >(({ className, ...props }, ref) => ( 94 | 102 | )); 103 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 104 | 105 | const DialogDescription = React.forwardRef< 106 | React.ElementRef, 107 | React.ComponentPropsWithoutRef 108 | >(({ className, ...props }, ref) => ( 109 | 114 | )); 115 | DialogDescription.displayName = DialogPrimitive.Description.displayName; 116 | 117 | export { 118 | Dialog, 119 | DialogPortal, 120 | DialogOverlay, 121 | DialogClose, 122 | DialogTrigger, 123 | DialogContent, 124 | DialogHeader, 125 | DialogFooter, 126 | DialogTitle, 127 | DialogDescription, 128 | }; 129 | -------------------------------------------------------------------------------- /components/ui/drawer.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import type * as React from "react" 4 | import { Drawer as DrawerPrimitive } from "vaul" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Drawer({ 9 | ...props 10 | }: React.ComponentProps) { 11 | return 12 | } 13 | 14 | function DrawerTrigger({ 15 | ...props 16 | }: React.ComponentProps) { 17 | return 18 | } 19 | 20 | function DrawerPortal({ 21 | ...props 22 | }: React.ComponentProps) { 23 | return 24 | } 25 | 26 | function DrawerClose({ 27 | ...props 28 | }: React.ComponentProps) { 29 | return 30 | } 31 | 32 | function DrawerOverlay({ 33 | className, 34 | ...props 35 | }: React.ComponentProps) { 36 | return ( 37 | 45 | ) 46 | } 47 | 48 | function DrawerContent({ 49 | className, 50 | children, 51 | ...props 52 | }: React.ComponentProps) { 53 | return ( 54 | 55 | 56 | 68 |
69 | {children} 70 | 71 | 72 | ) 73 | } 74 | 75 | function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { 76 | return ( 77 |
82 | ) 83 | } 84 | 85 | function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { 86 | return ( 87 |
92 | ) 93 | } 94 | 95 | function DrawerTitle({ 96 | className, 97 | ...props 98 | }: React.ComponentProps) { 99 | return ( 100 | 105 | ) 106 | } 107 | 108 | function DrawerDescription({ 109 | className, 110 | ...props 111 | }: React.ComponentProps) { 112 | return ( 113 | 118 | ) 119 | } 120 | 121 | export { 122 | Drawer, 123 | DrawerPortal, 124 | DrawerOverlay, 125 | DrawerTrigger, 126 | DrawerClose, 127 | DrawerContent, 128 | DrawerHeader, 129 | DrawerFooter, 130 | DrawerTitle, 131 | DrawerDescription, 132 | } 133 | -------------------------------------------------------------------------------- /components/ui/google-logo.tsx: -------------------------------------------------------------------------------- 1 | import type { SVGProps } from "react"; 2 | 3 | export function GoogleLogo(props: Readonly>) { 4 | return ( 5 | 14 | Google Logo 15 | 19 | 23 | 27 | 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /components/ui/icons/discord.tsx: -------------------------------------------------------------------------------- 1 | interface DiscordIconProps { 2 | size?: number; 3 | className?: string; 4 | } 5 | 6 | export const DiscordIcon = ({ size = 32, className }: DiscordIconProps) => ( 7 | 17 | 21 | 22 | ); 23 | -------------------------------------------------------------------------------- /components/ui/icons/github.tsx: -------------------------------------------------------------------------------- 1 | interface GithubIconProps { 2 | size?: number; 3 | className?: string; 4 | } 5 | 6 | export const GithubIcon = ({ size = 32, className }: GithubIconProps) => ( 7 | 18 | 27 | 28 | ); 29 | -------------------------------------------------------------------------------- /components/ui/icons/gmail.tsx: -------------------------------------------------------------------------------- 1 | interface GmailIconProps { 2 | size?: number; 3 | className?: string; 4 | } 5 | 6 | export const GmailIcon = ({ size = 32, className }: GmailIconProps) => ( 7 | 16 | 17 | 21 | 25 | 29 | 30 | 34 | 39 | 40 | ); 41 | -------------------------------------------------------------------------------- /components/ui/icons/google-calendar.tsx: -------------------------------------------------------------------------------- 1 | interface GoogleCalendarIconProps { 2 | size?: number; 3 | className?: string; 4 | } 5 | 6 | export const GoogleCalendarIcon = ({ 7 | size = 32, 8 | className, 9 | }: GoogleCalendarIconProps) => ( 10 | 22 | 23 | 27 | 31 | 35 | 39 | 43 | 47 | 51 | 55 | 56 | 57 | ); 58 | -------------------------------------------------------------------------------- /components/ui/icons/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crafter-station/text0/125ec86d0a6ec83e0b20a9197ec713e6768da38c/components/ui/icons/index.ts -------------------------------------------------------------------------------- /components/ui/icons/linear.tsx: -------------------------------------------------------------------------------- 1 | interface LinearIconProps { 2 | size?: number; 3 | className?: string; 4 | } 5 | 6 | export const LinearIcon = ({ size = 32, className }: LinearIconProps) => ( 7 | 18 | 22 | 23 | ); 24 | -------------------------------------------------------------------------------- /components/ui/icons/notion.tsx: -------------------------------------------------------------------------------- 1 | interface NotionIconProps { 2 | size?: number; 3 | className?: string; 4 | } 5 | 6 | export const NotionIcon = ({ size = 32, className }: NotionIconProps) => ( 7 | 17 | 21 | 22 | 23 | ); 24 | -------------------------------------------------------------------------------- /components/ui/icons/slack.tsx: -------------------------------------------------------------------------------- 1 | interface SlackIconProps { 2 | size?: number; 3 | className?: string; 4 | } 5 | 6 | export const SlackIcon = ({ size = 32, className }: SlackIconProps) => ( 7 | 17 | 18 | 22 | 26 | 30 | 34 | 35 | 36 | ); 37 | -------------------------------------------------------------------------------- /components/ui/icons/spinner.tsx: -------------------------------------------------------------------------------- 1 | export const SpinnerIcon = ({ className = "" }: { className?: string }) => ( 2 | 14 | Loading spinner 15 | 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /components/ui/icons/vercel.tsx: -------------------------------------------------------------------------------- 1 | type VercelIconProps = { 2 | size?: number; 3 | className?: string; 4 | }; 5 | 6 | export const VercelIcon = ({ size = 32, className }: VercelIconProps) => ( 7 | 15 | 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /components/ui/icons/x-icon.tsx: -------------------------------------------------------------------------------- 1 | interface XIconProps { 2 | size?: number; 3 | className?: string; 4 | } 5 | 6 | export const XIcon = ({ size = 32, className }: XIconProps) => ( 7 | 17 | 21 | 22 | ); 23 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 18 | ); 19 | }, 20 | ); 21 | Input.displayName = "Input"; 22 | 23 | export { Input }; 24 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as LabelPrimitive from "@radix-ui/react-label"; 2 | import { type VariantProps, cva } from "class-variance-authority"; 3 | import * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", 9 | ); 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )); 22 | Label.displayName = LabelPrimitive.Root.displayName; 23 | 24 | export { Label }; 25 | -------------------------------------------------------------------------------- /components/ui/llama-logo.tsx: -------------------------------------------------------------------------------- 1 | import type { SVGProps } from "react"; 2 | 3 | export function LlamaLogo(props: Readonly>) { 4 | return ( 5 | 13 | Llama Logo 14 | 18 | 22 | 26 | 27 | 35 | 36 | 37 | 38 | 39 | 40 | 48 | 49 | 50 | 51 | 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /components/ui/openai-logo.tsx: -------------------------------------------------------------------------------- 1 | import type { SVGProps } from "react"; 2 | 3 | export function OpenAILogo(props: Readonly>) { 4 | return ( 5 | 14 | OpenAI Logo 15 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /components/ui/pagination.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ChevronLeftIcon, 3 | ChevronRightIcon, 4 | MoreHorizontalIcon, 5 | } from "lucide-react"; 6 | import type * as React from "react"; 7 | 8 | import { type Button, buttonVariants } from "@/components/ui/button"; 9 | import { cn } from "@/lib/utils"; 10 | 11 | function Pagination({ className, ...props }: React.ComponentProps<"nav">) { 12 | return ( 13 |