feat: initial commit

This commit is contained in:
DuroCodes
2024-08-18 21:03:46 -04:00
commit ce6ba992ff
23 changed files with 666 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

4
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# sponge bin
a pastebin for code snippets; rendered with shiki

9
astro.config.mjs Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'astro/config';
import tailwind from "@astrojs/tailwind";
import preact from "@astrojs/preact";
// https://astro.build/config
export default defineConfig({
integrations: [tailwind(), preact()],
output: 'server'
});

BIN
bun.lockb Executable file

Binary file not shown.

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "pastebin",
"version": "0.0.1",
"dependencies": {
"@astrojs/check": "^0.9.2",
"@astrojs/preact": "^3.5.1",
"@astrojs/tailwind": "^5.1.0",
"@nanostores/preact": "^0.5.2",
"astro": "^4.14.2",
"nanostores": "^0.11.2",
"preact": "^10.23.2",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.4"
},
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro"
},
"type": "module",
"devDependencies": {
"prettier-plugin-astro": "^0.14.1"
}
}

120
public/favicon.svg Normal file
View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img"
class="iconify iconify--twemoji" preserveAspectRatio="xMidYMid meet">
<path fill="#F4900C"
d="M35.676 18.625c.057.794.011 5.907.006 6.093a6.821 6.821 0 0 1-.026.422c-.618 6.445-5.937 10.342-12.187 9.375c-6.062-.938-8.938-5.5-11.938-8.5S1.656 22.827.641 19.312a7.065 7.065 0 0 1-.294-2.267c.011-.256-.141-5.031.035-6.169c.338-2.189 35.008 3.795 35.294 7.749z">
</path>
<path fill="#FFCC4D"
d="M4.718 4.856c3.5-2.125 8.964-2.286 12.5 1.25c3 3 4.243 3.843 9.243 4.843s9.632 3.532 9.194 8.094c-.618 6.445-5.937 10.342-12.187 9.375c-6.062-.938-8.938-5.5-11.938-8.5S1.655 16.73.64 13.215c-1.109-3.842.996-6.487 4.078-8.359z">
</path>
<g fill="#FFAC33">
<circle cx="12.513" cy="14.106" r="1.5">
</circle>
<circle cx="29.013" cy="25.794" r="1">
</circle>
<circle cx="17.013" cy="24.106" r="1">
</circle>
<circle cx="24.513" cy="24.794" r="1.5">
</circle>
<circle cx="31.825" cy="21.606" r="1.5">
</circle>
<circle cx="29.013" cy="18.419" r="1">
</circle>
<circle cx="25.013" cy="20.356" r="1">
</circle>
<circle cx="19.513" cy="21.356" r="1">
</circle>
<path
d="M14.013 20.606a1.5 1.5 0 0 0-1.5-1.5c-.496 0-.933.244-1.206.616c.074.067.155.127.224.197c.598.598 1.192 1.258 1.806 1.939c.406-.268.676-.728.676-1.252z">
</path>
<circle cx="9.575" cy="16.419" r="1">
</circle>
<circle cx="16.013" cy="17.419" r="1">
</circle>
<circle cx="24.513" cy="13.606" r="1.5">
</circle>
<circle cx="21.013" cy="17.419" r="1.5">
</circle>
<circle cx="12.013" cy="10.106" r="1">
</circle>
<circle cx="20.513" cy="11.106" r="1">
</circle>
<circle cx="14.513" cy="7.856" r="1">
</circle>
<circle cx="7.575" cy="12.606" r="1">
</circle>
<circle cx="6.013" cy="6.856" r="1">
</circle>
<circle cx="28.575" cy="14.106" r="1">
</circle>
<circle cx="33.325" cy="17.419" r="1">
</circle>
<circle cx="3.263" cy="10.606" r="1.5">
</circle>
<circle cx="10.575" cy="5.856" r="1.5">
</circle>
<circle cx="16.513" cy="11.106" r="1.5">
</circle>
<path
d="M19.513 26.106c-.408 0-.778.164-1.048.429c.77.486 1.61.908 2.531 1.243c.007-.057.017-.113.017-.172a1.5 1.5 0 0 0-1.5-1.5zm-14.75-10.5a.996.996 0 0 0-1.994-.029c.491.288 1.034.549 1.615.795a.987.987 0 0 0 .379-.766z">
</path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
public/sponge.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -0,0 +1,47 @@
import { useStore } from "@nanostores/preact";
import {
$codeStore,
$langStore,
$themeStore,
highlighter,
} from "../utils/theme";
export default function CodeBlock() {
const lang = useStore($langStore);
const theme = useStore($themeStore);
const code = useStore($codeStore);
const handleCodeChange = (event: Event) => {
const newCode = (event.target as HTMLTextAreaElement).value;
$codeStore.set(newCode);
};
const __html = highlighter.codeToHtml($codeStore.get(), {
lang,
theme,
});
return (
<div class="relative">
<textarea
class="ml-4 w-full p-2 z-0 rounded absolute top-0 left-0 bg-transparent caret-transparent text-transparent resize-none"
value={code}
onInput={handleCodeChange}
rows={10}
spellCheck={false}
style={{
height: "100%",
width: "100%",
}}
/>
<div
class="ml-4 shiki pointer-events-none"
dangerouslySetInnerHTML={{ __html }}
style={{
whiteSpace: "pre-wrap",
fontFamily: "monospace",
}}
/>
</div>
);
}

