From 613f099a8b40df80f536be2413ffabfcd2259ac2 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Sun, 24 May 2026 23:02:49 +0200 Subject: [PATCH] add item titles --- migrations/f0ckm_schema.sql | 3 +- public/s/css/f0ckm.css | 82 ++++++++++++++++++++++++++++++++- public/s/css/upload.css | 51 ++++++++++++++++++++ public/s/js/f0ckm.js | 75 ++++++++++++++++++++++++++++++ public/s/js/upload.js | 39 +++++++++++++--- src/inc/locales/en.json | 2 +- src/inc/routeinc/f0cklib.mjs | 1 + src/inc/routes/apiv2/index.mjs | 27 +++++++++++ src/inc/routes/apiv2/upload.mjs | 13 ++++-- src/upload_handler.mjs | 10 +++- views/item-partial-legacy.html | 20 ++++++++ views/item-partial-modern.html | 20 ++++++++ views/snippets/upload-form.html | 7 +++ 13 files changed, 334 insertions(+), 16 deletions(-) diff --git a/migrations/f0ckm_schema.sql b/migrations/f0ckm_schema.sql index 6905845..cb74cbf 100644 --- a/migrations/f0ckm_schema.sql +++ b/migrations/f0ckm_schema.sql @@ -905,7 +905,8 @@ CREATE TABLE public.items ( is_pinned boolean DEFAULT false, is_oc boolean DEFAULT false, xd_score integer DEFAULT 0 NOT NULL, - original_filename text + original_filename text, + title text ); diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index a754ffe..00b914f 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -1725,7 +1725,7 @@ body.sidebar-right-hidden .global-sidebar-right { grid-column: 2; height: calc(100vh - var(--navbar-h, 50px)); display: grid; - grid-template-rows: auto auto 1fr; + grid-template-rows: auto auto auto 1fr; justify-items: center; overflow: hidden; padding: 20px; @@ -1754,6 +1754,10 @@ body.sidebar-right-hidden .global-sidebar-right { align-self: start; } + body.layout-modern .item-layout-container .item-main-content .item_title { + align-self: start; + } + body.layout-modern .item-layout-container .item-main-content .content { align-self: start; } @@ -2165,6 +2169,7 @@ body.sidebar-right-hidden .global-sidebar-right { /* Common sizing for the Legacy content area */ .item-layout-container .item-main-content>._204863, +.item-layout-container .item-main-content>.item_title, .item-layout-container .item-main-content>.content, .item-layout-container .item-main-content>.metadata, .item-layout-container .item-main-content #comments-container { @@ -4258,6 +4263,24 @@ span.placeholder { position: relative; } +.item_title { + width: 100%; + max-width: 1100px; + padding: 4px 10px; + border-top: none; + color: rgba(255, 255, 255, 0.72); + font-size: 0.82em; + font-family: inherit; + letter-spacing: 0.02em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.6; + box-sizing: border-box; + z-index: 1; + position: relative; +} + .location { padding-left: 5px; } @@ -14635,6 +14658,63 @@ body.scroller-active #gchat-reopen-bubble { border-bottom: none; } +/* ─── Info Modal: Inline Title Editor ─────────────────────────────────────── */ + +.info-title-edit-wrap { + display: flex; + align-items: center; + gap: 6px; +} + +.info-title-input { + flex: 1; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.15); + color: var(--white, #eee); + padding: 5px 8px; + font-size: 0.88em; + font-family: inherit; + outline: none; + border-radius: 0; + transition: border-color 0.15s; + min-width: 0; +} + +.info-title-input:focus { + border-color: var(--accent, #9f0); + background: rgba(255, 255, 255, 0.1); +} + +.info-title-save-btn { + flex-shrink: 0; + background: var(--accent, #9f0); + border: none; + color: #111; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 13px; + transition: opacity 0.15s; +} + +.info-title-save-btn:hover { + opacity: 0.85; +} + +.info-title-save-btn:disabled { + opacity: 0.5; + cursor: wait; +} + +.info-title-status { + font-size: 0.8em; + margin-top: 3px; + display: block; +} + /* ============================================= NSFW / NSFL PREMIUM ZERO-LAG BACKGROUND BLUR ============================================= */ diff --git a/public/s/css/upload.css b/public/s/css/upload.css index 8c57c7d..7ff10cc 100644 --- a/public/s/css/upload.css +++ b/public/s/css/upload.css @@ -551,6 +551,34 @@ border-color: var(--accent); } +.item-title-container { + margin-top: 6px; + width: 100%; +} + +.item-title-input { + width: 100%; + background: rgba(0,0,0,0.3); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 4px; + padding: 5px 10px; + color: #fff; + font-size: 0.8rem; + font-family: inherit; + outline: none; + transition: border-color 0.2s; + box-sizing: border-box; +} + +.item-title-input::placeholder { + color: rgba(255,255,255,0.3); + font-style: italic; +} + +.item-title-input:focus { + border-color: var(--accent); +} + .rating-options { display: flex; gap: 1rem; @@ -678,6 +706,29 @@ outline: none; } +/* Global title input (normal mode) — matches tag-input-container style */ +.upload-title-input { + width: 100%; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 0; + padding: 0.5rem; + color: inherit; + font-family: inherit; + font-size: 0.9rem; + outline: none; + transition: border-color 0.2s; + box-sizing: border-box; +} + +.upload-title-input:focus { + border-color: var(--accent, #7c5cbf); +} + +.upload-title-input::placeholder { + color: rgba(255, 255, 255, 0.3); +} + .tag-count { font-weight: normal; font-size: 0.85rem; diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 0dc17b4..f46df08 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -8991,6 +8991,81 @@ if (navigator.vibrate) { return; } + // Title save button + const saveBtn = e.target.closest('#info-title-save'); + if (saveBtn) { + e.preventDefault(); + const input = document.getElementById('info-title-input'); + const status = document.getElementById('info-title-status'); + if (!input) return; + + const itemId = input.dataset.itemId; + const newTitle = input.value.trim(); + const csrf = window.f0ckSession?.csrf_token; + + saveBtn.disabled = true; + const origIcon = saveBtn.innerHTML; + saveBtn.innerHTML = ''; + + fetch(`/api/v2/items/${itemId}/title`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrf + }, + body: JSON.stringify({ title: newTitle }) + }) + .then(r => r.json()) + .then(data => { + saveBtn.disabled = false; + saveBtn.innerHTML = origIcon; + if (data.success) { + // Update the live .item_title bar below the ID bar + let titleBar = document.querySelector('.item_title'); + if (data.title) { + if (titleBar) { + titleBar.textContent = data.title; + titleBar.style.display = ''; + } else { + // Create it if it doesn't exist yet + const idBar = document.querySelector('.item-main-content > ._204863'); + if (idBar) { + titleBar = document.createElement('div'); + titleBar.className = 'item_title'; + titleBar.textContent = data.title; + idBar.insertAdjacentElement('afterend', titleBar); + } + } + } else { + // Title cleared + if (titleBar) titleBar.remove(); + } + if (status) { + status.textContent = '✓ Saved'; + status.style.color = 'var(--accent, #5cb85c)'; + status.style.display = 'inline'; + setTimeout(() => { status.style.display = 'none'; }, 2000); + } + } else { + if (status) { + status.textContent = data.msg || 'Error saving title'; + status.style.color = '#e84040'; + status.style.display = 'inline'; + } + } + }) + .catch(() => { + saveBtn.disabled = false; + saveBtn.innerHTML = origIcon; + if (status) { + status.textContent = 'Network error'; + status.style.color = '#e84040'; + status.style.display = 'inline'; + } + }); + return; + } + // Close when clicking outside modal content const infoModal = document.getElementById('info-modal'); if (infoModal && e.target === infoModal) { diff --git a/public/s/js/upload.js b/public/s/js/upload.js index ecf5483..220ae96 100644 --- a/public/s/js/upload.js +++ b/public/s/js/upload.js @@ -590,7 +590,7 @@ window.initUploadForm = (selector) => { } lines.forEach(url => { if (!selectedFiles.some(item => item.type === 'url' && item.url === url)) { - selectedFiles.push({ type: 'url', url, rating: '', tags: [], comment: '', is_oc: false }); + selectedFiles.push({ type: 'url', url, rating: '', tags: [], comment: '', title: '', is_oc: false }); } }); urlInput.value = ''; @@ -613,7 +613,7 @@ window.initUploadForm = (selector) => { const val = urlInput.value.trim(); if (!val || !/^https?:\/\//i.test(val)) return; if (!selectedFiles.some(item => item.type === 'url' && item.url === val)) { - selectedFiles.push({ type: 'url', url: val, rating: '', tags: [], comment: '', is_oc: false }); + selectedFiles.push({ type: 'url', url: val, rating: '', tags: [], comment: '', title: '', is_oc: false }); } urlInput.value = ''; if (urlBadge) urlBadge.style.display = 'none'; @@ -812,7 +812,7 @@ window.initUploadForm = (selector) => { if (!selectedFiles.some(f => (f.file || f).name === file.name && (f.file || f).size === file.size)) { if (isShitpost) { - selectedFiles.push({ type: 'file', file: file, rating: '', tags: [], comment: '', is_oc: false }); + selectedFiles.push({ type: 'file', file: file, rating: '', tags: [], comment: '', title: '', is_oc: false }); } else { selectedFiles.push(file); // Legacy single file mode uses raw File } @@ -978,6 +978,7 @@ window.initUploadForm = (selector) => { let tagsUI = ''; let ocUI = ''; let commentUI = ''; + let titleUI = ''; if (isShitpost) { const nsflEnabled = !!form.querySelector('input[name="rating"][value="nsfl"]'); // Build per-item rating HTML @@ -1012,8 +1013,13 @@ window.initUploadForm = (selector) => { `; + titleUI = ` +
+ +
+ `; - const commentPlaceholder = window.f0ckI18n?.upload_comment_placeholder || 'Comment (optional)...'; + const commentPlaceholder = window.f0ckI18n?.upload_comment_placeholder || 'AddComment...'; const maxLenHtml = (commentMaxLen !== null && !isNaN(commentMaxLen)) ? ` maxlength="${commentMaxLen}"` : ''; commentUI = `
@@ -1035,6 +1041,7 @@ window.initUploadForm = (selector) => { ${window.escapeHtmlUpload(fileNameStr)} ${fileSizeStr}
+ ${titleUI} ${ratingSwitch} ${tagsUI} ${commentUI} @@ -1057,6 +1064,12 @@ window.initUploadForm = (selector) => { if (emojiTrigger) setupItemEmojiPicker(commentInput, emojiTrigger); } + // Handle Title + const titleInput = infoRow.querySelector('.item-title-input'); + if (titleInput) { + titleInput.oninput = () => { item.title = titleInput.value.trim(); }; + } + // Handle Tags const tagList = infoRow.querySelector('.item-tags-list'); const tagInput = infoRow.querySelector('.item-tag-input'); @@ -1794,6 +1807,15 @@ window.initUploadForm = (selector) => { window.f0ckInitTagAutocomplete(tagInput, tagSuggestions, addTag, () => tags); } + // Prevent Enter in the title input from submitting the form and + // accidentally flushing whatever is currently typed in the tag input as a tag. + const titleInputEl = form.querySelector('.upload-title-input'); + if (titleInputEl) { + titleInputEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter') e.preventDefault(); + }); + } + form.querySelectorAll('input[name="rating"]').forEach(radio => { radio.addEventListener('change', updateSubmitButton); }); @@ -1831,6 +1853,7 @@ window.initUploadForm = (selector) => { const dragModal = form.closest('#upload-drag-modal'); const comment = form.querySelector('.upload-comment')?.value.trim() || ''; const isOc = form.querySelector('#upload-oc-checkbox')?.checked || false; + const titleVal = form.querySelector('.upload-title-input')?.value.trim() || ''; const setBtnLoading = (text) => { isUploading = true; @@ -1893,7 +1916,8 @@ window.initUploadForm = (selector) => { rating: globalRatingEl.value, tags: tags.join(','), comment: comment, - is_oc: isOc + is_oc: isOc, + title: titleVal || undefined }) }); @@ -1980,6 +2004,7 @@ window.initUploadForm = (selector) => { const fileRating = isShitpost ? item.rating : (globalRatingEl ? globalRatingEl.value : 'sfw'); const fileTags = isShitpost ? item.tags : tags; const fileComment = isShitpost ? item.comment : comment; + const fileTitle = isShitpost ? (item.title || '') : titleVal; if (isShitpost) { const statusMsg = window.f0ckI18n?.upload_shitposting_status || 'Shitposting'; @@ -1996,6 +2021,7 @@ window.initUploadForm = (selector) => { formData.append('tags', fileTags.join(',')); formData.append('is_oc', (isShitpost ? item.is_oc : isOc) ? 'true' : 'false'); if (isShitpost) formData.append('is_shitpost', 'true'); + if (fileTitle) formData.append('title', fileTitle); // Add custom thumbnail if provided (only for single SWF files) if (selectedFiles.length === 1 && thumbInput && thumbInput.files && thumbInput.files[0]) { @@ -2046,7 +2072,8 @@ window.initUploadForm = (selector) => { tags: fileTags.join(','), is_oc: (isShitpost ? item.is_oc : isOc), comment: fileComment, - is_shitpost: isShitpost ? true : undefined + is_shitpost: isShitpost ? true : undefined, + title: fileTitle || undefined })); } else { xhr.send(formData); diff --git a/src/inc/locales/en.json b/src/inc/locales/en.json index bb6307a..617172e 100644 --- a/src/inc/locales/en.json +++ b/src/inc/locales/en.json @@ -65,7 +65,7 @@ "cancel_upload": "Cancel Upload", "shitpost_success": "Successfully shitposted {n} items!", "shitposting_status": "Uploading", - "item_comment_placeholder": "Comment (optional)...", + "item_comment_placeholder": "Write a Comment...", "item_tags_placeholder": "Tags...", "btn_add_urls": "Add URL(s)", "tags_required_shitpost": "All items need tags", diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs index 52e021f..a86ec3f 100644 --- a/src/inc/routeinc/f0cklib.mjs +++ b/src/inc/routeinc/f0cklib.mjs @@ -657,6 +657,7 @@ export default { author_avatar: actitem.author_avatar, author_avatar_file: actitem.author_avatar_file, author_description: actitem.author_description, + title: actitem.title || null, src: { long: actitem.src, diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs index 0add98f..51e3806 100644 --- a/src/inc/routes/apiv2/index.mjs +++ b/src/inc/routes/apiv2/index.mjs @@ -817,6 +817,33 @@ export default router => { }); + // PATCH /api/v2/items/:id/title — set or clear the title for an item + // Allowed by: item owner, moderators, admins + group.patch(/\/items\/(?\d+)\/title$/, lib.loggedin, async (req, res) => { + const id = +req.params.id; + if (!id) return res.json({ success: false, msg: 'Invalid item id' }, 400); + + // Fetch item to check ownership + const rows = await db`SELECT id, username FROM items WHERE id = ${id} AND active = true LIMIT 1`; + if (!rows.length) return res.json({ success: false, msg: 'Item not found' }, 404); + + const item = rows[0]; + const isOwner = req.session.user === item.username; + const isMod = !!(req.session.is_moderator || req.session.admin); + if (!isOwner && !isMod) return res.json({ success: false, msg: 'Forbidden' }, 403); + + // Accept title from JSON or URL-encoded body + let rawTitle = req.post?.title ?? req.body?.title ?? null; + if (rawTitle !== null) rawTitle = String(rawTitle).trim(); + // Empty string → null (clears the title) + const title = (rawTitle === '' || rawTitle === null) ? null : rawTitle.substring(0, 500); + + await db`UPDATE items SET title = ${title} WHERE id = ${id}`; + + return res.json({ success: true, title }); + }); + + group.post(/\/admin\/deletepost$/, lib.modAuth, async (req, res) => { if (req.post.postid === undefined || req.post.postid === null) { return res.json({ diff --git a/src/inc/routes/apiv2/upload.mjs b/src/inc/routes/apiv2/upload.mjs index 8b26b01..00c9c41 100644 --- a/src/inc/routes/apiv2/upload.mjs +++ b/src/inc/routes/apiv2/upload.mjs @@ -204,7 +204,8 @@ export default router => { return res.json({ success: false, msg: 'URL uploads are disabled' }, 403); } - const { url: inputUrl, rating, tags: tagsRaw, comment, is_oc, is_shitpost } = req.post || {}; + const { url: inputUrl, rating, tags: tagsRaw, comment, is_oc, is_shitpost, title: rawTitle } = req.post || {}; + const title = (rawTitle && typeof rawTitle === 'string' && rawTitle.trim()) ? rawTitle.trim().substring(0, 500) : null; const maxLen = cfg.main.comment_max_length; if (comment && maxLen !== null && maxLen !== undefined && comment.length > maxLen) { @@ -280,8 +281,9 @@ export default router => { usernetwork: 'web', stamp: ~~(Date.now() / 1000), active: !isApprovalRequired, - is_oc: !!is_oc - }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc')} + is_oc: !!is_oc, + title: title + }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')} RETURNING id `; @@ -564,8 +566,9 @@ export default router => { usernetwork: 'web', stamp: ~~(Date.now() / 1000), active: !isApprovalRequired, - is_oc: !!is_oc - }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc')} + is_oc: !!is_oc, + title: title + }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')} RETURNING id `; diff --git a/src/upload_handler.mjs b/src/upload_handler.mjs index 1498bf6..3078726 100644 --- a/src/upload_handler.mjs +++ b/src/upload_handler.mjs @@ -18,6 +18,9 @@ const sendJson = (res, data, code = 200) => { // One-time migration: add original_filename column if it doesn't exist db`ALTER TABLE items ADD COLUMN IF NOT EXISTS original_filename text`.catch(() => {}); +// One-time migration: restore title column for backwards compatibility with old databases +db`ALTER TABLE items ADD COLUMN IF NOT EXISTS title text`.catch(() => {}); + export const handleUpload = async (req, res, self) => { // Manual session lookup is required here because this handler is called from a // bypass middleware that runs in parallel with the main session middleware. @@ -106,6 +109,8 @@ export const handleUpload = async (req, res, self) => { const rating = parts.rating; const tagsRaw = parts.tags; const comment = parts.comment ? parts.comment.trim() : ''; + const rawTitle = parts.title ? parts.title.trim() : ''; + const title = rawTitle.length > 0 ? rawTitle.substring(0, 500) : null; const is_oc = (parts.is_oc === 'true' || parts.is_oc === '1'); @@ -354,8 +359,9 @@ export const handleUpload = async (req, res, self) => { stamp: ~~(Date.now() / 1000), active: !manualApproval, is_oc: is_oc, - original_filename: originalFilename - }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename') + original_filename: originalFilename, + title: title + }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title') } `; diff --git a/views/item-partial-legacy.html b/views/item-partial-legacy.html index 6d309aa..94ffebd 100644 --- a/views/item-partial-legacy.html +++ b/views/item-partial-legacy.html @@ -9,6 +9,9 @@
{{ link.main }}{{ item.id }}{{ link.suffix }}
+ @if(item.title) +
{{ item.title }}
+ @endif