api/ui: Allow per-scratch TTL down to 1m
Format / gofmt (push) Successful in 26s
Format / gofmt (pull_request) Successful in 26s
CI / Build (push) Successful in 33s
CI / Build (pull_request) Successful in 33s
CI / Go Tests (push) Successful in 35s
CI / Go Tests (pull_request) Successful in 35s
Format / gofmt (push) Successful in 26s
Format / gofmt (pull_request) Successful in 26s
CI / Build (push) Successful in 33s
CI / Build (pull_request) Successful in 33s
CI / Go Tests (push) Successful in 35s
CI / Go Tests (pull_request) Successful in 35s
#4
This commit is contained in:
+106
-2
@@ -7,6 +7,8 @@
|
||||
|
||||
let maxUploadSizeBytes = $state(0);
|
||||
let defaultTTL = $state("15m");
|
||||
let maxTTL = $state("24h");
|
||||
let selectedTTL = $state("15m");
|
||||
let uploadAllowed = $state(true);
|
||||
let configError = $state("");
|
||||
|
||||
@@ -29,6 +31,8 @@
|
||||
|
||||
const maxUploadSizeDisplay = $derived(formatBytes(maxUploadSizeBytes, true));
|
||||
const defaultTTLDisplay = $derived(formatTTLDisplay(defaultTTL));
|
||||
const maxTTLDisplay = $derived(formatTTLDisplay(maxTTL));
|
||||
const ttlOptions = $derived(buildTTLOptions(defaultTTL, maxTTL));
|
||||
|
||||
const viewPageTitle = $derived(
|
||||
viewMeta?.filename
|
||||
@@ -101,11 +105,15 @@
|
||||
maxUploadSizeBytes = Number.isFinite(max) ? max : 0;
|
||||
uploadAllowed = Boolean(data.upload_allowed);
|
||||
defaultTTL = String(data.default_ttl ?? "").trim() || "15m";
|
||||
maxTTL = String(data.max_ttl ?? "").trim() || "24h";
|
||||
selectedTTL = defaultTTL;
|
||||
} catch (err) {
|
||||
configError = err instanceof Error ? err.message : String(err);
|
||||
maxUploadSizeBytes = 0;
|
||||
uploadAllowed = true;
|
||||
defaultTTL = "15m";
|
||||
maxTTL = "24h";
|
||||
selectedTTL = defaultTTL;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +313,85 @@
|
||||
}
|
||||
});
|
||||
|
||||
function parseTTLToSeconds(rawTTL) {
|
||||
const source = String(rawTTL ?? "").trim().toLowerCase();
|
||||
if (!source) {
|
||||
return NaN;
|
||||
}
|
||||
const matches = Array.from(source.matchAll(/(\d+)\s*([hms])/g));
|
||||
if (matches.length === 0) {
|
||||
return NaN;
|
||||
}
|
||||
let total = 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") {
|
||||
total += value * 3600;
|
||||
} else if (unit === "m") {
|
||||
total += value * 60;
|
||||
} else if (unit === "s") {
|
||||
total += value;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function buildTTLOptions(defaultRaw, maxRaw) {
|
||||
const presets = ["1m", "5m", "15m", "30m", "1h", "6h", "12h", "24h"];
|
||||
const maxSeconds = parseTTLToSeconds(maxRaw);
|
||||
const defaultSeconds = parseTTLToSeconds(defaultRaw);
|
||||
const seen = new Set();
|
||||
const options = [];
|
||||
|
||||
function addOption(value) {
|
||||
const seconds = parseTTLToSeconds(value);
|
||||
if (!Number.isFinite(seconds) || seconds < 60) {
|
||||
return;
|
||||
}
|
||||
if (Number.isFinite(maxSeconds) && seconds > maxSeconds) {
|
||||
return;
|
||||
}
|
||||
if (seen.has(seconds)) {
|
||||
return;
|
||||
}
|
||||
seen.add(seconds);
|
||||
options.push({
|
||||
value,
|
||||
label: formatTTLDisplay(value),
|
||||
seconds
|
||||
});
|
||||
}
|
||||
|
||||
for (const preset of presets) {
|
||||
addOption(preset);
|
||||
}
|
||||
addOption(defaultRaw);
|
||||
addOption(maxRaw);
|
||||
|
||||
options.sort((a, b) => a.seconds - b.seconds);
|
||||
|
||||
// Ensure selected/default is present even if parsing failed above.
|
||||
if (defaultRaw && !options.some((opt) => opt.value === defaultRaw)) {
|
||||
options.unshift({
|
||||
value: defaultRaw,
|
||||
label: formatTTLDisplay(defaultRaw),
|
||||
seconds: Number.isFinite(defaultSeconds) ? defaultSeconds : 0
|
||||
});
|
||||
}
|
||||
|
||||
return options.map((opt) => ({
|
||||
value: opt.value,
|
||||
label:
|
||||
opt.value === defaultRaw
|
||||
? `${opt.label} (default)`
|
||||
: opt.label
|
||||
}));
|
||||
}
|
||||
|
||||
function expiresInText(expiresAt, now) {
|
||||
const expiry = Date.parse(String(expiresAt ?? ""));
|
||||
if (!Number.isFinite(expiry)) {
|
||||
@@ -393,6 +480,8 @@
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", selectedFile);
|
||||
const ttlValue = String(selectedTTL ?? "").trim() || defaultTTL;
|
||||
formData.append("ttl", ttlValue);
|
||||
const response = await fetch("/api/scratch", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
@@ -509,7 +598,7 @@
|
||||
{:else if routeMode === "upload"}
|
||||
<section class="panel">
|
||||
<p class="helper">Size limit: {maxUploadSizeDisplay}</p>
|
||||
<p class="helper">Expiration: {defaultTTLDisplay}</p>
|
||||
<p class="helper">Default expiration: {defaultTTLDisplay} (max {maxTTLDisplay})</p>
|
||||
{#if configError}
|
||||
<p class="helper warning">Could not load UI config: {configError}</p>
|
||||
{/if}
|
||||
@@ -530,6 +619,20 @@
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section id="ttl-panel" class="input-panel">
|
||||
<label for="ttl">Expiration</label>
|
||||
<select
|
||||
id="ttl"
|
||||
name="ttl"
|
||||
bind:value={selectedTTL}
|
||||
disabled={loading}
|
||||
>
|
||||
{#each ttlOptions as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<button class="link-button nav-button action-button submit-button" type="submit" disabled={loading}>
|
||||
{#if loading}Creating...{:else}Create scratch{/if}
|
||||
</button>
|
||||
@@ -657,7 +760,8 @@
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
input[type="file"],
|
||||
select {
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
background: #121826;
|
||||
|
||||
Reference in New Issue
Block a user