feat: add editor tabs

This commit is contained in:
DuroCodes
2026-03-20 11:24:34 -04:00
parent 1a35f33c29
commit e2e702829a
17 changed files with 654 additions and 267 deletions

View File

@@ -17,7 +17,6 @@
"@vercel/analytics": "^1.5.0", "@vercel/analytics": "^1.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"drizzle-orm": "^0.42.0", "drizzle-orm": "^0.42.0",
"lucide-react": "^0.488.0", "lucide-react": "^0.488.0",
"monaco-editor": "^0.52.2", "monaco-editor": "^0.52.2",
@@ -332,8 +331,6 @@
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "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": ["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=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],

View File

@@ -0,0 +1 @@
ALTER TABLE "paste" ADD COLUMN "tabs" jsonb;

View File

@@ -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": {}
}
}

View File

@@ -22,6 +22,13 @@
"when": 1745172454939, "when": 1745172454939,
"tag": "0002_fine_lady_vermin", "tag": "0002_fine_lady_vermin",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1774019339525,
"tag": "0003_slim_tag",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,5 +1,5 @@
{ {
"name": "project-4", "name": "spongebin",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -22,7 +22,6 @@
"@vercel/analytics": "^1.5.0", "@vercel/analytics": "^1.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"drizzle-orm": "^0.42.0", "drizzle-orm": "^0.42.0",
"lucide-react": "^0.488.0", "lucide-react": "^0.488.0",
"monaco-editor": "^0.52.2", "monaco-editor": "^0.52.2",

View File

@@ -3,6 +3,7 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "~/db/drizzle"; import { db } from "~/db/drizzle";
import { paste } from "~/db/schema"; import { paste } from "~/db/schema";
import type { PasteTab } from "~/utils/paste-tabs";
export const getPasteById = async (id: string) => { export const getPasteById = async (id: string) => {
const pasteData = await db const pasteData = await db
@@ -14,17 +15,22 @@ export const getPasteById = async (id: string) => {
return pasteData[0]; return pasteData[0];
}; };
export const addPaste = async ( export const addPaste = async ({
content: string, tabs,
language: string, theme,
theme: string, }: {
) => { tabs: PasteTab[];
theme: string;
}) => {
const primaryTab = tabs[0];
const pasteData = await db const pasteData = await db
.insert(paste) .insert(paste)
.values({ .values({
content, content: primaryTab?.content ?? "",
language, language: primaryTab?.language ?? "text",
theme, theme,
tabs,
}) })
.returning({ id: paste.id }); .returning({ id: paste.id });

View File

@@ -3,6 +3,11 @@ import { EditorProvider } from "~/components/editor-provider";
import { MonacoEditor } from "~/components/monaco-editor"; import { MonacoEditor } from "~/components/monaco-editor";
import { getPasteById } from "~/actions/paste"; import { getPasteById } from "~/actions/paste";
import { Header } from "~/components/header"; import { Header } from "~/components/header";
import { LANGUAGES_SET, type LanguageName } from "~/utils/languages";
import {
createEmptyTab,
normalizeTabs,
} from "~/utils/paste-tabs";
interface Props { interface Props {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -14,14 +19,34 @@ export default async function PastePage({ params }: Props) {
if (!paste) redirect("/"); 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 ( return (
<EditorProvider <EditorProvider
initialContent={paste.content} initialTabs={initialTabs}
initialLanguage={paste.language} initialActiveTabId={initialTabs[0]?.id ?? ""}
initialTheme={paste.theme} initialTheme={paste.theme}
> >
<Header /> <main className="flex h-[100dvh] flex-col overflow-hidden">
<MonacoEditor /> <Header />
<div className="min-h-0 flex-1">
<MonacoEditor />
</div>
</main>
</EditorProvider> </EditorProvider>
); );
} }
@@ -38,11 +63,15 @@ export async function generateMetadata({ params }: Props) {
twitter: { card: "summary" }, 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 { return {
title: `spongebin • ${paste.id}`, 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" }, openGraph: { images: "/sponge.png" },
twitter: { card: "summary" }, twitter: { card: "summary" },
}; };

View File

@@ -1,34 +1,57 @@
import { NextRequest, NextResponse } from "next/server"; 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 { addPaste } from "~/actions/paste";
import { THEME_MAP } from "~/utils/themes"; 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) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
const tabs = normalizeTabs(body.tabs);
if (!body.content) if (!tabs.length && !body.content)
return NextResponse.json( return NextResponse.json(
{ error: "Content is required" }, { error: "At least one file is required" },
{ status: 400 }, { status: 400 },
); );
const language = body.language || "text";
const theme = body.theme || "catppuccin-mocha"; 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)) for (const tab of normalizedTabs) {
return NextResponse.json( if (!isLanguageName(tab.language))
{ error: `Invalid language: ${language}` }, return NextResponse.json(
{ status: 400 }, { error: `Invalid language: ${tab.language}` },
); { status: 400 },
);
}
if (!Object.keys(THEME_MAP).includes(theme)) if (!(theme in THEME_MAP))
return NextResponse.json( return NextResponse.json(
{ error: `Invalid theme: ${theme}` }, { error: `Invalid theme: ${theme}` },
{ status: 400 }, { status: 400 },
); );
const paste = await addPaste(body.content, language, theme); const paste = await addPaste({
tabs: normalizedTabs,
theme,
});
if (!paste.id) if (!paste.id)
return NextResponse.json( return NextResponse.json(

View File

@@ -5,8 +5,12 @@ import { Header } from "~/components/header";
export default function Home() { export default function Home() {
return ( return (
<EditorProvider> <EditorProvider>
<Header /> <main className="flex h-[100dvh] flex-col overflow-hidden">
<MonacoEditor /> <Header />
<div className="min-h-0 flex-1">
<MonacoEditor />
</div>
</main>
</EditorProvider> </EditorProvider>
); );
} }

View File

@@ -1,12 +1,31 @@
"use client"; "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 { interface EditorContextType {
content: string; tabs: PasteTab[];
setContent: (content: string) => void; activeTab: PasteTab;
language: string; activeTabId: string;
setLanguage: (language: string) => void; setActiveTabId: (tabId: string) => void;
updateActiveTabContent: (content: string) => void;
updateActiveTabLanguage: (language: LanguageName) => void;
updateActiveTabFilename: (filename: string) => void;
addTab: () => void;
closeTab: (tabId: string) => void;
theme: string; theme: string;
setTheme: (theme: string) => void; setTheme: (theme: string) => void;
} }
@@ -15,32 +34,109 @@ const EditorContext = createContext<EditorContextType | undefined>(undefined);
interface EditorProviderProps { interface EditorProviderProps {
children: ReactNode; children: ReactNode;
initialContent?: string; initialTabs?: PasteTab[];
initialLanguage?: string; initialActiveTabId?: string | null;
initialTheme?: string; initialTheme?: string;
} }
export function EditorProvider({ export function EditorProvider({
children, children,
initialContent = "", initialTabs,
initialLanguage = "typescript", initialActiveTabId,
initialTheme = "catppuccin-mocha", initialTheme = "catppuccin-mocha",
}: EditorProviderProps) { }: EditorProviderProps) {
const [content, setContent] = useState(initialContent); const [tabs, setTabs] = useState(() =>
const [language, setLanguage] = useState(initialLanguage); initialTabs?.length ? initialTabs : [createEmptyTab(1)],
);
const [activeTabId, setActiveTabId] = useState(
initialActiveTabId ?? initialTabs?.[0]?.id ?? "",
);
const [theme, setTheme] = useState(initialTheme); const [theme, setTheme] = useState(initialTheme);
const value = { const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0]!;
content,
setContent, useEffect(() => {
language, if (!tabs.some((tab) => tab.id === activeTabId)) {
setLanguage, setActiveTabId(tabs[0]!.id);
theme, }
setTheme, }, [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 ( return (
<EditorContext.Provider value={value}>{children}</EditorContext.Provider> <EditorContext.Provider
value={{
tabs,
activeTab,
activeTabId,
setActiveTabId,
updateActiveTabContent,
updateActiveTabLanguage,
updateActiveTabFilename,
addTab,
closeTab,
theme,
setTheme,
}}
>
{children}
</EditorContext.Provider>
); );
} }

View File

@@ -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 (
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2 sm:hidden">
{hasMultipleTabs ? (
<SearchableSelect
options={tabs.map((tab) => ({
value: tab.id,
label: tab.filename,
}))}
placeholder="files"
value={activeTabId}
onValueChange={setActiveTabId}
onPreview={setActiveTabId}
className="min-w-0 flex-1"
/>
) : (
<span className="text-muted-foreground block min-w-0 flex-1 truncate px-2 text-sm font-medium">
{activeTab.filename}
</span>
)}
{hasMultipleTabs && (
<button
type="button"
onClick={() => closeTab(activeTabId)}
className={iconBtnClass}
aria-label={`Close ${activeTab.filename}`}
>
<X className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={addTab}
className={iconBtnClass}
aria-label="Add tab"
>
<Plus className="h-4 w-4" />
</button>
</div>
<div className="hidden min-w-0 flex-1 overflow-x-auto sm:block">
<div className="flex min-w-max items-center gap-2">
{hasMultipleTabs &&
tabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
className={cn(
"border-input dark:bg-input/30 bg-background text-foreground flex h-9 items-center gap-1 rounded-md border pr-0.5 text-sm shadow-xs transition-colors",
isActive &&
"bg-primary text-primary-foreground border-transparent",
)}
>
{isActive ? (
<input
value={activeTab.filename}
onChange={(event) =>
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"
/>
) : (
<button
type="button"
onClick={() => setActiveTabId(tab.id)}
className="h-full px-3 text-sm font-medium"
>
<span className="text-muted-foreground block max-w-40 truncate">
{tab.filename}
</span>
</button>
)}
<button
type="button"
onClick={(event) => {
event.stopPropagation();
closeTab(tab.id);
}}
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors",
isActive
? "text-primary-foreground/80 hover:text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
)}
aria-label={`Close ${tab.filename}`}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
})}
<button
type="button"
onClick={addTab}
className={iconBtnClass}
aria-label="Add tab"
>
<Plus className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}

View File

@@ -2,68 +2,82 @@
import Link from "next/link"; import Link from "next/link";
import { useEditor } from "./editor-provider"; import { useEditor } from "./editor-provider";
import { EditorTabs } from "./editor-tabs";
import { Button, buttonVariants } from "~/components/ui/button"; import { Button, buttonVariants } from "~/components/ui/button";
import { SearchableSelect } from "./searchable-select"; 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 { THEME_MAP } from "~/utils/themes";
import { SaveButton } from "./save-button"; import { SaveButton } from "./save-button";
import { Icons } from "./icons"; import { Icons } from "./icons";
import { cn } from "~/utils/cn"; import { cn } from "~/utils/cn";
export function Header() { 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 ( return (
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2 px-4 py-2"> <header className="border-border/70 bg-background shrink-0 border-b">
<div className="grid grid-cols-5 gap-2 w-full sm:flex sm:w-auto"> <div className="flex flex-col gap-2 px-4 py-3 xl:flex-row xl:items-center">
<Link <div className="grid w-full grid-cols-5 gap-2 sm:flex sm:w-auto xl:shrink-0">
className={cn( <Link
buttonVariants({ variant: "outline" }), className={cn(
"col-span-1 sm:w-auto p-2", buttonVariants({ variant: "outline" }),
)} "col-span-1 p-2 sm:w-auto",
href="https://github.com/durocodes/spongebin" )}
> href="https://github.com/durocodes/spongebin"
<Icons.GitHub className="h-4 w-4 mx-auto" /> >
</Link> <Icons.GitHub className="mx-auto h-4 w-4" />
</Link>
<Button <Button
variant="outline" variant="outline"
onClick={() => (location.href = "/")} onClick={() => (location.href = "/")}
className="col-span-2 sm:w-auto" className="col-span-2 sm:w-auto"
> >
new new
</Button> </Button>
<SaveButton <SaveButton
content={content} tabs={tabs}
language={language} theme={theme}
theme={theme} className="col-span-2 sm:w-auto"
className="col-span-2 sm:w-auto" />
/> </div>
<div className="w-full xl:min-w-0 xl:flex-1">
<EditorTabs />
</div>
<div className="grid w-full grid-cols-2 gap-2 xl:ml-2 xl:flex xl:w-auto xl:shrink-0">
<SearchableSelect
options={LANGUAGES.map((language) => ({
value: language,
label: language,
}))}
placeholder="language"
value={activeTab.language}
onValueChange={setLanguage}
onPreview={setLanguage}
className="w-full xl:w-40"
/>
<SearchableSelect
options={Object.keys(THEME_MAP).map((currentTheme) => ({
value: currentTheme,
label: currentTheme,
}))}
placeholder="theme"
value={theme}
onValueChange={setTheme}
onPreview={setTheme}
className="w-full xl:w-52"
/>
</div>
</div> </div>
</header>
<div className="grid grid-cols-2 gap-2 w-full sm:flex sm:flex-row sm:w-auto">
<SearchableSelect
options={LANGUAGE_NAMES.map((l) => ({ value: l, label: l }))}
placeholder="language"
value={language}
onValueChange={setLanguage}
onPreview={setLanguage}
className="w-full sm:w-40"
/>
<SearchableSelect
options={Object.keys(THEME_MAP).map((t) => ({
value: t,
label: t,
}))}
placeholder="theme"
value={theme}
onValueChange={setTheme}
onPreview={setTheme}
className="w-full sm:w-52"
/>
</div>
</div>
); );
} }

View File

@@ -8,21 +8,21 @@ import { useEffect, useState } from "react";
import { Editor, type Monaco } from "@monaco-editor/react"; import { Editor, type Monaco } from "@monaco-editor/react";
import { shikiToMonaco } from "@shikijs/monaco"; import { shikiToMonaco } from "@shikijs/monaco";
import { createHighlighter } from "shiki"; import { createHighlighter } from "shiki";
import { LANGUAGES, LANGUAGE_NAMES } from "~/utils/languages"; import { LANGUAGES, MONACO_LANGUAGES } from "~/utils/languages";
import { useEditor } from "./editor-provider"; import { useEditor } from "./editor-provider";
import { THEME_MAP } from "~/utils/themes"; import { THEME_MAP } from "~/utils/themes";
export function MonacoEditor() { export function MonacoEditor() {
const { language, theme, content, setContent } = useEditor(); const { activeTab, theme, updateActiveTabContent } = useEditor();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { useEffect(() => {
const colors = THEME_MAP[theme].ui; const colors = THEME_MAP[theme]?.ui;
if (!colors) return; if (!colors) return;
Object.entries(colors).forEach(([key, value]) => { for (const [key, value] of Object.entries(colors)) {
document.documentElement.style.setProperty(`--${key}`, value); document.documentElement.style.setProperty(`--${key}`, value);
}); }
}, [theme]); }, [theme]);
const handleEditorDidMount = async (monaco: Monaco) => { const handleEditorDidMount = async (monaco: Monaco) => {
@@ -32,11 +32,11 @@ export function MonacoEditor() {
.filter(([key]) => key !== theme) .filter(([key]) => key !== theme)
.map(([key, value]) => value.theme ?? key); .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({ const highlighter = await createHighlighter({
themes: [currentTheme, ...restThemes], themes: [currentTheme, ...restThemes],
langs: LANGUAGES, langs: [...MONACO_LANGUAGES],
}); });
shikiToMonaco(highlighter, monaco); shikiToMonaco(highlighter, monaco);
@@ -54,7 +54,7 @@ export function MonacoEditor() {
}; };
return ( return (
<div className="relative w-full h-[calc(100vh-6rem)] sm:h-[calc(100vh-3.25rem)]"> <div className="relative h-full min-h-0 w-full">
{isLoading && ( {isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-background z-10"> <div className="absolute inset-0 flex items-center justify-center bg-background z-10">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
@@ -65,17 +65,25 @@ export function MonacoEditor() {
)} )}
<Editor <Editor
className="h-[calc(100vh-6rem)] sm:h-calc(100vh-3.25rem)]" path={`${activeTab.id}/${activeTab.filename}`}
saveViewState
className="h-full"
theme={theme} theme={theme}
language={language} language={activeTab.language}
value={content} value={activeTab.content}
onChange={(val) => setContent(val || "")} onChange={(val) => updateActiveTabContent(val || "")}
onMount={async (editor, monaco) => { onMount={async (editor, monaco) => {
handleEditorDidMount(monaco).catch(console.error); try {
await AutoTypings.create(editor, { await handleEditorDidMount(monaco);
sourceCache: new LocalStorageCache(), if (!editor.getModel()) return;
monaco,
}); await AutoTypings.create(editor, {
sourceCache: new LocalStorageCache(),
monaco,
});
} catch (error) {
console.warn("AutoTypings init failed (ignored):", error);
}
}} }}
options={{ options={{
fontSize: 14, fontSize: 14,

View File

@@ -1,30 +1,25 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect } from "react"; import { useCallback, useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { addPaste } from "~/actions/paste"; import { addPaste } from "~/actions/paste";
import type { PasteTab } from "~/utils/paste-tabs";
interface SaveButtonProps { interface SaveButtonProps {
content: string; tabs: PasteTab[];
language: string;
theme: string; theme: string;
className?: string; className?: string;
} }
export function SaveButton({ export function SaveButton({ tabs, theme, className }: SaveButtonProps) {
content,
language,
theme,
className,
}: SaveButtonProps) {
const router = useRouter(); const router = useRouter();
const handleSave = async () => { const handleSave = useCallback(async () => {
try { try {
if (!content) return; if (!tabs.some((tab) => tab.content.trim())) return;
const result = await addPaste(content, language, theme); const result = await addPaste({ tabs, theme });
if (!result.id) return; if (!result.id) return;
const url = `${window.location.origin}/${result.id}`; const url = `${window.location.origin}/${result.id}`;
@@ -36,18 +31,18 @@ export function SaveButton({
console.error("Failed to save paste:", error); console.error("Failed to save paste:", error);
toast("failed to save paste"); toast("failed to save paste");
} }
}; }, [tabs, theme, router]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "s" || !(e.ctrlKey || e.metaKey)) return; if (e.key !== "s" || !(e.ctrlKey || e.metaKey)) return;
e.preventDefault(); e.preventDefault();
handleSave(); void handleSave();
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", onKeyDown);
}, [content, language, theme]); }, [handleSave]);
return ( return (
<Button variant="outline" onClick={handleSave} className={className}> <Button variant="outline" onClick={handleSave} className={className}>

View File

@@ -1,5 +1,6 @@
import { pgTable, text } from "drizzle-orm/pg-core"; import { jsonb, pgTable, text } from "drizzle-orm/pg-core";
import { customAlphabet } from "nanoid"; import { customAlphabet } from "nanoid";
import type { PasteTab } from "~/utils/paste-tabs";
const nanoid = customAlphabet( const nanoid = customAlphabet(
"1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
@@ -13,4 +14,5 @@ export const paste = pgTable("paste", {
content: text("content").notNull(), content: text("content").notNull(),
language: text("language").notNull(), language: text("language").notNull(),
theme: text("theme").notNull(), theme: text("theme").notNull(),
tabs: jsonb("tabs").$type<PasteTab[]>(),
}); });

View File

@@ -1,218 +1,93 @@
import sfm from "./languages/sfm.json"; import sfm from "./languages/sfm.json";
export const LANGUAGES = [ const BUILTIN_LANGUAGE_NAMES = [
"text", "text",
"abap", "abap",
// "actionscript-3",
"ada", "ada",
// "angular-html",
// "angular-ts",
// "apache",
// "apex",
"apl", "apl",
// "applescript",
// "ara",
// "asciidoc",
"asm", "asm",
"astro", "astro",
// "awk",
// "ballerina",
"bat", "bat",
// "beancount",
// "berry",
"bibtex", "bibtex",
// "bicep",
"blade", "blade",
// "bsl",
"c", "c",
// "cadence",
// "cairo",
// "clarity",
"clojure", "clojure",
// "cmake",
"cobol", "cobol",
// "codeowners",
// "codeql",
"coffeescript", "coffeescript",
"common-lisp", "common-lisp",
// "coq",
"c++", "c++",
"crystal", "crystal",
"c#", "c#",
"css", "css",
// "csv",
// "cue",
// "cypher",
"d", "d",
"dart", "dart",
"dax", "dax",
// "desktop",
"diff", "diff",
// "docker",
// "dotenv",
// "dream-maker",
// "edge",
"elixir", "elixir",
"elm", "elm",
// "emacs-lisp",
// "erb",
"erlang", "erlang",
// "fennel",
// "fish",
// "fluent",
"f#", "f#",
// "gdresource",
// "gdscript",
// "gdshader",
// "genie",
// "gherkin",
// "git-commit",
// "git-rebase",
"gleam", "gleam",
// "glsl",
// "gnuplot",
"go", "go",
"graphql", "graphql",
"groovy", "groovy",
"hack", "hack",
// "haml",
// "handlebars",
"haskell", "haskell",
"haxe", "haxe",
// "hcl",
// "hjson",
// "hlsl",
"html", "html",
// "http",
// "hxml",
// "hy",
// "imba",
// "ini",
"java", "java",
"javascript", "javascript",
"jinja", "jinja",
"json", "json",
"json5", "json5",
// "jsonnet",
// "jssm",
"jsx", "jsx",
"julia", "julia",
"kotlin", "kotlin",
// "kusto",
"latex", "latex",
// "lean",
// "less",
// "liquid",
// "llvm",
"log", "log",
// "logo",
"lua", "lua",
// "luau",
// "make",
"markdown", "markdown",
"matlab", "matlab",
// "mdc",
"mdx", "mdx",
"mermaid", "mermaid",
// "mipsasm",
"mojo", "mojo",
// "move",
// "narrat",
// "nextflow",
// "nginx",
"nim", "nim",
"nix", "nix",
// "nushell",
// "objective-c",
// "objective-cpp",
"ocaml", "ocaml",
"pascal", "pascal",
"perl", "perl",
"php", "php",
// "plsql",
// "po",
// "polar",
// "postcss",
// "powerquery",
"powershell", "powershell",
"prisma", "prisma",
// "prolog",
// "proto",
// "pug",
// "puppet",
"purescript", "purescript",
"python", "python",
// "qml",
// "qss",
"r", "r",
// "racket",
// "raku",
"razor", "razor",
// "reg",
// "regex",
// "rel",
// "riscv",
// "rst",
"ruby", "ruby",
"rust", "rust",
// "sas",
// "sass",
"scala", "scala",
"scheme", "scheme",
"scss", "scss",
// "sdbl",
// "shaderlab",
"shellscript", "shellscript",
// "smalltalk",
"solidity", "solidity",
// "soy",
// "sparql",
// "splunk",
"sql", "sql",
// "ssh-config",
// "stata",
// "stylus",
"svelte", "svelte",
"swift", "swift",
// "system-verilog",
// "systemd",
// "talonscript",
// "tasl",
// "tcl",
// "templ",
// "terraform",
// "tex",
"toml", "toml",
// "ts-tags",
// "tsv",
"tsx", "tsx",
// "turtle",
// "twig",
"typescript", "typescript",
// "typespec",
"typst", "typst",
"v", "v",
// "vala",
"vb", "vb",
// "verilog",
// "vhdl",
// "viml",
"vue", "vue",
// "vyper",
"wasm", "wasm",
// "wenyan",
// "wgsl",
// "wikitext",
// "wit",
"wolfram", "wolfram",
"xml", "xml",
// "xsl",
"yaml", "yaml",
// "zenscript",
"zig", "zig",
sfm, ] as const;
];
export const LANGUAGE_NAMES = LANGUAGES.map((l) => export const MONACO_LANGUAGES = [...BUILTIN_LANGUAGE_NAMES, sfm] as const;
typeof l === "string" ? l : l.name, export const LANGUAGES = [...BUILTIN_LANGUAGE_NAMES, sfm.name] as const;
); export const LANGUAGES_SET = new Set(LANGUAGES);
export type LanguageName = (typeof LANGUAGES)[number];

128
src/utils/paste-tabs.ts Normal file
View File

@@ -0,0 +1,128 @@
import { LANGUAGES, LANGUAGES_SET, type LanguageName } from "~/utils/languages";
export interface PasteTab {
id: string;
filename: string;
language: LanguageName;
content: string;
}
export const LANGUAGE_EXTENSIONS: Record<LanguageName, string> = {
text: "txt",
asm: "s",
bat: "bat",
c: "c",
"c#": "cs",
"c++": "cpp",
coffeescript: "coffee",
"common-lisp": "lisp",
css: "css",
dax: "dax",
diff: "diff",
"f#": "fs",
graphql: "graphql",
html: "html",
javascript: "js",
json: "json",
json5: "json5",
jsx: "jsx",
latex: "tex",
log: "log",
markdown: "md",
mdx: "mdx",
mermaid: "mmd",
powershell: "ps1",
python: "py",
r: "r",
razor: "cshtml",
ruby: "rb",
rust: "rs",
scheme: "scm",
scss: "scss",
shellscript: "sh",
solidity: "sol",
sql: "sql",
toml: "toml",
tsx: "tsx",
typescript: "ts",
vb: "vb",
vue: "vue",
wasm: "wat",
wolfram: "wl",
xml: "xml",
yaml: "yml",
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),
]),
) as Record<LanguageName, string>;
const EXTENSION_TO_LANGUAGE = Object.fromEntries(
Object.entries(LANGUAGE_TO_EXTENSION).map(([language, extension]) => [
extension,
language,
]),
) as Record<string, LanguageName>;
const createTabId = () => crypto.randomUUID();
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;
};
export const replaceFilenameExtension = (
filename: string,
language: LanguageName,
) => {
const trimmedFilename = filename.trim();
if (!trimmedFilename) return `file.${LANGUAGE_TO_EXTENSION[language]}`;
const lastDotIndex = trimmedFilename.lastIndexOf(".");
if (lastDotIndex <= 0) return trimmedFilename;
return `${trimmedFilename.slice(0, lastDotIndex)}.${LANGUAGE_TO_EXTENSION[language]}`;
};
export const createEmptyTab = (
index: number,
language: LanguageName = "typescript",
) => ({
id: createTabId(),
filename: `file${index}.${LANGUAGE_TO_EXTENSION[language]}`,
language,
content: "",
});
export const normalizeTabs = (tabs: unknown) =>
!Array.isArray(tabs)
? []
: tabs.flatMap((tab, index) => {
if (!tab || typeof tab !== "object") return [];
const candidate = tab as Partial<PasteTab> & { language?: string };
const fallbackTab = createEmptyTab(index + 1);
const filename = candidate.filename?.trim() ?? fallbackTab.filename;
const language = LANGUAGES_SET.has(candidate.language ?? "")
? (candidate.language ?? "")
: (inferLanguage(filename) ?? "text");
return [
{
id: candidate.id ?? createTabId(),
filename,
language,
content: candidate.content ?? "",
},
];
});