Many changes
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Generated
+1173
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { mount } from "svelte";
|
||||
import App from "./App.svelte";
|
||||
|
||||
const target = document.getElementById("app");
|
||||
|
||||
if (target) {
|
||||
mount(App, {
|
||||
target
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user