File upload preview in editor (#171) #175
@@ -299,3 +299,27 @@ func TestNotFound(t *testing.T) {
|
|||||||
t.Fatalf("expected 404, got %d", rec.Code)
|
t.Fatalf("expected 404, got %d", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #173: a missing paste ID on the UI route (/p/{id}, i.e. /{id} HTML view)
|
||||||
|
// should render the main UI page with a friendly "Paste ID not found"
|
||||||
|
// message, not a bare text 404. Status stays 404.
|
||||||
|
func TestPasteViewNotFoundFriendly(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
req := httptest.NewRequest("GET", "/zzzzzz", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404 status, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "Paste ID not found") {
|
||||||
|
t.Fatalf("expected friendly message in body, got: %.200s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "<nav>") {
|
||||||
|
t.Fatalf("expected main UI chrome in body")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "404 page not found") {
|
||||||
|
t.Fatalf("body still contains bare Go 404 text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -129,9 +129,12 @@ func (a *apiServer) routes() http.Handler {
|
|||||||
r.Get("/{id}", a.handlePasteView)
|
r.Get("/{id}", a.handlePasteView)
|
||||||
r.Post("/{id}", a.handlePasteView)
|
r.Post("/{id}", a.handlePasteView)
|
||||||
|
|
||||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
// #173: missing paste URLs render the main UI with a friendly not-found
|
||||||
writeErr(w, 404, "not found")
|
// message instead of a bare JSON 404. Known routes (above) handle real pages;
|
||||||
})
|
// anything else is a nonexistent paste ID or typo.
|
||||||
|
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.webHandlers().HandleNotFoundPage(w, r)
|
||||||
|
})
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -341,6 +341,9 @@ td a.slug.paste-name { background: none; padding: 0; border-radius: 0; font-fami
|
|||||||
}
|
}
|
||||||
.inner h1 { font-size: 29.2px; font-weight: 600; margin-bottom: 6px; }
|
.inner h1 { font-size: 29.2px; font-weight: 600; margin-bottom: 6px; }
|
||||||
.inner .sub { font-size: 22.4px; color: var(--muted-fg); margin-bottom: 20px; }
|
.inner .sub { font-size: 22.4px; color: var(--muted-fg); margin-bottom: 20px; }
|
||||||
|
/* #173: missing paste message box */
|
||||||
|
.notfound-err { color: var(--err); }
|
||||||
|
.notfound-card .btn { display: block; margin-top: 10px; }
|
||||||
.pwinput {
|
.pwinput {
|
||||||
width: 100%; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius);
|
width: 100%; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
background: var(--bg); color: var(--fg); font: inherit; font-size: 23.2px; outline: none; text-align: center;
|
background: var(--bg); color: var(--fg); font: inherit; font-size: 23.2px; outline: none; text-align: center;
|
||||||
@@ -744,6 +747,19 @@ button[type="submit"]:focus-visible,
|
|||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
.dropzone.dragover { border-width: 2px; }
|
.dropzone.dragover { border-width: 2px; }
|
||||||
|
/* #171: uploaded-file preview replaces the editor area; image fitted inside */
|
||||||
|
.file-preview {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.editor-wrap.previewing .editor,
|
||||||
|
.editor-wrap.previewing .gutter { display: none; }
|
||||||
|
|
||||||
.file-chip {
|
.file-chip {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
border: 1px solid var(--border); border-radius: var(--radius);
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
|||||||
@@ -230,6 +230,64 @@ function humanSize(n) {
|
|||||||
return n + ' B';
|
return n + ' B';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #171: text-file extensions -> language key for title placeholder
|
||||||
|
// convention (e.g. Python.py); fallback Text.
|
||||||
|
const LANG_BY_EXT = {
|
||||||
|
py: 'python', go: 'go', js: 'javascript', mjs: 'javascript', ts: 'typescript',
|
||||||
|
tsx: 'typescript', rs: 'rust', c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp',
|
||||||
|
java: 'java', cs: 'csharp', sh: 'bash', bash: 'bash', sql: 'sql', yml: 'yaml',
|
||||||
|
yaml: 'yaml', json: 'json', html: 'html', htm: 'html', css: 'css', xml: 'xml',
|
||||||
|
php: 'php', rb: 'ruby', pl: 'perl', lua: 'lua', toml: 'toml', ini: 'ini',
|
||||||
|
diff: 'diff', md: 'markdown', txt: 'text', log: 'text',
|
||||||
|
};
|
||||||
|
const TEXT_EXTS = new Set(Object.keys(LANG_BY_EXT));
|
||||||
|
|
||||||
|
function extOf(name) {
|
||||||
|
const i = name.lastIndexOf('.');
|
||||||
|
return i >= 0 ? name.slice(i + 1).toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// #171: when a file is attached it takes over the main editing area.
|
||||||
|
// Images render fitted into the editor area; text files load their
|
||||||
|
// content into the editor. Title auto-fills only if still blank (house
|
||||||
|
// rule: never overwrite a typed title).
|
||||||
|
const IMAGE_RE = /^image\//;
|
||||||
|
let previewURL = null;
|
||||||
|
|
||||||
|
async function showFileInEditor(file) {
|
||||||
|
const wrap = document.querySelector('.editor-wrap');
|
||||||
|
const img = $('file-preview');
|
||||||
|
if (IMAGE_RE.test(file.type)) {
|
||||||
|
if (previewURL) URL.revokeObjectURL(previewURL);
|
||||||
|
previewURL = URL.createObjectURL(file);
|
||||||
|
img.src = previewURL;
|
||||||
|
img.alt = file.name;
|
||||||
|
wrap.classList.add('previewing');
|
||||||
|
img.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wrap.classList.remove('previewing');
|
||||||
|
img.classList.add('hidden');
|
||||||
|
if (!TEXT_EXTS.has(extOf(file.name))) return; // unknown binary: leave editor alone
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
if (!content.value.trim()) {
|
||||||
|
content.value = text;
|
||||||
|
updateGutter();
|
||||||
|
guessLang();
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPreview() {
|
||||||
|
const wrap = document.querySelector('.editor-wrap');
|
||||||
|
const img = $('file-preview');
|
||||||
|
wrap.classList.remove('previewing');
|
||||||
|
img.classList.add('hidden');
|
||||||
|
img.removeAttribute('src');
|
||||||
|
if (previewURL) { URL.revokeObjectURL(previewURL); previewURL = null; }
|
||||||
|
}
|
||||||
|
|
||||||
function setAttachedFile(file) {
|
function setAttachedFile(file) {
|
||||||
if (!file) return clearAttachedFile();
|
if (!file) return clearAttachedFile();
|
||||||
if (file.size > MAX_FILE_BYTES) {
|
if (file.size > MAX_FILE_BYTES) {
|
||||||
@@ -239,6 +297,23 @@ function setAttachedFile(file) {
|
|||||||
attachedFile = file;
|
attachedFile = file;
|
||||||
renderFileChip();
|
renderFileChip();
|
||||||
setHidden('file-text-note', false);
|
setHidden('file-text-note', false);
|
||||||
|
// #171: title auto-fill — images take the file name; text files use the
|
||||||
|
// language-placeholder convention (e.g. Python.py, fallback Text.txt).
|
||||||
|
// Only when the title is still blank; never overwrite a typed title.
|
||||||
|
if (!$('title').value.trim()) {
|
||||||
|
if (IMAGE_RE.test(file.type)) {
|
||||||
|
$('title').value = file.name;
|
||||||
|
} else {
|
||||||
|
const lang = LANG_BY_EXT[extOf(file.name)];
|
||||||
|
if (lang) {
|
||||||
|
const fn = defaultFilename(lang);
|
||||||
|
if (fn) $('title').value = fn;
|
||||||
|
} else if (TEXT_EXTS.has(extOf(file.name))) {
|
||||||
|
$('title').value = defaultFilename('text') || 'Text.txt';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showFileInEditor(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAttachedFile() {
|
function clearAttachedFile() {
|
||||||
@@ -246,6 +321,7 @@ function clearAttachedFile() {
|
|||||||
$('file-input').value = '';
|
$('file-input').value = '';
|
||||||
renderFileChip();
|
renderFileChip();
|
||||||
setHidden('file-text-note', true);
|
setHidden('file-text-note', true);
|
||||||
|
clearPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFileChip() {
|
function renderFileChip() {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
<div class="float editor-wrap">
|
<div class="float editor-wrap">
|
||||||
<div class="gutter" id="gutter">1</div>
|
<div class="gutter" id="gutter">1</div>
|
||||||
<textarea class="editor" id="content" placeholder="Paste your code, text, or notes here…" spellcheck="false"></textarea>
|
<textarea class="editor" id="content" placeholder="Paste your code, text, or notes here…" spellcheck="false"></textarea>
|
||||||
|
<img id="file-preview" class="file-preview hidden" alt="File preview">
|
||||||
</div>
|
</div>
|
||||||
<div class="created-banner" id="created"></div>
|
<div class="created-banner" id="created"></div>
|
||||||
<div class="actionbar">
|
<div class="actionbar">
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{{template "head" .}}
|
||||||
|
{{template "topbar" .}}
|
||||||
|
<div class="center">
|
||||||
|
<div class="float notfound-card">
|
||||||
|
<div class="inner">
|
||||||
|
<div class="lockring"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
|
||||||
|
<h1>Paste ID not found</h1>
|
||||||
|
<p class="sub">The paste <span class="slug">/{{.ID}}</span> does not exist, has expired, or was burned.</p>
|
||||||
|
<a class="btn" href="/new">Create a new paste</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "foot" .}}
|
||||||
+38
-4
@@ -104,6 +104,13 @@ func (h *Handlers) renderPage(w http.ResponseWriter, name string, data any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RenderNotFoundPage is the exported not-found renderer used by the api
|
||||||
|
// package (#173): the router's NotFound handler renders the main UI with a
|
||||||
|
// friendly "Paste ID not found" message, still with HTTP 404.
|
||||||
|
func (h *Handlers) RenderNotFoundPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderNotFound(w, r, r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
// RenderPage is the exported wrapper used by the api package (#4 can pages).
|
// RenderPage is the exported wrapper used by the api package (#4 can pages).
|
||||||
func (h *Handlers) RenderPage(w http.ResponseWriter, name string, data any) {
|
func (h *Handlers) RenderPage(w http.ResponseWriter, name string, data any) {
|
||||||
h.renderPage(w, name, data)
|
h.renderPage(w, name, data)
|
||||||
@@ -200,6 +207,30 @@ func (h *Handlers) writeRateLimited(w http.ResponseWriter, retryAfterSecs int) {
|
|||||||
w.Write([]byte(`{"error":"rate limit exceeded"}`))
|
w.Write([]byte(`{"error":"rate limit exceeded"}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderNotFound serves the friendly not-found page (#173): the main UI
|
||||||
|
// chrome (topbar, centered card) with a "Paste ID not found" message in the
|
||||||
|
// result card, instead of a bare text 404. Still returns HTTP 404 so
|
||||||
|
// crawlers/validators see the correct status.
|
||||||
|
func (h *Handlers) renderNotFound(w http.ResponseWriter, r *http.Request, id string) {
|
||||||
|
h.renderPageStatus(w, "notfound.html", http.StatusNotFound, map[string]any{"Page": "notfound", "ID": id})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleNotFoundPage serves the friendly not-found page for unknown routes
|
||||||
|
// (#173): main UI chrome with a "Paste ID not found" message. Called from the
|
||||||
|
// chi NotFound handler in the api package.
|
||||||
|
func (h *Handlers) HandleNotFoundPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderNotFound(w, r, r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderPageStatus renders a template with an explicit HTTP status code.
|
||||||
|
func (h *Handlers) renderPageStatus(w http.ResponseWriter, name string, status int, data any) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if err := h.UI.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||||
|
http.Error(w, "template error: "+err.Error(), 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justCreated bool, deletionToken string, readsRemaining *int) {
|
func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justCreated bool, deletionToken string, readsRemaining *int) {
|
||||||
lines := strings.Count(row.Content, "\n") + 1
|
lines := strings.Count(row.Content, "\n") + 1
|
||||||
gutter := ""
|
gutter := ""
|
||||||
@@ -262,11 +293,14 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if row == nil {
|
if row == nil {
|
||||||
http.NotFound(w, r)
|
// #173: a missing paste ID gets the main UI with a friendly message,
|
||||||
|
// not a bare text 404 page.
|
||||||
|
h.renderNotFound(w, r, id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
|
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
|
||||||
http.Error(w, "paste expired", 404)
|
// #173: expired pastes render the same friendly not-found UI.
|
||||||
|
h.renderNotFound(w, r, id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if row.PasswordHash.Valid {
|
if row.PasswordHash.Valid {
|
||||||
@@ -333,8 +367,8 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Just-created first render does not count as a read for the creator.
|
// Just-created first render does not count as a read for the creator.
|
||||||
if !justCreated {
|
if !justCreated {
|
||||||
rem, admitted := h.Store.RegisterRead(row, h.ViewerID(r), h.BurnWindowMin())
|
rem, admitted := h.Store.RegisterRead(row, h.ViewerID(r), h.BurnWindowMin())
|
||||||
if !admitted { // #58: lost the burn claim; do not render content
|
if !admitted { // #58: lost the burn claim; #173: friendly not-found UI
|
||||||
http.NotFound(w, r)
|
h.renderNotFound(w, r, row.ID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.renderPaste(w, row, false, "", rem)
|
h.renderPaste(w, row, false, "", rem)
|
||||||
|
|||||||
Reference in New Issue
Block a user