Many changes

This commit is contained in:
2026-05-31 20:17:49 -05:00
parent 9e398957a9
commit 99d46ecd2a
28 changed files with 3729 additions and 594 deletions
+21
View File
@@ -0,0 +1,21 @@
package webassets
import (
"embed"
"io/fs"
)
//go:embed static
var embeddedFiles embed.FS
func StaticFS() fs.FS {
return mustSub("static")
}
func mustSub(dir string) fs.FS {
sub, err := fs.Sub(embeddedFiles, dir)
if err != nil {
panic(err)
}
return sub
}
-54
View File
@@ -1,54 +0,0 @@
body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
background: #10141a;
color: #e5e7eb;
}
.container {
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem 2rem;
}
textarea {
width: 100%;
box-sizing: border-box;
border: 1px solid #334155;
border-radius: 6px;
background: #0f172a;
color: #e2e8f0;
padding: 0.75rem;
margin-bottom: 1rem;
}
button {
background: #3b82f6;
border: 0;
color: white;
border-radius: 6px;
padding: 0.6rem 1rem;
cursor: pointer;
}
.error {
margin-top: 1rem;
color: #fecaca;
}
.hidden {
display: none;
}
pre {
background: #0b1220;
border: 1px solid #334155;
border-radius: 6px;
padding: 1rem;
overflow: auto;
white-space: pre-wrap;
}
a {
color: #93c5fd;
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
body{color:#e7ebf3;background:#0b0e14;margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif}.container.svelte-1n46o8q{max-width:960px;margin:0 auto;padding:2rem 1.25rem 3rem}.site-header.svelte-1n46o8q,.site-footer.svelte-1n46o8q,.panel.svelte-1n46o8q{background:#101726;border:1px solid #2a3243;border-radius:10px}.site-header.svelte-1n46o8q,.site-footer.svelte-1n46o8q{padding:1rem 1.1rem}.site-header.svelte-1n46o8q{justify-content:space-between;align-items:center;gap:1rem;display:flex}.brand-row.svelte-1n46o8q{flex-direction:column;gap:.1rem;display:flex}.brand.svelte-1n46o8q{color:#f5f7fc;font-size:1.3rem;font-weight:700;text-decoration:none}.tagline.svelte-1n46o8q{color:#b1b7c7;font-size:.95rem}.site-nav.svelte-1n46o8q{gap:.85rem;display:flex}.panel.svelte-1n46o8q{margin-top:1rem;padding:1.2rem}.site-footer.svelte-1n46o8q{color:#b1b7c7;margin-top:1rem}.helper.svelte-1n46o8q{color:#b1b7c7;margin-top:.5rem}.link-button.svelte-1n46o8q{color:#f2f5fb;background:#213f75;border:1px solid #2f67c8;border-radius:8px;justify-content:center;align-items:center;padding:.45rem .8rem;font-weight:600;text-decoration:none;display:inline-flex}.nav-button.svelte-1n46o8q{background:#162640;border-color:#355487;font-weight:500}.warning.svelte-1n46o8q{color:#f4c16e}.mode-picker.svelte-1n46o8q,.input-panel.svelte-1n46o8q,#error.svelte-1n46o8q{margin-top:1rem}.mode-picker.svelte-1n46o8q{border:1px solid #2a3243;border-radius:8px;padding:.75rem}.mode-buttons.svelte-1n46o8q{gap:.5rem;display:flex}.mode-button.svelte-1n46o8q{color:#e7ebf3;cursor:pointer;background:#121826;border:1px solid #2a3243;border-radius:8px;padding:.4rem .8rem}.mode-button.is-active.svelte-1n46o8q{background:#2b4b89;border-color:#4a75c4}textarea.svelte-1n46o8q,input[type=file].svelte-1n46o8q{color:#e7ebf3;background:#121826;border:1px solid #2a3243;border-radius:8px;width:100%;margin-top:.5rem;padding:.7rem}button[type=submit].svelte-1n46o8q{color:#f2f5fb;cursor:pointer;background:#2b4b89;border:1px solid #2f67c8;border-radius:8px;margin-top:1rem;padding:.6rem 1rem}button[disabled].svelte-1n46o8q{opacity:.65;cursor:not-allowed}pre.svelte-1n46o8q{white-space:pre-wrap;background:#101726;border:1px solid #2a3243;border-radius:8px;max-width:100%;padding:1rem;overflow:auto}.scratch-image.svelte-1n46o8q{background:#0b0e14;border:1px solid #2a3243;border-radius:8px;max-width:100%;max-height:70vh;display:block}.error.svelte-1n46o8q{color:#ff9b9b}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scratchbox</title>
<script type="module" crossorigin src="/assets/index-Bu5AaCmv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C05A_xhw.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
-70
View File
@@ -1,70 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scratchbox</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<main class="container">
<h1>Scratchbox</h1>
<p>Create an unauthenticated scratch. It expires automatically.</p>
<form id="scratch-form">
<label for="content">Text content</label>
<textarea id="content" name="content" rows="14" placeholder="Paste text here"></textarea>
<button type="submit">Create scratch</button>
</form>
<section id="result" class="hidden">
<h2>Created</h2>
<p><strong>View:</strong> <a id="view-link" href="#" target="_blank" rel="noreferrer"></a></p>
<p><strong>Raw:</strong> <a id="raw-link" href="#" target="_blank" rel="noreferrer"></a></p>
<p id="expire-text"></p>
</section>
<section id="error" class="error hidden"></section>
</main>
<script>
const form = document.getElementById("scratch-form");
const result = document.getElementById("result");
const error = document.getElementById("error");
const viewLink = document.getElementById("view-link");
const rawLink = document.getElementById("raw-link");
const expireText = document.getElementById("expire-text");
form.addEventListener("submit", async (event) => {
event.preventDefault();
result.classList.add("hidden");
error.classList.add("hidden");
const content = document.getElementById("content").value;
try {
const response = await fetch("/api/scratch", {
method: "POST",
headers: { "Content-Type": "text/plain; charset=utf-8" },
body: content
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || `request failed (${response.status})`);
}
const payload = await response.json();
viewLink.href = payload.view_url;
viewLink.textContent = payload.view_url;
rawLink.href = payload.raw_url;
rawLink.textContent = payload.raw_url;
expireText.textContent = `Expires at: ${payload.expires_at}`;
result.classList.remove("hidden");
} catch (err) {
error.textContent = err.message;
error.classList.remove("hidden");
}
});
</script>
</body>
</html>
-26
View File
@@ -1,26 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scratch {{ .ID }}</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<main class="container">
<h1>Scratch {{ .ID }}</h1>
<p>Content type: <code>{{ .ContentType }}</code></p>
<p>Expires at: {{ .ExpiresAt }}</p>
<p><a href="{{ .RawURL }}">Open raw</a></p>
{{ if .Binary }}
<p>This scratch looks binary and is not rendered in-page. Use the raw link.</p>
{{ else }}
<pre>{{ .Content }}</pre>
{{ if .Truncated }}
<p>Preview truncated to 2MB. Open raw for full content.</p>
{{ end }}
{{ end }}
</main>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scratchbox</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1173
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"name": "scratchbox-web-ui",
"private": true,
"type": "module",
"scripts": {
"build": "vite build"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"svelte": "^5.56.0",
"vite": "^8.0.14"
}
}
+641
View File
@@ -0,0 +1,641 @@
<script>
import { onDestroy, onMount } from "svelte";
let mode = $state("text");
let content = $state("");
let selectedFile = $state(null);
let loading = $state(false);
let errorMessage = $state("");
let maxUploadSizeBytes = $state(0);
let uploadAllowed = $state(true);
let configError = $state("");
let routeMode = $state("home");
let scratchID = $state("");
let viewLoading = $state(false);
let viewError = $state("");
let viewMeta = $state(null);
let viewContent = $state("");
let viewIsBinary = $state(false);
let viewIsImage = $state(false);
let refreshTriggered = $state(false);
let nowMs = $state(Date.now());
let nowTicker = null;
const maxUploadSizeDisplay = $derived(formatBytes(maxUploadSizeBytes, true));
onMount(async () => {
nowTicker = window.setInterval(() => {
nowMs = Date.now();
}, 1000);
detectRoute();
await loadUIConfig();
if (routeMode === "view") {
await loadScratchView();
}
});
onDestroy(() => {
if (nowTicker !== null) {
window.clearInterval(nowTicker);
nowTicker = null;
}
});
function detectRoute() {
const path = window.location.pathname;
if (path === "/" || path === "") {
routeMode = "home";
scratchID = "";
return;
}
if (path === "/u" || path === "/u/") {
routeMode = "upload";
scratchID = "";
return;
}
if (!path.startsWith("/s/")) {
routeMode = "notfound";
scratchID = "";
return;
}
const rawID = path.slice("/s/".length).trim();
if (rawID.length === 0) {
routeMode = "notfound";
scratchID = "";
return;
}
routeMode = "view";
scratchID = decodeURIComponent(rawID);
}
async function loadUIConfig() {
configError = "";
try {
const response = await fetch("/api/config");
if (!response.ok) {
throw new Error(`failed to load UI config (${response.status})`);
}
const data = await response.json();
const max = Number.parseInt(String(data.max_upload_size_bytes ?? "0"), 10);
maxUploadSizeBytes = Number.isFinite(max) ? max : 0;
uploadAllowed = Boolean(data.upload_allowed);
} catch (err) {
configError = err instanceof Error ? err.message : String(err);
maxUploadSizeBytes = 0;
uploadAllowed = true;
}
}
async function loadScratchView() {
if (!scratchID) {
viewError = "Scratch id is missing.";
return;
}
viewLoading = true;
viewError = "";
viewMeta = null;
viewContent = "";
viewIsBinary = false;
viewIsImage = false;
refreshTriggered = false;
try {
const metaResponse = await fetch(`/api/scratch/${encodeURIComponent(scratchID)}`);
if (!metaResponse.ok) {
if (metaResponse.status === 404 || metaResponse.status === 410) {
routeMode = "expired";
viewError = "This scratch is no longer available.";
return;
}
const msg = await metaResponse.text();
throw new Error(msg || `failed to load scratch metadata (${metaResponse.status})`);
}
viewMeta = await metaResponse.json();
const rawResponse = await fetch(`/api/raw/${encodeURIComponent(scratchID)}`);
if (!rawResponse.ok) {
const msg = await rawResponse.text();
throw new Error(msg || `failed to load scratch content (${rawResponse.status})`);
}
const contentType = rawResponse.headers.get("content-type") ?? "";
if (isLikelyTextContentType(contentType)) {
viewContent = await rawResponse.text();
viewIsBinary = false;
viewIsImage = false;
} else if (isBrowserImageContentType(contentType)) {
viewIsImage = true;
viewIsBinary = false;
} else {
viewIsImage = false;
viewIsBinary = true;
}
} catch (err) {
viewError = err instanceof Error ? err.message : String(err);
} finally {
viewLoading = false;
}
}
function trimTrailingZeros(value) {
return value.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1");
}
function formatBytes(bytes, binary) {
if (!Number.isFinite(bytes) || bytes <= 0) {
return "0 bytes";
}
const base = binary ? 1024 : 1000;
const units = binary ? ["KiB", "MiB", "GiB"] : ["KB", "MB", "GB"];
if (bytes < base) {
return `${bytes} bytes`;
}
let value = bytes;
let unitIndex = -1;
while (value >= base && unitIndex < units.length - 1) {
value /= base;
unitIndex += 1;
}
const rounded = value >= 10 ? value.toFixed(1) : value.toFixed(2);
return `${trimTrailingZeros(rounded)} ${units[unitIndex]}`;
}
function isLikelyTextContentType(contentType) {
const normalized = String(contentType).toLowerCase();
if (normalized.startsWith("text/")) {
return true;
}
return (
normalized.startsWith("application/json") ||
normalized.startsWith("application/xml") ||
normalized.startsWith("application/javascript") ||
normalized.startsWith("application/x-www-form-urlencoded")
);
}
function isBrowserImageContentType(contentType) {
return String(contentType).toLowerCase().startsWith("image/");
}
$effect(() => {
if (refreshTriggered || routeMode !== "view" || !viewMeta?.expires_at) {
return;
}
const expiry = Date.parse(String(viewMeta.expires_at));
if (!Number.isFinite(expiry)) {
return;
}
if (nowMs >= expiry+10_000) {
refreshTriggered = true;
window.location.reload();
}
});
function expiresInText(expiresAt, now) {
const expiry = Date.parse(String(expiresAt ?? ""));
if (!Number.isFinite(expiry)) {
return "unknown";
}
const deltaMs = Math.max(0, expiry - now);
if (deltaMs === 0) {
return "expired";
}
const totalSeconds = Math.floor(deltaMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`;
}
if (minutes > 0) {
return `${minutes}m ${seconds}s`;
}
return `${seconds}s`;
}
function switchMode(nextMode) {
if (loading || nextMode === mode) {
return;
}
mode = nextMode;
errorMessage = "";
if (mode === "text") {
selectedFile = null;
} else {
content = "";
}
}
function onFileChange(event) {
selectedFile = event.currentTarget.files?.[0] ?? null;
}
async function onSubmit(event) {
event.preventDefault();
if (loading) {
return;
}
if (!uploadAllowed) {
errorMessage = "Uploads are not available from your network location.";
return;
}
errorMessage = "";
const trimmedContent = content.trim();
if (mode === "text") {
if (!trimmedContent) {
errorMessage = "Enter text content before creating a scratch.";
return;
}
} else {
if (!selectedFile) {
errorMessage = "Choose a file before creating a scratch.";
return;
}
if (maxUploadSizeBytes > 0 && selectedFile.size > maxUploadSizeBytes) {
errorMessage = `Selected file is too large (${formatBytes(selectedFile.size, true)}). Max upload size is ${maxUploadSizeDisplay}.`;
return;
}
}
loading = true;
try {
let response;
if (mode === "file") {
const formData = new FormData();
formData.append("file", selectedFile);
response = await fetch("/api/scratch", {
method: "POST",
body: formData
});
} else {
response = await fetch("/api/scratch", {
method: "POST",
headers: { "Content-Type": "text/plain; charset=utf-8" },
body: trimmedContent
});
}
if (!response.ok) {
const message = await response.text();
throw new Error(message || `request failed (${response.status})`);
}
const created = await response.json();
const viewURL = typeof created?.view_url === "string" ? created.view_url : "";
if (!viewURL) {
throw new Error("scratch created but view URL is missing");
}
window.location.assign(viewURL);
return;
} catch (err) {
if (mode === "file" && err instanceof TypeError) {
errorMessage = `Upload failed before the server returned a response. Max upload size is ${maxUploadSizeDisplay}.`;
} else {
errorMessage = err instanceof Error ? err.message : String(err);
}
} finally {
loading = false;
}
}
</script>
<main class="container">
<header class="site-header">
<div class="brand-row">
<a class="brand" href="/">Scratchbox</a>
<span class="tagline">Quick unauthenticated scratch uploads</span>
</div>
<nav class="site-nav" aria-label="Primary">
<a class="link-button nav-button" href="/">Home</a>
{#if uploadAllowed}
<a class="link-button nav-button" href="/u">Upload</a>
{/if}
</nav>
</header>
{#if routeMode === "home"}
<section class="panel">
<h1>Share scratch files and text quickly</h1>
<p>
Scratchbox is a minimal temporary upload service. Every upload creates one scratch with an expiration
timestamp. It supports plain text and single file uploads, plus direct raw retrieval for automation.
</p>
<p>
No accounts, no sessions, no friction. Create a scratch, grab the URL, and share it.
</p>
{#if !uploadAllowed}
<p class="helper">Uploads are currently unavailable from your network location.</p>
{/if}
</section>
{:else if routeMode === "view"}
<section class="panel">
{#if viewLoading}
<p>Loading scratch...</p>
{:else if viewError}
<section id="error" class="error">{viewError}</section>
{:else if viewMeta}
{#if viewIsImage}
<img
class="scratch-image"
src={viewMeta.raw_url}
alt={`Scratch ${scratchID}`}
loading="lazy"
/>
{:else if viewIsBinary}
<p>This scratch looks binary and is not rendered in-page. Use the raw link.</p>
{:else}
<pre>{viewContent}</pre>
{/if}
<p><strong>Expires in:</strong> {expiresInText(viewMeta.expires_at, nowMs)}</p>
<p><a class="link-button" href={viewMeta.raw_url} target="_blank" rel="noreferrer">Download</a></p>
{/if}
</section>
{:else if routeMode === "upload"}
<section class="panel">
<h1>Upload</h1>
<p>Create an unauthenticated scratch. It expires automatically.</p>
<p class="helper">Max upload size: {maxUploadSizeDisplay}</p>
{#if configError}
<p class="helper warning">Could not load UI config: {configError}</p>
{/if}
{#if !uploadAllowed}
<section id="error" class="error">
Uploads are disabled for your current IP based on server allowlist settings.
</section>
{:else}
<form id="scratch-form" onsubmit={onSubmit}>
<fieldset class="mode-picker">
<legend>Upload mode</legend>
<div class="mode-buttons" role="tablist" aria-label="Upload mode">
<button
id="mode-text"
type="button"
class:mode-button={true}
class:is-active={mode === "text"}
aria-pressed={mode === "text"}
onclick={() => switchMode("text")}
>
Text
</button>
<button
id="mode-file"
type="button"
class:mode-button={true}
class:is-active={mode === "file"}
aria-pressed={mode === "file"}
onclick={() => switchMode("file")}
>
File
</button>
</div>
</fieldset>
{#if mode === "text"}
<section id="text-panel" class="input-panel">
<label for="content">Text content</label>
<textarea
id="content"
name="content"
rows="14"
placeholder="Paste text here"
bind:value={content}
disabled={loading}
></textarea>
</section>
{:else}
<section id="file-panel" class="input-panel">
<label for="file">File upload</label>
<input
id="file"
name="file"
type="file"
onchange={onFileChange}
disabled={loading}
/>
<p class="helper">Upload one file per scratch.</p>
</section>
{/if}
<button type="submit" disabled={loading}>
{#if loading}Creating...{:else}Create scratch{/if}
</button>
</form>
{/if}
{#if errorMessage}
<section id="error" class="error">{errorMessage}</section>
{/if}
</section>
{:else if routeMode === "expired"}
<section class="panel">
<h1>Scratch expired</h1>
<p>{viewError || "The scratch you requested is no longer available because its retention time elapsed."}</p>
</section>
{:else}
<section class="panel">
<h1>Page not found</h1>
<p>The requested path does not exist.</p>
</section>
{/if}
<footer class="site-footer">
<p>Scratchbox - simple temporary uploads</p>
</footer>
</main>
<style>
:global(body) {
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
background: #0b0e14;
color: #e7ebf3;
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 2rem 1.25rem 3rem;
}
.site-header,
.site-footer,
.panel {
border: 1px solid #2a3243;
border-radius: 10px;
background: #101726;
}
.site-header,
.site-footer {
padding: 1rem 1.1rem;
}
.site-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.brand-row {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.brand {
font-size: 1.3rem;
font-weight: 700;
text-decoration: none;
color: #f5f7fc;
}
.tagline {
color: #b1b7c7;
font-size: 0.95rem;
}
.site-nav {
display: flex;
gap: 0.85rem;
}
.panel {
margin-top: 1rem;
padding: 1.2rem;
}
.site-footer {
margin-top: 1rem;
color: #b1b7c7;
}
.helper {
color: #b1b7c7;
margin-top: 0.5rem;
}
.link-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.45rem 0.8rem;
border: 1px solid #2f67c8;
border-radius: 8px;
background: #213f75;
color: #f2f5fb;
text-decoration: none;
font-weight: 600;
}
.nav-button {
background: #162640;
border-color: #355487;
font-weight: 500;
}
.warning {
color: #f4c16e;
}
.mode-picker,
.input-panel,
#error {
margin-top: 1rem;
}
.mode-picker {
border: 1px solid #2a3243;
border-radius: 8px;
padding: 0.75rem;
}
.mode-buttons {
display: flex;
gap: 0.5rem;
}
.mode-button {
border: 1px solid #2a3243;
border-radius: 8px;
background: #121826;
color: #e7ebf3;
padding: 0.4rem 0.8rem;
cursor: pointer;
}
.mode-button.is-active {
background: #2b4b89;
border-color: #4a75c4;
}
textarea,
input[type="file"] {
margin-top: 0.5rem;
width: 100%;
background: #121826;
color: #e7ebf3;
border: 1px solid #2a3243;
border-radius: 8px;
padding: 0.7rem;
}
button[type="submit"] {
margin-top: 1rem;
border: 1px solid #2f67c8;
background: #2b4b89;
color: #f2f5fb;
border-radius: 8px;
padding: 0.6rem 1rem;
cursor: pointer;
}
button[disabled] {
opacity: 0.65;
cursor: not-allowed;
}
pre {
max-width: 100%;
overflow: auto;
white-space: pre-wrap;
background: #101726;
border: 1px solid #2a3243;
border-radius: 8px;
padding: 1rem;
}
.scratch-image {
display: block;
max-width: 100%;
max-height: 70vh;
border: 1px solid #2a3243;
border-radius: 8px;
background: #0b0e14;
}
.error {
color: #ff9b9b;
}
</style>
+10
View File
@@ -0,0 +1,10 @@
import { mount } from "svelte";
import App from "./App.svelte";
const target = document.getElementById("app");
if (target) {
mount(App, {
target
});
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
export default defineConfig({
plugins: [svelte()],
build: {
outDir: "../static",
emptyOutDir: true
}
});