├── .env.example
├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── app
├── 404
│ └── page.tsx
├── actions.tsx
├── actions
│ └── github.ts
├── globals.css
├── layout.tsx
├── login
│ └── page.tsx
├── new
│ └── page.tsx
├── page.tsx
├── project
│ ├── [id]
│ │ └── page.tsx
│ └── layout.tsx
└── projects
│ └── page.tsx
├── components.json
├── components
├── editor
│ ├── constants
│ │ └── editorDefaults.tsx
│ ├── editor-container.tsx
│ ├── editor.tsx
│ ├── hooks
│ │ ├── useAIAssist.tsx
│ │ ├── useEditorSetup.tsx
│ │ ├── useEditorTheme.tsx
│ │ └── useLatexSyntaxHighlighting.tsx
│ ├── image-viewer.tsx
│ ├── types.ts
│ └── utils
│ │ ├── WidgetCreator.tsx
│ │ ├── applyEdit.tsx
│ │ ├── calculateDiff.tsx
│ │ └── promptModal.tsx
├── file-tree
│ ├── file-tree-loading.tsx
│ ├── file-tree-node.jsx
│ └── file-tree.jsx
├── latex-render
│ ├── latex-canvas.tsx
│ ├── latex-error.tsx
│ ├── latex-loading.tsx
│ └── latex.tsx
├── loading
│ └── loading-page.tsx
├── nav
│ ├── loading-side-nav.tsx
│ └── side-nav.tsx
├── page-utils
│ └── grid-texture.tsx
├── profile
│ └── profile.tsx
├── projects
│ ├── auth-buttons.tsx
│ ├── project-card.tsx
│ ├── project-nav.tsx
│ └── project-skeleton.tsx
├── theme-provider.tsx
└── ui
│ ├── alert.tsx
│ ├── avatar.tsx
│ ├── badge.tsx
│ ├── button.tsx
│ ├── card.tsx
│ ├── context-menu.tsx
│ ├── dialog.tsx
│ ├── dropdown-menu.tsx
│ ├── input.tsx
│ ├── label.tsx
│ ├── mode-toggle.tsx
│ ├── popover.tsx
│ ├── rainbow-button.tsx
│ ├── resizable.tsx
│ ├── scroll-area.tsx
│ ├── select.tsx
│ ├── separator.tsx
│ ├── skeleton.tsx
│ ├── switch.tsx
│ ├── textarea.tsx
│ ├── toggle.tsx
│ └── tooltip.tsx
├── contexts
├── FrontendContext.tsx
└── ProjectContext.tsx
├── hooks
├── data.ts
└── useDebounce.tsx
├── lib
├── constants.ts
├── constants
│ └── templates.ts
├── utils.ts
└── utils
│ ├── client-utils.ts
│ ├── db-utils.ts
│ └── pdf-utils.ts
├── next.config.js
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
├── article_preview.webp
├── blank_preview.webp
├── favicon.ico
├── hero.png
├── letter_preview.webp
├── next.svg
├── pdf.worker.mjs
├── placeholder.svg
├── proposal_preview.webp
├── report_preview.webp
├── resume_preview.webp
├── tex.tsx
└── vercel.svg
├── railway-api
├── .gitignore
├── main.py
├── railway.json
└── requirements.txt
├── tailwind.config.ts
└── tsconfig.json
/.env.example:
--------------------------------------------------------------------------------
1 | ANTHROPIC_API_KEY=sk-ant-apixx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
2 | INSTANT_APP_ADMIN_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
3 | NEXT_PUBLIC_INSTANT_APP_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
4 | NEXT_PUBLIC_RAILWAY_ENDPOINT_URL=https://your-railway-endpoint-url.com/
--------------------------------------------------------------------------------
/.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.js
7 | .yarn/install-state.gz
8 | .env
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "singleQuote": true,
4 | "tabWidth": 2,
5 | "trailingComma": "es5",
6 | "printWidth": 120,
7 | "bracketSpacing": true,
8 | "arrowParens": "always",
9 | "endOfLine": "lf"
10 | }
11 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Shelwin
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 | # Jules: AI LaTeX Editor
2 |
3 | [Jules](https://juleseditor.com) is a proof of concept AI-Latex Editor. You select a range, type into the dialogue, and the LLM will (hopefully) create a diff that turns your natural language request into compileable LaTeX.
4 |
5 | https://github.com/user-attachments/assets/b5587351-7ff4-40be-9ba1-bffdbbb71b39
6 |
7 |
8 | It has basic LaTeX project management features, like adding/deleting files and folders, images, svgs, etc. `main.tex` is required to compile.
9 |
10 | Note this is a `proof-of-concept` and not a "production" application. There will be bugs, important missing features, and UX issues that make it not quite ready yet for daily usage.
11 |
12 | ## Local Setup
13 |
14 | To get Jules working locally, you need:
15 | - An Anthropic Key
16 | - An App ID from [InstantDB](https://www.instantdb.com/)
17 | - A deployed instance of the API on railway (`railway-api`).
18 |
19 | Set those in a `.env.local` file.
20 |
21 | You can then:
22 |
23 | ```
24 | git clone git@github.com:shelwinsunga/jules.git
25 | cd jules
26 | npm install i
27 | npm run dev
28 | ```
29 |
30 | You can run the flask endpoint locally by running:
31 |
32 | ```
33 | cd railway-api
34 | hypercorn main:app --reload
35 | ```
36 |
37 | You need pdflatex installed as well as the stuff in requirements.txt
38 |
39 |
40 | ## Acknowledgments
41 |
42 | Created by [Shelwin Sunga](https://x.com/shelwin_). Licensed under the [MIT License](https://github.com/shelwinsunga/jules/blob/main/LICENSE)
43 |
44 | - Inspired by Cursor and Overleaf
45 |
46 |
--------------------------------------------------------------------------------
/app/404/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 | import { Home } from 'lucide-react'
3 |
4 | export default function NotFound() {
5 | return (
6 |
7 |
8 |
9 |
10 | 404
11 |
12 |
13 |
14 | Whoops! Page not found
15 |
16 |
17 |
18 | Looks like you've stumbled into the void. Don't panic! Even the best of us get lost in cyberspace sometimes.
19 |
20 |
21 |
25 |
26 | Beam Me Home, Scotty!
27 |
28 |
29 |
30 | )
31 | }
--------------------------------------------------------------------------------
/app/actions.tsx:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { streamText } from 'ai'
4 | import { openai } from '@ai-sdk/openai'
5 | import { createStreamableValue } from 'ai/rsc'
6 | import { anthropic } from '@ai-sdk/anthropic'
7 | export async function generate(input: string) {
8 | const stream = createStreamableValue({ content: '', isComplete: false })
9 |
10 | ;(async () => {
11 | const { textStream } = await streamText({
12 | model: anthropic('claude-3-5-sonnet-20240620'),
13 | system:
14 | 'Imagine you are embedded in a latex editor. You only write latex. You do not write anything else or converse; you only write latex.' +
15 | "Do not write in backticks like this: ```latex ...```. Write your latex directly. You must consider the context of where you're starting from and ending from. For example, if you start in the middle of the document, would not write documentclass{article} or \begin{document} and add packages. You must consider the context of where you're starting from and ending from.",
16 | prompt: input,
17 | })
18 |
19 | for await (const delta of textStream) {
20 | stream.update({ content: delta, isComplete: false })
21 | }
22 |
23 | stream.update({ content: '', isComplete: true })
24 | stream.done()
25 | })()
26 |
27 | return { output: stream.value }
28 | }
29 |
30 | // 'use server';
31 |
32 | // import { createStreamableValue } from 'ai/rsc';
33 |
34 | // export async function generate(input: string) {
35 | // const stream = createStreamableValue({content: '', isComplete: false});
36 |
37 | // (async () => {
38 | // const fakeLatexContent = `section{Introduction}
39 | // This is a sample introduction to demonstrate the fake stream.
40 |
41 | // \\subsection{Background}
42 | // Here's some background information about the topic.
43 |
44 | // \\section{Methodology}
45 | // We used the following methods in our study:
46 | // \\begin{itemize}
47 | // \\item Method 1
48 | // \\item Method 2
49 | // \\item Method 3
50 | // \\end{itemize}
51 |
52 | // \\section{Results}
53 | // Our results show that...`;
54 |
55 | // for (const char of fakeLatexContent) {
56 | // await new Promise(resolve => setTimeout(resolve, 1)); // Simulate delay
57 | // stream.update({content: char, isComplete: false});
58 | // }
59 |
60 | // stream.update({content: '', isComplete: true});
61 | // stream.done();
62 | // })();
63 |
64 | // return { output: stream.value};
65 | // }
66 |
--------------------------------------------------------------------------------
/app/actions/github.ts:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 20 14.3% 4.1%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 20 14.3% 4.1%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 20 14.3% 4.1%;
13 | --primary: 47.9 95.8% 53.1%;
14 | --primary-foreground: 26 83.3% 14.1%;
15 | --secondary: 60 4.8% 95.9%;
16 | --secondary-foreground: 24 9.8% 10%;
17 | --muted: 60 4.8% 95.9%;
18 | --muted-foreground: 25 5.3% 44.7%;
19 | --accent: 60 4.8% 95.9%;
20 | --accent-foreground: 24 9.8% 10%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 60 9.1% 97.8%;
23 | --border: 20 5.9% 90%;
24 | --input: 20 5.9% 90%;
25 | --ring: 20 14.3% 4.1%;
26 | --radius: 0.5rem;
27 | --chart-1: 12 76% 61%;
28 | --chart-2: 173 58% 39%;
29 | --chart-3: 197 37% 24%;
30 | --chart-4: 43 74% 66%;
31 | --chart-5: 27 87% 67%;
32 | --color-1: 0 100% 63%;
33 | --color-2: 270 100% 63%;
34 | --color-3: 210 100% 63%;
35 | --color-4: 195 100% 63%;
36 | --color-5: 90 100% 63%;
37 | }
38 |
39 | .dark {
40 | --background: 0 0 5%;
41 | --foreground: 60 9.1% 97.8%;
42 | --card: 20 14.3% 4.1%;
43 | --card-foreground: 60 9.1% 97.8%;
44 | --popover: 20 14.3% 4.1%;
45 | --popover-foreground: 60 9.1% 97.8%;
46 | --primary: 47.9 95.8% 53.1%;
47 | --primary-foreground: 26 83.3% 14.1%;
48 | --secondary: 12 6.5% 15.1%;
49 | --secondary-foreground: 60 9.1% 97.8%;
50 | --muted: 12 6.5% 15.1%;
51 | --muted-foreground: 24 5.4% 63.9%;
52 | --accent: 12 6.5% 15.1%;
53 | --accent-foreground: 60 9.1% 97.8%;
54 | --destructive: 0 62.8% 30.6%;
55 | --destructive-foreground: 60 9.1% 97.8%;
56 | --border: 12 6.5% 15.1%;
57 | --input: 12 6.5% 15.1%;
58 | --ring: 35.5 91.7% 32.9%;
59 | --chart-1: 220 70% 50%;
60 | --chart-2: 160 60% 45%;
61 | --chart-3: 30 80% 55%;
62 | --chart-4: 280 65% 60%;
63 | --chart-5: 340 75% 55%;
64 | --color-1: 0 100% 63%;
65 | --color-2: 270 100% 63%;
66 | --color-3: 210 100% 63%;
67 | --color-4: 195 100% 63%;
68 | --color-5: 90 100% 63%;
69 | }
70 | }
71 |
72 | @layer base {
73 | * {
74 | @apply border-border;
75 | }
76 | body {
77 | @apply bg-background text-foreground;
78 | }
79 |
80 | .monaco-editor .scroll-decoration {
81 | box-shadow: none !important;
82 | }
83 | }
84 |
85 | .diff-old-content {
86 | background-color: #ffeeee;
87 | }
88 | .diff-new-content {
89 | background-color: #eeffee;
90 | }
91 |
92 | .dark .diff-old-content {
93 | background-color: #2a1414;
94 | }
95 | .dark .diff-new-content {
96 | background-color: #142a14;
97 | }
98 |
99 | .tree-overflow {
100 | overflow: auto;
101 | scrollbar-width: thin;
102 | scrollbar-color: hsl(var(--border)) transparent;
103 | }
104 |
105 | .tree-overflow::-webkit-scrollbar {
106 | width: 10px;
107 | height: 10px;
108 | }
109 |
110 | .tree-overflow::-webkit-scrollbar-track {
111 | background: transparent;
112 | }
113 |
114 | .tree-overflow::-webkit-scrollbar-thumb {
115 | background-color: hsl(var(--border));
116 | border-radius: 9999px;
117 | border: 3px solid transparent;
118 | background-clip: content-box;
119 | }
120 |
121 | .tree-overflow::-webkit-scrollbar-thumb:hover {
122 | background-color: hsl(var(--border) / 0.8);
123 | }
124 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next'
2 | import { Inter } from 'next/font/google'
3 | import './globals.css'
4 | import { FrontendProvider } from '@/contexts/FrontendContext'
5 | import { ThemeProvider } from '@/components/theme-provider'
6 | import { TooltipProvider } from '@/components/ui/tooltip'
7 | import { Analytics } from "@vercel/analytics/react"
8 |
9 | const inter = Inter({ subsets: ['latin'] })
10 | export const metadata: Metadata = {
11 | title: 'Jules',
12 | description: 'AI LaTeX Editor',
13 | icons: {
14 | icon: '/favicon.ico',
15 | },
16 | }
17 |
18 | export default function RootLayout({
19 | children,
20 | }: Readonly<{
21 | children: React.ReactNode
22 | }>) {
23 | return (
24 |
25 |
26 |
27 |
28 |
29 |
30 | {children}
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/app/login/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React, { useState } from 'react'
4 | import { Button } from '@/components/ui/button'
5 | import { Input } from '@/components/ui/input'
6 | import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
7 | import { useRouter } from 'next/navigation'
8 | import { db } from '@/lib/constants'
9 | import { useEffect } from 'react'
10 | import { tx } from '@instantdb/react'
11 |
12 | export default function Login() {
13 | const router = useRouter()
14 | const { isLoading, user, error } = db.useAuth()
15 |
16 | useEffect(() => {
17 | if (user) {
18 | const userProperties = Object.entries(user).reduce((acc, [key, value]) => {
19 | if (key !== 'id') {
20 | acc[key] = value;
21 | }
22 | return acc;
23 | }, {} as Record);
24 |
25 | db.transact(tx.users[user.id].update(userProperties));
26 | router.push('/projects')
27 | }
28 | }, [user, router]);
29 |
30 | if (isLoading) {
31 | return null
32 | }
33 | if (error) {
34 | return Uh oh! {error.message}
35 | }
36 | return
37 | }
38 |
39 | function LoginForm() {
40 | const [sentEmail, setSentEmail] = useState('')
41 | return (
42 |
43 | {!sentEmail ? : }
44 |
45 | )
46 | }
47 |
48 | function Email({ setSentEmail }: { setSentEmail: (email: string) => void }) {
49 | const [email, setEmail] = useState('')
50 |
51 | const handleSubmit = (e: React.FormEvent) => {
52 | e.preventDefault()
53 | if (!email) return
54 | setSentEmail(email)
55 | db.auth.sendMagicCode({ email }).catch((err) => {
56 | alert('Uh oh :' + err.body?.message)
57 | setSentEmail('')
58 | })
59 | }
60 |
61 | return (
62 |
63 |
64 | Let's log you in!
65 |
66 |
67 |
73 |
74 |
75 | )
76 | }
77 |
78 | function MagicCode({ sentEmail }: { sentEmail: string }) {
79 | const [code, setCode] = useState('')
80 |
81 | const handleSubmit = (e: React.FormEvent) => {
82 | e.preventDefault()
83 | const user = db.auth.signInWithMagicCode({ email: sentEmail, code }).catch((err) => {
84 | alert('Uh oh :' + err.body?.message)
85 | setCode('')
86 | })
87 | }
88 |
89 | return (
90 |
91 |
92 | Okay, we sent you an email! What was the code?
93 |
94 |
95 |
101 |
102 |
103 | )
104 | }
105 |
--------------------------------------------------------------------------------
/app/new/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState } from 'react'
4 | import ProjectNav from '@/components/projects/project-nav'
5 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
6 | import { Input } from '@/components/ui/input'
7 | import { Label } from '@/components/ui/label'
8 | import { Button } from '@/components/ui/button'
9 | import { db } from '@/lib/constants'
10 | import { tx, id } from '@instantdb/react'
11 | import { CheckIcon } from 'lucide-react'
12 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
13 | import { useRouter } from 'next/navigation'
14 | import { templateContent } from '@/lib/constants/templates'
15 | import { useFrontend } from '@/contexts/FrontendContext'
16 |
17 | const templates = [
18 | { id: 'blank', title: 'Blank', image: '/blank_preview.webp' },
19 | { id: 'article', title: 'Article', image: '/article_preview.webp' },
20 | { id: 'report', title: 'Report', image: '/report_preview.webp' },
21 | { id: 'resume', title: 'Resume', image: '/resume_preview.webp' },
22 | { id: 'letter', title: 'Letter', image: '/letter_preview.webp' },
23 | { id: 'proposal', title: 'Proposal', image: '/proposal_preview.webp' },
24 | ]
25 |
26 | type TemplateKey = keyof typeof templateContent
27 |
28 | export default function NewDocument() {
29 | const { user } = useFrontend()
30 | const router = useRouter()
31 | const [title, setTitle] = useState('New Document')
32 | const [selectedTemplate, setSelectedTemplate] = useState('blank')
33 | const [titleError, setTitleError] = useState('')
34 |
35 | const handleSubmit = (e: React.FormEvent) => {
36 | e.preventDefault()
37 | if (!title.trim()) {
38 | setTitleError('Title cannot be empty')
39 | return
40 | }
41 | setTitleError('')
42 | const newProjectId = id()
43 |
44 | const createFileStructure = () => {
45 |
46 | return [
47 | {
48 | name: 'main.tex',
49 | type: 'file',
50 | parent_id: null,
51 | content: templateContent[selectedTemplate],
52 | isExpanded: null,
53 | pathname: 'main.tex',
54 | user_id: user.id,
55 | },
56 | ]
57 | }
58 |
59 | const fileStructure = createFileStructure()
60 |
61 | db.transact([
62 | tx.projects[newProjectId].update({
63 | user_id: user?.id,
64 | title: title.trim(),
65 | project_content: templateContent[selectedTemplate], // remove later because its stored in files table
66 | template: selectedTemplate,
67 | last_compiled: new Date(),
68 | word_count: 0,
69 | page_count: 0,
70 | document_class: selectedTemplate,
71 | created_at: new Date(),
72 | }),
73 | ...fileStructure.map((node) =>
74 | tx.files[id()].update({
75 | user_id: user?.id,
76 | projectId: newProjectId,
77 | name: node.name,
78 | type: node.type,
79 | parent_id: node.parent_id,
80 | content: node.content || '',
81 | created_at: new Date(),
82 | isExpanded: node.isExpanded,
83 | isOpen: node.name === 'main.tex' ? true : false,
84 | main_file: node.name === 'main.tex' ? true : false,
85 | pathname: node.pathname,
86 | })
87 | ),
88 | ])
89 |
90 | router.push(`/project/${newProjectId}`)
91 | }
92 |
93 | return (
94 |
95 |
96 |
97 |
98 |
99 |
100 | {templates.map((template) => (
101 |
107 |
108 |
129 |
130 |
131 | ))}
132 |
133 |
134 |
135 |
136 | Create a new Document
137 |
138 |
139 |
173 |
174 |
175 |
176 |
177 |
178 | )
179 | }
180 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button'
2 | import { ArrowRight } from 'lucide-react'
3 | import Link from 'next/link'
4 | import AuthButtons from '@/components/projects/auth-buttons';
5 | import Spline from '@splinetool/react-spline/next';
6 | import GridTexture from '@/components/page-utils/grid-texture';
7 | import { RainbowButton } from "@/components/ui/rainbow-button";
8 | import { Star } from 'lucide-react';
9 | import { Github } from 'lucide-react';
10 |
11 | export default async function Home() {
12 | const stars = await getGitHubStars();
13 |
14 | return (
15 |
16 |
17 |
18 |
19 | Jules
20 |
21 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | Star on GitHub
33 |
34 |
35 | {stars}
36 |
37 |
38 |
39 |
40 |
41 | AI-Powered LaTeX Editing
42 |
43 |
44 | Jules is inspired by Cursor and Overleaf. This is a proof of concept.
45 |
46 |
47 |
53 |
54 |
55 |
56 |
57 |
61 |
62 |
63 |
64 | )
65 | }
66 |
67 | async function getGitHubStars() {
68 | const response = await fetch('https://api.github.com/repos/shelwinsunga/jules', {
69 | headers: {
70 | Authorization: `Bearer ${process.env.GITHUB_API_KEY}`,
71 | },
72 | next: { revalidate: 3600 }, // Cache for 1 hour
73 | });
74 |
75 | if (!response.ok) {
76 | return null;
77 | }
78 |
79 | const data = await response.json();
80 | return data.stargazers_count;
81 | }
--------------------------------------------------------------------------------
/app/project/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
3 | import SideNav from '@/components/nav/side-nav'
4 | import LatexRenderer from '@/components/latex-render/latex'
5 | import EditorContainer from '@/components/editor/editor-container'
6 | import { ProjectProvider } from '@/contexts/ProjectContext'
7 | import { useParams } from 'next/navigation'
8 | import { useFrontend } from '@/contexts/FrontendContext'
9 | export const maxDuration = 30
10 |
11 | export default function Home() {
12 | const { id } = useParams<{ id: string }>()
13 | const sideNavSize = typeof window !== 'undefined' ? (window.innerWidth < 1440 ? 20 : 16) : 16
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/app/project/layout.tsx:
--------------------------------------------------------------------------------
1 | export default function ProjectLayout({
2 | children,
3 | }: Readonly<{
4 | children: React.ReactNode
5 | }>) {
6 | return <>{children}>
7 | }
8 |
--------------------------------------------------------------------------------
/app/projects/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useState } from 'react'
3 | import { Card, CardContent, CardFooter } from '@/components/ui/card'
4 | import { Button } from '@/components/ui/button'
5 | import { Input } from '@/components/ui/input'
6 | import { SearchIcon, PlusIcon } from 'lucide-react'
7 | import ProjectNav from '@/components/projects/project-nav'
8 | import Link from 'next/link'
9 | import ProjectSkeleton from '@/components/projects/project-skeleton'
10 | import ProjectCard from '@/components/projects/project-card'
11 | import { useFrontend } from '@/contexts/FrontendContext'
12 | import { getAllProjects } from '@/hooks/data'
13 |
14 | export default function Projects() {
15 | const { user } = useFrontend();
16 | const [searchTerm, setSearchTerm] = useState('');
17 | const { isLoading, error, data } = getAllProjects(user.id);
18 |
19 | if (isLoading) return
20 |
21 | const projects = data?.projects || []
22 | const filteredProjects = projects.filter((project) => project.title.toLowerCase().includes(searchTerm.toLowerCase()))
23 | const recentProjects = projects.slice(0, 3)
24 | const allProjects = projects
25 |
26 | return (
27 |
28 |
29 |
30 |
31 |
32 |
33 | setSearchTerm(e.target.value)}
38 | />
39 |
40 |
46 |
47 |
48 | {allProjects.length === 0 ? (
49 |
50 | No Projects Yet
51 | Get started by creating your first LaTeX project.
52 |
58 |
59 | ) : searchTerm ? (
60 |
61 | Results
62 |
63 | {filteredProjects.map((project) => (
64 |
65 | ))}
66 |
67 |
68 | ) : (
69 | <>
70 |
71 | Recents
72 |
73 | {recentProjects.map((project) => (
74 |
75 | ))}
76 |
77 |
78 |
79 |
80 | All Projects
81 |
82 | {allProjects.map((project) => (
83 |
84 | ))}
85 |
86 |
87 | >
88 | )}
89 |
90 |
91 | )
92 | }
93 |
--------------------------------------------------------------------------------
/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": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/components/editor/constants/editorDefaults.tsx:
--------------------------------------------------------------------------------
1 | import { editor } from 'monaco-editor'
2 |
3 | export const editorDefaultOptions: editor.IStandaloneEditorConstructionOptions = {
4 | wordWrap: 'on',
5 | folding: false,
6 | lineNumbersMinChars: 3,
7 | fontSize: 16,
8 | scrollBeyondLastLine: true,
9 | scrollBeyondLastColumn: 5,
10 | automaticLayout: true,
11 | minimap: { enabled: false },
12 | overviewRulerLanes: 0,
13 | hideCursorInOverviewRuler: true,
14 | scrollbar: {
15 | vertical: 'hidden',
16 | horizontal: 'visible',
17 | useShadows: true,
18 | verticalScrollbarSize: 10,
19 | horizontalScrollbarSize: 10,
20 | verticalHasArrows: false,
21 | horizontalHasArrows: false,
22 | arrowSize: 30,
23 | },
24 | overviewRulerBorder: false,
25 | }
26 |
--------------------------------------------------------------------------------
/components/editor/editor-container.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React, { useState, useCallback, useEffect, useRef } from 'react'
4 | import { CodeEditor } from './editor'
5 | import { Button } from '@/components/ui/button'
6 | import { useTheme } from 'next-themes'
7 | import { db } from '@/lib/constants'
8 | import { tx } from '@instantdb/react'
9 | import { Skeleton } from '@/components/ui/skeleton'
10 | import { useProject } from '@/contexts/ProjectContext'
11 | import { getFileExtension } from '@/lib/utils/client-utils';
12 | import ImageViewer from './image-viewer'
13 | import { MousePointer2 } from 'lucide-react'
14 | import { Command } from 'lucide-react' // Add this import for the Command icon
15 |
16 |
17 | const isMac = navigator.userAgent.includes('Macintosh');
18 |
19 | const EditorContainer = () => {
20 | const { theme, systemTheme } = useTheme()
21 | const [localContent, setLocalContent] = useState('')
22 | const [openFile, setOpenFile] = useState(null)
23 | const { currentlyOpen, isFilesLoading, isProjectLoading } = useProject();
24 | const [isStreaming,setIsStreaming] = useState(false);
25 | const isStreamingRef = useRef(false);
26 | const fileType = getFileExtension(currentlyOpen?.name || '');
27 |
28 |
29 | const isImageFile = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(fileType.toLowerCase());
30 |
31 | useEffect(() => {
32 | if (currentlyOpen && currentlyOpen.id !== openFile?.id) {
33 | setOpenFile(currentlyOpen)
34 | setLocalContent(currentlyOpen.content)
35 | }
36 | }, [currentlyOpen?.id])
37 |
38 | const handleCodeChange = useCallback(
39 | (newCode: string) => {
40 | if (newCode !== localContent && !isStreamingRef.current) {
41 | setLocalContent(newCode);
42 | db.transact([tx.files[openFile.id].update({ content: newCode })]);
43 | }
44 | },
45 | [localContent, openFile]
46 | )
47 |
48 | const handleIsStreamingChange = useCallback((streaming: boolean) => {
49 | setIsStreaming(streaming);
50 | isStreamingRef.current = streaming;
51 | }, []);
52 |
53 | if (isProjectLoading || isFilesLoading) {
54 | return (
55 |
61 | )
62 | }
63 |
64 | return (
65 |
66 |
67 |
68 | Select and
69 | {isMac ? (
70 | <>
71 |
72 | K
73 | >
74 | ) : (
75 | Ctrl+K
76 | )}
77 | for AI Autocomplete
78 |
79 |
80 | {!currentlyOpen ? (
81 |
82 | No file open
83 |
84 | ) : isImageFile ? (
85 |
99 | ) : (
100 |
101 | )}
102 |
103 | )
104 | }
105 |
106 | export default EditorContainer
107 |
--------------------------------------------------------------------------------
/components/editor/editor.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Editor from '@monaco-editor/react'
3 | import { useEditorSetup } from './hooks/useEditorSetup'
4 | import { useAIAssist } from './hooks/useAIAssist'
5 | import { editorDefaultOptions } from './constants/editorDefaults'
6 | import { Loader2 } from 'lucide-react'
7 |
8 | interface CodeEditorProps {
9 | onChange: (value: string) => void
10 | value: string
11 | setIsStreaming: (isStreaming: boolean) => void
12 | }
13 |
14 | const EditorLoading = () => null
15 |
16 | export const CodeEditor = ({ onChange, value, setIsStreaming }: CodeEditorProps) => {
17 | const { editorRef, handleEditorDidMount } = useEditorSetup(onChange, value)
18 | const { handleAIAssist } = useAIAssist()
19 |
20 | return (
21 | {
29 | handleEditorDidMount(editor, monaco)
30 | handleAIAssist(editor, monaco, setIsStreaming)
31 | }}
32 | options={editorDefaultOptions}
33 | loading={}
34 | />
35 | )
36 | }
37 |
38 | export default CodeEditor
39 |
--------------------------------------------------------------------------------
/components/editor/hooks/useAIAssist.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useState } from 'react'
3 | import { generate } from '@/app/actions'
4 | import { readStreamableValue } from 'ai/rsc'
5 | import { calculateDiff } from '../utils/calculateDiff'
6 | import { createContentWidget } from '../utils/WidgetCreator'
7 | import { promptModal } from '../utils/promptModal'
8 | import { applyEdit } from '../utils/applyEdit'
9 | import * as monaco from 'monaco-editor'
10 | import type { editor } from 'monaco-editor'
11 |
12 | export const useAIAssist = () => {
13 | const handleAIAssist = (editor: editor.IStandaloneCodeEditor, monacoInstance: typeof monaco, setIsStreaming: (isStreaming: boolean) => void) => {
14 | editor.addCommand(monacoInstance.KeyMod.CtrlCmd | monacoInstance.KeyCode.KeyK, async () => {
15 | const selection = editor.getSelection()
16 | const model = editor.getModel()
17 | if (!model || !selection) return
18 | const initialText = model.getValue()
19 | const range = new monaco.Range(
20 | selection.startLineNumber,
21 | selection.startColumn,
22 | selection.endLineNumber,
23 | selection.endColumn
24 | )
25 |
26 | const oldText = model.getValueInRange(range)
27 | const context = `Replace lines ${selection.startLineNumber}-${selection.endLineNumber}:\n${oldText}`
28 |
29 | const userInput = await promptModal(editor, monacoInstance, selection)
30 |
31 | const { output } = await generate(
32 | `File content:\n${initialText}\n\nContext: ${context}\n\nUser input: ${userInput}`
33 | )
34 |
35 | let newText = ''
36 | let oldDecorations: string[] = []
37 | let currentLine = selection.startLineNumber
38 | let buffer = ''
39 | setIsStreaming(true)
40 |
41 |
42 | for await (const delta of readStreamableValue(output)) {
43 | if (!delta) continue
44 | buffer += delta.content
45 | if (buffer.endsWith('\n') || buffer.length > 0) {
46 | newText += buffer
47 | const {
48 | diffText,
49 | decorations,
50 | currentLine: updatedLine,
51 | } = calculateDiff(oldText, newText, monacoInstance, selection)
52 | currentLine = updatedLine
53 | await applyEdit(editor, initialText, range, diffText)
54 | oldDecorations = editor.deltaDecorations(oldDecorations, decorations)
55 | buffer = ''
56 | }
57 | }
58 |
59 | setIsStreaming(false)
60 |
61 | const contentWidget = createContentWidget(
62 | editor,
63 | monacoInstance,
64 | selection,
65 | oldText,
66 | newText,
67 | currentLine,
68 | oldDecorations
69 | )
70 | editor.addContentWidget(contentWidget)
71 | })
72 | }
73 |
74 | return { handleAIAssist }
75 | }
76 |
--------------------------------------------------------------------------------
/components/editor/hooks/useEditorSetup.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useRef } from 'react'
3 | import { useEditorTheme } from './useEditorTheme'
4 | import { editor, languages } from 'monaco-editor'
5 | import * as monaco from 'monaco-editor'
6 | import latex from 'monaco-latex'
7 | import { useLatexSyntaxHighlighting } from './useLatexSyntaxHighlighting'
8 |
9 | export function useEditorSetup(onChange: (value: string) => void, value: string) {
10 | const editorRef = useRef(null)
11 | const { setTheme } = useEditorTheme()
12 | const { setupLatexSyntaxHighlighting } = useLatexSyntaxHighlighting()
13 |
14 | const handleEditorDidMount = (editor: editor.IStandaloneCodeEditor, monacoInstance: typeof monaco) => {
15 | editorRef.current = editor
16 | editor.onDidChangeModelContent(() => {
17 | onChange(editor.getValue())
18 | })
19 | editor.getModel()?.updateOptions({ tabSize: 4, insertSpaces: true })
20 | monacoInstance.editor.setModelLanguage(editor.getModel()!, 'latex')
21 | editor.setScrollTop(1)
22 | editor.setPosition({ lineNumber: 2, column: 0 })
23 | editor.focus()
24 |
25 | setTheme(monacoInstance)
26 |
27 | setupLatexSyntaxHighlighting(monacoInstance)
28 |
29 | languages.register({ id: 'latex' });
30 | languages.setMonarchTokensProvider('latex', latex);
31 |
32 | monacoInstance.editor.setModelLanguage(editor.getModel()!, 'latex');
33 |
34 | editor.setValue(value)
35 |
36 | editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Slash, () => {
37 | const selection = editor.getSelection()
38 | if (selection) {
39 | const model = editor.getModel()
40 | if (model) {
41 | const startLineNumber = selection.startLineNumber
42 | const endLineNumber = selection.endLineNumber
43 | const operations = []
44 |
45 | for (let i = startLineNumber; i <= endLineNumber; i++) {
46 | const lineContent = model.getLineContent(i)
47 | if (lineContent.startsWith('%')) {
48 | operations.push({
49 | range: new monaco.Range(i, 1, i, 2),
50 | text: '',
51 | })
52 | } else {
53 | operations.push({
54 | range: new monaco.Range(i, 1, i, 1),
55 | text: '%',
56 | })
57 | }
58 | }
59 |
60 | model.pushEditOperations([], operations, () => null)
61 | }
62 | }
63 | })
64 | }
65 |
66 | return { editorRef, handleEditorDidMount }
67 | }
68 |
--------------------------------------------------------------------------------
/components/editor/hooks/useEditorTheme.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import * as monaco from 'monaco-editor'
3 | import { useTheme } from 'next-themes'
4 | const cssVariables = ['--background', '--foreground', '--primary', '--secondary', '--muted', '--accent', '--border']
5 |
6 | export const useEditorTheme = () => {
7 | const { theme, systemTheme } = useTheme()
8 | const setTheme = (monacoInstance: typeof monaco) => {
9 | const getColorFromVariable = (variable: string) => {
10 | const hslColor = getComputedStyle(document.documentElement).getPropertyValue(variable).trim()
11 | const rgbColor = hslToRgb(hslColor)
12 | return rgbToHex(rgbColor.r, rgbColor.g, rgbColor.b)
13 | }
14 |
15 | const colors = cssVariables.reduce(
16 | (acc, variable) => {
17 | acc[variable] = getColorFromVariable(variable)
18 | return acc
19 | },
20 | {} as Record
21 | )
22 |
23 | const base = theme === 'system' ? (systemTheme === 'dark' ? 'vs-dark' : 'vs') : theme === 'dark' ? 'vs-dark' : 'vs'
24 |
25 | monacoInstance.editor.defineTheme('myDarkTheme', {
26 | base: base,
27 | inherit: true,
28 | rules: [],
29 | colors: {
30 | 'editor.background': colors['--background'],
31 | 'editor.foreground': colors['--foreground'],
32 | 'editor.lineHighlightBackground': colors['--background'],
33 | 'editorCursor.foreground': '#d4d4d4',
34 | 'editorWhitespace.foreground': '#3a3a3a',
35 | 'editorIndentGuide.background': '#404040',
36 | 'editor.selectionBackground': colors['--secondary'],
37 | 'editor.inactiveSelectionBackground': colors['--muted'],
38 | focusBorder: `${colors['--border']}00`, // Appending '00' for full transparency
39 | },
40 | })
41 | monacoInstance.editor.setTheme('myDarkTheme')
42 | }
43 |
44 | return { setTheme }
45 | }
46 |
47 | // Helper function to convert HSL to RGB
48 | function hslToRgb(hsl: string): { r: number; g: number; b: number } {
49 | const [h, s, l] = hsl.split(' ').map(parseFloat)
50 | if (isNaN(h) || isNaN(s) || isNaN(l)) {
51 | console.error('Invalid HSL values:', hsl)
52 | return { r: 0, g: 0, b: 0 }
53 | }
54 | const hue = h / 360
55 | const saturation = s / 100
56 | const lightness = l / 100
57 | let r, g, b
58 |
59 | if (saturation === 0) {
60 | r = g = b = lightness
61 | } else {
62 | const hue2rgb = (p: number, q: number, t: number) => {
63 | if (t < 0) t += 1
64 | if (t > 1) t -= 1
65 | if (t < 1 / 6) return p + (q - p) * 6 * t
66 | if (t < 1 / 2) return q
67 | if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
68 | return p
69 | }
70 |
71 | const q = lightness < 0.5 ? lightness * (1 + saturation) : lightness + saturation - lightness * saturation
72 | const p = 2 * lightness - q
73 | r = hue2rgb(p, q, hue + 1 / 3)
74 | g = hue2rgb(p, q, hue)
75 | b = hue2rgb(p, q, hue - 1 / 3)
76 | }
77 |
78 | return {
79 | r: Math.round(r * 255),
80 | g: Math.round(g * 255),
81 | b: Math.round(b * 255),
82 | }
83 | }
84 |
85 | // Helper function to convert RGB to Hex
86 | function rgbToHex(r: number, g: number, b: number): string {
87 | const toHex = (c: number) => {
88 | const hex = Math.max(0, Math.min(255, Math.round(c))).toString(16)
89 | return hex.length === 1 ? '0' + hex : hex
90 | }
91 | return '#' + toHex(r) + toHex(g) + toHex(b)
92 | }
93 |
--------------------------------------------------------------------------------
/components/editor/hooks/useLatexSyntaxHighlighting.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { loader } from '@monaco-editor/react'
4 | import * as monaco from 'monaco-editor'
5 |
6 | export const useLatexSyntaxHighlighting = () => {
7 | const setupLatexSyntaxHighlighting = (monacoInstance: typeof monaco) => {
8 | monacoInstance.languages.register({ id: 'latex' })
9 | monacoInstance.languages.setMonarchTokensProvider('latex', {
10 | tokenizer: {
11 | root: [
12 | [/\\[a-zA-Z]+/, 'keyword'],
13 | [/\{|\}/, 'delimiter.curly'],
14 | [/\[|\]/, 'delimiter.square'],
15 | [/\$.*?\$/, 'string'],
16 | [/%.*$/, 'comment'],
17 | ],
18 | },
19 | })
20 | }
21 |
22 | loader.init().then(setupLatexSyntaxHighlighting)
23 |
24 | return { setupLatexSyntaxHighlighting }
25 | }
--------------------------------------------------------------------------------
/components/editor/image-viewer.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState, useEffect } from 'react'
4 | import { Dialog, DialogTrigger, DialogContent } from '@/components/ui/dialog'
5 | import Image from 'next/image'
6 |
7 | export default function ImageViewer({ src, alt }: { src: string, alt: string }) {
8 | const [isOpen, setIsOpen] = useState(false)
9 | const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
10 | const [aspectRatio, setAspectRatio] = useState(1)
11 |
12 | useEffect(() => {
13 | if (dimensions.width && dimensions.height) {
14 | setAspectRatio(dimensions.width / dimensions.height)
15 | }
16 | }, [dimensions])
17 |
18 | const getIntrinsicSize = () => {
19 | const maxWidth = typeof window !== 'undefined' ? window.innerWidth * 0.8 : 1000
20 | const maxHeight = typeof window !== 'undefined' ? window.innerHeight * 0.8 : 800
21 |
22 | let width = dimensions.width
23 | let height = dimensions.height
24 |
25 | if (width > maxWidth) {
26 | width = maxWidth
27 | height = width / aspectRatio
28 | }
29 |
30 | if (height > maxHeight) {
31 | height = maxHeight
32 | width = height * aspectRatio
33 | }
34 |
35 | return { width, height }
36 | }
37 |
38 | useEffect(() => {
39 | if (src) {
40 | const img = new window.Image()
41 | img.onload = () => {
42 | setDimensions({ width: img.width, height: img.height })
43 | }
44 | img.src = src
45 | }
46 | }, [src])
47 | return (
48 |
71 | )
72 | }
--------------------------------------------------------------------------------
/components/editor/types.ts:
--------------------------------------------------------------------------------
1 | import type { editor, languages } from 'monaco-editor'
2 | import * as monaco from 'monaco-editor'
3 |
4 | export interface CodeEditorProps {
5 | onChange: (value: string) => void
6 | value: string
7 | }
8 |
9 | export interface ApplyEditProps {
10 | editor: Editor
11 | initialText: string
12 | range: Range
13 | diffText: string
14 | }
15 |
16 | export type Range = monaco.Range
17 |
18 | export type Editor = editor.IStandaloneCodeEditor
19 |
20 | export type StreamableValue = {}
21 |
--------------------------------------------------------------------------------
/components/editor/utils/WidgetCreator.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 | import ReactDOM from 'react-dom/client'
5 | import { Button } from '@/components/ui/button'
6 | import * as monaco from 'monaco-editor'
7 |
8 | export const createContentWidget = (
9 | editor: monaco.editor.IStandaloneCodeEditor,
10 | monacoInstance: typeof monaco,
11 | selection: monaco.Range,
12 | oldText: string,
13 | newText: string,
14 | currentLine: number,
15 | oldDecorations: string[]
16 | ) => {
17 | return {
18 | getDomNode: function () {
19 | const container = document.createElement('div')
20 | const model = editor.getModel()
21 | if (!model) return container // Return empty container if no model
22 |
23 | const handleReject = () => {
24 | editor.executeEdits('reject-changes', [
25 | {
26 | range: new monacoInstance.Range(
27 | selection.startLineNumber,
28 | 1,
29 | currentLine - 1,
30 | model.getLineMaxColumn(currentLine - 1)
31 | ),
32 | text: oldText,
33 | forceMoveMarkers: true,
34 | },
35 | ])
36 | editor.deltaDecorations(oldDecorations, [])
37 | editor.removeContentWidget(this)
38 | }
39 |
40 | const handleApprove = () => {
41 | editor.executeEdits('approve-changes', [
42 | {
43 | range: new monacoInstance.Range(
44 | selection.startLineNumber,
45 | 1,
46 | currentLine - 1,
47 | model.getLineMaxColumn(currentLine - 1)
48 | ),
49 | text: newText,
50 | forceMoveMarkers: true,
51 | },
52 | ])
53 | editor.deltaDecorations(oldDecorations, [])
54 | editor.removeContentWidget(this)
55 | }
56 |
57 | const WidgetContent = () => (
58 |
59 |
62 |
65 |
66 | )
67 |
68 | const root = ReactDOM.createRoot(container)
69 | root.render()
70 | return container
71 | },
72 | getId: function () {
73 | return 'diff-widget'
74 | },
75 | getPosition: function () {
76 | return {
77 | position: {
78 | lineNumber: currentLine,
79 | column: 1,
80 | },
81 | preference: [monacoInstance.editor.ContentWidgetPositionPreference.BELOW],
82 | }
83 | },
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/components/editor/utils/applyEdit.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { editor, Range } from 'monaco-editor'
3 |
4 | export const applyEdit = async (
5 | editor: editor.IStandaloneCodeEditor,
6 | initialText: string,
7 | range: Range,
8 | diffText: string
9 | ) => {
10 | const model = editor.getModel()
11 | if (!model) return
12 | model.setValue(initialText)
13 | model.pushEditOperations(
14 | [],
15 | [
16 | {
17 | range: range,
18 | text: diffText,
19 | },
20 | ],
21 | () => null
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/components/editor/utils/calculateDiff.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import * as monaco from 'monaco-editor'
3 |
4 | export const calculateDiff = (
5 | oldText: string,
6 | newText: string,
7 | monacoInstance: typeof monaco,
8 | selection: monaco.Selection
9 | ) => {
10 | const oldLines = oldText.split('\n')
11 | const newLines = newText.split('\n')
12 |
13 | let diffText = ''
14 | let decorations: monaco.editor.IModelDeltaDecoration[] = []
15 | let currentLine = selection.startLineNumber
16 |
17 | const diff = []
18 | let oldIndex = 0
19 | let newIndex = 0
20 |
21 | while (oldIndex < oldLines.length || newIndex < newLines.length) {
22 | if (oldIndex < oldLines.length && newIndex < newLines.length && oldLines[oldIndex] === newLines[newIndex]) {
23 | diff.push({ value: oldLines[oldIndex] })
24 | oldIndex++
25 | newIndex++
26 | } else if (oldIndex < oldLines.length) {
27 | diff.push({ removed: true, value: oldLines[oldIndex] })
28 | oldIndex++
29 | } else if (newIndex < newLines.length) {
30 | diff.push({ added: true, value: newLines[newIndex] })
31 | newIndex++
32 | }
33 | }
34 |
35 | diff.forEach((part) => {
36 | if (part.removed) {
37 | diffText += part.value + '\n'
38 | decorations.push({
39 | range: new monacoInstance.Range(currentLine, 1, currentLine, 1),
40 | options: { isWholeLine: true, className: 'diff-old-content' },
41 | })
42 | currentLine++
43 | } else if (part.added) {
44 | diffText += part.value + '\n'
45 | decorations.push({
46 | range: new monacoInstance.Range(currentLine, 1, currentLine, 1),
47 | options: { isWholeLine: true, className: 'diff-new-content' },
48 | })
49 | currentLine++
50 | } else {
51 | diffText += part.value + '\n'
52 | currentLine++
53 | }
54 | })
55 |
56 | // Remove trailing newline if it wasn't in the original text
57 | if (!oldText.endsWith('\n') && !newText.endsWith('\n')) {
58 | diffText = diffText.slice(0, -1)
59 | }
60 |
61 | return { diffText, decorations, currentLine }
62 | }
63 |
--------------------------------------------------------------------------------
/components/editor/utils/promptModal.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef } from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import { Button } from '@/components/ui/button'
4 | import { Textarea } from '@/components/ui/textarea'
5 | import * as monaco from 'monaco-editor'
6 |
7 | export const promptModal = async (
8 | editor: monaco.editor.IStandaloneCodeEditor,
9 | monacoInstance: typeof monaco,
10 | selection: monaco.Range
11 | ) => {
12 | return new Promise((resolve, reject) => {
13 | const inputContainer = document.createElement('div')
14 | inputContainer.style.position = 'fixed'
15 | inputContainer.style.width = '400px'
16 | inputContainer.style.height = '150px'
17 | inputContainer.style.zIndex = '1000'
18 |
19 | const PromptContent = () => {
20 | const [inputValue, setInputValue] = useState('')
21 | const textareaRef = useRef(null)
22 |
23 | useEffect(() => {
24 | if (textareaRef.current) {
25 | textareaRef.current.focus()
26 | }
27 | }, [])
28 |
29 | const handleSubmit = () => {
30 | resolve(inputValue)
31 | document.body.removeChild(inputContainer)
32 | }
33 |
34 | const handleClose = () => {
35 | document.body.removeChild(inputContainer)
36 | reject(new Error('User cancelled input'))
37 | }
38 |
39 | const handleKeyDown = (event: React.KeyboardEvent) => {
40 | if (event.key === 'Enter' && !event.shiftKey) {
41 | event.preventDefault()
42 | handleSubmit()
43 | } else if (event.key === 'k' && (event.ctrlKey || event.metaKey)) {
44 | event.preventDefault()
45 | handleClose()
46 | }
47 | }
48 |
49 | return (
50 |
51 |
52 |
61 |
62 |
65 |
68 |
69 |
70 | )
71 | }
72 |
73 | const root = ReactDOM.createRoot(inputContainer)
74 | root.render()
75 |
76 | const editorDomNode = editor.getDomNode()
77 | if (editorDomNode) {
78 | const rect = editorDomNode.getBoundingClientRect()
79 | const lineHeight = editor.getOption(monacoInstance.editor.EditorOption.lineHeight)
80 | const lineTop = editor.getTopForLineNumber(selection.startLineNumber)
81 |
82 | const modalHeight = 150 // Set this to the actual height of your modal
83 | const modalWidth = 400 // Set this to the actual width of your modal
84 |
85 | let top = rect.top + lineTop - lineHeight
86 | let left = rect.left
87 |
88 | if (window) {
89 | const viewportHeight = window.innerHeight
90 | const viewportWidth = window.innerWidth
91 |
92 | // Adjust vertical position
93 | if (top + modalHeight > viewportHeight) {
94 | // If modal would overflow bottom, position it above the cursor
95 | top = Math.max(0, top - modalHeight)
96 | }
97 |
98 | // Adjust horizontal position
99 | if (left + modalWidth > viewportWidth) {
100 | left = Math.max(0, viewportWidth - modalWidth)
101 | }
102 |
103 | // Ensure the modal is fully visible
104 | top = Math.min(Math.max(0, top), viewportHeight - modalHeight)
105 | left = Math.min(Math.max(0, left), viewportWidth - modalWidth)
106 | }
107 |
108 | inputContainer.style.top = `${top}px`
109 | inputContainer.style.left = `${left}px`
110 | }
111 |
112 | document.body.appendChild(inputContainer)
113 | })
114 | }
115 |
--------------------------------------------------------------------------------
/components/file-tree/file-tree-loading.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from '@/components/ui/skeleton'
2 |
3 | const FileTreeSkeleton = () => {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | {[...Array(5)].map((_, index) => (
16 |
17 |
18 |
19 |
20 | ))}
21 |
22 | {[...Array(3)].map((_, index) => (
23 |
24 |
25 |
26 |
27 | ))}
28 |
29 |
30 |
31 |
32 | )
33 | }
34 |
35 | export default FileTreeSkeleton
36 |
--------------------------------------------------------------------------------
/components/file-tree/file-tree-node.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'
2 | import { File, Folder, FolderOpen, ChevronRight, ChevronDown, Edit, Trash2, FilePlus2, FolderPlus } from 'lucide-react'
3 | import { cn } from '@/lib/utils'
4 | import Tex from '@/public/tex'
5 | import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
6 | import { Input } from '@/components/ui/input'
7 |
8 | const FileTreeNode = ({ node, style, dragHandle }) => {
9 | const [nodeStyle, setNodeStyle] = useState({ base: style })
10 | const [isRenaming, setIsRenaming] = useState(false)
11 | const [newName, setNewName] = useState(node.data.name)
12 | const inputRef = useRef(null)
13 | const [isNewItem, setIsNewItem] = useState(false)
14 | const [inputValue, setInputValue] = useState(node.data.name)
15 |
16 | const onMouseOver = () => {
17 | if (node.data.hover && !node.data.isOpen) {
18 | setNodeStyle(() => ({
19 | base: { ...style, ...{ backgroundColor: 'hsl(var(--muted))' } },
20 | }))
21 | }
22 | }
23 |
24 | const onMouseLeave = () => {
25 | if (node.data.hover && !node.data.isOpen) {
26 | setNodeStyle(() => ({ base: style }))
27 | }
28 | }
29 |
30 | const handleToggleClick = (e) => {
31 | e.stopPropagation()
32 | node.toggle()
33 | node.tree.props.onToggle({
34 | id: node.id,
35 | isExpanded: !node.data.isExpanded,
36 | type: node.data.type,
37 | isOpen: node.data.isOpen,
38 | })
39 | }
40 |
41 | const handleRenameClick = (e) => {
42 | e.stopPropagation()
43 | setIsRenaming(true)
44 | setNewName(node.data.name)
45 | }
46 |
47 | useEffect(() => {
48 | if (node.data.isOpen) {
49 | setNodeStyle(() => ({
50 | base: { ...style, ...{ backgroundColor: 'hsl(var(--accent))' } },
51 | }))
52 | } else {
53 | setNodeStyle(() => ({ base: style }))
54 | }
55 | }, [node.data.isOpen, style])
56 |
57 | useEffect(() => {
58 | if (isRenaming) {
59 | const timeoutId = setTimeout(() => {
60 | if (inputRef.current) {
61 | inputRef.current.focus()
62 | inputRef.current.setSelectionRange(newName.length, newName.length)
63 | }
64 | }, 50)
65 |
66 | return () => clearTimeout(timeoutId)
67 | }
68 | }, [isRenaming, newName])
69 |
70 | useEffect(() => {
71 | if (node.tree.props.newItemType && node.id === node.tree.props.newItemParentId) {
72 | const newItemIndex = node.children.findIndex((child) => !child.data.id)
73 | if (newItemIndex !== -1) {
74 | setIsNewItem(true)
75 | setIsRenaming(true)
76 | setNewName(node.children[newItemIndex].data.name)
77 | }
78 | }
79 | }, [node, node.tree.props.newItemType, node.tree.props.newItemParentId])
80 |
81 | const handleRenameSubmit = () => {
82 | if (inputValue.trim() !== '') {
83 | if (isNewItem) {
84 | node.tree.props.onRename({ id: node.children[node.children.length - 1].id, name: inputValue.trim() })
85 | setIsNewItem(false)
86 | } else {
87 | node.tree.props.onRename({ id: node.id, name: inputValue.trim() })
88 | }
89 | }
90 | setIsRenaming(false)
91 | }
92 |
93 | const handleDelete = () => {
94 | node.tree.props.onDelete({ ids: [node.id], type: node.data.type })
95 | }
96 |
97 | const handleAddFile = () => {
98 | node.tree.props.onAddItem('file', node.id)
99 | }
100 |
101 | const handleInputChange = useCallback((e) => {
102 | setInputValue(e.target.value)
103 | }, [])
104 |
105 |
106 | if (isRenaming) {
107 | return (
108 |
109 |
e.stopPropagation()} style={style} className="flex items-center gap-2 p-1 rounded-md">
110 | {
116 | e.stopPropagation()
117 | if (e.key === 'Enter') handleRenameSubmit()
118 | if (e.key === 'Escape') {
119 | setIsRenaming(false)
120 | setInputValue(node.data.name) // Reset input value
121 | }
122 | }}
123 | className="h-6 py-0 px-1 text-sm"
124 | />
125 |
126 |
127 | )
128 | }
129 |
130 | return (
131 |
132 |
133 |
134 |
146 |
147 |
148 | {node.isLeaf ? (
149 | node.data.name && node.data.name.endsWith('.tex') ? (
150 |
151 | ) : (
152 |
153 | )
154 | ) : node.data.isExpanded ? (
155 |
156 | ) : (
157 |
158 | )}
159 | {isRenaming ? (
160 | setNewName(e.target.value)}
164 | onBlur={handleRenameSubmit}
165 | onKeyDown={(e) => {
166 | e.stopPropagation()
167 | if (e.key === 'Enter') handleRenameSubmit()
168 | if (e.key === 'Escape') setIsRenaming(false)
169 | }}
170 | onClick={(e) => e.stopPropagation()}
171 | className="h-6 py-0 px-1 text-sm"
172 | />
173 | ) : (
174 | {node.data.name}
175 | )}
176 |
177 | {!node.isLeaf && (
178 |
179 | {node.data.isExpanded ? : }
180 |
181 | )}
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 | Rename
190 |
191 |
192 |
193 | Delete
194 |
195 | {!node.isLeaf && (
196 | <>
197 |
198 |
199 | Add File
200 |
201 | node.tree.props.onAddItem('folder', node.id)}>
202 |
203 | Add Folder
204 |
205 | >
206 | )}
207 |
208 |
209 | )
210 | }
211 |
212 | export default React.memo(FileTreeNode)
213 |
--------------------------------------------------------------------------------
/components/file-tree/file-tree.jsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'
3 | import { Tree } from 'react-arborist'
4 | import { db } from '@/lib/constants'
5 | import { tx, id } from '@instantdb/react'
6 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
7 | import { FilePlus2, FolderPlus } from 'lucide-react';
8 | import { Button } from '@/components/ui/button';
9 | import FileTreeNode from './file-tree-node';
10 | import FileTreeSkeleton from './file-tree-loading';
11 | import { Upload } from 'lucide-react';
12 | import { getFileExtension } from '@/lib/utils/client-utils';
13 | import { useFrontend } from '@/contexts/FrontendContext';
14 |
15 | const FileTree = ({ projectId, query = '' }) => {
16 | const { user } = useFrontend()
17 | const {
18 | data: filesData,
19 | error,
20 | isLoading,
21 | } = db.useQuery({
22 | files: {
23 | $: {
24 | where: {
25 | projectId: projectId,
26 | },
27 | },
28 | },
29 | })
30 | const transformedData = useMemo(() => {
31 | if (!filesData?.files) return []
32 |
33 | const buildTree = (parentId = null, parentPath = '') => {
34 | return filesData.files
35 | .filter((file) => file.parent_id === parentId)
36 | .map((file) => {
37 | const currentPath = parentPath ? `${parentPath}/${file.name}` : file.name
38 | const node = {
39 | id: file.id,
40 | name: file.name,
41 | type: file.type,
42 | hover: true,
43 | isOpen: file.isOpen ?? false,
44 | isExpanded: file.isExpanded ?? false,
45 | pathname: currentPath,
46 | user_id: user.id,
47 | }
48 |
49 | if (file.type === 'folder') {
50 | const children = buildTree(file.id, currentPath)
51 | if (children.length > 0 || file.name.toLowerCase().includes(query.toLowerCase())) {
52 | node.children = children
53 | return node
54 | }
55 | } else if (file.name.toLowerCase().includes(query.toLowerCase())) {
56 | return node
57 | }
58 |
59 | return null
60 | })
61 | .filter(Boolean)
62 | }
63 |
64 | return buildTree()
65 | }, [filesData, query])
66 |
67 | const initialOpenState = useMemo(() => {
68 | if (!filesData?.files) return {}
69 |
70 | return filesData.files.reduce((acc, file) => {
71 | acc[file.id] = file.isExpanded ?? false
72 | return acc
73 | }, {})
74 | }, [filesData])
75 |
76 | const treeContainerRef = useRef(null)
77 | const [treeContainer, setTreeContainer] = useState({
78 | width: 256,
79 | height: 735,
80 | })
81 |
82 | const [newItemType, setNewItemType] = useState(null)
83 | const [newItemParentId, setNewItemParentId] = useState(null)
84 |
85 | const handleAddItem = useCallback(
86 | (type, parentId = null) => {
87 | const newItemId = id()
88 | const parentPath = filesData.files.find((file) => file.id === parentId)?.pathname || ''
89 | const newItemPath = parentPath ? `${parentPath}/${type === 'file' ? 'untitled.tex' : 'Untitled Folder'}` : (type === 'file' ? 'untitled.tex' : 'Untitled Folder')
90 | const newItem = {
91 | id: newItemId,
92 | name: type === 'file' ? 'untitled.tex' : 'Untitled Folder',
93 | type: type,
94 | parent_id: parentId,
95 | projectId: projectId,
96 | isExpanded: type === 'folder' ? true : null,
97 | content: '',
98 | created_at: new Date(),
99 | pathname: newItemPath,
100 | user_id: user.id,
101 | }
102 |
103 | db.transact([tx.files[newItemId].update(newItem)])
104 |
105 | setNewItemType(type)
106 | setNewItemParentId(parentId)
107 | },
108 | [projectId, filesData]
109 | )
110 |
111 | const handleRename = useCallback(({ id, name }) => {
112 | const file = filesData.files.find((file) => file.id === id)
113 | const newPathname = file.pathname.replace(/[^/]+$/, name)
114 | db.transact([tx.files[id].update({ name: name, pathname: newPathname })])
115 | }, [filesData])
116 |
117 | const handleMove = ({ dragIds, parentId, index }) => {
118 | const updates = dragIds.map((id) => {
119 | const file = filesData.files.find((file) => file.id === id)
120 | const parentPath = filesData.files.find((file) => file.id === parentId)?.pathname || ''
121 | const newPathname = parentPath ? `${parentPath}/${file.name}` : file.name
122 | return {
123 | id: id,
124 | parent_id: parentId,
125 | pathname: newPathname,
126 | }
127 | })
128 |
129 | // Get all files with the same parent_id
130 | const siblingFiles = filesData.files.filter((file) => file.parent_id === parentId)
131 |
132 | // Insert the moved files at the specified index
133 | const updatedSiblings = [...siblingFiles.slice(0, index), ...updates, ...siblingFiles.slice(index)]
134 |
135 | // Update the order of all affected files
136 | const orderUpdates = updatedSiblings.map((file, i) => ({
137 | id: file.id,
138 | order: i,
139 | }))
140 |
141 | // Combine all updates
142 | const allUpdates = [...updates, ...orderUpdates]
143 |
144 | // Perform the database transaction
145 | db.transact(allUpdates.map((update) => tx.files[update.id].update(update)))
146 | }
147 |
148 | const handleDelete = ({ ids, type }) => {
149 | if (type === 'file') {
150 | db.transact([tx.files[ids[0]].delete()])
151 | } else if (type === 'folder') {
152 | // Recursive function to collect all child files and folders
153 | const collectChildren = (folderId) => {
154 | return filesData.files
155 | .filter((file) => file.parent_id === folderId)
156 | .flatMap((child) => {
157 | if (child.type === 'folder') {
158 | return [child.id, ...collectChildren(child.id)]
159 | }
160 | return child.id
161 | })
162 | }
163 |
164 | const childrenIds = collectChildren(ids[0])
165 |
166 | // Delete the folder and all its children
167 | db.transact([...childrenIds.map((id) => tx.files[id].delete()), tx.files[ids[0]].delete()])
168 | }
169 | }
170 |
171 | const handleToggle = ({ id, isExpanded, type, isOpen }) => {
172 | if (type === 'folder') {
173 | db.transact([tx.files[id].update({ isExpanded: isExpanded })])
174 | } else if (type === 'file') {
175 | const previouslyOpenFile = filesData.files.find((file) => file.isOpen)
176 | const updates = [tx.files[id].update({ isOpen: true })]
177 | if (previouslyOpenFile) {
178 | updates.push(tx.files[previouslyOpenFile.id].update({ isOpen: false }))
179 | }
180 | db.transact(updates)
181 | }
182 | }
183 |
184 | const handleUpload = async () => {
185 | try {
186 | const input = document.createElement('input');
187 | input.type = 'file';
188 | input.accept = 'image/*,.tex'; // Accept images and .tex files
189 | input.multiple = true;
190 | input.onchange = async (e) => {
191 | const files = Array.from(e.target.files);
192 | for (const file of files) {
193 | const newFileId = id();
194 | const isImage = file.type.startsWith('image/');
195 |
196 | if (isImage) {
197 | // Handle image file
198 | const pathname = `images/${newFileId}-${file.name}`;
199 | await db.storage.upload(pathname, file);
200 | const imageUrl = await db.storage.getDownloadUrl(pathname);
201 |
202 | const newFile = {
203 | id: newFileId,
204 | name: file.name,
205 | type: 'file',
206 | content: imageUrl,
207 | parent_id: null,
208 | projectId: projectId,
209 | isExpanded: null,
210 | created_at: new Date(),
211 | pathname: file.name,
212 | user_id: user.id,
213 | };
214 | db.transact([tx.files[newFileId].update(newFile)]);
215 | } else {
216 | // Handle non-image file (e.g., .tex)
217 | const reader = new FileReader();
218 | reader.onload = async (event) => {
219 | const content = event.target.result;
220 | const newFile = {
221 | id: newFileId,
222 | name: file.name,
223 | type: 'file',
224 | content: content,
225 | parent_id: null,
226 | projectId: projectId,
227 | isExpanded: null,
228 | created_at: new Date(),
229 | pathname: file.name,
230 | user_id: user.id,
231 | };
232 | db.transact([tx.files[newFileId].update(newFile)]);
233 | };
234 | reader.readAsText(file);
235 | }
236 | }
237 | };
238 | input.click();
239 | } catch (error) {
240 | console.error('Error uploading files:', error);
241 | }
242 | };
243 |
244 | useEffect(() => {
245 |
246 | const resizeObserver = new ResizeObserver(([entry]) => {
247 | const { width, height } = entry.contentRect
248 | setTreeContainer({
249 | width: width,
250 | height: height - 65,
251 | })
252 | })
253 |
254 | const observeElement = () => {
255 | if (treeContainerRef.current) {
256 | resizeObserver.observe(treeContainerRef.current)
257 | } else {
258 | setTimeout(observeElement, 100)
259 | }
260 | }
261 |
262 | observeElement()
263 |
264 | return () => {
265 | resizeObserver.disconnect()
266 | }
267 | }, [])
268 |
269 | if (isLoading) return
270 | if (error) return Error: {error.message}
271 |
272 | return (
273 |
274 |
275 |
Files
276 |
277 |
278 |
279 |
282 |
283 | Add file
284 |
285 |
286 |
287 |
290 |
291 | Add folder
292 |
293 |
294 |
295 |
298 |
299 | Upload file
300 |
301 |
302 |
303 |
304 |
319 | {FileTreeNode}
320 |
321 |
322 |
323 | )
324 | }
325 |
326 | export default FileTree
327 |
--------------------------------------------------------------------------------
/components/latex-render/latex-canvas.tsx:
--------------------------------------------------------------------------------
1 | import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
2 | import { Document, Page, pdfjs } from 'react-pdf'
3 | import { Skeleton } from "@/components/ui/skeleton"
4 |
5 | export default function LatexCanvas({
6 | pdfUrl,
7 | onDocumentLoadSuccess,
8 | options,
9 | isDocumentReady,
10 | numPages,
11 | scale
12 | }: {
13 | pdfUrl: string;
14 | onDocumentLoadSuccess: (result: { numPages: number }) => void;
15 | options: any;
16 | isDocumentReady: boolean;
17 | numPages: number;
18 | scale: number;
19 | }) {
20 | return (
21 |
22 | }
27 | options={options}
28 | >
29 | {isDocumentReady &&
30 | Array.from(new Array(numPages), (el, index) => (
31 | }
38 | />
39 | ))}
40 |
41 |
42 |
43 |
44 | )
45 | }
--------------------------------------------------------------------------------
/components/latex-render/latex-error.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
3 | import { AlertCircle } from 'lucide-react'
4 |
5 | function parseLatexError(error: string): string {
6 | const errorMessages: { [key: string]: string } = {
7 | "Missing File: No main.tex file found": "Missing File: No main.tex file found\n\nDetails: The main.tex file is required for LaTeX compilation."
8 | };
9 |
10 | return errorMessages[error] || error;
11 | }
12 |
13 | export default function LatexError({ error }: { error: string }) {
14 | const parsedError = parseLatexError(error);
15 |
16 | return (
17 |
18 |
19 | LaTeX Error
20 |
21 | {parsedError}
22 |
23 |
24 | )
25 | }
--------------------------------------------------------------------------------
/components/latex-render/latex-loading.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { motion } from 'framer-motion'
4 | import { useMemo } from 'react'
5 |
6 | export default function LatexLoading() {
7 | const pageCount = 8
8 |
9 | const loadingElements = useMemo(() => {
10 | return [...Array(pageCount)].map((_, index) => (
11 |
18 |
19 |
20 | {[...Array(5)].map((_, lineIndex) => (
21 |
28 | ))}
29 |
30 |
31 | ));
32 | }, [pageCount]);
33 |
34 | return (
35 |
36 |
37 |
38 |
43 |
48 |
49 | {loadingElements}
50 |
51 |
52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/components/latex-render/latex.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useState, useEffect, useMemo } from 'react'
3 | import { pdfjs } from 'react-pdf'
4 | import 'react-pdf/dist/Page/AnnotationLayer.css'
5 | import 'react-pdf/dist/Page/TextLayer.css'
6 | import { Button } from '@/components/ui/button'
7 | import { Switch } from '@/components/ui/switch'
8 | import LatexError from './latex-error'
9 | import { Label } from '@/components/ui/label'
10 | import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'
11 | import { savePdfToStorage, savePreviewToStorage } from '@/lib/utils/db-utils'
12 | import { Download } from 'lucide-react'
13 | import { useProject } from '@/contexts/ProjectContext'
14 | import { createPathname } from '@/lib/utils/client-utils'
15 | import { useFrontend } from '@/contexts/FrontendContext'
16 | import { fetchPdf } from '@/lib/utils/pdf-utils'
17 | import { Loader2 } from 'lucide-react'
18 | import LatexLoading from './latex-loading'
19 | import LatexCanvas from './latex-canvas'
20 | import { updateProject } from '@/hooks/data'
21 |
22 | pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs'
23 |
24 | function LatexRenderer() {
25 | const { user } = useFrontend();
26 | const { project: data, isLoading: isDataLoading, projectId, currentlyOpen, files } = useProject();
27 | const scale = data?.projectScale ?? 0.9;
28 | const autoFetch = data?.isAutoFetching ?? false;
29 | const latex = currentlyOpen?.content
30 |
31 | const [numPages, setNumPages] = useState(0)
32 | const [pdfUrl, setPdfUrl] = useState(null)
33 | const [isLoading, setIsLoading] = useState(false)
34 | const [error, setError] = useState(null)
35 | const [isDocumentReady, setIsDocumentReady] = useState(false)
36 |
37 | useEffect(() => {
38 | if (!isDataLoading && data?.cachedPdfUrl) {
39 | setPdfUrl(data.cachedPdfUrl)
40 | }
41 | }, [isDataLoading, data])
42 |
43 | const handlePdf = async () => {
44 | if (isDataLoading || !user) return
45 | setIsLoading(true)
46 | setError(null)
47 | setIsDocumentReady(false)
48 | try {
49 | const blob = await fetchPdf(files);
50 | const pathname = createPathname(user.id, projectId)
51 | await savePdfToStorage(blob, pathname + 'main.pdf', projectId)
52 | await savePreviewToStorage(blob, pathname + 'preview.webp', projectId)
53 | const url = URL.createObjectURL(blob)
54 | setPdfUrl(url)
55 | } catch (error) {
56 | console.error('Error fetching PDF:', error)
57 | setError(error instanceof Error ? error.message : String(error))
58 | } finally {
59 | setIsLoading(false)
60 | }
61 | }
62 |
63 | useEffect(() => {
64 | let debounceTimer: NodeJS.Timeout
65 |
66 | const resetTimer = () => {
67 | clearTimeout(debounceTimer)
68 | debounceTimer = setTimeout(() => {
69 | if (autoFetch && latex && latex.trim() !== '') {
70 | handlePdf()
71 | }
72 | }, 1000)
73 | }
74 |
75 | resetTimer()
76 |
77 | return () => clearTimeout(debounceTimer)
78 | }, [latex, autoFetch, isDataLoading, user])
79 |
80 | const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => {
81 | setNumPages(numPages)
82 | setIsDocumentReady(true)
83 | }
84 |
85 | // Options for PDF.js rendering
86 | // cMapUrl: URL for character map (cMap) files
87 | // cMapPacked: Use packed character maps for better performance
88 | const options = useMemo(
89 | () => ({
90 | cMapUrl: 'cmaps/',
91 | cMapPacked: true,
92 | }),
93 | []
94 | )
95 |
96 | const handleZoomIn = () => {
97 | const newScale = Math.min(scale + 0.1, 2.0)
98 | updateProject(projectId, { projectScale: newScale })
99 | }
100 |
101 | const handleZoomOut = () => {
102 | const newScale = Math.max(scale - 0.1, 0.5)
103 | updateProject(projectId, { projectScale: newScale })
104 | }
105 |
106 | const handleResetZoom = () => {
107 | updateProject(projectId, { projectScale: 0.9 })
108 | }
109 |
110 | const handleDownload = () => {
111 | if (pdfUrl) {
112 | fetch(pdfUrl)
113 | .then(response => response.blob())
114 | .then(blob => {
115 | const url = window.URL.createObjectURL(blob);
116 | const link = document.createElement('a');
117 | link.href = url;
118 | link.download = `${data?.title || 'document'}.pdf`;
119 | link.style.display = 'none';
120 | document.body.appendChild(link);
121 | link.click();
122 | document.body.removeChild(link);
123 | window.URL.revokeObjectURL(url);
124 | })
125 | .catch(error => console.error('Error downloading PDF:', error));
126 | }
127 | }
128 |
129 | if (isDataLoading) {
130 | return (
131 |
132 |
133 |
134 | )
135 | }
136 |
137 | return (
138 |
139 |
140 |
141 |
151 | updateProject(projectId, { isAutoFetching: checked })} />
152 |
153 |
154 |
155 |
158 |
161 |
164 |
167 |
168 |
169 | {isLoading ? (
170 |
171 | ) : error ? (
172 |
173 |
174 |
175 | ) : pdfUrl ? (
176 |
184 | ) : (
185 | null
186 | )}
187 |
188 | )
189 | }
190 |
191 | export default LatexRenderer
--------------------------------------------------------------------------------
/components/loading/loading-page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Card, CardContent, CardFooter } from '@/components/ui/card'
3 | import { Skeleton } from '@/components/ui/skeleton'
4 |
5 | export default function LoadingPage() {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | {[...Array(3)].map((_, i) => (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | ))}
39 |
40 |
41 |
42 |
43 |
44 |
45 | {[...Array(8)].map((_, i) => (
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | ))}
68 |
69 |
70 |
71 | )
72 | }
73 |
--------------------------------------------------------------------------------
/components/nav/loading-side-nav.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from '@/components/ui/skeleton'
2 | import { Button } from '@/components/ui/button'
3 | import { Input } from '@/components/ui/input'
4 | import { Search } from 'lucide-react'
5 |
6 | export default function LoadingSideNav() {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/components/nav/side-nav.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import FileTree from '@/components/file-tree/file-tree'
3 | import { ModeToggle } from '@/components/ui/mode-toggle'
4 | import { useState } from 'react'
5 | import { Input } from '@/components/ui/input'
6 | import { Button } from '@/components/ui/button'
7 | import { Search } from 'lucide-react'
8 | import Link from 'next/link'
9 | import Profile from '@/components/profile/profile'
10 | import LoadingSideNav from '@/components/nav/loading-side-nav'
11 | import { useProject } from '@/contexts/ProjectContext'
12 |
13 | export default function SideNav() {
14 | const { project, isProjectLoading, projectId } = useProject();
15 | const projectTitle = project?.title
16 | const [query, setQuery] = useState('')
17 | if (isProjectLoading) {
18 | return
19 | }
20 |
21 | return (
22 |
23 |
24 |
25 |
26 |
27 | {projectTitle
28 | ? projectTitle
29 | .split(' ')
30 | .map((word: string) => word[0])
31 | .join('')
32 | : ''}
33 |
34 |
35 |
{projectTitle}
36 |
37 |
38 |
39 |
40 | setQuery(e.target.value)} />
41 |
42 |
43 |
44 |
45 |
48 |
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/components/page-utils/grid-texture.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { motion } from 'framer-motion';
4 |
5 | export default function GridTexture() {
6 | return (
7 |
20 | );
21 | };
--------------------------------------------------------------------------------
/components/profile/profile.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 | import { Button } from '@/components/ui/button'
5 | import { Avatar, AvatarFallback } from '@/components/ui/avatar'
6 | import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
7 | import { LogOut } from 'lucide-react'
8 | import { db } from '@/lib/constants'
9 | import { useRouter } from 'next/navigation'
10 |
11 | export default function Profile() {
12 | const router = useRouter()
13 | const { user } = db.useAuth()
14 |
15 | const handleSignOut = () => {
16 | db.auth.signOut()
17 | router.push('/')
18 | }
19 |
20 | return (
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
{user?.email}
32 |
33 |
34 |
35 |
36 |
40 |
41 |
42 |
43 |
44 |
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/components/projects/auth-buttons.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { db } from '@/lib/constants'
3 | import { Button } from '@/components/ui/button'
4 | import { LayoutGrid } from 'lucide-react'
5 | import Link from 'next/link'
6 | import { ModeToggle } from '@/components/ui/mode-toggle';
7 |
8 | export default function AuthButtons() {
9 | const { user } = db.useAuth()
10 |
11 | return (
12 |
13 | {user ? (
14 |
24 | ) : (
25 |
28 | )}
29 |
30 |
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/components/projects/project-card.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useEffect } from 'react'
3 | import { Badge } from '@/components/ui/badge'
4 | import { Button } from '@/components/ui/button'
5 | import { Card, CardContent, CardFooter } from '@/components/ui/card'
6 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
7 | import { CopyIcon, DownloadIcon, Edit2Icon, MoreVertical, XIcon } from 'lucide-react'
8 | import { db } from '@/lib/constants'
9 | import { tx } from '@instantdb/react'
10 | import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
11 | import Link from 'next/link'
12 | import { useState } from 'react'
13 | import { Input } from '@/components/ui/input'
14 | import { id } from '@instantdb/react'
15 | import Image from 'next/image'
16 | import { savePdfToStorage, savePreviewToStorage } from '@/lib/utils/db-utils'
17 | import { createPathname } from '@/lib/utils/client-utils'
18 | import { getAllProjectFiles } from '@/hooks/data'
19 | import { useFrontend } from '@/contexts/FrontendContext';
20 | import { deleteFileFromStorage } from '@/lib/utils/db-utils';
21 |
22 | export default function ProjectCard({ project, detailed = false }: { project: any; detailed?: boolean }) {
23 | const [isDialogOpen, setIsDialogOpen] = useState(false)
24 | const [newTitle, setNewTitle] = useState(project.title)
25 | const [isDropdownOpen, setIsDropdownOpen] = useState(false)
26 | const [imageURL, setImageURL] = useState('')
27 | const [imageError, setImageError] = useState(false)
28 | const { user } = useFrontend();
29 | const { email, id: userId } = user
30 | const [downloadURL, setDownloadURL] = useState('');
31 | const { data: files} = getAllProjectFiles(project.id, userId)
32 |
33 | useEffect(() => {
34 | if (email && userId) {
35 | const pathname = createPathname(userId, project.id)
36 | if (project.cachedPdfExpiresAt < Date.now() || project.cachedPreviewExpiresAt < Date.now()) {
37 | const expiresAt = Date.now() + 30 * 60 * 1000;
38 | db.storage.getDownloadUrl(pathname + 'main.pdf').then((url) => {
39 | db.transact(tx.projects[project.id].update({ cachedPdfUrl: url, cachedPdfExpiresAt: expiresAt })).then(() => {
40 | db.storage.getDownloadUrl(pathname + 'main.pdf').then((validatedUrl) => {
41 | setDownloadURL(validatedUrl)
42 | })
43 | })
44 | })
45 | db.storage.getDownloadUrl(pathname + 'preview.webp').then((url) => {
46 | db.transact(tx.projects[project.id].update({ cachedPreviewUrl: url, cachedPreviewExpiresAt: expiresAt })).then(() => {
47 | db.storage.getDownloadUrl(pathname + 'preview.webp').then((validatedUrl) => {
48 | setImageURL(validatedUrl)
49 | })
50 | })
51 | })
52 | } else {
53 | setImageURL(project.cachedPreviewUrl)
54 | setDownloadURL(project.cachedPdfUrl)
55 | }
56 | }
57 | }, [project.id, project.title, email, userId])
58 |
59 | const handleDelete = (e: React.MouseEvent) => {
60 | e.preventDefault()
61 | e.stopPropagation();
62 |
63 | db.transact([tx.projects[project.id].delete()]);
64 | if (files && files.files) {
65 | files.files.map((file) => db.transact([tx.files[file.id].delete()]))
66 | }
67 | deleteFileFromStorage(`${userId}/${project.id}/main.pdf`)
68 | deleteFileFromStorage(`${userId}/${project.id}/preview.webp`)
69 | setIsDropdownOpen(false)
70 | }
71 |
72 | const handleEdit = (e: React.MouseEvent) => {
73 | e.preventDefault()
74 | e.stopPropagation()
75 | setIsDialogOpen(true)
76 | setIsDropdownOpen(false)
77 | }
78 |
79 | const handleDuplicate = async (e: React.MouseEvent) => {
80 | e.preventDefault()
81 | e.stopPropagation()
82 | setIsDropdownOpen(false);
83 | if (!files) {
84 | return;
85 | }
86 |
87 | const newProjectId = id();
88 |
89 | // Create a mapping of old file IDs to new file IDs
90 | const fileIdMapping = new Map();
91 | files.files.forEach((file) => {
92 | fileIdMapping.set(file.id, id());
93 | });
94 |
95 | const fileContents = files.files.map((file) => {
96 | return {
97 | id: fileIdMapping.get(file.id),
98 | name: file.name,
99 | content: file.content,
100 | pathname: file.pathname,
101 | project_id: newProjectId,
102 | created_at: new Date(),
103 | parent_id: file.parent_id ? fileIdMapping.get(file.parent_id) : null,
104 | updated_at: new Date(),
105 | isOpen: file.isOpen,
106 | isExpanded: file.isExpanded,
107 | type: file.type,
108 | main_file: file.main_file,
109 | }
110 | })
111 |
112 | if (downloadURL) {
113 | try {
114 | const response = await fetch(downloadURL)
115 | const blob = await response.blob()
116 | const pathname = createPathname(userId, newProjectId)
117 | await savePreviewToStorage(blob, pathname + 'preview.webp', newProjectId)
118 | await savePdfToStorage(blob, pathname + 'main.pdf', newProjectId)
119 | } catch (error) {
120 | console.error('Error downloading file:', error)
121 | }
122 | }
123 |
124 | db.transact([
125 | tx.projects[newProjectId].update({
126 | title: `${project.title} (Copy)`,
127 | project_content: project.project_content,
128 | document_class: project.document_class,
129 | template: project.template,
130 | user_id: project.user_id,
131 | created_at: new Date(),
132 | last_compiled: new Date(),
133 | word_count: 0,
134 | page_count: 0,
135 | cachedPdfUrl: project.cachedPdfUrl,
136 | cachedPreviewUrl: project.cachedPreviewUrl,
137 | cachedPdfExpiresAt: project.cachedPdfExpiresAt,
138 | cachedPreviewExpiresAt: project.cachedPreviewExpiresAt,
139 | }),
140 | ...fileContents.map((file) =>
141 | tx.files[file.id].update({
142 | user_id: userId,
143 | projectId: newProjectId,
144 | name: file.name,
145 | pathname: file.pathname,
146 | content: file.content,
147 | created_at: new Date(),
148 | updated_at: new Date(),
149 | isOpen: file.isOpen,
150 | isExpanded: file.isExpanded,
151 | type: file.type,
152 | main_file: file.main_file,
153 | parent_id: file.parent_id,
154 | })
155 | )
156 | ])
157 | }
158 |
159 | const handleDownload = (e: React.MouseEvent) => {
160 | e.preventDefault()
161 | e.stopPropagation()
162 | if (downloadURL) {
163 | fetch(downloadURL)
164 | .then((response) => response.blob())
165 | .then((blob) => {
166 | const blobUrl = window.URL.createObjectURL(blob)
167 | const link = document.createElement('a')
168 | link.href = blobUrl
169 | link.download = `${project.title}.pdf`
170 | link.click()
171 | window.URL.revokeObjectURL(blobUrl)
172 | })
173 | .catch((error) => {
174 | console.error('Error downloading file:', error)
175 | })
176 | } else {
177 | console.error('Download URL is not available')
178 | }
179 | setIsDropdownOpen(false)
180 | }
181 |
182 | const handleDialogOpenChange = (open: boolean) => {
183 | setIsDialogOpen(open)
184 | if (!open) {
185 | setIsDropdownOpen(false)
186 | }
187 | }
188 |
189 | return (
190 | <>
191 |
192 |
193 |
194 |
src}
201 | onError={() => setImageError(true)}
202 | />
203 |
204 |
205 |
206 |
218 |
219 | {
222 | e.preventDefault()
223 | e.stopPropagation()
224 | }}
225 | >
226 |
227 |
228 | Delete
229 |
230 |
231 |
232 | Edit
233 |
234 |
235 |
236 | Duplicate
237 |
238 |
239 |
240 | Download
241 |
242 |
243 |
244 |
245 |
246 |
247 | {project.title}
248 |
249 | {project.last_compiled
250 | ? `Last updated: ${getTimeAgo(new Date(project.last_compiled))}`
251 | : "Not compiled yet"}
252 |
253 |
254 | {detailed && (
255 |
256 |
259 |
260 | )}
261 |
262 |
263 |
294 | >
295 | )
296 | }
297 |
298 |
299 | function getTimeAgo(date: Date): string {
300 | const now = new Date();
301 | const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
302 |
303 | if (diffInSeconds < 60) {
304 | return 'Just now';
305 | } else if (diffInSeconds < 3600) {
306 | const minutes = Math.floor(diffInSeconds / 60);
307 | return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
308 | } else if (diffInSeconds < 86400) {
309 | const hours = Math.floor(diffInSeconds / 3600);
310 | return `${hours} hour${hours > 1 ? 's' : ''} ago`;
311 | } else if (diffInSeconds < 2592000) {
312 | const days = Math.floor(diffInSeconds / 86400);
313 | return `${days} day${days > 1 ? 's' : ''} ago`;
314 | } else if (diffInSeconds < 31536000) {
315 | const months = Math.floor(diffInSeconds / 2592000);
316 | return `${months} month${months > 1 ? 's' : ''} ago`;
317 | } else {
318 | const years = Math.floor(diffInSeconds / 31536000);
319 | return `${years} year${years > 1 ? 's' : ''} ago`;
320 | }
321 | }
--------------------------------------------------------------------------------
/components/projects/project-nav.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 | import Link from 'next/link'
5 | import { Button } from '@/components/ui/button'
6 | import { Avatar } from '@/components/ui/avatar'
7 | import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
8 | import { LogOut } from 'lucide-react'
9 | import { db } from '@/lib/constants'
10 | import { ModeToggle } from '@/components/ui/mode-toggle'
11 | import { useRouter } from 'next/navigation'
12 |
13 | export default function ProjectNav() {
14 | const router = useRouter()
15 | const { user } = db.useAuth()
16 |
17 | const handleSignOut = () => {
18 | db.auth.signOut()
19 | router.push('/')
20 | }
21 |
22 | return (
23 |
65 | )
66 | }
67 |
--------------------------------------------------------------------------------
/components/projects/project-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from '@/components/ui/skeleton'
2 | import { Card, CardContent, CardFooter } from '@/components/ui/card'
3 | import ProjectNav from '@/components/projects/project-nav'
4 |
5 | export default function ProjectSkeleton() {
6 | return (
7 |
8 |
9 |
10 |
16 |
17 |
18 |
19 |
20 | {[...Array(3)].map((_, i) => (
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | ))}
43 |
44 |
45 |
46 |
47 |
48 |
49 | {[...Array(8)].map((_, i) => (
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | ))}
72 |
73 |
74 |
75 |
76 | )
77 | }
78 |
--------------------------------------------------------------------------------
/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import { ThemeProvider as NextThemesProvider } from 'next-themes'
5 | import { type ThemeProviderProps } from 'next-themes/dist/types'
6 |
7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
8 | return {children}
9 | }
10 |
--------------------------------------------------------------------------------
/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { cva, type VariantProps } from 'class-variance-authority'
3 |
4 | import { cn } from '@/lib/utils'
5 |
6 | const alertVariants = cva(
7 | 'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7',
8 | {
9 | variants: {
10 | variant: {
11 | default: 'bg-background text-foreground',
12 | destructive: 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
13 | },
14 | },
15 | defaultVariants: {
16 | variant: 'default',
17 | },
18 | }
19 | )
20 |
21 | const Alert = React.forwardRef<
22 | HTMLDivElement,
23 | React.HTMLAttributes & VariantProps
24 | >(({ className, variant, ...props }, ref) => (
25 |
26 | ))
27 | Alert.displayName = 'Alert'
28 |
29 | const AlertTitle = React.forwardRef>(
30 | ({ className, ...props }, ref) => (
31 |
32 | )
33 | )
34 | AlertTitle.displayName = 'AlertTitle'
35 |
36 | const AlertDescription = React.forwardRef>(
37 | ({ className, ...props }, ref) => (
38 |
39 | )
40 | )
41 | AlertDescription.displayName = 'AlertDescription'
42 |
43 | export { Alert, AlertTitle, AlertDescription }
44 |
--------------------------------------------------------------------------------
/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as AvatarPrimitive from '@radix-ui/react-avatar'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
17 | ))
18 | Avatar.displayName = AvatarPrimitive.Root.displayName
19 |
20 | const AvatarImage = React.forwardRef<
21 | React.ElementRef,
22 | React.ComponentPropsWithoutRef
23 | >(({ className, ...props }, ref) => (
24 |
25 | ))
26 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
27 |
28 | const AvatarFallback = React.forwardRef<
29 | React.ElementRef,
30 | React.ComponentPropsWithoutRef
31 | >(({ className, ...props }, ref) => (
32 |
37 | ))
38 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
39 |
40 | export { Avatar, AvatarImage, AvatarFallback }
41 |
--------------------------------------------------------------------------------
/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { cva, type VariantProps } from 'class-variance-authority'
3 |
4 | import { cn } from '@/lib/utils'
5 |
6 | const badgeVariants = cva(
7 | 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
8 | {
9 | variants: {
10 | variant: {
11 | default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
12 | secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
13 | destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
14 | outline: 'text-foreground',
15 | },
16 | },
17 | defaultVariants: {
18 | variant: 'default',
19 | },
20 | }
21 | )
22 |
23 | export interface BadgeProps extends React.HTMLAttributes, VariantProps {}
24 |
25 | function Badge({ className, variant, ...props }: BadgeProps) {
26 | return
27 | }
28 |
29 | export { Badge, badgeVariants }
30 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Slot } from '@radix-ui/react-slot'
3 | import { cva, type VariantProps } from 'class-variance-authority'
4 |
5 | import { cn } from '@/lib/utils'
6 |
7 | const buttonVariants = cva(
8 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
9 | {
10 | variants: {
11 | variant: {
12 | default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
13 | destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
14 | outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
15 | secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
16 | ghost: 'hover:bg-accent hover:text-accent-foreground',
17 | link: 'text-primary underline-offset-4 hover:underline',
18 | },
19 | size: {
20 | default: 'h-9 px-4 py-2',
21 | sm: 'h-8 rounded-md px-3 text-xs',
22 | lg: 'h-10 rounded-md px-8',
23 | icon: 'h-9 w-9',
24 | },
25 | },
26 | defaultVariants: {
27 | variant: 'default',
28 | size: 'default',
29 | },
30 | }
31 | )
32 |
33 | export interface ButtonProps
34 | extends React.ButtonHTMLAttributes,
35 | VariantProps {
36 | asChild?: boolean
37 | }
38 |
39 | const Button = React.forwardRef(
40 | ({ className, variant, size, asChild = false, ...props }, ref) => {
41 | const Comp = asChild ? Slot : 'button'
42 | return
43 | }
44 | )
45 | Button.displayName = 'Button'
46 |
47 | export { Button, buttonVariants }
48 |
--------------------------------------------------------------------------------
/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '@/lib/utils'
4 |
5 | const Card = React.forwardRef>(({ className, ...props }, ref) => (
6 |
7 | ))
8 | Card.displayName = 'Card'
9 |
10 | const CardHeader = React.forwardRef>(
11 | ({ className, ...props }, ref) => (
12 |
13 | )
14 | )
15 | CardHeader.displayName = 'CardHeader'
16 |
17 | const CardTitle = React.forwardRef>(
18 | ({ className, ...props }, ref) => (
19 |
20 | )
21 | )
22 | CardTitle.displayName = 'CardTitle'
23 |
24 | const CardDescription = React.forwardRef>(
25 | ({ className, ...props }, ref) => (
26 |
27 | )
28 | )
29 | CardDescription.displayName = 'CardDescription'
30 |
31 | const CardContent = React.forwardRef>(
32 | ({ className, ...props }, ref) =>
33 | )
34 | CardContent.displayName = 'CardContent'
35 |
36 | const CardFooter = React.forwardRef>(
37 | ({ className, ...props }, ref) =>
38 | )
39 | CardFooter.displayName = 'CardFooter'
40 |
41 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
42 |
--------------------------------------------------------------------------------
/components/ui/context-menu.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
5 | import { CheckIcon, ChevronRightIcon, DotFilledIcon } from '@radix-ui/react-icons'
6 |
7 | import { cn } from '@/lib/utils'
8 |
9 | const ContextMenu = ContextMenuPrimitive.Root
10 |
11 | const ContextMenuTrigger = ContextMenuPrimitive.Trigger
12 |
13 | const ContextMenuGroup = ContextMenuPrimitive.Group
14 |
15 | const ContextMenuPortal = ContextMenuPrimitive.Portal
16 |
17 | const ContextMenuSub = ContextMenuPrimitive.Sub
18 |
19 | const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
20 |
21 | const ContextMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ))
40 | ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
41 |
42 | const ContextMenuSubContent = React.forwardRef<
43 | React.ElementRef,
44 | React.ComponentPropsWithoutRef
45 | >(({ className, ...props }, ref) => (
46 |
54 | ))
55 | ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
56 |
57 | const ContextMenuContent = React.forwardRef<
58 | React.ElementRef,
59 | React.ComponentPropsWithoutRef
60 | >(({ className, ...props }, ref) => (
61 |
62 |
70 |
71 | ))
72 | ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
73 |
74 | const ContextMenuItem = React.forwardRef<
75 | React.ElementRef,
76 | React.ComponentPropsWithoutRef & {
77 | inset?: boolean
78 | }
79 | >(({ className, inset, ...props }, ref) => (
80 |
89 | ))
90 | ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
91 |
92 | const ContextMenuCheckboxItem = React.forwardRef<
93 | React.ElementRef,
94 | React.ComponentPropsWithoutRef
95 | >(({ className, children, checked, ...props }, ref) => (
96 |
105 |
106 |
107 |
108 |
109 |
110 | {children}
111 |
112 | ))
113 | ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName
114 |
115 | const ContextMenuRadioItem = React.forwardRef<
116 | React.ElementRef,
117 | React.ComponentPropsWithoutRef
118 | >(({ className, children, ...props }, ref) => (
119 |
127 |
128 |
129 |
130 |
131 |
132 | {children}
133 |
134 | ))
135 | ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
136 |
137 | const ContextMenuLabel = React.forwardRef<
138 | React.ElementRef,
139 | React.ComponentPropsWithoutRef & {
140 | inset?: boolean
141 | }
142 | >(({ className, inset, ...props }, ref) => (
143 |
148 | ))
149 | ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
150 |
151 | const ContextMenuSeparator = React.forwardRef<
152 | React.ElementRef,
153 | React.ComponentPropsWithoutRef
154 | >(({ className, ...props }, ref) => (
155 |
156 | ))
157 | ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
158 |
159 | const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => {
160 | return
161 | }
162 | ContextMenuShortcut.displayName = 'ContextMenuShortcut'
163 |
164 | export {
165 | ContextMenu,
166 | ContextMenuTrigger,
167 | ContextMenuContent,
168 | ContextMenuItem,
169 | ContextMenuCheckboxItem,
170 | ContextMenuRadioItem,
171 | ContextMenuLabel,
172 | ContextMenuSeparator,
173 | ContextMenuShortcut,
174 | ContextMenuGroup,
175 | ContextMenuPortal,
176 | ContextMenuSub,
177 | ContextMenuSubContent,
178 | ContextMenuSubTrigger,
179 | ContextMenuRadioGroup,
180 | }
181 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as DialogPrimitive from '@radix-ui/react-dialog'
5 | import { Cross2Icon } from '@radix-ui/react-icons'
6 |
7 | import { cn } from '@/lib/utils'
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
57 |
58 | )
59 | DialogHeader.displayName = 'DialogHeader'
60 |
61 | const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
62 |
63 | )
64 | DialogFooter.displayName = 'DialogFooter'
65 |
66 | const DialogTitle = React.forwardRef<
67 | React.ElementRef,
68 | React.ComponentPropsWithoutRef
69 | >(({ className, ...props }, ref) => (
70 |
75 | ))
76 | DialogTitle.displayName = DialogPrimitive.Title.displayName
77 |
78 | const DialogDescription = React.forwardRef<
79 | React.ElementRef,
80 | React.ComponentPropsWithoutRef
81 | >(({ className, ...props }, ref) => (
82 |
83 | ))
84 | DialogDescription.displayName = DialogPrimitive.Description.displayName
85 |
86 | export {
87 | Dialog,
88 | DialogPortal,
89 | DialogOverlay,
90 | DialogTrigger,
91 | DialogClose,
92 | DialogContent,
93 | DialogHeader,
94 | DialogFooter,
95 | DialogTitle,
96 | DialogDescription,
97 | }
98 |
--------------------------------------------------------------------------------
/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
5 | import { CheckIcon, ChevronRightIcon, DotFilledIcon } from '@radix-ui/react-icons'
6 |
7 | import { cn } from '@/lib/utils'
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ))
40 | DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
41 |
42 | const DropdownMenuSubContent = React.forwardRef<
43 | React.ElementRef,
44 | React.ComponentPropsWithoutRef
45 | >(({ className, ...props }, ref) => (
46 |
54 | ))
55 | DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
56 |
57 | const DropdownMenuContent = React.forwardRef<
58 | React.ElementRef,
59 | React.ComponentPropsWithoutRef
60 | >(({ className, sideOffset = 4, ...props }, ref) => (
61 |
62 |
72 |
73 | ))
74 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
75 |
76 | const DropdownMenuItem = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef & {
79 | inset?: boolean
80 | }
81 | >(({ className, inset, ...props }, ref) => (
82 |
91 | ))
92 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
93 |
94 | const DropdownMenuCheckboxItem = React.forwardRef<
95 | React.ElementRef,
96 | React.ComponentPropsWithoutRef
97 | >(({ className, children, checked, ...props }, ref) => (
98 |
107 |
108 |
109 |
110 |
111 |
112 | {children}
113 |
114 | ))
115 | DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
116 |
117 | const DropdownMenuRadioItem = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, children, ...props }, ref) => (
121 |
129 |
130 |
131 |
132 |
133 |
134 | {children}
135 |
136 | ))
137 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
138 |
139 | const DropdownMenuLabel = React.forwardRef<
140 | React.ElementRef,
141 | React.ComponentPropsWithoutRef & {
142 | inset?: boolean
143 | }
144 | >(({ className, inset, ...props }, ref) => (
145 |
150 | ))
151 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
152 |
153 | const DropdownMenuSeparator = React.forwardRef<
154 | React.ElementRef,
155 | React.ComponentPropsWithoutRef
156 | >(({ className, ...props }, ref) => (
157 |
158 | ))
159 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
160 |
161 | const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => {
162 | return
163 | }
164 | DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
165 |
166 | export {
167 | DropdownMenu,
168 | DropdownMenuTrigger,
169 | DropdownMenuContent,
170 | DropdownMenuItem,
171 | DropdownMenuCheckboxItem,
172 | DropdownMenuRadioItem,
173 | DropdownMenuLabel,
174 | DropdownMenuSeparator,
175 | DropdownMenuShortcut,
176 | DropdownMenuGroup,
177 | DropdownMenuPortal,
178 | DropdownMenuSub,
179 | DropdownMenuSubContent,
180 | DropdownMenuSubTrigger,
181 | DropdownMenuRadioGroup,
182 | }
183 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '@/lib/utils'
4 |
5 | export interface InputProps extends React.InputHTMLAttributes {}
6 |
7 | const Input = React.forwardRef(({ className, type, ...props }, ref) => {
8 | return (
9 |
18 | )
19 | })
20 | Input.displayName = 'Input'
21 |
22 | export { Input }
23 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as LabelPrimitive from '@radix-ui/react-label'
5 | import { cva, type VariantProps } from 'class-variance-authority'
6 |
7 | import { cn } from '@/lib/utils'
8 |
9 | const labelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70')
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef & VariantProps
14 | >(({ className, ...props }, ref) => (
15 |
16 | ))
17 | Label.displayName = LabelPrimitive.Root.displayName
18 |
19 | export { Label }
20 |
--------------------------------------------------------------------------------
/components/ui/mode-toggle.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import { Moon, Sun } from 'lucide-react'
5 | import { useTheme } from 'next-themes'
6 | import { Button } from '@/components/ui/button'
7 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
8 |
9 | export function ModeToggle() {
10 | const { setTheme } = useTheme()
11 |
12 | return (
13 |
14 |
15 |
20 |
21 |
22 | setTheme('light')}>Light
23 | setTheme('dark')}>Dark
24 | setTheme('system')}>System
25 |
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as PopoverPrimitive from '@radix-ui/react-popover'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const Popover = PopoverPrimitive.Root
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger
11 |
12 | const PopoverAnchor = PopoverPrimitive.Anchor
13 |
14 | const PopoverContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
18 |
19 |
29 |
30 | ))
31 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
32 |
33 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
34 |
--------------------------------------------------------------------------------
/components/ui/rainbow-button.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 | interface RainbowButtonProps
5 | extends React.ButtonHTMLAttributes {}
6 |
7 | export function RainbowButton({
8 | children,
9 | className,
10 | ...props
11 | }: RainbowButtonProps) {
12 | return (
13 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/components/ui/resizable.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { DragHandleDots2Icon } from '@radix-ui/react-icons'
4 | import * as ResizablePrimitive from 'react-resizable-panels'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps) => (
9 |
13 | )
14 |
15 | const ResizablePanel = ResizablePrimitive.Panel
16 |
17 | const ResizableHandle = ({
18 | withHandle,
19 | className,
20 | ...props
21 | }: React.ComponentProps & {
22 | withHandle?: boolean
23 | }) => (
24 | div]:rotate-90',
27 | className
28 | )}
29 | {...props}
30 | >
31 | {withHandle && (
32 |
33 |
34 |
35 | )}
36 |
37 | )
38 |
39 | export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
40 |
--------------------------------------------------------------------------------
/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const ScrollArea = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
13 | {children}
14 |
15 |
16 |
17 | ))
18 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
19 |
20 | const ScrollBar = React.forwardRef<
21 | React.ElementRef,
22 | React.ComponentPropsWithoutRef
23 | >(({ className, orientation = 'vertical', ...props }, ref) => (
24 |
35 |
36 |
37 | ))
38 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
39 |
40 | export { ScrollArea, ScrollBar }
41 |
--------------------------------------------------------------------------------
/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import { CaretSortIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons'
5 | import * as SelectPrimitive from '@radix-ui/react-select'
6 |
7 | import { cn } from '@/lib/utils'
8 |
9 | const Select = SelectPrimitive.Root
10 |
11 | const SelectGroup = SelectPrimitive.Group
12 |
13 | const SelectValue = SelectPrimitive.Value
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 | span]:line-clamp-1',
23 | className
24 | )}
25 | {...props}
26 | >
27 | {children}
28 |
29 |
30 |
31 |
32 | ))
33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
34 |
35 | const SelectScrollUpButton = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
44 |
45 |
46 | ))
47 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
48 |
49 | const SelectScrollDownButton = React.forwardRef<
50 | React.ElementRef,
51 | React.ComponentPropsWithoutRef
52 | >(({ className, ...props }, ref) => (
53 |
58 |
59 |
60 | ))
61 | SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
62 |
63 | const SelectContent = React.forwardRef<
64 | React.ElementRef,
65 | React.ComponentPropsWithoutRef
66 | >(({ className, children, position = 'popper', ...props }, ref) => (
67 |
68 |
79 |
80 |
87 | {children}
88 |
89 |
90 |
91 |
92 | ))
93 | SelectContent.displayName = SelectPrimitive.Content.displayName
94 |
95 | const SelectLabel = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, ...props }, ref) => (
99 |
100 | ))
101 | SelectLabel.displayName = SelectPrimitive.Label.displayName
102 |
103 | const SelectItem = React.forwardRef<
104 | React.ElementRef,
105 | React.ComponentPropsWithoutRef
106 | >(({ className, children, ...props }, ref) => (
107 |
115 |
116 |
117 |
118 |
119 |
120 | {children}
121 |
122 | ))
123 | SelectItem.displayName = SelectPrimitive.Item.displayName
124 |
125 | const SelectSeparator = React.forwardRef<
126 | React.ElementRef,
127 | React.ComponentPropsWithoutRef
128 | >(({ className, ...props }, ref) => (
129 |
130 | ))
131 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
132 |
133 | export {
134 | Select,
135 | SelectGroup,
136 | SelectValue,
137 | SelectTrigger,
138 | SelectContent,
139 | SelectLabel,
140 | SelectItem,
141 | SelectSeparator,
142 | SelectScrollUpButton,
143 | SelectScrollDownButton,
144 | }
145 |
--------------------------------------------------------------------------------
/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as SeparatorPrimitive from '@radix-ui/react-separator'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
12 |
19 | ))
20 | Separator.displayName = SeparatorPrimitive.Root.displayName
21 |
22 | export { Separator }
23 |
--------------------------------------------------------------------------------
/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/lib/utils'
2 |
3 | function Skeleton({ className, ...props }: React.HTMLAttributes) {
4 | return
5 | }
6 |
7 | export { Skeleton }
8 |
--------------------------------------------------------------------------------
/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as SwitchPrimitives from '@radix-ui/react-switch'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ))
27 | Switch.displayName = SwitchPrimitives.Root.displayName
28 |
29 | export { Switch }
30 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '@/lib/utils'
4 |
5 | export interface TextareaProps extends React.TextareaHTMLAttributes {}
6 |
7 | const Textarea = React.forwardRef(({ className, ...props }, ref) => {
8 | return (
9 |
17 | )
18 | })
19 | Textarea.displayName = 'Textarea'
20 |
21 | export { Textarea }
22 |
--------------------------------------------------------------------------------
/components/ui/toggle.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as TogglePrimitive from '@radix-ui/react-toggle'
5 | import { cva, type VariantProps } from 'class-variance-authority'
6 |
7 | import { cn } from '@/lib/utils'
8 |
9 | const toggleVariants = cva(
10 | 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
11 | {
12 | variants: {
13 | variant: {
14 | default: 'bg-transparent',
15 | outline: 'border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground',
16 | },
17 | size: {
18 | default: 'h-9 px-3',
19 | sm: 'h-8 px-2',
20 | lg: 'h-10 px-3',
21 | },
22 | },
23 | defaultVariants: {
24 | variant: 'default',
25 | size: 'default',
26 | },
27 | }
28 | )
29 |
30 | const Toggle = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef & VariantProps
33 | >(({ className, variant, size, ...props }, ref) => (
34 |
35 | ))
36 |
37 | Toggle.displayName = TogglePrimitive.Root.displayName
38 |
39 | export { Toggle, toggleVariants }
40 |
--------------------------------------------------------------------------------
/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as TooltipPrimitive from '@radix-ui/react-tooltip'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = TooltipPrimitive.Root
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ))
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
31 |
--------------------------------------------------------------------------------
/contexts/FrontendContext.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import React, { createContext, useState, useContext, ReactNode, useEffect } from 'react'
3 | import { useRouter } from 'next/navigation'
4 | import { usePathname } from 'next/navigation'
5 | import { db } from '@/lib/constants'
6 | import { tx } from '@instantdb/react'
7 | // TODO: Add types
8 | const FrontendContext = createContext(undefined)
9 |
10 | export function FrontendProvider({ children }: { children: ReactNode }) {
11 | const { user, isLoading } = db.useAuth();
12 | const router = useRouter();
13 | const pathname = usePathname();
14 |
15 | useEffect(() => {
16 | if (!isLoading && user === null && pathname !== '/') {
17 | router.push('/login')
18 | }
19 | }, [isLoading, user, router, pathname])
20 |
21 | if (isLoading) {
22 | return null;
23 | }
24 |
25 | // useEffect(() => {
26 | // if (user) {
27 | // const userProperties = Object.entries(user).reduce((acc, [key, value]) => {
28 | // if (key !== 'id') {
29 | // acc[key] = value;
30 | // }
31 | // return acc;
32 | // }, {} as Record);
33 |
34 | // db.transact(tx.users[user.id].update(userProperties));
35 | // }
36 | // }, [user]);
37 |
38 | const value = {
39 | user,
40 | isLoading,
41 | }
42 | return {children}
43 | }
44 |
45 | export const useFrontend = () => {
46 | const context = useContext(FrontendContext)
47 | if (context === undefined) {
48 | throw new Error('useFrontend must be used within a FrontendProvider')
49 | }
50 | return context
51 | }
52 |
--------------------------------------------------------------------------------
/contexts/ProjectContext.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import React, { createContext, useContext, ReactNode } from 'react'
3 | import { useProjectData, useProjectFiles } from '@/hooks/data';
4 | import { useFrontend } from '@/contexts/FrontendContext';
5 | import { useRouter } from 'next/navigation';
6 | import { useEffect } from 'react';
7 |
8 | // TODO: Add better types
9 | interface ProjectContextType {
10 | project: any
11 | files: any
12 | isLoading: boolean
13 | error: any
14 | }
15 |
16 | const ProjectContext = createContext(undefined)
17 |
18 | export function ProjectProvider({ children, projectId }: { children: ReactNode; projectId: string }) {
19 | const { user } = useFrontend();
20 | const { data: projectData, isLoading: isProjectLoading, error: projectError } = useProjectData(projectId, user.id)
21 | const { data: filesData, isLoading: isFilesLoading, error: filesError } = useProjectFiles(projectId, user.id)
22 | const currentlyOpen = filesData?.files?.find((file) => file.isOpen === true)
23 |
24 | const router = useRouter();
25 | useEffect(() => {
26 | if (!isProjectLoading && !projectData?.projects.length && !isFilesLoading && !filesData?.files.length) {
27 | router.push('/404');
28 | }
29 | }, [isProjectLoading, projectData, isFilesLoading, filesData, router]);
30 |
31 | const value = {
32 | projectId,
33 | project: projectData?.projects[0],
34 | files: filesData?.files,
35 | isProjectLoading,
36 | isFilesLoading,
37 | currentlyOpen,
38 | error: projectError || filesError,
39 | }
40 |
41 | return {children}
42 | }
43 |
44 | export function useProject() {
45 | return useContext(ProjectContext)
46 | }
47 |
--------------------------------------------------------------------------------
/hooks/data.ts:
--------------------------------------------------------------------------------
1 | import { db } from '@/lib/constants'
2 | import { tx } from '@instantdb/react'
3 |
4 | export function useProjectData(projectId: string, userId: string) {
5 | return db.useQuery({
6 | projects: {
7 | $: {
8 | where: {
9 | id: projectId,
10 | user_id: userId,
11 | },
12 | },
13 | },
14 | })
15 | }
16 |
17 | export function useProjectFiles(projectId: string, userId: string) {
18 | return db.useQuery({
19 | files: {
20 | $: {
21 | where: {
22 | projectId: projectId,
23 | user_id: userId,
24 | },
25 | },
26 | },
27 | })
28 | }
29 |
30 | export function getAllProjects(userId: string) {
31 | return db.useQuery({
32 | projects: {
33 | $: {
34 | where: {
35 | user_id: userId,
36 | },
37 | },
38 | },
39 | })
40 | }
41 |
42 | export function getAllProjectFiles(projectId: string, userId: string) {
43 | return db.useQuery({
44 | files: {
45 | $: {
46 | where: {
47 | projectId: projectId,
48 | user_id: userId,
49 | },
50 | },
51 | },
52 | })
53 | }
54 |
55 |
56 | interface ProjectFields {
57 | [key: string]: any;
58 | }
59 |
60 | export function updateProject(projectId: string, fields: ProjectFields) {
61 | const updateObject: ProjectFields = {};
62 |
63 | for (const [key, value] of Object.entries(fields)) {
64 | updateObject[key] = value;
65 | }
66 |
67 | return db.transact([
68 | tx.projects[projectId].update(updateObject)
69 | ]);
70 | }
--------------------------------------------------------------------------------
/hooks/useDebounce.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useCallback, useRef } from 'react'
3 |
4 | export function useDebounce void>(callback: T, delay: number): T {
5 | const timeoutRef = useRef(null)
6 |
7 | return useCallback(
8 | (...args: Parameters) => {
9 | if (timeoutRef.current) {
10 | clearTimeout(timeoutRef.current)
11 | }
12 |
13 | timeoutRef.current = setTimeout(() => {
14 | callback(...args)
15 | }, delay)
16 | },
17 | [callback, delay]
18 | ) as T
19 | }
20 |
--------------------------------------------------------------------------------
/lib/constants.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { init } from '@instantdb/react'
3 |
4 | export const APP_ID = process.env.NEXT_PUBLIC_INSTANT_APP_ID as string
5 | export const db = init({ appId: APP_ID })
6 | export const RAILWAY_ENDPOINT_URL = process.env.NEXT_PUBLIC_RAILWAY_ENDPOINT_URL as string;
7 |
8 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx'
2 | import { twMerge } from 'tailwind-merge'
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/lib/utils/client-utils.ts:
--------------------------------------------------------------------------------
1 |
2 | export function createPathname(userId: string | undefined, locationId: string): string {
3 | if (!userId) {
4 | throw new Error('CreatePathname requires a userId')
5 | }
6 | return `${userId}/${locationId}/`
7 | }
8 |
9 | export function getFileExtension(filename: string): string {
10 | const parts = filename.split('.');
11 | return parts.length > 1 ? parts[parts.length - 1] : '';
12 | }
13 |
--------------------------------------------------------------------------------
/lib/utils/db-utils.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { db } from '@/lib/constants'
3 | import { createPreview } from '@/lib/utils/pdf-utils'
4 | import { pdfjs } from 'react-pdf'
5 | import { tx } from '@instantdb/react'
6 |
7 | // Set the worker source
8 | pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs'
9 |
10 | export async function savePdfToStorage(blob: Blob, pathname: string, projectId: string): Promise {
11 |
12 | const pdfFile = new File([blob], 'main.pdf', { type: blob.type })
13 | const expiresAt = Date.now() + 30 * 60 * 1000; // 30 minutes
14 | await db.storage.upload(pathname, pdfFile)
15 | const downloadURL = await db.storage.getDownloadUrl(pathname)
16 | db.transact([
17 | tx.projects[projectId].update({
18 | cachedPdfUrl: downloadURL,
19 | cachedPdfExpiresAt: expiresAt,
20 | last_compiled: new Date().toISOString()
21 | })
22 | ])
23 | }
24 |
25 | export async function savePreviewToStorage(blob: Blob, pathname: string, projectId: string): Promise {
26 | const pdfDocument = await pdfjs.getDocument({ data: await blob.arrayBuffer() }).promise
27 | const { previewFile } = await createPreview(pdfDocument, pathname)
28 | const expiresAt = Date.now() + 30 * 60 * 1000; // 30 minutes
29 | await db.storage.upload(pathname, previewFile)
30 | const downloadURL = await db.storage.getDownloadUrl(pathname)
31 | db.transact([
32 | tx.projects[projectId].update({
33 | cachedPreviewUrl: downloadURL,
34 | cachedPreviewExpiresAt: expiresAt
35 | })
36 | ])
37 | }
38 |
39 | export async function deleteFileFromStorage(pathname: string): Promise {
40 | await db.storage.delete(pathname)
41 | }
42 |
--------------------------------------------------------------------------------
/lib/utils/pdf-utils.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import '@ungap/with-resolvers';
3 | import { PDFDocumentProxy } from 'pdfjs-dist'
4 | import { RAILWAY_ENDPOINT_URL } from '@/lib/constants'
5 |
6 | export async function createPreview(
7 | pdfDocument: PDFDocumentProxy,
8 | pathname: string
9 | ): Promise<{ previewFile: File; previewPathname: string }> {
10 | const page = await pdfDocument.getPage(1)
11 | const viewport = page.getViewport({ scale: 1.0 })
12 | const canvas = document.createElement('canvas')
13 | const context = canvas.getContext('2d')
14 | canvas.height = viewport.height
15 | canvas.width = viewport.width
16 |
17 | if (!context) {
18 | throw new Error('Failed to get canvas context')
19 | }
20 |
21 | await page.render({ canvasContext: context, viewport: viewport }).promise
22 |
23 | const previewBlob = await new Promise((resolve, reject) => {
24 | canvas.toBlob(
25 | (blob) => {
26 | if (blob) resolve(blob)
27 | else reject(new Error('Failed to create blob'))
28 | },
29 | 'image/webp',
30 | 0.8
31 | )
32 | })
33 |
34 | const previewFile = new File([previewBlob], 'preview.webp', { type: 'image/webp' })
35 | const previewPathname = `${pathname}/preview.webp`
36 |
37 | return { previewFile, previewPathname }
38 | }
39 |
40 | interface EditorFiles {
41 | [key: string]: any;
42 | }
43 |
44 | export async function fetchPdf(files: EditorFiles) {
45 | if (!files.some((file: EditorFiles) => file.name === 'main.tex')) {
46 | const errorData = {
47 | error: 'Missing File',
48 | message: 'No main.tex file found',
49 | details: 'The main.tex file is required for LaTeX compilation.'
50 | };
51 | console.error('Error fetching PDF:', errorData);
52 | throw new Error(`${errorData.error}: ${errorData.message}\n\nDetails: ${errorData.details}`);
53 | }
54 |
55 | const formData = new FormData();
56 |
57 | await Promise.all(files.map(async (file: EditorFiles) => {
58 | if (file.type === 'file') {
59 | const extension = file.name.split('.').pop()?.toLowerCase();
60 | let mimeType: string;
61 |
62 | switch (extension) {
63 | case 'tex':
64 | mimeType = 'text/plain';
65 | break;
66 | case 'png':
67 | mimeType = 'image/png';
68 | break;
69 | case 'jpg':
70 | case 'jpeg':
71 | mimeType = 'image/jpeg';
72 | break;
73 | case 'svg':
74 | mimeType = 'image/svg+xml';
75 | break;
76 | default:
77 | throw new Error(`Unsupported file type: ${extension}`);
78 | }
79 |
80 | const pathname = file.pathname;
81 |
82 | let blob: Blob;
83 | if (extension !== 'tex' && typeof file.content === 'string') {
84 | // For images, file.content is a URL
85 | const response = await fetch(file.content);
86 | blob = await response.blob();
87 | } else {
88 | // For .tex files, create blob from content
89 | blob = new Blob([file.content], { type: mimeType });
90 | }
91 | formData.append(pathname, blob);
92 | }
93 | }));
94 |
95 | const response = await fetch(RAILWAY_ENDPOINT_URL, {
96 | method: 'POST',
97 | body: formData,
98 | });
99 |
100 | if (!response.ok) {
101 | const errorData = await response.json();
102 | throw new Error(`${errorData.error}: ${errorData.message}\n\nDetails: ${errorData.details}`);
103 | }
104 |
105 | return response.blob();
106 | }
107 |
108 |
109 | export function containsMainTex(files: File[]): boolean {
110 | return files.some(file => file.name === 'main.tex');
111 | }
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | domains: ['instant-storage.s3.amazonaws.com'],
5 | },
6 | }
7 |
8 | module.exports = nextConfig
9 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {}
3 |
4 | export default nextConfig
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "skaarf",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@ai-sdk/anthropic": "^0.0.47",
13 | "@ai-sdk/openai": "^0.0.49",
14 | "@instantdb/react": "^0.12.13",
15 | "@monaco-editor/react": "^4.6.0",
16 | "@radix-ui/react-avatar": "^1.1.0",
17 | "@radix-ui/react-context-menu": "^2.2.1",
18 | "@radix-ui/react-dialog": "^1.1.1",
19 | "@radix-ui/react-dropdown-menu": "^2.1.1",
20 | "@radix-ui/react-icons": "^1.3.0",
21 | "@radix-ui/react-label": "^2.1.0",
22 | "@radix-ui/react-popover": "^1.1.1",
23 | "@radix-ui/react-scroll-area": "^1.1.0",
24 | "@radix-ui/react-select": "^2.1.1",
25 | "@radix-ui/react-separator": "^1.1.0",
26 | "@radix-ui/react-slot": "^1.1.0",
27 | "@radix-ui/react-switch": "^1.1.0",
28 | "@radix-ui/react-toggle": "^1.1.0",
29 | "@radix-ui/react-tooltip": "^1.1.2",
30 | "@splinetool/react-spline": "^4.0.0",
31 | "@ungap/with-resolvers": "^0.1.0",
32 | "@vercel/analytics": "^1.3.1",
33 | "ai": "^3.3.11",
34 | "class-variance-authority": "^0.7.0",
35 | "clsx": "^2.1.1",
36 | "framer-motion": "^11.3.28",
37 | "lucide-react": "^0.429.0",
38 | "monaco-latex": "^0.0.1",
39 | "nanoid": "^5.0.7",
40 | "next": "14.2.5",
41 | "next-themes": "^0.3.0",
42 | "react": "^18",
43 | "react-arborist": "^3.4.0",
44 | "react-dom": "^18",
45 | "react-pdf": "^9.1.0",
46 | "react-resizable-panels": "^2.1.1",
47 | "tailwind-merge": "^2.5.2",
48 | "tailwindcss-animate": "^1.0.7"
49 | },
50 | "devDependencies": {
51 | "@types/node": "^20",
52 | "@types/react": "^18",
53 | "@types/react-dom": "^18",
54 | "postcss": "^8",
55 | "prettier": "3.3.3",
56 | "tailwindcss": "^3.4.1",
57 | "typescript": "^5"
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | }
7 |
8 | export default config
9 |
--------------------------------------------------------------------------------
/public/article_preview.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shelwinsunga/jules/d23959d0f6bcec525300e0dce3172a086c55dd7c/public/article_preview.webp
--------------------------------------------------------------------------------
/public/blank_preview.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shelwinsunga/jules/d23959d0f6bcec525300e0dce3172a086c55dd7c/public/blank_preview.webp
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shelwinsunga/jules/d23959d0f6bcec525300e0dce3172a086c55dd7c/public/favicon.ico
--------------------------------------------------------------------------------
/public/hero.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shelwinsunga/jules/d23959d0f6bcec525300e0dce3172a086c55dd7c/public/hero.png
--------------------------------------------------------------------------------
/public/letter_preview.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shelwinsunga/jules/d23959d0f6bcec525300e0dce3172a086c55dd7c/public/letter_preview.webp
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/placeholder.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/proposal_preview.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shelwinsunga/jules/d23959d0f6bcec525300e0dce3172a086c55dd7c/public/proposal_preview.webp
--------------------------------------------------------------------------------
/public/report_preview.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shelwinsunga/jules/d23959d0f6bcec525300e0dce3172a086c55dd7c/public/report_preview.webp
--------------------------------------------------------------------------------
/public/resume_preview.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shelwinsunga/jules/d23959d0f6bcec525300e0dce3172a086c55dd7c/public/resume_preview.webp
--------------------------------------------------------------------------------
/public/tex.tsx:
--------------------------------------------------------------------------------
1 | const Tex = () => {
2 | return (
3 |
10 | )
11 | }
12 |
13 | export default Tex
14 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/railway-api/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # poetry
98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 |
104 | # pdm
105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106 | #pdm.lock
107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108 | # in version control.
109 | # https://pdm.fming.dev/#use-with-ide
110 | .pdm.toml
111 |
112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113 | __pypackages__/
114 |
115 | # Celery stuff
116 | celerybeat-schedule
117 | celerybeat.pid
118 |
119 | # SageMath parsed files
120 | *.sage.py
121 |
122 | # Environments
123 | .env
124 | .venv
125 | env/
126 | venv/
127 | ENV/
128 | env.bak/
129 | venv.bak/
130 |
131 | # Spyder project settings
132 | .spyderproject
133 | .spyproject
134 |
135 | # Rope project settings
136 | .ropeproject
137 |
138 | # mkdocs documentation
139 | /site
140 |
141 | # mypy
142 | .mypy_cache/
143 | .dmypy.json
144 | dmypy.json
145 |
146 | # Pyre type checker
147 | .pyre/
148 |
149 | # pytype static type analyzer
150 | .pytype/
151 |
152 | # Cython debug symbols
153 | cython_debug/
154 |
155 | # PyCharm
156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158 | # and can be added to the global gitignore or merged into this file. For a more nuclear
159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160 | #.idea/
--------------------------------------------------------------------------------
/railway-api/main.py:
--------------------------------------------------------------------------------
1 | from flask import Flask, send_file, request
2 | import os
3 | import subprocess
4 | import tempfile
5 | from flask_cors import CORS
6 |
7 | app = Flask(__name__)
8 |
9 | CORS(app)
10 |
11 | @app.route('/', methods=['POST'])
12 | def latex_to_pdf():
13 | print("Received request to convert LaTeX to PDF")
14 | files = request.files
15 |
16 | if 'main.tex' not in files:
17 | print("No main.tex file provided in the request")
18 | return {
19 | "error": "No main.tex file provided",
20 | "message": "Please upload a main.tex file."
21 | }, 400
22 |
23 | with tempfile.TemporaryDirectory() as temp_dir:
24 | print(f"Created temporary directory at {temp_dir}")
25 | generated_files = []
26 | try:
27 | for filename, file in files.items():
28 | file_path = os.path.join(temp_dir, filename)
29 | os.makedirs(os.path.dirname(file_path), exist_ok=True)
30 | file.save(file_path)
31 | generated_files.append(file_path)
32 | print(f"Saved file {filename} to {file_path}")
33 |
34 | input_file = os.path.join(temp_dir, 'main.tex')
35 | print(f"Input file path set to {input_file}")
36 | try:
37 | print("Running pdflatex for the first time")
38 | subprocess.run(['pdflatex', '-shell-escape', '-output-directory', temp_dir, input_file],
39 | check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
40 | env=dict(os.environ, PATH=f"{os.environ['PATH']}:/usr/bin:/usr/local/bin"),
41 | text=True)
42 |
43 | print("Running pdflatex for the second time")
44 | result = subprocess.run(['pdflatex', '-shell-escape', '-output-directory', temp_dir, input_file],
45 | check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
46 | env=dict(os.environ, PATH=f"{os.environ['PATH']}:/usr/bin:/usr/local/bin"),
47 | text=True)
48 |
49 | # Track generated files
50 | for ext in ['.aux', '.log', '.pdf']:
51 | generated_files.append(os.path.join(temp_dir, f'main{ext}'))
52 |
53 | pdf_path = os.path.join(temp_dir, 'main.pdf')
54 | print(f"PDF generated successfully at {pdf_path}")
55 | return send_file(pdf_path,
56 | mimetype='application/pdf',
57 | as_attachment=True,
58 | download_name='output.pdf')
59 | except subprocess.CalledProcessError as e:
60 | output = e.stdout + e.stderr
61 | print(f"Error generating PDF: {e}\nOutput: {output}")
62 | return {
63 | "error": "Error generating PDF",
64 | "message": str(e),
65 | "details": output
66 | }, 500
67 | finally:
68 | # Manually remove tracked temporary files
69 | for file_path in generated_files:
70 | try:
71 | if os.path.isfile(file_path) or os.path.islink(file_path):
72 | os.unlink(file_path)
73 | elif os.path.isdir(file_path):
74 | os.rmdir(file_path)
75 | print(f"Removed temporary file {file_path}")
76 | except Exception as e:
77 | print(f"Failed to remove temporary file {file_path}. Reason: {e}")
78 |
79 | if __name__ == '__main__':
80 | print("Starting Flask app")
81 | app.run(debug=True)
--------------------------------------------------------------------------------
/railway-api/railway.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://railway.app/railway.schema.json",
3 | "build": {
4 | "builder": "NIXPACKS",
5 | "nixpacksPlan": {
6 | "phases": {
7 | "setup": {
8 | "nixPkgs": ["python3", "texliveFull"]
9 | }
10 | }
11 | }
12 | },
13 | "deploy": {
14 | "startCommand": "hypercorn main:app --bind \"[::]:$PORT\""
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/railway-api/requirements.txt:
--------------------------------------------------------------------------------
1 | hypercorn==0.14.4
2 | click==8.1.7
3 | Flask==3.0.3
4 | itsdangerous==2.2.0
5 | Jinja2==3.1.3
6 | MarkupSafe==2.1.5
7 | Werkzeug==3.0.3
8 | Flask-Cors==4.0.1
9 | Pygments==2.17.2
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss'
2 |
3 | const config = {
4 | darkMode: ['class'],
5 | content: ['./pages/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', './app/**/*.{ts,tsx}', './src/**/*.{ts,tsx}'],
6 | prefix: '',
7 | theme: {
8 | container: {
9 | center: true,
10 | padding: '2rem',
11 | screens: {
12 | '2xl': '1400px'
13 | }
14 | },
15 | extend: {
16 | colors: {
17 | border: 'hsl(var(--border))',
18 | input: 'hsl(var(--input))',
19 | ring: 'hsl(var(--ring))',
20 | background: 'hsl(var(--background))',
21 | foreground: 'hsl(var(--foreground))',
22 | primary: {
23 | DEFAULT: 'hsl(var(--primary))',
24 | foreground: 'hsl(var(--primary-foreground))'
25 | },
26 | secondary: {
27 | DEFAULT: 'hsl(var(--secondary))',
28 | foreground: 'hsl(var(--secondary-foreground))'
29 | },
30 | destructive: {
31 | DEFAULT: 'hsl(var(--destructive))',
32 | foreground: 'hsl(var(--destructive-foreground))'
33 | },
34 | muted: {
35 | DEFAULT: 'hsl(var(--muted))',
36 | foreground: 'hsl(var(--muted-foreground))'
37 | },
38 | accent: {
39 | DEFAULT: 'hsl(var(--accent))',
40 | foreground: 'hsl(var(--accent-foreground))'
41 | },
42 | popover: {
43 | DEFAULT: 'hsl(var(--popover))',
44 | foreground: 'hsl(var(--popover-foreground))'
45 | },
46 | card: {
47 | DEFAULT: 'hsl(var(--card))',
48 | foreground: 'hsl(var(--card-foreground))'
49 | },
50 | 'color-1': 'hsl(var(--color-1))',
51 | 'color-2': 'hsl(var(--color-2))',
52 | 'color-3': 'hsl(var(--color-3))',
53 | 'color-4': 'hsl(var(--color-4))',
54 | 'color-5': 'hsl(var(--color-5))'
55 | },
56 | borderRadius: {
57 | lg: 'var(--radius)',
58 | md: 'calc(var(--radius) - 2px)',
59 | sm: 'calc(var(--radius) - 4px)'
60 | },
61 | keyframes: {
62 | 'accordion-down': {
63 | from: {
64 | height: '0'
65 | },
66 | to: {
67 | height: 'var(--radix-accordion-content-height)'
68 | }
69 | },
70 | 'accordion-up': {
71 | from: {
72 | height: 'var(--radix-accordion-content-height)'
73 | },
74 | to: {
75 | height: '0'
76 | }
77 | },
78 | rainbow: {
79 | '0%': {
80 | 'background-position': '0%'
81 | },
82 | '100%': {
83 | 'background-position': '200%'
84 | }
85 | }
86 | },
87 | animation: {
88 | 'accordion-down': 'accordion-down 0.2s ease-out',
89 | 'accordion-up': 'accordion-up 0.2s ease-out',
90 | rainbow: 'rainbow var(--speed, 2s) infinite linear'
91 | }
92 | }
93 | },
94 | plugins: [require('tailwindcss-animate')],
95 | } satisfies Config
96 |
97 | export default config
98 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------