32
src/components/Editor.tsx Normal file
View File

@@ -0,0 +1,32 @@
import LanguageSelect from "./LanguageSelect";
import SaveButton from "./SaveButton";
import { $themeColorsStore } from "../utils/theme";
import ThemeSelect from "./ThemeSelect";
import { useStore } from "@nanostores/preact";
import CodeBlock from "./CodeBlock";
export default function Editor() {
const colors = useStore($themeColorsStore);
return (
<div style={{ backgroundColor: colors.background }} class="h-screen">
<div class="mb-4 p-2" style={{ backgroundColor: colors.navbar }}>
<nav
class="flex justify-between mx-4"
style={{ color: colors.primary }}
>
<div class="flex justify-between">
<SaveButton />
</div>
<div class="flex gap-2">
<LanguageSelect />
<ThemeSelect />
<a href="github.com/durocodes/spongebin">[source]</a>
</div>
</nav>
</div>
<CodeBlock />
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { bundledLanguages } from "shiki";
import MenuButton from "./MenuButton";
import { $langStore } from "../utils/theme";
export default function LanguageSelect() {
return (
<MenuButton
label="language"
ids={Object.keys(bundledLanguages)}
$store={$langStore}
/>
);
}

View File

@@ -0,0 +1,127 @@
import { useEffect, useRef, useState } from "preact/hooks";
import type { PreinitializedWritableAtom } from "nanostores";
import { $themeColorsStore } from "../utils/theme";
import { useStore } from "@nanostores/preact";
export interface MenuButtonProps {
label: string;
ids: string[];
minWidth?: string;
$store: PreinitializedWritableAtom<string>;
}
export default function MenuButton({
label,
ids,
minWidth,
$store,
}: MenuButtonProps) {
const [open, setOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const colors = useStore($themeColorsStore);
useEffect(() => {
const listener = () => setOpen(false);
window.addEventListener("click", listener);
return () => window.removeEventListener("click", listener);
}, []);
useEffect(() => {
if (open && inputRef.current) {
inputRef.current.focus();
}
}, [open]);
function toggleOpen(e: MouseEvent) {
e.stopPropagation();
setOpen(!open);
}
function select(e: MouseEvent | KeyboardEvent, id: string) {
e.stopPropagation();
setOpen(false);
$store.set(id);
}
function handleInputClick(e: MouseEvent) {
e.stopPropagation();
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Enter") {
e.preventDefault();
if (filteredIds.length > 0) {
select(e, filteredIds[0]);
}
}
}
const filteredIds = ids.filter((id) =>
id.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<div class="relative inline-block">
<button
class="rounded focus:outline-none"
style={{ color: colors.primary }}
onClick={toggleOpen}
>
<span class="hidden md:inline">
[{label}: {$store.get()}]
</span>
<span class="md:hidden">[{$store.get()}]</span>
</button>
{open && (
<div
class="absolute right-0 z-10 mt-4 rounded-xl max-h-60 overflow-auto w-full"
style={{
backgroundColor: colors.navbar,
minWidth: minWidth ?? "13rem",
}}
>
<input
type="text"
class="w-full px-4 py-2 border-b focus:outline-none"
style={{
backgroundColor: colors.navbar,
borderColor: `${colors.primary}aa`,
color: colors.primary,
}}
placeholder="type to search..."
value={searchTerm}
onInput={(e) => setSearchTerm((e.target as HTMLInputElement).value)}
onClick={handleInputClick}
onKeyDown={handleKeyDown}
ref={inputRef}
/>
<ul>
{filteredIds.length > 0 ? (
filteredIds.map((id) => (
<li
key={id}
class={`px-4 py-2 cursor-pointer ${id === $store.get() ? "font-bold" : ""}`}
style={{
backgroundColor:
id === $store.get()
? `${colors.background}77`
: `${colors.navbar}`,
}}
onClick={(e) => select(e, id)}
>
{id === $store.get() ? `*${id}` : id}
</li>
))
) : (
<li class="px-4 py-2" style={{ color: colors.primary }}>
No results
</li>
)}
</ul>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { useStore } from "@nanostores/preact";
import { useEffect, useState } from "preact/hooks";
import { unwrapOr } from "../utils/result";
import { encode } from "../utils/encode";
import { $codeStore, $langStore, $themeStore } from "../utils/theme";
export default function SaveButton() {
const code = useStore($codeStore);
const [saving, setSaving] = useState(false);
const [buttonText, setButtonText] = useState("[save]");
const save = async () => {
setSaving(true);
console.log({ code });
const result = await unwrapOr(encode(code), "");
const url = `${window.location.origin}/?l=${$langStore.get()}&t=${$themeStore.get()}&c=${result}`;
navigator.clipboard.writeText(url);
setButtonText("[link copied!]");
setTimeout(() => {
setButtonText("[save]");
setSaving(false);
}, 5000);
};
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.ctrlKey && event.key === "s") {
event.preventDefault();
save();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [code, saving]);
return (
<button class="rounded focus:outline-none" disabled={saving} onClick={save}>
{buttonText}
</button>
);
}

View File

@@ -0,0 +1,13 @@
import { bundledThemes } from "shiki";
import MenuButton from "./MenuButton";
import { $themeStore } from "../utils/theme";
export default function ThemeSelect() {
return (
<MenuButton
label="theme"
ids={Object.keys(bundledThemes)}
$store={$themeStore}
/>
);
}

1
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />

48
src/pages/index.astro Normal file
View File

@@ -0,0 +1,48 @@
---
import "../styles/global.css";
import { type BundledTheme } from "shiki";
import Editor from "../components/Editor";
import { $codeStore, $langStore, $themeStore, parseLang } from "../utils/theme";
import { decode } from "../utils/encode";
import { unwrapOr } from "../utils/result";
const langParam = Astro.url.searchParams.get("l") ?? "txt";
const themeParam = Astro.url.searchParams.get("t") ?? "github-dark-default";
const codeParam =
Astro.url.searchParams.get("c") ??
"H4sIAAAAAAAAE_PLV3DOT0lVCCjKL8tMSU0BAGFoYswQAAAA";
const code = await unwrapOr(
decode(codeParam),
"H4sIAAAAAAAAE_PLV3DOT0lVCCjKL8tMSU0BAGFoYswQAAAA",
);
$codeStore.set(code);
$langStore.set(parseLang(langParam));
$themeStore.set(themeParam as BundledTheme);
---
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>sponge bin</title>
<meta name="theme-color" content="#FFCC4D" />
<meta name="description" content="a pastebin made of sponge" />
<meta property="og:title" content="sponge bin" />
<meta property="og:description" content="a pastebin made of sponge" />
<meta
property="og:image"
content={new URL("/images/sponge.png", Astro.url)}
/>
<meta property="og:url" content={Astro.url} />
</head>
<body>
<Editor client:load />
</body>
</html>

19
src/styles/global.css Normal file
View File

@@ -0,0 +1,19 @@
* {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
}
code {
counter-reset: step;
counter-increment: step 0;
}
code .line::before {
content: counter(step);
counter-increment: step;
width: 1rem;
margin-right: 1.5rem;
display: inline-block;
text-align: right;
color: rgba(115, 138, 148, 0.4);
}

35
src/utils/encode.ts Normal file
View File

@@ -0,0 +1,35 @@
import { tryCatch } from "./result";
const processStream = async (
str: string,
format: CompressionFormat,
StreamConstructor: typeof CompressionStream | typeof DecompressionStream,
encode = true,
) => {
const input = encode
? new TextEncoder().encode(str)
: Uint8Array.from(atob(str.replace(/-/g, "+").replace(/_/g, "/")), (c) =>
c.charCodeAt(0),
);
const stream = new StreamConstructor(format);
const writer = stream.writable.getWriter();
writer.write(input);
writer.close();
const buffer = await new Response(stream.readable).arrayBuffer();
const base64 = encode
? btoa(String.fromCharCode(...new Uint8Array(buffer)))
: new TextDecoder().decode(buffer);
return encode
? base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
: base64;
};
export const encode = (str: string, format: CompressionFormat = "gzip") =>
tryCatch(() => processStream(str, format, CompressionStream, true));
export const decode = (str: string, format: CompressionFormat = "gzip") =>
tryCatch(() => processStream(str, format, DecompressionStream, false));

16
src/utils/result.ts Normal file
View File

@@ -0,0 +1,16 @@
export type Result<Ok, Err> =
| { ok: true; value: Ok }
| { ok: false; error: Err };
export const Ok = <Ok>(value: Ok) => ({ ok: true, value } as const);
export const Err = <Err>(error: Err) => ({ ok: false, error } as const);
export const tryCatch = <T>(fn: () => T): Result<T, unknown> => {
try {
return Ok(fn());
} catch (error) {
return Err(error);
}
};
export const unwrapOr = <Ok, Err, T>(result: Result<Ok, Err>, fallback: T) =>
result.ok ? result.value : fallback;

53
src/utils/theme.ts Normal file
View File

@@ -0,0 +1,53 @@
import { atom, computed, task } from "nanostores";
import {
bundledLanguages,
type BundledLanguage,
type BundledTheme,
createHighlighter,
bundledThemes,
} from "shiki";
export const highlighter = await createHighlighter({
themes: Object.keys(bundledThemes),
langs: Object.keys(bundledLanguages),
});
export const $langStore = atom<BundledLanguage | "txt">("txt");
export const $codeStore = atom<string>("");
export const $themeStore = atom<BundledTheme>("github-dark-default");
export const $themeColorsStore = computed($themeStore, (theme) =>
themeColors(theme),
);
export const parseLang = (lang: string | null = "txt") =>
Object.keys(bundledLanguages).includes(lang ?? "txt")
? (lang as BundledLanguage)
: "txt";
const navbarColor = (backgroundColor: string): string => {
const expandHex = (hex: string) =>
hex.length === 4 ? `#${[...hex.slice(1)].map((c) => c + c).join("")}` : hex;
const isLight = (hex: string) => parseInt(hex.slice(1), 16) > 0xffffff / 2;
const adjustColor = (hex: string, amt: number) => {
const hexCode = (parseInt(hex.slice(1), 16) + amt * 0x010101)
.toString(16)
.padStart(6, "0");
return `#${hexCode}`;
};
const expandedColor = expandHex(backgroundColor);
return adjustColor(expandedColor, isLight(expandedColor) ? -10 : 10);
};
export const themeColors = (theme: BundledTheme) => {
const shikiTheme = highlighter.getTheme(theme);
return {
background: shikiTheme.bg,
primary: shikiTheme.fg,
navbar: navbarColor(shikiTheme.bg),
};
};

8
tailwind.config.mjs Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {},
},
plugins: [],
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "preact"
}
}