feat: implement API documentation, add database migrations for site features, and include comment file attachments in API responses
This commit is contained in:
@@ -248,10 +248,27 @@
|
|||||||
return `[video](${fullUrl})`;
|
return `[video](${fullUrl})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use marked for each line individually
|
// Use marked for each line individually.
|
||||||
let mdSafe = processedLine.replace(/\*/g, '\\*').replace(/_/g, '\\_');
|
// Protect URLs and already-formed Markdown link/image tokens from the
|
||||||
const bs = String.fromCharCode(92);
|
// italic-prevention pass so that underscores in query params
|
||||||
mdSafe = mdSafe.split(bs + bs + '_').join(bs + bs + bs + '_');
|
// (e.g. ?v=_FcvmypiHg4) are never turned into ?v=\_FcvmypiHg4.
|
||||||
|
const mdProtected = [];
|
||||||
|
// Match [text](url) /  tokens AND bare http(s) URLs
|
||||||
|
let mdSafe = processedLine.replace(
|
||||||
|
/(!?\[[^\]]*\]\([^)]*\))|https?:\/\/\S+/g,
|
||||||
|
(match) => {
|
||||||
|
const idx = mdProtected.length;
|
||||||
|
mdProtected.push(match);
|
||||||
|
return `\x02MDURL${idx}\x03`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Escape * and _ only in the non-URL portions
|
||||||
|
mdSafe = mdSafe
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/\*/g, '\\*')
|
||||||
|
.replace(/_/g, '\\_');
|
||||||
|
// Restore protected URLs/tokens
|
||||||
|
mdSafe = mdSafe.replace(/\x02MDURL(\d+)\x03/g, (_, i) => mdProtected[+i]);
|
||||||
|
|
||||||
let rendered = marked.parseInline ? marked.parseInline(mdSafe, { renderer: renderer }) : marked.parse(mdSafe, { renderer: renderer }).replace(/<p>|<\/p>/g, '');
|
let rendered = marked.parseInline ? marked.parseInline(mdSafe, { renderer: renderer }) : marked.parse(mdSafe, { renderer: renderer }).replace(/<p>|<\/p>/g, '');
|
||||||
|
|
||||||
@@ -376,9 +393,27 @@
|
|||||||
const SIDEBAR_MAX_CHARS = 200;
|
const SIDEBAR_MAX_CHARS = 200;
|
||||||
const SIDEBAR_MAX_EMOJIS = 12;
|
const SIDEBAR_MAX_EMOJIS = 12;
|
||||||
|
|
||||||
|
const renderCommentAttachments = (files, content = '') => {
|
||||||
|
if (!files || files.length === 0) return '';
|
||||||
|
const items = files.map(f => {
|
||||||
|
const url = `/c/${f.dest}`;
|
||||||
|
if (content.includes(url)) return ''; // Skip if already rendered in content
|
||||||
|
if (f.mime.startsWith('image/')) {
|
||||||
|
return `<a href="${url}" target="_blank" class="cf-attachment cf-image" style="display: inline-block; max-width: 100%;"><img src="${url}" alt="${escapeHtml(f.original_filename || 'image')}" loading="lazy" style="max-width: 100%; max-height: 150px; object-fit: contain; border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 4px; margin-top: 4px;"></a>`;
|
||||||
|
} else if (f.mime.startsWith('video/')) {
|
||||||
|
return `<div class="cf-attachment cf-video" style="max-width: 100%;"><video src="${url}" controls preload="metadata" style="max-width: 100%; max-height: 150px; border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 4px; margin-top: 4px;"></video></div>`;
|
||||||
|
} else if (f.mime.startsWith('audio/')) {
|
||||||
|
return `<div class="cf-attachment cf-audio" style="max-width: 100%;"><audio src="${url}" controls preload="metadata" style="width: 100%; max-height: 40px; margin-top: 4px;"></audio></div>`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}).join('');
|
||||||
|
return items ? `<div class="comment-attachments" style="display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px;">${items}</div>` : '';
|
||||||
|
};
|
||||||
|
|
||||||
const renderActivityItem = (c) => {
|
const renderActivityItem = (c) => {
|
||||||
const rawContent = c.content || c.body || '';
|
const rawContent = c.content || c.body || '';
|
||||||
const displayContent = renderCommentContent(rawContent, c.id, c.item_id);
|
const displayContent = renderCommentContent(rawContent, c.id, c.item_id);
|
||||||
|
const attachmentsHtml = renderCommentAttachments(c.files, rawContent);
|
||||||
|
|
||||||
// Build avatar URL — same priority as the rest of the app
|
// Build avatar URL — same priority as the rest of the app
|
||||||
let avatarSrc = '/a/default.png';
|
let avatarSrc = '/a/default.png';
|
||||||
@@ -437,7 +472,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="comment-time timeago" style="font-size: 0.75em;"${tsAttr}>${timeStr}</span>
|
<span class="comment-time timeago" style="font-size: 0.75em;"${tsAttr}>${timeStr}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-content"><div class="comment-content-inner">${displayContent}</div><button class="read-more-btn">${window.f0ckI18n?.sidebar_read_more || 'read more'}</button></div>
|
<div class="comment-content"><div class="comment-content-inner">${displayContent}${attachmentsHtml}</div><button class="read-more-btn">${window.f0ckI18n?.sidebar_read_more || 'read more'}</button></div>
|
||||||
${itemPreview}
|
${itemPreview}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ export default (router, tpl) => {
|
|||||||
const commentId = parseInt(newComment[0].id, 10);
|
const commentId = parseInt(newComment[0].id, 10);
|
||||||
|
|
||||||
// Link uploaded files to this comment (if any)
|
// Link uploaded files to this comment (if any)
|
||||||
|
let activityFiles = [];
|
||||||
const fileIdsRaw = body.file_ids || '';
|
const fileIdsRaw = body.file_ids || '';
|
||||||
if (fileIdsRaw) {
|
if (fileIdsRaw) {
|
||||||
const fileIds = fileIdsRaw.split(',').map(id => parseInt(id, 10)).filter(id => !isNaN(id) && id > 0);
|
const fileIds = fileIdsRaw.split(',').map(id => parseInt(id, 10)).filter(id => !isNaN(id) && id > 0);
|
||||||
@@ -311,6 +312,13 @@ export default (router, tpl) => {
|
|||||||
AND user_id = ${req.session.id}
|
AND user_id = ${req.session.id}
|
||||||
AND comment_id IS NULL
|
AND comment_id IS NULL
|
||||||
`;
|
`;
|
||||||
|
// Fetch the linked files to send with live notification and post response
|
||||||
|
activityFiles = await db`
|
||||||
|
SELECT id, comment_id, dest, mime, size, original_filename
|
||||||
|
FROM comment_files
|
||||||
|
WHERE comment_id = ${commentId}
|
||||||
|
ORDER BY id ASC
|
||||||
|
`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[COMMENTS] Failed to link files to comment:', err);
|
console.error('[COMMENTS] Failed to link files to comment:', err);
|
||||||
}
|
}
|
||||||
@@ -458,7 +466,8 @@ export default (router, tpl) => {
|
|||||||
username_color: req.session.username_color,
|
username_color: req.session.username_color,
|
||||||
display_name: req.session.display_name || null,
|
display_name: req.session.display_name || null,
|
||||||
xd_score: xdRow?.xd_score ?? null,
|
xd_score: xdRow?.xd_score ?? null,
|
||||||
video_time: newComment[0]?.video_time ?? null
|
video_time: newComment[0]?.video_time ?? null,
|
||||||
|
files: activityFiles
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Thread live update
|
// 1. Thread live update
|
||||||
@@ -477,7 +486,8 @@ export default (router, tpl) => {
|
|||||||
avatar_file: req.session.avatar_file,
|
avatar_file: req.session.avatar_file,
|
||||||
username: req.session.user,
|
username: req.session.user,
|
||||||
username_color: req.session.username_color,
|
username_color: req.session.username_color,
|
||||||
display_name: req.session.display_name || null
|
display_name: req.session.display_name || null,
|
||||||
|
files: activityFiles
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Automatically subscribe user to the thread
|
// Automatically subscribe user to the thread
|
||||||
@@ -493,7 +503,10 @@ export default (router, tpl) => {
|
|||||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
comment: newComment[0],
|
comment: {
|
||||||
|
...newComment[0],
|
||||||
|
files: activityFiles
|
||||||
|
},
|
||||||
xd_score: xdRow?.xd_score ?? null,
|
xd_score: xdRow?.xd_score ?? null,
|
||||||
is_new_subscription
|
is_new_subscription
|
||||||
})
|
})
|
||||||
@@ -863,6 +876,26 @@ export default (router, tpl) => {
|
|||||||
LIMIT ${limit} OFFSET ${offset}
|
LIMIT ${limit} OFFSET ${offset}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Fetch comment file attachments
|
||||||
|
const filesMap = new Map();
|
||||||
|
if (comments.length > 0) {
|
||||||
|
const commentIds = comments.map(c => c.id);
|
||||||
|
try {
|
||||||
|
const files = await db`
|
||||||
|
SELECT id, comment_id, dest, mime, size, original_filename
|
||||||
|
FROM comment_files
|
||||||
|
WHERE comment_id = ANY(${commentIds}::int[])
|
||||||
|
ORDER BY id ASC
|
||||||
|
`;
|
||||||
|
for (const f of files) {
|
||||||
|
if (!filesMap.has(f.comment_id)) filesMap.set(f.comment_id, []);
|
||||||
|
filesMap.get(f.comment_id).push(f);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ACTIVITY] Failed to fetch comment files:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const processedComments = comments.map(c => {
|
const processedComments = comments.map(c => {
|
||||||
let ratingLabel = '?';
|
let ratingLabel = '?';
|
||||||
let ratingClass = 'untagged';
|
let ratingClass = 'untagged';
|
||||||
@@ -875,7 +908,8 @@ export default (router, tpl) => {
|
|||||||
content: (c.content || '').trim(),
|
content: (c.content || '').trim(),
|
||||||
username_color: c.username_color,
|
username_color: c.username_color,
|
||||||
item_rating_class: ratingClass,
|
item_rating_class: ratingClass,
|
||||||
item_rating_label: ratingLabel
|
item_rating_label: ratingLabel,
|
||||||
|
files: filesMap.get(c.id) || []
|
||||||
// created_at stays as the raw ISO timestamp so the frontend f0ckTimeAgo can localize it
|
// created_at stays as the raw ISO timestamp so the frontend f0ckTimeAgo can localize it
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user