feat: media uploads
All checks were successful
/ upload (release) Successful in 5m37s

This commit is contained in:
pavel 2026-02-27 15:38:46 +01:00
commit e1dd679c47
19 changed files with 1807 additions and 73 deletions

View file

@ -144,6 +144,10 @@
<div class="chat-input-wrapper">
<form id="message-form" class="message-form">
<input id="media-file-input" type="file" class="hidden-file-input" accept="image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z,.tar,.gz,.json,.csv,.md" />
<button type="button" id="media-upload-btn" class="input-action-btn" title="Upload File">
<i data-lucide="paperclip"></i>
</button>
<button type="button" id="gif-btn" class="input-action-btn" title="Open GIF Picker">
<i data-lucide="image"></i>
</button>

View file

@ -183,6 +183,8 @@ const el = {
messageList: document.getElementById("message-list"),
messageForm: document.getElementById("message-form"),
messageBody: document.getElementById("message-body"),
mediaUploadBtn: document.getElementById("media-upload-btn"),
mediaFileInput: document.getElementById("media-file-input"),
// User Panel
userName: document.getElementById("user-name"),
@ -343,6 +345,43 @@ function formatMessageBody(body) {
return escaped;
}
function formatBytes(size) {
if (!Number.isFinite(size) || size < 1024) return `${size || 0} B`;
const units = ["KB", "MB", "GB"];
let value = size;
let unitIndex = -1;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
function renderAttachments(attachments = []) {
if (!attachments.length) return "";
return attachments.map((attachment) => {
const url = escapeHtml(attachment.media_url);
const mime = attachment.mime_type || "application/octet-stream";
const fileName = escapeHtml(attachment.original_filename || "file");
const size = formatBytes(attachment.size_bytes);
if (mime.startsWith("image/")) {
return `<a class="msg-attachment msg-attachment-image" href="${url}" target="_blank" rel="noopener noreferrer"><img src="${url}" alt="${fileName}" loading="lazy" /></a>`;
}
if (mime.startsWith("video/")) {
return `<div class="msg-attachment msg-attachment-video"><video controls preload="metadata" src="${url}"></video></div>`;
}
if (mime.startsWith("audio/")) {
return `<div class="msg-attachment msg-attachment-audio"><audio controls preload="metadata" src="${url}"></audio><a href="${url}" target="_blank" rel="noopener noreferrer">${fileName}</a></div>`;
}
return `<a class="msg-attachment msg-attachment-file" href="${url}" target="_blank" rel="noopener noreferrer"><i data-lucide="file"></i><span>${fileName}</span><small>${size}</small></a>`;
}).join("");
}
function formatDate(isoString) {
const d = new Date(isoString);
const now = new Date();
@ -609,9 +648,13 @@ function renderMessages(messages) {
row.className = `msg ${isGrouped ? "msg-grouped" : ""}`;
const displayName = m.author_display_name || "Unknown User";
const formattedBody = formatMessageBody(m.body || "");
const attachmentsHtml = renderAttachments(m.attachments);
const bodyHtml = formattedBody ? `<div class="msg-body">${formattedBody}</div>` : "";
const contentHtml = `${bodyHtml}${attachmentsHtml}`;
if (isGrouped) {
row.innerHTML = `<div class="msg-content"><div class="msg-body">${formatMessageBody(m.body)}</div></div>`;
row.innerHTML = `<div class="msg-content">${contentHtml}</div>`;
} else {
row.innerHTML = `
<div class="msg-avatar">${shortName(displayName)}</div>
@ -620,7 +663,7 @@ function renderMessages(messages) {
<span class="msg-author">${escapeHtml(displayName)}</span>
<span class="msg-time">${formatDate(m.created_at)}</span>
</div>
<div class="msg-body">${formatMessageBody(m.body)}</div>
${contentHtml}
</div>
`;
}
@ -644,6 +687,15 @@ function renderMessages(messages) {
gifImage.addEventListener("load", scrollMessagesToBottom, { once: true });
gifImage.addEventListener("error", scrollMessagesToBottom, { once: true });
}
for (const media of el.messageList.querySelectorAll(".msg-attachment img, .msg-attachment video, .msg-attachment audio")) {
if ("complete" in media && media.complete) continue;
media.addEventListener("loadeddata", scrollMessagesToBottom, { once: true });
media.addEventListener("load", scrollMessagesToBottom, { once: true });
media.addEventListener("error", scrollMessagesToBottom, { once: true });
}
lucide.createIcons();
}
function renderMembers() {
@ -1862,6 +1914,26 @@ async function init() {
} catch (err) { alert(err.message); }
};
el.mediaUploadBtn.onclick = () => {
if (!state.selectedTextChannelId && !state.selectedDmUserId) {
alert("Select a chat first.");
return;
}
el.mediaFileInput.click();
};
el.mediaFileInput.onchange = async () => {
const file = el.mediaFileInput.files?.[0];
if (!file) return;
try {
await uploadMediaFile(file);
} catch (err) {
alert(err.message);
} finally {
el.mediaFileInput.value = "";
}
};
// 4. Voice/Soundboard Controls
el.voiceVideoBtn.onclick = toggleVideo;
el.voiceScreenBtn.onclick = toggleScreenShare;
@ -1962,6 +2034,54 @@ async function init() {
}
}
async function uploadMediaFile(file) {
const token = storageGet("chattz_token");
if (!token) throw new Error("You need to log in again.");
const path = state.selectedTextChannelId
? `/channels/${state.selectedTextChannelId}/attachments`
: state.selectedDmUserId
? `/dms/${state.selectedDmUserId}/attachments`
: null;
if (!path) throw new Error("Select a chat first.");
const formData = new FormData();
formData.append("file", file);
el.mediaUploadBtn.disabled = true;
el.mediaUploadBtn.title = "Uploading...";
const fullUrl = API_BASE_URL ? `${API_BASE_URL}${path}` : path;
try {
const response = await fetch(fullUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
if (!response.ok) {
let detail = "upload failed";
try {
const payload = await response.json();
detail = payload.error || detail;
} catch { }
throw new Error(`${response.status}: ${detail}`);
}
const messages = state.selectedTextChannelId
? await api(`/channels/${state.selectedTextChannelId}/messages?limit=100`)
: await api(`/dms/${state.selectedDmUserId}/messages?limit=100`);
renderMessages(messages);
await loadDMConversations();
renderDMs();
} finally {
el.mediaUploadBtn.disabled = false;
el.mediaUploadBtn.title = "Upload File";
}
}
// 6. Initial Data Loading & App Start
try {
state.me = await api("/me");

View file

@ -885,6 +885,10 @@ select {
color: var(--text-normal);
}
.hidden-file-input {
display: none;
}
/* ═══════════════════════════════════════════════════════════ */
/* GIF Picker Modal */
/* ═══════════════════════════════════════════════════════════ */
@ -996,6 +1000,91 @@ select {
object-fit: contain;
}
.msg-attachment {
display: block;
margin-top: 8px;
}
.msg-attachment-image {
max-width: min(440px, 100%);
border-radius: var(--radius-md);
overflow: hidden;
}
.msg-attachment-image img {
width: 100%;
max-height: 360px;
object-fit: contain;
display: block;
background: rgba(0, 0, 0, 0.18);
}
.msg-attachment-video {
max-width: min(520px, 100%);
}
.msg-attachment-video video {
width: 100%;
max-height: 420px;
border-radius: var(--radius-md);
background: #000;
}
.msg-attachment-audio {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px;
max-width: min(420px, 100%);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.msg-attachment-audio audio {
width: 100%;
}
.msg-attachment-audio a {
color: var(--text-link);
font-size: 13px;
}
.msg-attachment-file {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
max-width: min(420px, 100%);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
color: var(--text-normal);
}
.msg-attachment-file:hover {
background: rgba(255, 255, 255, 0.07);
}
.msg-attachment-file i {
width: 18px;
height: 18px;
color: var(--text-link);
flex-shrink: 0;
}
.msg-attachment-file span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.msg-attachment-file small {
color: var(--text-muted);
margin-left: auto;
white-space: nowrap;
}
/* ═══════════════════════════════════════════════════════════ */
/* Sound Board */
/* ═══════════════════════════════════════════════════════════ */
@ -1618,4 +1707,4 @@ select {
.chat-header {
padding: 0 8px;
}
}
}