From e2e702829a470ee51b129b9c378a74e239110ff9 Mon Sep 17 00:00:00 2001 From: DuroCodes Date: Fri, 20 Mar 2026 11:24:34 -0400 Subject: [PATCH 1/9] feat: add editor tabs --- bun.lock | 3 - migrations/0003_slim_tag.sql | 1 + migrations/meta/0003_snapshot.json | 62 +++++++++++++ migrations/meta/_journal.json | 7 ++ package.json | 3 +- src/actions/paste.ts | 20 ++-- src/app/[id]/page.tsx | 41 +++++++-- src/app/api/paste/route.ts | 45 ++++++--- src/app/page.tsx | 8 +- src/components/editor-provider.tsx | 134 +++++++++++++++++++++++---- src/components/editor-tabs.tsx | 141 +++++++++++++++++++++++++++++ src/components/header.tsx | 114 +++++++++++++---------- src/components/monaco-editor.tsx | 42 +++++---- src/components/save-button.tsx | 31 +++---- src/db/schema.ts | 4 +- src/utils/languages.ts | 137 ++-------------------------- src/utils/paste-tabs.ts | 128 ++++++++++++++++++++++++++ 17 files changed, 654 insertions(+), 267 deletions(-) create mode 100644 migrations/0003_slim_tag.sql create mode 100644 migrations/meta/0003_snapshot.json create mode 100644 src/components/editor-tabs.tsx create mode 100644 src/utils/paste-tabs.ts diff --git a/bun.lock b/bun.lock index d982e96..9a7d39a 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,6 @@ "@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", @@ -332,8 +331,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=="], 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 3bf92a2..98ba595 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "project-4", + "name": "spongebin", "version": "0.1.0", "private": true, "scripts": { @@ -22,7 +22,6 @@ "@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", 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..a82ab25 100644 --- a/src/app/[id]/page.tsx +++ b/src/app/[id]/page.tsx @@ -3,6 +3,11 @@ import { EditorProvider } from "~/components/editor-provider"; import { MonacoEditor } from "~/components/monaco-editor"; import { getPasteById } from "~/actions/paste"; import { Header } from "~/components/header"; +import { LANGUAGES_SET, type LanguageName } from "~/utils/languages"; +import { + createEmptyTab, + normalizeTabs, +} from "~/utils/paste-tabs"; interface Props { params: Promise<{ id: string }>; @@ -14,14 +19,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 ( -
- +
+
+
+ +
+
); } @@ -38,11 +63,15 @@ export async function generateMetadata({ params }: Props) { twitter: { card: "summary" }, }; - 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; return { title: `spongebin • ${paste.id}`, - description: `a paste containing ${numLines} lines of ${paste.language}`, + description: `a paste containing ${totalTabs} file${totalTabs === 1 ? "" : "s"} and ${totalLines} lines`, openGraph: { images: "/sponge.png" }, twitter: { card: "summary" }, }; diff --git a/src/app/api/paste/route.ts b/src/app/api/paste/route.ts index 25130b3..4a3686e 100644 --- a/src/app/api/paste/route.ts +++ b/src/app/api/paste/route.ts @@ -1,34 +1,57 @@ 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/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/components/editor-provider.tsx b/src/components/editor-provider.tsx index a494f4d..36f07b7 100644 --- a/src/components/editor-provider.tsx +++ b/src/components/editor-provider.tsx @@ -1,12 +1,31 @@ "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_TO_EXTENSION, + 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; } @@ -15,32 +34,109 @@ 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 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_TO_EXTENSION[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} + ); } diff --git a/src/components/editor-tabs.tsx b/src/components/editor-tabs.tsx new file mode 100644 index 0000000..123dbcd --- /dev/null +++ b/src/components/editor-tabs.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { Plus, X } from "lucide-react"; +import { useEditor } from "~/components/editor-provider"; +import { SearchableSelect } from "~/components/searchable-select"; +import { cn } from "~/utils/cn"; + +const iconBtnClass = + "border-input dark:bg-input/30 text-muted-foreground hover:text-foreground inline-flex h-9 w-9 items-center justify-center rounded-md border bg-background shadow-xs transition-colors"; + +export function EditorTabs() { + const { + tabs, + activeTab, + activeTabId, + setActiveTabId, + updateActiveTabFilename, + addTab, + closeTab, + } = useEditor(); + const hasMultipleTabs = tabs.length > 1; + + return ( +
+
+ {hasMultipleTabs ? ( + ({ + value: tab.id, + label: tab.filename, + }))} + placeholder="files" + value={activeTabId} + onValueChange={setActiveTabId} + onPreview={setActiveTabId} + className="min-w-0 flex-1" + /> + ) : ( + + {activeTab.filename} + + )} + + {hasMultipleTabs && ( + + )} + + +
+ +
+
+ {hasMultipleTabs && + tabs.map((tab) => { + const isActive = tab.id === activeTabId; + + return ( +
+ {isActive ? ( + + updateActiveTabFilename(event.target.value) + } + onClick={(event) => event.stopPropagation()} + className="placeholder:text-primary-foreground/70 bg-transparent px-3 text-sm font-medium outline-none" + style={{ + width: `${Math.min( + 24, + Math.max(10, activeTab.filename.length + 3), + )}ch`, + }} + placeholder="file.ts" + aria-label="Filename" + /> + ) : ( + + )} + + +
+ ); + })} + + +
+
+
+ ); +} diff --git a/src/components/header.tsx b/src/components/header.tsx index 5dde8d0..1f322cf 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -2,68 +2,82 @@ 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 } 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(); + const { tabs, activeTab, theme, updateActiveTabLanguage, setTheme } = + useEditor(); + + const setLanguage = (language: string) => { + if (LANGUAGES_SET.has(language)) updateActiveTabLanguage(language); + }; return ( -
-
- - - +
+
+
+ + + - + - + +
+ +
+ +
+ +
+ ({ + value: language, + label: language, + }))} + placeholder="language" + value={activeTab.language} + onValueChange={setLanguage} + onPreview={setLanguage} + className="w-full xl:w-40" + /> + + ({ + value: currentTheme, + label: currentTheme, + }))} + placeholder="theme" + value={theme} + onValueChange={setTheme} + onPreview={setTheme} + className="w-full xl:w-52" + /> +
- -
- ({ 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" - /> -
-
+
); } diff --git a/src/components/monaco-editor.tsx b/src/components/monaco-editor.tsx index c7485a7..b6ff91a 100644 --- a/src/components/monaco-editor.tsx +++ b/src/components/monaco-editor.tsx @@ -8,21 +8,21 @@ import { useEffect, 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, theme, updateActiveTabContent } = useEditor(); const [isLoading, setIsLoading] = useState(true); 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 +32,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 +54,7 @@ export function MonacoEditor() { }; return ( -
+
{isLoading && (
@@ -65,17 +65,25 @@ export function MonacoEditor() { )} setContent(val || "")} + language={activeTab.language} + value={activeTab.content} + onChange={(val) => updateActiveTabContent(val || "")} onMount={async (editor, monaco) => { - handleEditorDidMount(monaco).catch(console.error); - await AutoTypings.create(editor, { - sourceCache: new LocalStorageCache(), - monaco, - }); + try { + await handleEditorDidMount(monaco); + if (!editor.getModel()) return; + + await AutoTypings.create(editor, { + sourceCache: new LocalStorageCache(), + monaco, + }); + } catch (error) { + console.warn("AutoTypings init failed (ignored):", error); + } }} options={{ fontSize: 14, 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 ( - )} - - + + + + {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 bg-transparent px-3 text-sm font-medium outline-none" - style={{ - width: `${Math.min( - 24, - Math.max(10, activeTab.filename.length + 3), - )}ch`, - }} + 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 1f322cf..bccf5a7 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -1,5 +1,6 @@ "use client"; +import { WrapText } from "lucide-react"; import Link from "next/link"; import { useEditor } from "./editor-provider"; import { EditorTabs } from "./editor-tabs"; @@ -11,73 +12,108 @@ import { SaveButton } from "./save-button"; import { Icons } from "./icons"; import { cn } from "~/utils/cn"; -export function Header() { - const { tabs, activeTab, theme, updateActiveTabLanguage, setTheme } = - useEditor(); +function LanguageThemeControls() { + const { activeTab, theme, updateActiveTabLanguage, setTheme } = useEditor(); const setLanguage = (language: string) => { if (LANGUAGES_SET.has(language)) updateActiveTabLanguage(language); }; return ( -
-
-
- - - + <> + ({ + value: language, + label: language, + }))} + placeholder="language" + value={activeTab.language} + onValueChange={setLanguage} + onPreview={setLanguage} + className="w-full min-w-0 sm:w-40" + /> - - - -
- -
- -
- -
- ({ - value: language, - label: language, - }))} - placeholder="language" - value={activeTab.language} - onValueChange={setLanguage} - onPreview={setLanguage} - className="w-full xl:w-40" - /> - - ({ - value: currentTheme, - label: currentTheme, - }))} - placeholder="theme" - value={theme} - onValueChange={setTheme} - onPreview={setTheme} - className="w-full xl: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 b6ff91a..8c01e94 100644 --- a/src/components/monaco-editor.tsx +++ b/src/components/monaco-editor.tsx @@ -4,7 +4,7 @@ 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"; @@ -13,8 +13,19 @@ import { useEditor } from "./editor-provider"; import { THEME_MAP } from "~/utils/themes"; export function MonacoEditor() { - const { activeTab, theme, updateActiveTabContent } = 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; @@ -87,7 +98,7 @@ export function MonacoEditor() { }} options={{ fontSize: 14, - wordWrap: "off", + wordWrap: wordWrap ? "on" : "off", minimap: { enabled: false }, automaticLayout: true, bracketPairColorization: { diff --git a/src/utils/paste-tabs.ts b/src/utils/paste-tabs.ts index f89d2b1..52e06d5 100644 --- a/src/utils/paste-tabs.ts +++ b/src/utils/paste-tabs.ts @@ -54,13 +54,10 @@ export const LANGUAGE_EXTENSIONS: Record = { sfm: "sfm", }; -const toSafeExtension = (language: LanguageName) => - language.toLowerCase().replace(/[^a-z0-9]+/g, ""); - export const LANGUAGE_TO_EXTENSION = Object.fromEntries( LANGUAGES.map((language) => [ language, - LANGUAGE_EXTENSIONS[language] ?? toSafeExtension(language), + LANGUAGE_EXTENSIONS[language] ?? language.toLowerCase(), ]), ) as Record; @@ -77,7 +74,7 @@ export const inferLanguage = (filename: string) => { const extension = filename.trim().split(".").pop()?.toLowerCase(); if (!extension || extension === filename.trim().toLowerCase()) return null; - return EXTENSION_TO_LANGUAGE[extension] ?? null; + return EXTENSION_TO_LANGUAGE[extension] ?? "text"; }; export const replaceFilenameExtension = ( From 61dccc773e17041f676a7c0f160ea647d1d2355b Mon Sep 17 00:00:00 2001 From: DuroCodes Date: Fri, 20 Mar 2026 16:42:14 -0400 Subject: [PATCH 3/9] chore: remove monaco-editor-auto-typings since it doesn't work too well --- bun.lock | 3 --- package.json | 1 - src/components/monaco-editor.tsx | 18 +----------------- 3 files changed, 1 insertion(+), 21 deletions(-) diff --git a/bun.lock b/bun.lock index 9a7d39a..d0ffc47 100644 --- a/bun.lock +++ b/bun.lock @@ -20,7 +20,6 @@ "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", @@ -419,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/package.json b/package.json index 98ba595..545a6e5 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,6 @@ "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", diff --git a/src/components/monaco-editor.tsx b/src/components/monaco-editor.tsx index 8c01e94..42df81a 100644 --- a/src/components/monaco-editor.tsx +++ b/src/components/monaco-editor.tsx @@ -1,9 +1,5 @@ "use client"; -import { - AutoTypings, - LocalStorageCache, -} from "monaco-editor-auto-typings/custom-editor"; import { useEffect, useRef, useState } from "react"; import { Editor, type Monaco } from "@monaco-editor/react"; import { shikiToMonaco } from "@shikijs/monaco"; @@ -83,19 +79,7 @@ export function MonacoEditor() { language={activeTab.language} value={activeTab.content} onChange={(val) => updateActiveTabContent(val || "")} - onMount={async (editor, monaco) => { - try { - await handleEditorDidMount(monaco); - if (!editor.getModel()) return; - - await AutoTypings.create(editor, { - sourceCache: new LocalStorageCache(), - monaco, - }); - } catch (error) { - console.warn("AutoTypings init failed (ignored):", error); - } - }} + onMount={async (_editor, monaco) => await handleEditorDidMount(monaco)} options={{ fontSize: 14, wordWrap: wordWrap ? "on" : "off", From 7b2d411372a9c1ff65495f41d4514615bfde9737 Mon Sep 17 00:00:00 2001 From: DuroCodes Date: Fri, 20 Mar 2026 22:09:26 -0400 Subject: [PATCH 4/9] fix: make `LANGUAGES` use explicit strings, so it doesn't resolve to `string`, so the extension set must require all languages. (thanks @jacoobes) --- src/components/header.tsx | 5 +-- src/utils/languages.ts | 2 +- src/utils/paste-tabs.ts | 67 +++++++++++++++++++++++++++++++-------- 3 files changed, 57 insertions(+), 17 deletions(-) diff --git a/src/components/header.tsx b/src/components/header.tsx index bccf5a7..e6983bf 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -6,7 +6,7 @@ import { useEditor } from "./editor-provider"; import { EditorTabs } from "./editor-tabs"; import { Button, buttonVariants } from "~/components/ui/button"; import { SearchableSelect } from "./searchable-select"; -import { LANGUAGES, LANGUAGES_SET } 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"; @@ -16,7 +16,8 @@ function LanguageThemeControls() { const { activeTab, theme, updateActiveTabLanguage, setTheme } = useEditor(); const setLanguage = (language: string) => { - if (LANGUAGES_SET.has(language)) updateActiveTabLanguage(language); + if (LANGUAGES_SET.has(language as LanguageName)) + updateActiveTabLanguage(language as LanguageName); }; return ( diff --git a/src/utils/languages.ts b/src/utils/languages.ts index df28d0f..07092ae 100644 --- a/src/utils/languages.ts +++ b/src/utils/languages.ts @@ -88,6 +88,6 @@ const BUILTIN_LANGUAGE_NAMES = [ ] as const; export const MONACO_LANGUAGES = [...BUILTIN_LANGUAGE_NAMES, sfm] as const; -export const LANGUAGES = [...BUILTIN_LANGUAGE_NAMES, sfm.name] as const; +export const LANGUAGES = [...BUILTIN_LANGUAGE_NAMES, "sfm"] as const; export const LANGUAGES_SET = new Set(LANGUAGES); export type LanguageName = (typeof LANGUAGES)[number]; diff --git a/src/utils/paste-tabs.ts b/src/utils/paste-tabs.ts index 52e06d5..18afa1f 100644 --- a/src/utils/paste-tabs.ts +++ b/src/utils/paste-tabs.ts @@ -1,4 +1,4 @@ -import { LANGUAGES, LANGUAGES_SET, type LanguageName } from "~/utils/languages"; +import { LANGUAGES_SET, type LanguageName } from "~/utils/languages"; export interface PasteTab { id: string; @@ -7,59 +7,96 @@ export interface PasteTab { content: string; } -export const LANGUAGE_EXTENSIONS: Record = { +export const LANGUAGE_EXTENSIONS = { text: "txt", + abap: "abap", + ada: "ada", + apl: "apl", asm: "s", + astro: "astro", bat: "bat", + bibtex: "bib", + blade: "blade.php", c: "c", - "c#": "cs", - "c++": "cpp", + clojure: "clj", + cobol: "cob", coffeescript: "coffee", "common-lisp": "lisp", + "c++": "cpp", + crystal: "cr", + "c#": "cs", css: "css", + d: "d", + dart: "dart", dax: "dax", diff: "diff", + elixir: "ex", + elm: "elm", + erlang: "erl", "f#": "fs", + gleam: "gleam", + go: "go", graphql: "graphql", + groovy: "groovy", + hack: "hack", + haskell: "hs", + haxe: "hx", html: "html", + java: "java", javascript: "js", + jinja: "jinja", json: "json", json5: "json5", jsx: "jsx", + julia: "jl", + kotlin: "kt", latex: "tex", log: "log", + lua: "lua", markdown: "md", + matlab: "m", mdx: "mdx", mermaid: "mmd", + mojo: "mojo", + nim: "nim", + nix: "nix", + ocaml: "ml", + pascal: "pas", + perl: "pl", + php: "php", powershell: "ps1", + prisma: "prisma", + purescript: "purs", python: "py", r: "r", razor: "cshtml", ruby: "rb", rust: "rs", + scala: "scala", scheme: "scm", scss: "scss", shellscript: "sh", solidity: "sol", sql: "sql", + svelte: "svelte", + swift: "swift", toml: "toml", tsx: "tsx", typescript: "ts", + typst: "typ", + v: "v", vb: "vb", vue: "vue", wasm: "wat", wolfram: "wl", xml: "xml", yaml: "yml", + zig: "zig", sfm: "sfm", -}; +} as const satisfies Record; -export const LANGUAGE_TO_EXTENSION = Object.fromEntries( - LANGUAGES.map((language) => [ - language, - LANGUAGE_EXTENSIONS[language] ?? language.toLowerCase(), - ]), -) as Record; +export const LANGUAGE_TO_EXTENSION: Record = + LANGUAGE_EXTENSIONS; const EXTENSION_TO_LANGUAGE = Object.fromEntries( Object.entries(LANGUAGE_TO_EXTENSION).map(([language, extension]) => [ @@ -110,9 +147,11 @@ export const normalizeTabs = (tabs: unknown) => const fallbackTab = createEmptyTab(index + 1); const filename = candidate.filename?.trim() ?? fallbackTab.filename; - const language = LANGUAGES_SET.has(candidate.language ?? "") - ? (candidate.language ?? "") - : (inferLanguage(filename) ?? "text"); + const rawLanguage = candidate.language ?? ""; + const language: LanguageName = + rawLanguage !== "" && LANGUAGES_SET.has(rawLanguage as LanguageName) + ? (rawLanguage as LanguageName) + : (inferLanguage(filename) ?? "text"); return [ { From 1ec46f13ce4e48f1b84dbba41570929afe9b604a Mon Sep 17 00:00:00 2001 From: DuroCodes Date: Sat, 21 Mar 2026 00:08:49 -0400 Subject: [PATCH 5/9] fix: fix compound extensions reverting to wrong languages, such as `.blade.php` -> `php` (thanks @Tann2019) --- src/components/editor-provider.tsx | 4 +-- src/utils/paste-tabs.ts | 43 ++++++++++++++++++++++-------- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/components/editor-provider.tsx b/src/components/editor-provider.tsx index 95db728..5beeef3 100644 --- a/src/components/editor-provider.tsx +++ b/src/components/editor-provider.tsx @@ -11,7 +11,7 @@ import { type LanguageName } from "~/utils/languages"; import { createEmptyTab, inferLanguage, - LANGUAGE_TO_EXTENSION, + LANGUAGE_EXTENSIONS, replaceFilenameExtension, type PasteTab, } from "~/utils/paste-tabs"; @@ -77,7 +77,7 @@ export function EditorProvider({ const updateActiveTabLanguage = (language: LanguageName) => { updateTab(activeTab.id, (tab) => { const currentExtension = tab.filename.split(".").pop()?.toLowerCase(); - const currentLanguageExtension = LANGUAGE_TO_EXTENSION[tab.language]; + const currentLanguageExtension = LANGUAGE_EXTENSIONS[tab.language]; return { ...tab, diff --git a/src/utils/paste-tabs.ts b/src/utils/paste-tabs.ts index 18afa1f..61ffbaa 100644 --- a/src/utils/paste-tabs.ts +++ b/src/utils/paste-tabs.ts @@ -95,23 +95,34 @@ export const LANGUAGE_EXTENSIONS = { sfm: "sfm", } as const satisfies Record; -export const LANGUAGE_TO_EXTENSION: Record = - LANGUAGE_EXTENSIONS; - -const EXTENSION_TO_LANGUAGE = Object.fromEntries( - Object.entries(LANGUAGE_TO_EXTENSION).map(([language, extension]) => [ +const EXTENSION_LANGUAGES = Object.fromEntries( + Object.entries(LANGUAGE_EXTENSIONS).map(([language, extension]) => [ extension, language, ]), ) as Record; +const COMPOUND_EXTENSIONS = ( + Object.entries(LANGUAGE_EXTENSIONS) as [LanguageName, string][] +) + .filter(([, ext]) => ext.includes(".")) + .sort((a, b) => b[1].length - a[1].length); + const createTabId = () => crypto.randomUUID(); export const inferLanguage = (filename: string) => { - const extension = filename.trim().split(".").pop()?.toLowerCase(); - if (!extension || extension === filename.trim().toLowerCase()) return null; + const trimmed = filename.trim(); + if (!trimmed) return null; + const lower = trimmed.toLowerCase(); - return EXTENSION_TO_LANGUAGE[extension] ?? "text"; + for (const [language, ext] of COMPOUND_EXTENSIONS) { + if (lower.endsWith(`.${ext}`)) return language; + } + + const extension = lower.split(".").pop(); + if (!extension || extension === lower) return null; + + return EXTENSION_LANGUAGES[extension] ?? "text"; }; export const replaceFilenameExtension = ( @@ -119,12 +130,22 @@ export const replaceFilenameExtension = ( language: LanguageName, ) => { const trimmedFilename = filename.trim(); - if (!trimmedFilename) return `file.${LANGUAGE_TO_EXTENSION[language]}`; + if (!trimmedFilename) return `file.${LANGUAGE_EXTENSIONS[language]}`; + + const newExt = LANGUAGE_EXTENSIONS[language]; + const lower = trimmedFilename.toLowerCase(); + + for (const [, ext] of COMPOUND_EXTENSIONS) { + if (lower.endsWith(`.${ext}`)) { + const cut = trimmedFilename.length - (ext.length + 1); + return `${trimmedFilename.slice(0, cut)}.${newExt}`; + } + } const lastDotIndex = trimmedFilename.lastIndexOf("."); if (lastDotIndex <= 0) return trimmedFilename; - return `${trimmedFilename.slice(0, lastDotIndex)}.${LANGUAGE_TO_EXTENSION[language]}`; + return `${trimmedFilename.slice(0, lastDotIndex)}.${newExt}`; }; export const createEmptyTab = ( @@ -132,7 +153,7 @@ export const createEmptyTab = ( language: LanguageName = "typescript", ) => ({ id: createTabId(), - filename: `file${index}.${LANGUAGE_TO_EXTENSION[language]}`, + filename: `file${index}.${LANGUAGE_EXTENSIONS[language]}`, language, content: "", }); From 358cf7a743bfb6c1cef73f8afe185852afec2734 Mon Sep 17 00:00:00 2001 From: DuroCodes Date: Sat, 21 Mar 2026 11:55:19 -0400 Subject: [PATCH 6/9] style: format some files --- src/app/api/paste/route.ts | 3 ++- src/components/ui/sonner.tsx | 14 +++++++------- src/utils/cn.ts | 8 +++----- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/app/api/paste/route.ts b/src/app/api/paste/route.ts index 4a3686e..af18812 100644 --- a/src/app/api/paste/route.ts +++ b/src/app/api/paste/route.ts @@ -27,7 +27,8 @@ export async function POST(request: NextRequest) { id: crypto.randomUUID(), filename: "paste.txt", language: - typeof body.language === "string" && isLanguageName(body.language) + typeof body.language === "string" && + isLanguageName(body.language) ? body.language : "text", content: body.content, diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index 957524e..f125983 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -1,10 +1,10 @@ -"use client" +"use client"; -import { useTheme } from "next-themes" -import { Toaster as Sonner, ToasterProps } from "sonner" +import { useTheme } from "next-themes"; +import { Toaster as Sonner, ToasterProps } from "sonner"; const Toaster = ({ ...props }: ToasterProps) => { - const { theme = "system" } = useTheme() + const { theme = "system" } = useTheme(); return ( { } {...props} /> - ) -} + ); +}; -export { Toaster } +export { Toaster }; diff --git a/src/utils/cn.ts b/src/utils/cn.ts index bd0c391..a500a73 100644 --- a/src/utils/cn.ts +++ b/src/utils/cn.ts @@ -1,6 +1,4 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) -} +export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); From bd2ecd475b441d9c2b38c2f69d30e15ffb487eba Mon Sep 17 00:00:00 2001 From: DuroCodes Date: Sat, 21 Mar 2026 18:21:24 -0400 Subject: [PATCH 7/9] feat: add SEO stuff --- src/app/[id]/page.tsx | 45 +++++++++++++++++++++++++++++++++++++------ src/app/layout.tsx | 22 +++++++++++++++++++-- src/app/robots.ts | 12 ++++++++++++ src/app/sitemap.ts | 13 +++++++++++++ src/constants/site.ts | 1 + 5 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 src/app/robots.ts create mode 100644 src/app/sitemap.ts create mode 100644 src/constants/site.ts diff --git a/src/app/[id]/page.tsx b/src/app/[id]/page.tsx index 347915d..6f02b02 100644 --- a/src/app/[id]/page.tsx +++ b/src/app/[id]/page.tsx @@ -3,6 +3,7 @@ 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"; @@ -51,13 +52,27 @@ 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 tabs = normalizeTabs(paste.tabs); @@ -66,10 +81,28 @@ export async function generateMetadata({ params }: Props) { ? 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 ${totalTabs} file${totalTabs === 1 ? "" : "s"} and ${totalLines} lines`, - 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/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/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/constants/site.ts b/src/constants/site.ts new file mode 100644 index 0000000..15ce7b6 --- /dev/null +++ b/src/constants/site.ts @@ -0,0 +1 @@ +export const SITE_URL = "https://spongebin.dev"; From b18e4cc3c6302a0c8dacfcd1c5188d23ec475253 Mon Sep 17 00:00:00 2001 From: DuroCodes Date: Sat, 21 Mar 2026 18:24:38 -0400 Subject: [PATCH 8/9] fix: remove border bottom for desktop layouts --- src/components/header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/header.tsx b/src/components/header.tsx index e6983bf..18d6c88 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -54,7 +54,7 @@ export function Header() { return ( <> -
+
Date: Sat, 21 Mar 2026 18:31:12 -0400 Subject: [PATCH 9/9] fix: remove borders on header/footer --- src/components/header.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/header.tsx b/src/components/header.tsx index 18d6c88..9b37ce0 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -54,8 +54,8 @@ export function Header() { return ( <> -
-
+
+