Lots more changes
This commit is contained in:
+197
-163
@@ -1,13 +1,12 @@
|
||||
<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 defaultTTL = $state("15m");
|
||||
let uploadAllowed = $state(true);
|
||||
let configError = $state("");
|
||||
|
||||
@@ -20,12 +19,16 @@
|
||||
let viewContent = $state("");
|
||||
let viewIsBinary = $state(false);
|
||||
let viewIsImage = $state(false);
|
||||
let viewIsVideo = $state(false);
|
||||
let copyStatusMessage = $state("");
|
||||
let copyStatusKind = $state("");
|
||||
let refreshTriggered = $state(false);
|
||||
let nowMs = $state(Date.now());
|
||||
|
||||
let nowTicker = null;
|
||||
|
||||
const maxUploadSizeDisplay = $derived(formatBytes(maxUploadSizeBytes, true));
|
||||
const defaultTTLDisplay = $derived(formatTTLDisplay(defaultTTL));
|
||||
|
||||
onMount(async () => {
|
||||
nowTicker = window.setInterval(() => {
|
||||
@@ -89,16 +92,18 @@
|
||||
const max = Number.parseInt(String(data.max_upload_size_bytes ?? "0"), 10);
|
||||
maxUploadSizeBytes = Number.isFinite(max) ? max : 0;
|
||||
uploadAllowed = Boolean(data.upload_allowed);
|
||||
defaultTTL = String(data.default_ttl ?? "").trim() || "15m";
|
||||
} catch (err) {
|
||||
configError = err instanceof Error ? err.message : String(err);
|
||||
maxUploadSizeBytes = 0;
|
||||
uploadAllowed = true;
|
||||
defaultTTL = "15m";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScratchView() {
|
||||
if (!scratchID) {
|
||||
viewError = "Scratch id is missing.";
|
||||
viewError = "Scratch ID is missing.";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,6 +113,9 @@
|
||||
viewContent = "";
|
||||
viewIsBinary = false;
|
||||
viewIsImage = false;
|
||||
viewIsVideo = false;
|
||||
copyStatusMessage = "";
|
||||
copyStatusKind = "";
|
||||
refreshTriggered = false;
|
||||
|
||||
try {
|
||||
@@ -119,14 +127,14 @@
|
||||
return;
|
||||
}
|
||||
const msg = await metaResponse.text();
|
||||
throw new Error(msg || `failed to load scratch metadata (${metaResponse.status})`);
|
||||
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})`);
|
||||
throw new Error(msg || `Failed to load scratch content (${rawResponse.status}).`);
|
||||
}
|
||||
|
||||
const contentType = rawResponse.headers.get("content-type") ?? "";
|
||||
@@ -134,10 +142,17 @@
|
||||
viewContent = await rawResponse.text();
|
||||
viewIsBinary = false;
|
||||
viewIsImage = false;
|
||||
viewIsVideo = false;
|
||||
} else if (isBrowserImageContentType(contentType)) {
|
||||
viewIsImage = true;
|
||||
viewIsBinary = false;
|
||||
viewIsVideo = false;
|
||||
} else if (isBrowserVideoContentType(contentType)) {
|
||||
viewIsVideo = true;
|
||||
viewIsImage = false;
|
||||
viewIsBinary = false;
|
||||
} else {
|
||||
viewIsVideo = false;
|
||||
viewIsImage = false;
|
||||
viewIsBinary = true;
|
||||
}
|
||||
@@ -187,8 +202,81 @@
|
||||
);
|
||||
}
|
||||
|
||||
const browserImageContentTypes = new Set([
|
||||
"image/apng",
|
||||
"image/avif",
|
||||
"image/gif",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"image/x-icon",
|
||||
"image/vnd.microsoft.icon"
|
||||
]);
|
||||
|
||||
const browserVideoContentTypes = new Set([
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/ogg"
|
||||
]);
|
||||
|
||||
function normalizeContentType(contentType) {
|
||||
return String(contentType).toLowerCase().split(";")[0].trim();
|
||||
}
|
||||
|
||||
function isBrowserImageContentType(contentType) {
|
||||
return String(contentType).toLowerCase().startsWith("image/");
|
||||
return browserImageContentTypes.has(normalizeContentType(contentType));
|
||||
}
|
||||
|
||||
function isBrowserVideoContentType(contentType) {
|
||||
return browserVideoContentTypes.has(normalizeContentType(contentType));
|
||||
}
|
||||
|
||||
function formatTTLDisplay(rawTTL) {
|
||||
const source = String(rawTTL ?? "").trim().toLowerCase();
|
||||
if (!source) {
|
||||
return "15 minutes";
|
||||
}
|
||||
|
||||
const matches = Array.from(source.matchAll(/(\d+)\s*([hms])/g));
|
||||
if (matches.length === 0) {
|
||||
return rawTTL;
|
||||
}
|
||||
|
||||
let hours = 0;
|
||||
let minutes = 0;
|
||||
let seconds = 0;
|
||||
for (const match of matches) {
|
||||
const value = Number.parseInt(match[1], 10);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
continue;
|
||||
}
|
||||
const unit = match[2];
|
||||
if (unit === "h") {
|
||||
hours += value;
|
||||
} else if (unit === "m") {
|
||||
minutes += value;
|
||||
} else if (unit === "s") {
|
||||
seconds += value;
|
||||
}
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`);
|
||||
}
|
||||
if (minutes > 0) {
|
||||
parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`);
|
||||
}
|
||||
if (seconds > 0) {
|
||||
parts.push(`${seconds} ${seconds === 1 ? "second" : "seconds"}`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return rawTTL;
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -231,21 +319,43 @@
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function switchMode(nextMode) {
|
||||
if (loading || nextMode === mode) {
|
||||
function onFileChange(event) {
|
||||
selectedFile = event.currentTarget.files?.[0] ?? null;
|
||||
}
|
||||
|
||||
async function copyScratchURL() {
|
||||
if (!viewMeta?.raw_url) {
|
||||
return;
|
||||
}
|
||||
mode = nextMode;
|
||||
errorMessage = "";
|
||||
if (mode === "text") {
|
||||
selectedFile = null;
|
||||
} else {
|
||||
content = "";
|
||||
|
||||
copyStatusMessage = "";
|
||||
copyStatusKind = "";
|
||||
|
||||
const absoluteRawURL = new URL(String(viewMeta.raw_url), window.location.origin).toString();
|
||||
try {
|
||||
await navigator.clipboard.writeText(absoluteRawURL);
|
||||
copyStatusMessage = "Copied download URL.";
|
||||
copyStatusKind = "success";
|
||||
} catch {
|
||||
copyStatusMessage = "Unable to copy URL. Copy it manually from the address bar.";
|
||||
copyStatusKind = "error";
|
||||
}
|
||||
}
|
||||
|
||||
function onFileChange(event) {
|
||||
selectedFile = event.currentTarget.files?.[0] ?? null;
|
||||
function openScratchRawURL() {
|
||||
if (!viewMeta?.raw_url) {
|
||||
return;
|
||||
}
|
||||
const absoluteRawURL = new URL(String(viewMeta.raw_url), window.location.origin).toString();
|
||||
window.open(absoluteRawURL, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
function navigateTo(path) {
|
||||
const destination = String(path ?? "").trim();
|
||||
if (!destination) {
|
||||
return;
|
||||
}
|
||||
window.location.assign(destination);
|
||||
}
|
||||
|
||||
async function onSubmit(event) {
|
||||
@@ -260,40 +370,23 @@
|
||||
|
||||
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;
|
||||
}
|
||||
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
|
||||
});
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("file", selectedFile);
|
||||
const response = await fetch("/api/scratch", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
@@ -308,7 +401,7 @@
|
||||
window.location.assign(viewURL);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (mode === "file" && err instanceof TypeError) {
|
||||
if (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);
|
||||
@@ -323,22 +416,20 @@
|
||||
<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>
|
||||
<button class="link-button nav-button action-button" type="button" onclick={() => navigateTo("/")}>Home</button>
|
||||
{#if uploadAllowed}
|
||||
<a class="link-button nav-button" href="/u">Upload</a>
|
||||
<button class="link-button nav-button action-button" type="button" onclick={() => navigateTo("/u")}>Upload</button>
|
||||
{/if}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{#if routeMode === "home"}
|
||||
<section class="panel">
|
||||
<h1>Share scratch files and text quickly</h1>
|
||||
<h1>Share scratch files 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.
|
||||
Scratchbox is a minimal temporary upload service with expiration.
|
||||
</p>
|
||||
<p>
|
||||
No accounts, no sessions, no friction. Create a scratch, grab the URL, and share it.
|
||||
@@ -350,7 +441,7 @@
|
||||
{:else if routeMode === "view"}
|
||||
<section class="panel">
|
||||
{#if viewLoading}
|
||||
<p>Loading scratch...</p>
|
||||
<p>Loading scratch content...</p>
|
||||
{:else if viewError}
|
||||
<section id="error" class="error">{viewError}</section>
|
||||
{:else if viewMeta}
|
||||
@@ -361,21 +452,29 @@
|
||||
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}
|
||||
{:else if viewIsVideo}
|
||||
<video class="scratch-video" controls preload="metadata">
|
||||
<source src={viewMeta.raw_url} type={viewMeta.content_type} />
|
||||
Your browser does not support inline video playback.
|
||||
</video>
|
||||
{:else if !viewIsBinary}
|
||||
<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>
|
||||
<p class="scratch-actions">
|
||||
<button class="link-button action-button" type="button" onclick={openScratchRawURL}>Download</button>
|
||||
<button class="link-button action-button" type="button" onclick={copyScratchURL}>Copy Link</button>
|
||||
</p>
|
||||
{#if copyStatusMessage}
|
||||
<p class={copyStatusKind === "error" ? "copy-status error" : "copy-status"}>{copyStatusMessage}</p>
|
||||
{/if}
|
||||
{/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>
|
||||
<p class="helper">Size limit: {maxUploadSizeDisplay}</p>
|
||||
<p class="helper">Expiration: {defaultTTLDisplay}</p>
|
||||
{#if configError}
|
||||
<p class="helper warning">Could not load UI config: {configError}</p>
|
||||
{/if}
|
||||
@@ -385,59 +484,18 @@
|
||||
</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>
|
||||
<section id="file-panel" class="input-panel">
|
||||
<label for="file">Select a file to upload</label>
|
||||
<input
|
||||
id="file"
|
||||
name="file"
|
||||
type="file"
|
||||
onchange={onFileChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{#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}>
|
||||
<button class="link-button nav-button action-button submit-button" type="submit" disabled={loading}>
|
||||
{#if loading}Creating...{:else}Create scratch{/if}
|
||||
</button>
|
||||
</form>
|
||||
@@ -459,9 +517,6 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<footer class="site-footer">
|
||||
<p>Scratchbox - simple temporary uploads</p>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@@ -479,15 +534,13 @@
|
||||
}
|
||||
|
||||
.site-header,
|
||||
.site-footer,
|
||||
.panel {
|
||||
border: 1px solid #2a3243;
|
||||
border-radius: 10px;
|
||||
background: #101726;
|
||||
}
|
||||
|
||||
.site-header,
|
||||
.site-footer {
|
||||
.site-header {
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
@@ -511,11 +564,6 @@
|
||||
color: #f5f7fc;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
color: #b1b7c7;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: flex;
|
||||
gap: 0.85rem;
|
||||
@@ -526,11 +574,6 @@
|
||||
padding: 1.2rem;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
margin-top: 1rem;
|
||||
color: #b1b7c7;
|
||||
}
|
||||
|
||||
.helper {
|
||||
color: #b1b7c7;
|
||||
margin-top: 0.5rem;
|
||||
@@ -559,38 +602,26 @@
|
||||
color: #f4c16e;
|
||||
}
|
||||
|
||||
.mode-picker,
|
||||
.scratch-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copy-status {
|
||||
margin-top: 0.35rem;
|
||||
color: #9be8ac;
|
||||
}
|
||||
|
||||
.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%;
|
||||
@@ -601,14 +632,8 @@
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
.submit-button {
|
||||
margin-top: 1rem;
|
||||
border: 1px solid #2f67c8;
|
||||
background: #2b4b89;
|
||||
color: #f2f5fb;
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button[disabled] {
|
||||
@@ -635,6 +660,15 @@
|
||||
background: #0b0e14;
|
||||
}
|
||||
|
||||
.scratch-video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
border: 1px solid #2a3243;
|
||||
border-radius: 8px;
|
||||
background: #0b0e14;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff9b9b;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user