Merge pull request #2 from DuroCodes/feature/tabs

Tabs Feature
This commit is contained in:
Duro
2026-03-21 18:36:23 -04:00
committed by GitHub
24 changed files with 878 additions and 303 deletions

View File

@@ -17,11 +17,9 @@
"@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",
"monaco-editor-auto-typings": "^0.4.6",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"next": "15.3.6", "next": "15.3.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -332,8 +330,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=="],
@@ -422,8 +418,6 @@
"monaco-editor": ["monaco-editor@0.52.2", "", {}, "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ=="], "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=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="], "nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="],

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,11 +22,9 @@
"@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",
"monaco-editor-auto-typings": "^0.4.6",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"next": "15.3.8", "next": "15.3.8",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",

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,9 @@ 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 { SITE_URL } from "~/constants/site";
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 +17,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>
); );
} }
@@ -29,21 +52,57 @@ export default async function PastePage({ params }: Props) {
export async function generateMetadata({ params }: Props) { export async function generateMetadata({ params }: Props) {
const { id } = await params; const { id } = await params;
const paste = await getPasteById(id); const paste = await getPasteById(id);
const ogImage = { url: "/sponge.png", alt: "spongebin" };
if (!paste) if (!paste)
return { return {
title: "spongebin", title: "spongebin",
description: "a pastebin made with sponge", description: "a pastebin made with sponge",
openGraph: { images: "/sponge.png" }, openGraph: {
twitter: { card: "summary" }, 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 { return {
title: `spongebin • ${paste.id}`, title,
description: `a paste containing ${numLines} lines of ${paste.language}`, description,
openGraph: { images: "/sponge.png" }, alternates: { canonical },
twitter: { card: "summary" }, openGraph: {
type: "website",
locale: "en_US",
url: canonical,
siteName: "spongebin",
title,
description,
images: [ogImage],
},
twitter: {
card: "summary",
title,
description,
images: ["/sponge.png"],
},
}; };
} }

View File

@@ -1,34 +1,58 @@
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

@@ -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", "Fira Mono", "Droid Sans Mono", "Source Code Pro", "Consolas",
"DejaVu Sans Mono", monospace; "DejaVu Sans Mono", monospace;
font-feature-settings: "cv11", "cv13"; font-feature-settings: "cv11", "cv13";
@@ -123,3 +124,13 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
@layer utilities {
.scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
}

View File

@@ -2,12 +2,30 @@ import "./globals.css";
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Analytics } from "@vercel/analytics/next"; import { Analytics } from "@vercel/analytics/next";
import { Toaster } from "~/components/ui/sonner"; import { Toaster } from "~/components/ui/sonner";
import { SITE_URL } from "~/constants/site";
const site = new URL(SITE_URL);
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: site,
alternates: { canonical: SITE_URL },
title: "spongebin", title: "spongebin",
description: "a pastebin made with sponge", description: "a pastebin made with sponge",
openGraph: { images: "/sponge.png" }, openGraph: {
twitter: { card: "summary" }, 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 = { export const viewport: Viewport = {

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>
); );
} }

12
src/app/robots.ts Normal file
View File

@@ -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`,
};
}

13
src/app/sitemap.ts Normal file
View File

@@ -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,
},
];
}

View File

@@ -1,52 +1,154 @@
"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_EXTENSIONS,
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;
wordWrap: boolean;
setWordWrap: (value: boolean) => void;
} }
const EditorContext = createContext<EditorContextType | undefined>(undefined); 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 [wordWrap, setWordWrap] = useState(false);
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_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 ( return (
<EditorContext.Provider value={value}>{children}</EditorContext.Provider> <EditorContext.Provider
value={{
tabs,
activeTab,
activeTabId,
setActiveTabId,
updateActiveTabContent,
updateActiveTabLanguage,
updateActiveTabFilename,
addTab,
closeTab,
theme,
setTheme,
wordWrap,
setWordWrap,
}}
>
{children}
</EditorContext.Provider>
); );
} }
export function useEditor() { export function useEditor() {
const context = useContext(EditorContext); const context = useContext(EditorContext);
if (context === undefined)
if (!context)
throw new Error("useEditor must be used within an EditorProvider"); throw new Error("useEditor must be used within an EditorProvider");
return context; return context;

View File

@@ -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 (
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2 sm:hidden">
<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"
/>
<Button
type="button"
variant="outline"
size="icon"
className="relative z-10 shrink-0"
onClick={addTab}
aria-label="Add tab"
>
<Plus />
</Button>
{hasMultipleTabs && (
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
onClick={() => closeTab(activeTabId)}
aria-label={`Close ${activeTab.filename}`}
>
<X />
</Button>
)}
</div>
<div className="scrollbar-none hidden min-h-0 min-w-0 flex-1 overflow-x-auto overflow-y-hidden sm:block">
<div className="inline-flex min-w-max items-center gap-2">
{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 (
<div
key={tab.id}
className={cn(
"border-input dark:bg-input/30 bg-background text-foreground box-border inline-flex h-9 shrink-0 items-center gap-1 rounded-md border pr-0.5 text-sm transition-colors",
isActive &&
"border-primary bg-primary text-primary-foreground",
)}
>
{isActive ? (
<input
value={activeTab.filename}
spellCheck={false}
onChange={(event) =>
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"
/>
) : (
<Button
type="button"
variant="ghost"
className={cn(
"text-foreground h-full min-h-0 min-w-0 justify-start rounded-none px-3 py-0 text-left text-sm font-medium leading-none shadow-none",
"hover:bg-transparent hover:text-foreground",
"focus-visible:ring-0 focus-visible:ring-offset-0",
)}
style={labelWidthStyle}
onClick={() => setActiveTabId(tab.id)}
>
<span className="block min-w-0 truncate">
{tab.filename}
</span>
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 min-h-8 min-w-8 shrink-0 transition-colors hover:bg-transparent dark:hover:bg-transparent",
isActive
? "text-primary-foreground hover:text-primary-foreground/90"
: "text-muted-foreground hover:text-primary",
)}
onClick={(event) => {
event.stopPropagation();
closeTab(tab.id);
}}
aria-label={`Close ${tab.filename}`}
>
<X className="size-3.5" />
</Button>
</div>
);
})}
<div className="bg-background sticky right-0 z-10 shrink-0">
<Button
type="button"
variant="outline"
size="icon"
className="bg-background"
onClick={addTab}
aria-label="Add tab"
>
<Plus />
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,69 +1,120 @@
"use client"; "use client";
import { WrapText } from "lucide-react";
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, type LanguageName } 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() { function LanguageThemeControls() {
const { language, theme, content, setLanguage, setTheme } = useEditor(); const { activeTab, theme, updateActiveTabLanguage, setTheme } = useEditor();
const setLanguage = (language: string) => {
if (LANGUAGES_SET.has(language as LanguageName))
updateActiveTabLanguage(language as LanguageName);
};
return ( return (
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2 px-4 py-2"> <>
<div className="grid grid-cols-5 gap-2 w-full sm:flex sm:w-auto"> <SearchableSelect
<Link options={LANGUAGES.map((language) => ({
className={cn( value: language,
buttonVariants({ variant: "outline" }), label: language,
"col-span-1 sm:w-auto p-2", }))}
)} placeholder="language"
href="https://github.com/durocodes/spongebin" value={activeTab.language}
> onValueChange={setLanguage}
<Icons.GitHub className="h-4 w-4 mx-auto" /> onPreview={setLanguage}
</Link> className="w-full min-w-0 sm:w-40"
/>
<Button <SearchableSelect
variant="outline" options={Object.keys(THEME_MAP).map((currentTheme) => ({
onClick={() => (location.href = "/")} value: currentTheme,
className="col-span-2 sm:w-auto" label: currentTheme,
> }))}
new placeholder="theme"
</Button> value={theme}
onValueChange={setTheme}
<SaveButton onPreview={setTheme}
content={content} className="w-full min-w-0 sm:w-52"
language={language} />
theme={theme} </>
className="col-span-2 sm:w-auto" );
/> }
</div>
export function Header() {
<div className="grid grid-cols-2 gap-2 w-full sm:flex sm:flex-row sm:w-auto"> const { tabs, theme, wordWrap, setWordWrap } = useEditor();
<SearchableSelect
options={LANGUAGE_NAMES.map((l) => ({ value: l, label: l }))} return (
placeholder="language" <>
value={language} <header className="bg-background shrink-0">
onValueChange={setLanguage} <div className="flex flex-col gap-2 px-4 py-2 sm:flex-row sm:items-center">
onPreview={setLanguage} <div className="grid w-full grid-cols-6 gap-2 sm:flex sm:w-auto sm:shrink-0">
className="w-full sm:w-40" <Link
/> className={cn(
buttonVariants({ variant: "outline" }),
<SearchableSelect "col-span-1 p-2 sm:w-auto",
options={Object.keys(THEME_MAP).map((t) => ({ )}
value: t, href="https://github.com/durocodes/spongebin"
label: t, >
}))} <Icons.GitHub className="mx-auto h-4 w-4" />
placeholder="theme" </Link>
value={theme}
onValueChange={setTheme} <Button
onPreview={setTheme} type="button"
className="w-full sm:w-52" variant="outline"
/> onClick={() => setWordWrap(!wordWrap)}
</div> className={cn(
</div> "col-span-1 p-2 sm:w-auto",
wordWrap &&
"bg-accent text-accent-foreground hover:bg-accent hover:text-accent-foreground",
)}
aria-pressed={wordWrap}
aria-label={wordWrap ? "Disable word wrap" : "Enable word wrap"}
title="Word wrap"
>
<WrapText className="mx-auto h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => (location.href = "/")}
className="col-span-2 sm:w-auto"
>
new
</Button>
<SaveButton
tabs={tabs}
theme={theme}
className="col-span-2 sm:w-auto"
/>
</div>
<div className="w-full min-w-0 sm:flex-1">
<EditorTabs />
</div>
<div className="hidden sm:ml-2 sm:flex sm:w-auto sm:shrink-0 sm:gap-2">
<LanguageThemeControls />
</div>
</div>
</header>
<div
className="fixed bottom-0 left-0 right-0 z-40 grid grid-cols-2 gap-2 bg-background/95 px-4 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] backdrop-blur supports-[backdrop-filter]:bg-background/80 sm:hidden"
role="region"
aria-label="Language and theme"
>
<LanguageThemeControls />
</div>
</>
); );
} }

View File

@@ -1,28 +1,35 @@
"use client"; "use client";
import { import { useEffect, useRef, useState } from "react";
AutoTypings,
LocalStorageCache,
} from "monaco-editor-auto-typings/custom-editor";
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,
activeTabId,
closeTab,
theme,
wordWrap,
updateActiveTabContent,
} = useEditor();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const activeTabIdRef = useRef(activeTabId);
activeTabIdRef.current = activeTabId;
const closeTabRef = useRef(closeTab);
closeTabRef.current = closeTab;
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 +39,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 +61,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,21 +72,17 @@ 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) => await handleEditorDidMount(monaco)}
handleEditorDidMount(monaco).catch(console.error);
await AutoTypings.create(editor, {
sourceCache: new LocalStorageCache(),
monaco,
});
}}
options={{ options={{
fontSize: 14, fontSize: 14,
wordWrap: "off", wordWrap: wordWrap ? "on" : "off",
minimap: { enabled: false }, minimap: { enabled: false },
automaticLayout: true, automaticLayout: true,
bracketPairColorization: { bracketPairColorization: {

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,10 +1,10 @@
"use client" "use client";
import { useTheme } from "next-themes" import { useTheme } from "next-themes";
import { Toaster as Sonner, ToasterProps } from "sonner" import { Toaster as Sonner, ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = "system" } = useTheme();
return ( return (
<Sonner <Sonner
@@ -19,7 +19,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
} }
{...props} {...props}
/> />
) );
} };
export { Toaster } export { Toaster };

1
src/constants/site.ts Normal file
View File

@@ -0,0 +1 @@
export const SITE_URL = "https://spongebin.dev";

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,6 +1,4 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
return twMerge(clsx(inputs))
}

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"] as const;
); export const LANGUAGES_SET = new Set(LANGUAGES);
export type LanguageName = (typeof LANGUAGES)[number];

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

@@ -0,0 +1,185 @@
import { LANGUAGES_SET, type LanguageName } from "~/utils/languages";
export interface PasteTab {
id: string;
filename: string;
language: LanguageName;
content: string;
}
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",
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<LanguageName, string>;
const EXTENSION_LANGUAGES = Object.fromEntries(
Object.entries(LANGUAGE_EXTENSIONS).map(([language, extension]) => [
extension,
language,
]),
) as Record<string, LanguageName>;
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 trimmed = filename.trim();
if (!trimmed) return null;
const lower = trimmed.toLowerCase();
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 = (
filename: string,
language: LanguageName,
) => {
const trimmedFilename = filename.trim();
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)}.${newExt}`;
};
export const createEmptyTab = (
index: number,
language: LanguageName = "typescript",
) => ({
id: createTabId(),
filename: `file${index}.${LANGUAGE_EXTENSIONS[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 rawLanguage = candidate.language ?? "";
const language: LanguageName =
rawLanguage !== "" && LANGUAGES_SET.has(rawLanguage as LanguageName)
? (rawLanguage as LanguageName)
: (inferLanguage(filename) ?? "text");
return [
{
id: candidate.id ?? createTabId(),
filename,
language,
content: candidate.content ?? "",
},
];
});