diff --git a/bun.lock b/bun.lock index d982e96..d0ffc47 100644 --- a/bun.lock +++ b/bun.lock @@ -17,11 +17,9 @@ "@vercel/analytics": "^1.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "cmdk": "^1.1.1", "drizzle-orm": "^0.42.0", "lucide-react": "^0.488.0", "monaco-editor": "^0.52.2", - "monaco-editor-auto-typings": "^0.4.6", "nanoid": "^5.1.5", "next": "15.3.6", "next-themes": "^0.4.6", @@ -332,8 +330,6 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], - "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -422,8 +418,6 @@ "monaco-editor": ["monaco-editor@0.52.2", "", {}, "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ=="], - "monaco-editor-auto-typings": ["monaco-editor-auto-typings@0.4.6", "", { "peerDependencies": { "monaco-editor": "*" } }, "sha512-yN6yP2oQJkpyZtUyi5qT9AjZLILrTZWOFny7Km2yNiPMPqOWq/znkTS4b5Yk1sHumSaLJUw5m1EPKiopRqtEpA=="], - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="], diff --git a/migrations/0003_slim_tag.sql b/migrations/0003_slim_tag.sql new file mode 100644 index 0000000..7b68f50 --- /dev/null +++ b/migrations/0003_slim_tag.sql @@ -0,0 +1 @@ +ALTER TABLE "paste" ADD COLUMN "tabs" jsonb; \ No newline at end of file diff --git a/migrations/meta/0003_snapshot.json b/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..694ca9c --- /dev/null +++ b/migrations/meta/0003_snapshot.json @@ -0,0 +1,62 @@ +{ + "id": "a7d3d0b9-8280-4e41-80db-5ac3173effd2", + "prevId": "92d8f011-0a26-4d8c-b443-cc2f3711850c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.paste": { + "name": "paste", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tabs": { + "name": "tabs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index e112556..be982af 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1745172454939, "tag": "0002_fine_lady_vermin", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1774019339525, + "tag": "0003_slim_tag", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index bef8b08..f884064 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "project-4", + "name": "spongebin", "version": "0.1.0", "private": true, "scripts": { @@ -22,11 +22,9 @@ "@vercel/analytics": "^1.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "cmdk": "^1.1.1", "drizzle-orm": "^0.42.0", "lucide-react": "^0.488.0", "monaco-editor": "^0.52.2", - "monaco-editor-auto-typings": "^0.4.6", "nanoid": "^5.1.5", "next": "15.3.8", "next-themes": "^0.4.6", diff --git a/src/actions/paste.ts b/src/actions/paste.ts index 32c9136..04d7550 100644 --- a/src/actions/paste.ts +++ b/src/actions/paste.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import { db } from "~/db/drizzle"; import { paste } from "~/db/schema"; +import type { PasteTab } from "~/utils/paste-tabs"; export const getPasteById = async (id: string) => { const pasteData = await db @@ -14,17 +15,22 @@ export const getPasteById = async (id: string) => { return pasteData[0]; }; -export const addPaste = async ( - content: string, - language: string, - theme: string, -) => { +export const addPaste = async ({ + tabs, + theme, +}: { + tabs: PasteTab[]; + theme: string; +}) => { + const primaryTab = tabs[0]; + const pasteData = await db .insert(paste) .values({ - content, - language, + content: primaryTab?.content ?? "", + language: primaryTab?.language ?? "text", theme, + tabs, }) .returning({ id: paste.id }); diff --git a/src/app/[id]/page.tsx b/src/app/[id]/page.tsx index 2a6f624..6f02b02 100644 --- a/src/app/[id]/page.tsx +++ b/src/app/[id]/page.tsx @@ -3,6 +3,9 @@ import { EditorProvider } from "~/components/editor-provider"; import { MonacoEditor } from "~/components/monaco-editor"; import { getPasteById } from "~/actions/paste"; import { Header } from "~/components/header"; +import { SITE_URL } from "~/constants/site"; +import { LANGUAGES_SET, type LanguageName } from "~/utils/languages"; +import { createEmptyTab, normalizeTabs } from "~/utils/paste-tabs"; interface Props { params: Promise<{ id: string }>; @@ -14,14 +17,34 @@ export default async function PastePage({ params }: Props) { if (!paste) redirect("/"); + const tabs = normalizeTabs(paste.tabs); + const safePasteLanguage: LanguageName = LANGUAGES_SET.has( + paste.language as LanguageName, + ) + ? (paste.language as LanguageName) + : "text"; + const initialTabs = + tabs.length > 0 + ? tabs + : [ + { + ...createEmptyTab(1, safePasteLanguage), + content: paste.content, + }, + ]; + return ( -
- +
+
+
+ +
+
); } @@ -29,21 +52,57 @@ export default async function PastePage({ params }: Props) { export async function generateMetadata({ params }: Props) { const { id } = await params; const paste = await getPasteById(id); + const ogImage = { url: "/sponge.png", alt: "spongebin" }; if (!paste) return { title: "spongebin", description: "a pastebin made with sponge", - openGraph: { images: "/sponge.png" }, - twitter: { card: "summary" }, + openGraph: { + type: "website", + locale: "en_US", + url: SITE_URL, + siteName: "spongebin", + title: "spongebin", + description: "a pastebin made with sponge", + images: [ogImage], + }, + twitter: { + card: "summary", + title: "spongebin", + description: "a pastebin made with sponge", + images: ["/sponge.png"], + }, }; - const numLines = paste.content.split("\n").length; + const tabs = normalizeTabs(paste.tabs); + const totalTabs = tabs.length || 1; + const totalLines = tabs.length + ? tabs.reduce((sum, tab) => sum + tab.content.split("\n").length, 0) + : paste.content.split("\n").length; + + const title = `spongebin • ${paste.id}`; + const description = `a paste containing ${totalTabs} file${totalTabs === 1 ? "" : "s"} and ${totalLines} lines`; + const canonical = `${SITE_URL}/${paste.id}`; return { - title: `spongebin • ${paste.id}`, - description: `a paste containing ${numLines} lines of ${paste.language}`, - openGraph: { images: "/sponge.png" }, - twitter: { card: "summary" }, + title, + description, + alternates: { canonical }, + openGraph: { + type: "website", + locale: "en_US", + url: canonical, + siteName: "spongebin", + title, + description, + images: [ogImage], + }, + twitter: { + card: "summary", + title, + description, + images: ["/sponge.png"], + }, }; } diff --git a/src/app/api/paste/route.ts b/src/app/api/paste/route.ts index 25130b3..af18812 100644 --- a/src/app/api/paste/route.ts +++ b/src/app/api/paste/route.ts @@ -1,34 +1,58 @@ import { NextRequest, NextResponse } from "next/server"; -import { LANGUAGES } from "~/utils/languages"; +import { LANGUAGES_SET, type LanguageName } from "~/utils/languages"; import { addPaste } from "~/actions/paste"; import { THEME_MAP } from "~/utils/themes"; +import { normalizeTabs } from "~/utils/paste-tabs"; + +const isLanguageName = (value: unknown): value is LanguageName => + typeof value === "string" && LANGUAGES_SET.has(value as LanguageName); export async function POST(request: NextRequest) { try { const body = await request.json(); + const tabs = normalizeTabs(body.tabs); - if (!body.content) + if (!tabs.length && !body.content) return NextResponse.json( - { error: "Content is required" }, + { error: "At least one file is required" }, { status: 400 }, ); - const language = body.language || "text"; const theme = body.theme || "catppuccin-mocha"; + const normalizedTabs = + tabs.length > 0 + ? tabs + : [ + { + id: crypto.randomUUID(), + filename: "paste.txt", + language: + typeof body.language === "string" && + isLanguageName(body.language) + ? body.language + : "text", + content: body.content, + }, + ]; - if (!LANGUAGES.includes(language)) - return NextResponse.json( - { error: `Invalid language: ${language}` }, - { status: 400 }, - ); + for (const tab of normalizedTabs) { + if (!isLanguageName(tab.language)) + return NextResponse.json( + { error: `Invalid language: ${tab.language}` }, + { status: 400 }, + ); + } - if (!Object.keys(THEME_MAP).includes(theme)) + if (!(theme in THEME_MAP)) return NextResponse.json( { error: `Invalid theme: ${theme}` }, { status: 400 }, ); - const paste = await addPaste(body.content, language, theme); + const paste = await addPaste({ + tabs: normalizedTabs, + theme, + }); if (!paste.id) return NextResponse.json( diff --git a/src/app/globals.css b/src/app/globals.css index 46e9d8e..95da7a8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -74,7 +74,8 @@ } * { - font-family: "JetBrains Mono", "SF Mono", "Monaco", "Inconsolata", "Fira Code", + font-family: + "JetBrains Mono", "SF Mono", "Monaco", "Inconsolata", "Fira Code", "Fira Mono", "Droid Sans Mono", "Source Code Pro", "Consolas", "DejaVu Sans Mono", monospace; font-feature-settings: "cv11", "cv13"; @@ -123,3 +124,13 @@ @apply bg-background text-foreground; } } + +@layer utilities { + .scrollbar-none { + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ece62a6..d7d7b9f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,12 +2,30 @@ import "./globals.css"; import type { Metadata, Viewport } from "next"; import { Analytics } from "@vercel/analytics/next"; import { Toaster } from "~/components/ui/sonner"; +import { SITE_URL } from "~/constants/site"; + +const site = new URL(SITE_URL); export const metadata: Metadata = { + metadataBase: site, + alternates: { canonical: SITE_URL }, title: "spongebin", description: "a pastebin made with sponge", - openGraph: { images: "/sponge.png" }, - twitter: { card: "summary" }, + openGraph: { + type: "website", + locale: "en_US", + url: SITE_URL, + siteName: "spongebin", + title: "spongebin", + description: "a pastebin made with sponge", + images: [{ url: "/sponge.png", alt: "spongebin" }], + }, + twitter: { + card: "summary", + title: "spongebin", + description: "a pastebin made with sponge", + images: ["/sponge.png"], + }, }; export const viewport: Viewport = { diff --git a/src/app/page.tsx b/src/app/page.tsx index 4fadb62..6d20783 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,8 +5,12 @@ import { Header } from "~/components/header"; export default function Home() { return ( -
- +
+
+
+ +
+
); } diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..2998cd8 --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,12 @@ +import type { MetadataRoute } from "next"; +import { SITE_URL } from "~/constants/site"; + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + allow: "/", + }, + sitemap: `${SITE_URL}/sitemap.xml`, + }; +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..4d3846b --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,13 @@ +import type { MetadataRoute } from "next"; +import { SITE_URL } from "~/constants/site"; + +export default function sitemap(): MetadataRoute.Sitemap { + return [ + { + url: SITE_URL, + lastModified: new Date(), + changeFrequency: "weekly", + priority: 1, + }, + ]; +} diff --git a/src/components/editor-provider.tsx b/src/components/editor-provider.tsx index a494f4d..5beeef3 100644 --- a/src/components/editor-provider.tsx +++ b/src/components/editor-provider.tsx @@ -1,52 +1,154 @@ "use client"; -import { createContext, useContext, useState, ReactNode } from "react"; +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; +import { type LanguageName } from "~/utils/languages"; +import { + createEmptyTab, + inferLanguage, + LANGUAGE_EXTENSIONS, + replaceFilenameExtension, + type PasteTab, +} from "~/utils/paste-tabs"; interface EditorContextType { - content: string; - setContent: (content: string) => void; - language: string; - setLanguage: (language: string) => void; + tabs: PasteTab[]; + activeTab: PasteTab; + activeTabId: string; + setActiveTabId: (tabId: string) => void; + updateActiveTabContent: (content: string) => void; + updateActiveTabLanguage: (language: LanguageName) => void; + updateActiveTabFilename: (filename: string) => void; + addTab: () => void; + closeTab: (tabId: string) => void; theme: string; setTheme: (theme: string) => void; + wordWrap: boolean; + setWordWrap: (value: boolean) => void; } const EditorContext = createContext(undefined); interface EditorProviderProps { children: ReactNode; - initialContent?: string; - initialLanguage?: string; + initialTabs?: PasteTab[]; + initialActiveTabId?: string | null; initialTheme?: string; } export function EditorProvider({ children, - initialContent = "", - initialLanguage = "typescript", + initialTabs, + initialActiveTabId, initialTheme = "catppuccin-mocha", }: EditorProviderProps) { - const [content, setContent] = useState(initialContent); - const [language, setLanguage] = useState(initialLanguage); + const [tabs, setTabs] = useState(() => + initialTabs?.length ? initialTabs : [createEmptyTab(1)], + ); + const [activeTabId, setActiveTabId] = useState( + initialActiveTabId ?? initialTabs?.[0]?.id ?? "", + ); const [theme, setTheme] = useState(initialTheme); + const [wordWrap, setWordWrap] = useState(false); - const value = { - content, - setContent, - language, - setLanguage, - theme, - setTheme, + const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0]!; + + useEffect(() => { + if (!tabs.some((tab) => tab.id === activeTabId)) { + setActiveTabId(tabs[0]!.id); + } + }, [activeTabId, tabs]); + + const updateTab = (tabId: string, updater: (tab: PasteTab) => PasteTab) => { + setTabs((currentTabs) => + currentTabs.map((tab) => (tab.id === tabId ? updater(tab) : tab)), + ); + }; + + const updateActiveTabContent = (content: string) => { + updateTab(activeTab.id, (tab) => ({ ...tab, content })); + }; + + const updateActiveTabLanguage = (language: LanguageName) => { + updateTab(activeTab.id, (tab) => { + const currentExtension = tab.filename.split(".").pop()?.toLowerCase(); + const currentLanguageExtension = LANGUAGE_EXTENSIONS[tab.language]; + + return { + ...tab, + language, + filename: + currentExtension === currentLanguageExtension + ? replaceFilenameExtension(tab.filename, language) + : tab.filename, + }; + }); + }; + + const updateActiveTabFilename = (filename: string) => { + updateTab(activeTab.id, (tab) => ({ + ...tab, + filename, + language: inferLanguage(filename) ?? tab.language, + })); + }; + + const addTab = () => { + const nextTab = createEmptyTab(tabs.length + 1, activeTab.language); + setTabs((currentTabs) => [...currentTabs, nextTab]); + setActiveTabId(nextTab.id); + }; + + const closeTab = (tabId: string) => { + setTabs((currentTabs) => { + if (currentTabs.length === 1) return currentTabs; + + const tabIndex = currentTabs.findIndex((tab) => tab.id === tabId); + const nextTabs = currentTabs.filter((tab) => tab.id !== tabId); + + if (tabId === activeTabId) { + const nextActiveTab = + nextTabs[Math.max(0, tabIndex - 1)] ?? nextTabs[0]; + + if (nextActiveTab) setActiveTabId(nextActiveTab.id); + } + + return nextTabs; + }); }; return ( - {children} + + {children} + ); } export function useEditor() { const context = useContext(EditorContext); - if (context === undefined) + + if (!context) throw new Error("useEditor must be used within an EditorProvider"); return context; diff --git a/src/components/editor-tabs.tsx b/src/components/editor-tabs.tsx new file mode 100644 index 0000000..7ca9ab6 --- /dev/null +++ b/src/components/editor-tabs.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { Plus, X } from "lucide-react"; +import { useEditor } from "~/components/editor-provider"; +import { SearchableSelect } from "~/components/searchable-select"; +import { Button } from "~/components/ui/button"; +import { cn } from "~/utils/cn"; + +export function EditorTabs() { + const { + tabs, + activeTab, + activeTabId, + setActiveTabId, + updateActiveTabFilename, + addTab, + closeTab, + } = useEditor(); + const hasMultipleTabs = tabs.length > 1; + + return ( +
+
+ ({ + value: tab.id, + label: tab.filename, + }))} + placeholder="files" + value={activeTabId} + onValueChange={setActiveTabId} + onPreview={setActiveTabId} + className="min-w-0 flex-1" + /> + + + + {hasMultipleTabs && ( + + )} +
+ +
+
+ {hasMultipleTabs && + tabs.map((tab) => { + const isActive = tab.id === activeTabId; + const nameForWidth = isActive ? activeTab.filename : tab.filename; + const labelWidthCh = Math.min( + 24, + Math.max(10, nameForWidth.length + 3), + ); + const labelWidthStyle = { + width: `${labelWidthCh}ch`, + minWidth: `${labelWidthCh}ch`, + } as const; + + return ( +
+ {isActive ? ( + + updateActiveTabFilename(event.target.value) + } + onClick={(event) => event.stopPropagation()} + className="placeholder:text-primary-foreground/70 min-w-0 bg-transparent px-3 py-0 text-sm font-medium leading-none outline-none focus-visible:ring-0" + style={labelWidthStyle} + placeholder="file.ts" + aria-label="Filename" + /> + ) : ( + + )} + + +
+ ); + })} + +
+ +
+
+
+
+ ); +} diff --git a/src/components/header.tsx b/src/components/header.tsx index 5dde8d0..9b37ce0 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -1,69 +1,120 @@ "use client"; +import { WrapText } from "lucide-react"; import Link from "next/link"; import { useEditor } from "./editor-provider"; +import { EditorTabs } from "./editor-tabs"; import { Button, buttonVariants } from "~/components/ui/button"; import { SearchableSelect } from "./searchable-select"; -import { LANGUAGE_NAMES } from "~/utils/languages"; +import { LANGUAGES, LANGUAGES_SET, type LanguageName } from "~/utils/languages"; import { THEME_MAP } from "~/utils/themes"; import { SaveButton } from "./save-button"; import { Icons } from "./icons"; import { cn } from "~/utils/cn"; -export function Header() { - const { language, theme, content, setLanguage, setTheme } = useEditor(); +function LanguageThemeControls() { + const { activeTab, theme, updateActiveTabLanguage, setTheme } = useEditor(); + + const setLanguage = (language: string) => { + if (LANGUAGES_SET.has(language as LanguageName)) + updateActiveTabLanguage(language as LanguageName); + }; return ( -
-
- - - + <> + ({ + value: language, + label: language, + }))} + placeholder="language" + value={activeTab.language} + onValueChange={setLanguage} + onPreview={setLanguage} + className="w-full min-w-0 sm:w-40" + /> - - - -
- -
- ({ value: l, label: l }))} - placeholder="language" - value={language} - onValueChange={setLanguage} - onPreview={setLanguage} - className="w-full sm:w-40" - /> - - ({ - value: t, - label: t, - }))} - placeholder="theme" - value={theme} - onValueChange={setTheme} - onPreview={setTheme} - className="w-full sm:w-52" - /> -
-
+ ({ + value: currentTheme, + label: currentTheme, + }))} + placeholder="theme" + value={theme} + onValueChange={setTheme} + onPreview={setTheme} + className="w-full min-w-0 sm:w-52" + /> + + ); +} + +export function Header() { + const { tabs, theme, wordWrap, setWordWrap } = useEditor(); + + return ( + <> +
+
+
+ + + + + + + + + +
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ ); } diff --git a/src/components/monaco-editor.tsx b/src/components/monaco-editor.tsx index c7485a7..42df81a 100644 --- a/src/components/monaco-editor.tsx +++ b/src/components/monaco-editor.tsx @@ -1,28 +1,35 @@ "use client"; -import { - AutoTypings, - LocalStorageCache, -} from "monaco-editor-auto-typings/custom-editor"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Editor, type Monaco } from "@monaco-editor/react"; import { shikiToMonaco } from "@shikijs/monaco"; import { createHighlighter } from "shiki"; -import { LANGUAGES, LANGUAGE_NAMES } from "~/utils/languages"; +import { LANGUAGES, MONACO_LANGUAGES } from "~/utils/languages"; import { useEditor } from "./editor-provider"; import { THEME_MAP } from "~/utils/themes"; export function MonacoEditor() { - const { language, theme, content, setContent } = useEditor(); + const { + activeTab, + activeTabId, + closeTab, + theme, + wordWrap, + updateActiveTabContent, + } = useEditor(); const [isLoading, setIsLoading] = useState(true); + const activeTabIdRef = useRef(activeTabId); + activeTabIdRef.current = activeTabId; + const closeTabRef = useRef(closeTab); + closeTabRef.current = closeTab; useEffect(() => { - const colors = THEME_MAP[theme].ui; + const colors = THEME_MAP[theme]?.ui; if (!colors) return; - Object.entries(colors).forEach(([key, value]) => { + for (const [key, value] of Object.entries(colors)) { document.documentElement.style.setProperty(`--${key}`, value); - }); + } }, [theme]); const handleEditorDidMount = async (monaco: Monaco) => { @@ -32,11 +39,11 @@ export function MonacoEditor() { .filter(([key]) => key !== theme) .map(([key, value]) => value.theme ?? key); - LANGUAGE_NAMES.forEach((l) => monaco.languages.register({ id: l })); + LANGUAGES.forEach((l) => monaco.languages.register({ id: l })); const highlighter = await createHighlighter({ themes: [currentTheme, ...restThemes], - langs: LANGUAGES, + langs: [...MONACO_LANGUAGES], }); shikiToMonaco(highlighter, monaco); @@ -54,7 +61,7 @@ export function MonacoEditor() { }; return ( -
+
{isLoading && (
@@ -65,21 +72,17 @@ export function MonacoEditor() { )} setContent(val || "")} - onMount={async (editor, monaco) => { - handleEditorDidMount(monaco).catch(console.error); - await AutoTypings.create(editor, { - sourceCache: new LocalStorageCache(), - monaco, - }); - }} + language={activeTab.language} + value={activeTab.content} + onChange={(val) => updateActiveTabContent(val || "")} + onMount={async (_editor, monaco) => await handleEditorDidMount(monaco)} options={{ fontSize: 14, - wordWrap: "off", + wordWrap: wordWrap ? "on" : "off", minimap: { enabled: false }, automaticLayout: true, bracketPairColorization: { diff --git a/src/components/save-button.tsx b/src/components/save-button.tsx index d92cf47..ede8499 100644 --- a/src/components/save-button.tsx +++ b/src/components/save-button.tsx @@ -1,30 +1,25 @@ "use client"; import { useRouter } from "next/navigation"; -import { useEffect } from "react"; +import { useCallback, useEffect } from "react"; import { toast } from "sonner"; import { Button } from "~/components/ui/button"; import { addPaste } from "~/actions/paste"; +import type { PasteTab } from "~/utils/paste-tabs"; interface SaveButtonProps { - content: string; - language: string; + tabs: PasteTab[]; theme: string; className?: string; } -export function SaveButton({ - content, - language, - theme, - className, -}: SaveButtonProps) { +export function SaveButton({ tabs, theme, className }: SaveButtonProps) { const router = useRouter(); - const handleSave = async () => { + const handleSave = useCallback(async () => { try { - if (!content) return; - const result = await addPaste(content, language, theme); + if (!tabs.some((tab) => tab.content.trim())) return; + const result = await addPaste({ tabs, theme }); if (!result.id) return; const url = `${window.location.origin}/${result.id}`; @@ -36,18 +31,18 @@ export function SaveButton({ console.error("Failed to save paste:", error); toast("failed to save paste"); } - }; + }, [tabs, theme, router]); useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { + const onKeyDown = (e: KeyboardEvent) => { if (e.key !== "s" || !(e.ctrlKey || e.metaKey)) return; e.preventDefault(); - handleSave(); + void handleSave(); }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [content, language, theme]); + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [handleSave]); return (