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 = `
+ ${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
@@ -198,6 +201,23 @@
+ @if(can_manage_item)
+
+ | Title |
+
+
+
+
+
+
+ |
+
+ @elseif(item.title)
+
+ | Title |
+ {{ item.title }} |
+
+ @endif
| {{ t('info_modal.file_size') || 'File Size' }} |
{{ item.size }} |
diff --git a/views/item-partial-modern.html b/views/item-partial-modern.html
index cd16313..3a2c740 100644
--- a/views/item-partial-modern.html
+++ b/views/item-partial-modern.html
@@ -60,6 +60,9 @@
{{ link.main }}{{ item.id }}{{ link.suffix }}
+ @if(item.title)
+ {{ item.title }}
+ @endif