This commit is contained in:
maurice committed 2026-11-14 18:20:29 +01:00
1 parent 35e7259775
commit b444b5759d
17 files changed
+1043 -31

No files matched your search

+385 -3
View File
@@ -4,10 +4,11 @@
--muted: #60675c;
--accent: #376345;
--surface: #eef1e9;
--border: #d8ded2;
}
body {
max-width: 42rem;
max-width: 76rem;
margin: 2rem auto;
padding: 0 1rem;
font-family: system-ui, sans-serif;
@@ -16,6 +17,85 @@ body {
background: var(--background);
}
.site-header, .brand, .site-header nav {
display: flex;
align-items: center;
}
.site-header {
justify-content: space-between;
gap: 1rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--border);
}
.brand {
gap: 0.65rem;
font-size: 1.3rem;
font-weight: 750;
letter-spacing: -0.04em;
text-decoration: none;
}
.site-header nav {
gap: 1.25rem;
font-size: 0.85rem;
}
.site-header nav a {
color: var(--muted);
text-decoration: none;
}
.site-header nav a[aria-current] {
color: var(--accent);
font-weight: 650;
}
.readme {
max-width: 60rem;
margin-inline: auto;
overflow-wrap: anywhere;
}
.readme h2 {
margin-top: 2.25rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
font-size: 1.5rem;
}
.readme h3 {
margin-top: 1.75rem;
}
.readme h1, .readme h2, .readme h3 {
scroll-margin-top: 1rem;
}
.readme table {
display: block;
margin-block: 1.5rem;
overflow-x: auto;
}
.readme :not(pre) > code {
padding: 0.1rem 0.3rem;
border-radius: 0.2rem;
background: var(--surface);
font-size: 0.9em;
}
.paste-view h1 {
font-size: 1.75rem;
overflow-wrap: anywhere;
}
.view-counter {
display: inline-block;
flex-shrink: 0;
}
a {
color: var(--accent);
}
@@ -44,8 +124,9 @@ table {
}
th, td {
padding: 0.5rem;
border-bottom: 1px solid var(--muted);
padding: 0.8rem;
border-bottom: 1px solid var(--border);
font-size: 0.8rem;
text-align: left;
vertical-align: top;
}
@@ -60,3 +141,304 @@ td pre {
time {
white-space: nowrap;
}
h1, h2, p {
margin-top: 0;
}
h1 {
margin-bottom: 0.5rem;
font-size: clamp(2rem, 5vw, 3rem);
line-height: 1.15;
letter-spacing: -0.04em;
}
h2 {
font-size: 1.15rem;
letter-spacing: -0.02em;
}
.muted, .hint {
color: var(--muted);
}
.hint {
font-size: 0.85rem;
}
.eyebrow {
margin-bottom: 0.65rem;
font-size: 0.7rem;
font-weight: 750;
letter-spacing: 0.12em;
color: var(--accent);
}
.workspace-heading, .section-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.badge, .count {
padding: 0.2rem 0.65rem;
border-radius: 2rem;
background: var(--surface);
color: var(--accent);
font-size: 0.8rem;
overflow-wrap: anywhere;
}
.card {
padding: clamp(1rem, 3vw, 1.75rem);
border: 1px solid var(--border);
border-radius: 0.75rem;
background: #fff;
}
.account {
margin: 1rem 0 1.5rem;
}
.account summary {
cursor: pointer;
font-weight: 650;
}
.account form {
margin-top: 1rem;
}
.workspace-tabs {
display: flex;
gap: 1.5rem;
margin: 1.75rem 0;
border-bottom: 1px solid var(--border);
overflow-x: auto;
}
.workspace-tabs a {
padding: 0.75rem 0;
border-bottom: 2px solid transparent;
color: var(--muted);
text-decoration: none;
font-size: 0.9rem;
font-weight: 650;
white-space: nowrap;
}
.workspace-tabs a[aria-current] {
border-bottom-color: var(--accent);
color: var(--accent);
}
.editor-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 17rem;
align-items: start;
gap: 1.25rem;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
label {
display: block;
margin-bottom: 1rem;
font-size: 0.85rem;
font-weight: 600;
}
input, select, textarea, button {
box-sizing: border-box;
font: inherit;
}
input:not([type="checkbox"]), select, textarea {
display: block;
width: 100%;
margin-top: 0.4rem;
padding: 0.7rem 0.8rem;
border: 1px solid var(--border);
border-radius: 0.4rem;
color: var(--text);
background: var(--background);
font-weight: 400;
}
textarea {
resize: vertical;
font-family: ui-monospace, monospace;
font-size: 0.875rem;
line-height: 1.6;
tab-size: 4;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
button {
padding: 0.65rem 1rem;
border: 1px solid transparent;
border-radius: 0.4rem;
background: var(--accent);
color: white;
font-size: 0.85rem;
font-weight: 650;
cursor: pointer;
}
button:hover {
filter: brightness(0.94);
}
.secondary {
border-color: var(--border);
background: white;
color: var(--text);
}
.danger {
border-color: #ead5d1;
background: #fff8f6;
color: #923e31;
}
.link-button {
padding: 0;
background: transparent;
color: var(--accent);
text-decoration: underline;
text-align: left;
overflow-wrap: anywhere;
}
button:disabled {
opacity: 0.55;
cursor: wait;
}
input:disabled, select:disabled {
opacity: 0.65;
}
.actions, .copy-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.editor-actions {
margin-top: 1.5rem;
}
.copy-row input {
flex: 1;
min-width: 12rem;
margin-top: 0 !important;
}
.check-label {
display: flex;
align-items: center;
gap: 0.5rem;
}
input[type="checkbox"] {
accent-color: var(--accent);
}
hr {
margin: 1.5rem 0;
border: 0;
border-top: 1px solid var(--border);
}
.status {
padding: 0.9rem 1rem;
border: 1px solid #cadacb;
border-radius: 0.5rem;
background: #edf5ee;
color: #254e30;
overflow-wrap: anywhere;
}
.status[data-error="true"] {
border-color: #e8cfc8;
background: #fff1ec;
color: #7f3022;
}
.result, .preview {
margin-top: 1.25rem;
}
.result p:last-child {
margin-bottom: 0;
}
.search-label {
max-width: 26rem;
}
.empty-state {
padding: 3rem 1rem;
color: var(--muted);
text-align: center;
}
td small {
display: block;
margin-top: 0.3rem;
color: var(--muted);
}
.content-preview {
min-width: 10rem;
max-width: 18rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: ui-monospace, monospace;
}
.row-actions {
min-width: 9rem;
}
.row-actions button {
margin: 0 0.4rem 0.4rem 0;
padding: 0.35rem 0.6rem;
}
[hidden] {
display: none !important;
}
@media (max-width: 760px) {
.editor-layout, .form-grid {
grid-template-columns: 1fr;
}
.workspace-heading {
flex-wrap: wrap;
}
.site-header {
flex-wrap: wrap;
}
.workspace-tabs {
gap: 1.1rem;
}
body {
margin-top: 1rem;
}
}
+353
View File
@@ -0,0 +1,353 @@
"use strict";
const $ = id => document.getElementById(id);
let authorization = null;
let notes = [];
let editing = null;
function notify(message, error = false) {
$("status").textContent = message;
$("status").dataset.error = String(error);
$("status").hidden = false;
}
async function api(path, { method = "GET", body, accept = "text/plain", includeHeaders = false } = {}, auth = authorization) {
const headers = { Accept: accept };
if (auth) headers.Authorization = auth;
const response = await fetch(path, { method, body, headers, credentials: "omit", cache: "no-store" });
if (!response.ok) {
const messages = {
400: "Check the URL and settings. Expiry must be nonnegative and the view limit must be positive.",
401: "Sign in with the correct username and password.",
403: "Only the creator can change or delete this share.",
404: "This share does not exist, has expired, or has reached its view limit.",
413: "This share is too large for the server.",
422: "Check the form values and use whole numbers for expiry and views.",
};
throw new Error(messages[response.status] || `The request failed (${response.status}). Please try again.`);
}
const data = accept === "application/json" ? await response.json() : await response.text();
return includeHeaders ? { data, headers: response.headers } : data;
}
async function busy(button, action) {
button.disabled = true;
try {
await action();
} catch (error) {
notify(error.message, true);
} finally {
button.disabled = false;
}
}
function showPanel(id) {
if (!["compose", "collection", "open"].includes(id)) id = "compose";
for (const panel of document.querySelectorAll(".panel")) panel.hidden = panel.id !== id;
for (const link of document.querySelectorAll("[data-panel]")) {
if (link.dataset.panel === id) link.setAttribute("aria-current", "page");
else link.removeAttribute("aria-current");
}
history.replaceState(null, "", `#${id}`);
}
function isUrl(note) {
return note.paste_type === "UrlV1";
}
function sharePath(id, url) {
return `${url ? "/to/" : "/"}${encodeURIComponent(id)}`;
}
function date(value) {
if (value === null) return "—";
const parsed = new Date(value * 1000);
return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toLocaleString();
}
function cell(row, text) {
const td = document.createElement("td");
td.textContent = text;
row.append(td);
return td;
}
function action(label, callback, className = "secondary") {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.className = className;
button.addEventListener("click", () => busy(button, callback));
return button;
}
function renderNotes() {
$("note-count").textContent = notes.length;
$("note-rows").replaceChildren();
$("refresh").disabled = !authorization;
const query = $("search").value.toLowerCase();
const filtered = notes.filter(note => [note.id, note.content, note.language || ""]
.some(value => value.toLowerCase().includes(query)));
$("collection-table").hidden = filtered.length === 0;
$("collection-empty").hidden = filtered.length !== 0;
$("collection-empty").textContent = !authorization ? "Sign in to see and manage your shares."
: notes.length === 0 ? "Nothing here yet. Create a share while signed in to start your collection."
: "No shares match your search.";
for (const note of filtered) {
const row = document.createElement("tr");
const name = cell(row, "");
name.append(action(note.id, () => openEntry(note), "link-button"));
const type = document.createElement("small");
type.textContent = isUrl(note) ? "Short link" : note.language || "Plain text";
name.append(type);
const content = cell(row, note.content.slice(0, 140));
content.className = "content-preview";
cell(row, `${note.views} / ${note.max_views ?? "∞"}`);
cell(row, date(note.created_at));
cell(row, date(note.updated_at));
cell(row, note.expiration === null ? "Never" : date(note.expiration));
const controls = cell(row, "");
controls.className = "row-actions";
controls.append(
action("Edit", () => editNote(note)),
action("Delete", () => deleteNote(note), "danger"),
);
$("note-rows").append(row);
}
}
async function loadNotes() {
const auth = authorization;
if (!auth) return;
const result = await api("/list", { accept: "application/json" }, auth);
if (authorization !== auth) return;
notes = result;
renderNotes();
}
function updateEditor() {
const url = $("kind").value === "url";
$("kind").disabled = editing !== null;
$("custom-id").disabled = editing !== null;
$("new-note").hidden = editing === null;
$("once-field").hidden = url || editing !== null;
$("once").disabled = url || editing !== null;
if (url || editing) $("once").checked = false;
$("max-views").disabled = $("once").checked;
if ($("once").checked) $("max-views").value = "1";
$("language-field").hidden = url;
$("language").options[0].textContent = editing ? "Keep current language" : "Auto detect";
$("content-label").textContent = url ? "Destination URL" : "Your text or code";
$("content").rows = url ? 3 : 15;
$("content").placeholder = url ? "https://example.com" : "Paste something worth sharing…";
$("editor-title").textContent = editing ? `Edit ${url ? "link" : "paste"} · ${editing.id}` : "Create a share";
$("save").textContent = editing ? "Save changes" : url ? "Create short link"
: $("once").checked ? "Create one-time secret" : "Create share";
$("ownership-hint").textContent = authorization ? "Saved to your collection."
: "Sign in to edit or delete this share later.";
$("expires").placeholder = editing ? "Keep current expiry" : "Never";
$("edit-hint").hidden = editing === null;
if (editing) $("edit-hint").textContent = `Current expiry: ${editing.expiration === null ? "never" : date(editing.expiration)}. Leave expiry blank to keep it. Views stay at ${editing.views}.`;
}
function resetEditor() {
editing = null;
$("language").querySelector("[data-current]")?.remove();
$("editor-form").reset();
$("share-result").hidden = true;
updateEditor();
}
function editNote(note) {
resetEditor();
editing = note;
$("kind").value = isUrl(note) ? "url" : "note";
$("custom-id").value = note.id;
$("content").value = note.content;
const language = note.language || "";
if (language && !Array.from($("language").options).some(option => option.value === language)) {
const current = new Option(`${language} (current)`, language);
current.dataset.current = "";
$("language").add(current);
}
$("language").value = language;
$("max-views").value = note.max_views ?? "";
updateEditor();
showPanel("compose");
$("content").focus();
}
async function deleteNote(note) {
if (!window.confirm(`Permanently delete “${note.id}”?`)) return;
await api(sharePath(note.id, false), { method: "DELETE" });
if (editing?.id === note.id) resetEditor();
await loadNotes();
notify(`Deleted ${note.id}.`);
}
function reference(value, url) {
let id = value.trim();
if (/^(https?:\/\/|\/)/i.test(id)) {
const parsed = new URL(id, location.origin);
if (parsed.origin !== location.origin) throw new Error("Use an ID or a link from this Pastable server.");
url = parsed.pathname.startsWith("/to/");
id = decodeURIComponent(parsed.pathname.slice(url ? 4 : 1));
}
if (!id || id.includes("/") || ["gui", "list", "once", "to", "static"].includes(id)) {
throw new Error("Enter a valid share ID or a full Pastable share URL.");
}
return { id, url };
}
async function openPaste(id) {
$("preview").replaceChildren();
$("preview").hidden = true;
const highlighted = $("open-format").value === "html";
const { data: content, headers } = await api(sharePath(id, false), {
accept: highlighted ? "text/html" : "text/plain",
includeHeaders: true,
});
const counter = document.createElement("p");
counter.className = "badge view-counter";
counter.textContent = `Views: ${headers.get("x-paste-views")} / ${headers.get("x-paste-max-views") ?? "∞"}`;
$("preview").append(counter);
if (highlighted) {
const page = new DOMParser().parseFromString(content, "text/html");
const code = page.querySelector("main pre");
if (!code) throw new Error("The server returned an unexpected preview.");
$("preview").append(document.importNode(code, true));
} else {
const pre = document.createElement("pre");
pre.textContent = content;
$("preview").append(pre);
}
$("preview").hidden = false;
await loadNotes();
notify(`Opened ${id}. This read counts as a view.`);
}
async function openEntry(note) {
$("open-id").value = note.id;
$("open-kind").value = isUrl(note) ? "url" : "note";
updateOpen();
showPanel("open");
if (isUrl(note)) {
window.open(sharePath(note.id, true), "_blank", "noopener,noreferrer");
notify("The short link opens in a new tab. Refresh your collection to update its view count.");
} else {
await openPaste(note.id);
}
}
function updateOpen() {
const url = $("open-kind").value === "url";
$("format-field").hidden = url;
$("open-submit").textContent = url ? "Follow short link ↗" : "Open paste";
$("preview").hidden = true;
}
$("login-form").addEventListener("submit", event => {
event.preventDefault();
busy(event.submitter, async () => {
const username = $("username").value;
const credentials = new TextEncoder().encode(`${username}:${$("password").value}`);
const auth = `Basic ${btoa(Array.from(credentials, byte => String.fromCharCode(byte)).join(""))}`;
const result = await api("/list", { accept: "application/json" }, auth);
if (authorization && authorization !== auth) resetEditor();
authorization = auth;
notes = result;
$("password").value = "";
$("session-label").textContent = username;
$("logout").hidden = false;
$("account").open = false;
renderNotes();
updateEditor();
notify(`Signed in as ${username}. New shares will belong to your account.`);
});
});
$("logout").addEventListener("click", () => {
authorization = null;
notes = [];
$("login-form").reset();
$("logout").hidden = true;
$("session-label").textContent = "Anonymous";
$("preview").replaceChildren();
$("preview").hidden = true;
resetEditor();
renderNotes();
notify("Signed out.");
});
$("editor-form").addEventListener("submit", event => {
event.preventDefault();
busy(event.submitter, async () => {
const url = $("kind").value === "url";
const body = new URLSearchParams();
body.set(url ? "href" : "content", $("content").value);
if (!url && $("language").value) body.set("language", $("language").value);
if ($("expires").value !== "") body.set("expires", $("expires").value);
if ($("max-views").value !== "") body.set("max_views", $("max-views").value);
const requested = $("custom-id").value.trim();
let path = url ? "/to" : $("once").checked ? "/once" : "";
if (editing) path = sharePath(editing.id, url);
else path = requested ? `${path}/${encodeURIComponent(requested)}` : path || "/";
const result = await api(path, { method: editing ? "PUT" : "POST", body });
const id = editing ? editing.id : result;
const link = new URL(sharePath(id, url), location.origin).href;
$("share-url").value = link;
$("share-link").href = link;
$("result-title").textContent = editing ? "Changes saved" : "Your share is ready";
$("share-result").hidden = false;
await loadNotes();
if (editing) {
editing = notes.find(note => note.id === id) || editing;
$("expires").value = "";
updateEditor();
}
notify(`${editing ? "Updated" : "Created"} ${id}.${requested && requested !== id ? " The requested ID was unavailable, so a new one was generated." : ""}`);
});
});
$("copy-url").addEventListener("click", event => busy(event.currentTarget, async () => {
if (!navigator.clipboard) {
$("share-url").focus();
$("share-url").select();
notify("The link is selected. Copy it with your keyboard or context menu.");
return;
}
await navigator.clipboard.writeText($("share-url").value);
notify("Share link copied.");
}));
$("open-form").addEventListener("submit", event => {
event.preventDefault();
busy(event.submitter, async () => {
const ref = reference($("open-id").value, $("open-kind").value === "url");
$("open-kind").value = ref.url ? "url" : "note";
updateOpen();
if (ref.url) {
window.open(sharePath(ref.id, true), "_blank", "noopener,noreferrer");
notify("The short link opens in a new tab. Following it counts as a view.");
} else {
await openPaste(ref.id);
}
});
});
for (const link of document.querySelectorAll("[data-panel]")) {
link.addEventListener("click", event => {
event.preventDefault();
showPanel(link.dataset.panel);
});
}
$("kind").addEventListener("change", updateEditor);
$("once").addEventListener("change", updateEditor);
$("new-note").addEventListener("click", resetEditor);
$("open-kind").addEventListener("change", updateOpen);
$("search").addEventListener("input", renderNotes);
$("refresh").addEventListener("click", event => busy(event.currentTarget, loadNotes));
window.addEventListener("hashchange", () => showPanel(location.hash.slice(1)));
showPanel(location.hash.slice(1));
renderNotes();
updateEditor();