mirror of
https://github.com/SrIzan10/spongebin.git
synced 2026-05-01 11:05:09 +00:00
feat: add editor tabs
This commit is contained in:
3
bun.lock
3
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=="],
|
||||
|
||||
1
migrations/0003_slim_tag.sql
Normal file
1
migrations/0003_slim_tag.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "paste" ADD COLUMN "tabs" jsonb;
|
||||
62
migrations/meta/0003_snapshot.json
Normal file
62
migrations/meta/0003_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<EditorProvider
|
||||
initialContent={paste.content}
|
||||
initialLanguage={paste.language}
|
||||
initialTabs={initialTabs}
|
||||
initialActiveTabId={initialTabs[0]?.id ?? ""}
|
||||
initialTheme={paste.theme}
|
||||
>
|
||||
<Header />
|
||||
<MonacoEditor />
|
||||
<main className="flex h-[100dvh] flex-col overflow-hidden">
|
||||
<Header />
|
||||
<div className="min-h-0 flex-1">
|
||||
<MonacoEditor />
|
||||
</div>
|
||||
</main>
|
||||
</EditorProvider>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -5,8 +5,12 @@ import { Header } from "~/components/header";
|
||||
export default function Home() {
|
||||
return (
|
||||
<EditorProvider>
|
||||
<Header />
|
||||
<MonacoEditor />
|
||||
<main className="flex h-[100dvh] flex-col overflow-hidden">
|
||||
<Header />
|
||||
<div className="min-h-0 flex-1">
|
||||
<MonacoEditor />
|
||||
</div>
|
||||
</main>
|
||||
</EditorProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<EditorContextType | undefined>(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 (
|
||||
<EditorContext.Provider value={value}>{children}</EditorContext.Provider>
|
||||
<EditorContext.Provider
|
||||
value={{
|
||||
tabs,
|
||||
activeTab,
|
||||
activeTabId,
|
||||
setActiveTabId,
|
||||
updateActiveTabContent,
|
||||
updateActiveTabLanguage,
|
||||
updateActiveTabFilename,
|
||||
addTab,
|
||||
closeTab,
|
||||
theme,
|
||||
setTheme,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</EditorContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
141
src/components/editor-tabs.tsx
Normal file
141
src/components/editor-tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<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">
|
||||
<Link
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"col-span-1 sm:w-auto p-2",
|
||||
)}
|
||||
href="https://github.com/durocodes/spongebin"
|
||||
>
|
||||
<Icons.GitHub className="h-4 w-4 mx-auto" />
|
||||
</Link>
|
||||
<header className="border-border/70 bg-background shrink-0 border-b">
|
||||
<div className="flex flex-col gap-2 px-4 py-3 xl:flex-row xl:items-center">
|
||||
<div className="grid w-full grid-cols-5 gap-2 sm:flex sm:w-auto xl:shrink-0">
|
||||
<Link
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"col-span-1 p-2 sm:w-auto",
|
||||
)}
|
||||
href="https://github.com/durocodes/spongebin"
|
||||
>
|
||||
<Icons.GitHub className="mx-auto h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => (location.href = "/")}
|
||||
className="col-span-2 sm:w-auto"
|
||||
>
|
||||
new
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => (location.href = "/")}
|
||||
className="col-span-2 sm:w-auto"
|
||||
>
|
||||
new
|
||||
</Button>
|
||||
|
||||
<SaveButton
|
||||
content={content}
|
||||
language={language}
|
||||
theme={theme}
|
||||
className="col-span-2 sm:w-auto"
|
||||
/>
|
||||
<SaveButton
|
||||
tabs={tabs}
|
||||
theme={theme}
|
||||
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 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>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background z-10">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
@@ -65,17 +65,25 @@ export function MonacoEditor() {
|
||||
)}
|
||||
|
||||
<Editor
|
||||
className="h-[calc(100vh-6rem)] sm:h-calc(100vh-3.25rem)]"
|
||||
path={`${activeTab.id}/${activeTab.filename}`}
|
||||
saveViewState
|
||||
className="h-full"
|
||||
theme={theme}
|
||||
language={language}
|
||||
value={content}
|
||||
onChange={(val) => 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,
|
||||
|
||||
@@ -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 (
|
||||
<Button variant="outline" onClick={handleSave} className={className}>
|
||||
|
||||
@@ -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 type { PasteTab } from "~/utils/paste-tabs";
|
||||
|
||||
const nanoid = customAlphabet(
|
||||
"1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
|
||||
@@ -13,4 +14,5 @@ export const paste = pgTable("paste", {
|
||||
content: text("content").notNull(),
|
||||
language: text("language").notNull(),
|
||||
theme: text("theme").notNull(),
|
||||
tabs: jsonb("tabs").$type<PasteTab[]>(),
|
||||
});
|
||||
|
||||
@@ -1,218 +1,93 @@
|
||||
import sfm from "./languages/sfm.json";
|
||||
|
||||
export const LANGUAGES = [
|
||||
const BUILTIN_LANGUAGE_NAMES = [
|
||||
"text",
|
||||
"abap",
|
||||
// "actionscript-3",
|
||||
"ada",
|
||||
// "angular-html",
|
||||
// "angular-ts",
|
||||
// "apache",
|
||||
// "apex",
|
||||
"apl",
|
||||
// "applescript",
|
||||
// "ara",
|
||||
// "asciidoc",
|
||||
"asm",
|
||||
"astro",
|
||||
// "awk",
|
||||
// "ballerina",
|
||||
"bat",
|
||||
// "beancount",
|
||||
// "berry",
|
||||
"bibtex",
|
||||
// "bicep",
|
||||
"blade",
|
||||
// "bsl",
|
||||
"c",
|
||||
// "cadence",
|
||||
// "cairo",
|
||||
// "clarity",
|
||||
"clojure",
|
||||
// "cmake",
|
||||
"cobol",
|
||||
// "codeowners",
|
||||
// "codeql",
|
||||
"coffeescript",
|
||||
"common-lisp",
|
||||
// "coq",
|
||||
"c++",
|
||||
"crystal",
|
||||
"c#",
|
||||
"css",
|
||||
// "csv",
|
||||
// "cue",
|
||||
// "cypher",
|
||||
"d",
|
||||
"dart",
|
||||
"dax",
|
||||
// "desktop",
|
||||
"diff",
|
||||
// "docker",
|
||||
// "dotenv",
|
||||
// "dream-maker",
|
||||
// "edge",
|
||||
"elixir",
|
||||
"elm",
|
||||
// "emacs-lisp",
|
||||
// "erb",
|
||||
"erlang",
|
||||
// "fennel",
|
||||
// "fish",
|
||||
// "fluent",
|
||||
"f#",
|
||||
// "gdresource",
|
||||
// "gdscript",
|
||||
// "gdshader",
|
||||
// "genie",
|
||||
// "gherkin",
|
||||
// "git-commit",
|
||||
// "git-rebase",
|
||||
"gleam",
|
||||
// "glsl",
|
||||
// "gnuplot",
|
||||
"go",
|
||||
"graphql",
|
||||
"groovy",
|
||||
"hack",
|
||||
// "haml",
|
||||
// "handlebars",
|
||||
"haskell",
|
||||
"haxe",
|
||||
// "hcl",
|
||||
// "hjson",
|
||||
// "hlsl",
|
||||
"html",
|
||||
// "http",
|
||||
// "hxml",
|
||||
// "hy",
|
||||
// "imba",
|
||||
// "ini",
|
||||
"java",
|
||||
"javascript",
|
||||
"jinja",
|
||||
"json",
|
||||
"json5",
|
||||
// "jsonnet",
|
||||
// "jssm",
|
||||
"jsx",
|
||||
"julia",
|
||||
"kotlin",
|
||||
// "kusto",
|
||||
"latex",
|
||||
// "lean",
|
||||
// "less",
|
||||
// "liquid",
|
||||
// "llvm",
|
||||
"log",
|
||||
// "logo",
|
||||
"lua",
|
||||
// "luau",
|
||||
// "make",
|
||||
"markdown",
|
||||
"matlab",
|
||||
// "mdc",
|
||||
"mdx",
|
||||
"mermaid",
|
||||
// "mipsasm",
|
||||
"mojo",
|
||||
// "move",
|
||||
// "narrat",
|
||||
// "nextflow",
|
||||
// "nginx",
|
||||
"nim",
|
||||
"nix",
|
||||
// "nushell",
|
||||
// "objective-c",
|
||||
// "objective-cpp",
|
||||
"ocaml",
|
||||
"pascal",
|
||||
"perl",
|
||||
"php",
|
||||
// "plsql",
|
||||
// "po",
|
||||
// "polar",
|
||||
// "postcss",
|
||||
// "powerquery",
|
||||
"powershell",
|
||||
"prisma",
|
||||
// "prolog",
|
||||
// "proto",
|
||||
// "pug",
|
||||
// "puppet",
|
||||
"purescript",
|
||||
"python",
|
||||
// "qml",
|
||||
// "qss",
|
||||
"r",
|
||||
// "racket",
|
||||
// "raku",
|
||||
"razor",
|
||||
// "reg",
|
||||
// "regex",
|
||||
// "rel",
|
||||
// "riscv",
|
||||
// "rst",
|
||||
"ruby",
|
||||
"rust",
|
||||
// "sas",
|
||||
// "sass",
|
||||
"scala",
|
||||
"scheme",
|
||||
"scss",
|
||||
// "sdbl",
|
||||
// "shaderlab",
|
||||
"shellscript",
|
||||
// "smalltalk",
|
||||
"solidity",
|
||||
// "soy",
|
||||
// "sparql",
|
||||
// "splunk",
|
||||
"sql",
|
||||
// "ssh-config",
|
||||
// "stata",
|
||||
// "stylus",
|
||||
"svelte",
|
||||
"swift",
|
||||
// "system-verilog",
|
||||
// "systemd",
|
||||
// "talonscript",
|
||||
// "tasl",
|
||||
// "tcl",
|
||||
// "templ",
|
||||
// "terraform",
|
||||
// "tex",
|
||||
"toml",
|
||||
// "ts-tags",
|
||||
// "tsv",
|
||||
"tsx",
|
||||
// "turtle",
|
||||
// "twig",
|
||||
"typescript",
|
||||
// "typespec",
|
||||
"typst",
|
||||
"v",
|
||||
// "vala",
|
||||
"vb",
|
||||
// "verilog",
|
||||
// "vhdl",
|
||||
// "viml",
|
||||
"vue",
|
||||
// "vyper",
|
||||
"wasm",
|
||||
// "wenyan",
|
||||
// "wgsl",
|
||||
// "wikitext",
|
||||
// "wit",
|
||||
"wolfram",
|
||||
"xml",
|
||||
// "xsl",
|
||||
"yaml",
|
||||
// "zenscript",
|
||||
"zig",
|
||||
sfm,
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const LANGUAGE_NAMES = LANGUAGES.map((l) =>
|
||||
typeof l === "string" ? l : l.name,
|
||||
);
|
||||
export const MONACO_LANGUAGES = [...BUILTIN_LANGUAGE_NAMES, sfm] as const;
|
||||
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
128
src/utils/paste-tabs.ts
Normal 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 ?? "",
|
||||
},
|
||||
];
|
||||
});
|
||||
Reference in New Issue
Block a user