Skip to content

Commit a20488c

Browse files
Add markdown, media attachments, and mentions
1 parent 8b5000b commit a20488c

2 files changed

Lines changed: 269 additions & 6 deletions

File tree

home/index.html

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@
4040
.composer textarea { width: 100%; border: 1px solid var(--border); border-radius: 12px; min-height: 120px; resize: vertical; padding: 12px; font-size: 16px; font-family: inherit; outline: none; transition: border-color 0.2s ease; }
4141
.composer textarea:focus { border-color: var(--twitter-blue); }
4242
.composer-actions { margin-top: 10px; display: flex; align-items: center; justify-content: space-between; }
43+
.composer-tools { margin-top: 10px; display: flex; align-items: center; gap: 10px; }
44+
.tool-btn { border: 1px solid var(--border); border-radius: 999px; padding: 7px 12px; background: #eef3f8; color: #284661; font-weight: 700; cursor: pointer; }
45+
.tool-btn:hover { background: #dbecfa; color: #1d9bf0; }
46+
.composer-preview { margin-top: 10px; border: 1px solid var(--border); border-radius: 12px; padding: 10px; background: #f8fbfd; }
47+
.composer-preview img, .composer-preview video { width: 100%; max-height: 280px; object-fit: cover; border-radius: 10px; }
48+
.preview-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
49+
.remove-media { border: none; border-radius: 999px; padding: 6px 10px; background: #ffe8e8; color: #b00020; font-weight: 700; cursor: pointer; }
4350
.count { font-size: 13px; color: var(--muted); }
4451
.count.warn { color: #d93025; font-weight: 700; }
4552
.tweet-btn, .delete-btn { border: none; border-radius: 999px; font-weight: 700; cursor: pointer; }
@@ -52,7 +59,20 @@
5259
.tweet-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 14px; }
5360
.tweet-author { font-weight: 800; }
5461
.tweet-handle, .tweet-time { color: var(--muted); }
55-
.tweet-text { margin-top: 4px; white-space: pre-wrap; line-height: 1.45; }
62+
.tweet-text { margin-top: 4px; line-height: 1.45; word-break: break-word; }
63+
.tweet-text h1, .tweet-text h2, .tweet-text h3 { margin: 10px 0 4px; line-height: 1.2; }
64+
.tweet-text h1 { font-size: 20px; }
65+
.tweet-text h2 { font-size: 18px; }
66+
.tweet-text h3 { font-size: 16px; }
67+
.tweet-text p { margin: 0 0 8px; }
68+
.tweet-text code { background: #eef3f8; border-radius: 6px; padding: 2px 5px; font-family: Consolas, monospace; font-size: 13px; }
69+
.tweet-text pre { background: #0f1419; color: #e6f6ff; border-radius: 10px; padding: 10px; overflow: auto; margin: 8px 0; }
70+
.tweet-text pre code { background: transparent; padding: 0; color: inherit; }
71+
.tweet-text .mention { color: #1d9bf0; font-weight: 700; }
72+
.tweet-text a { color: #1d9bf0; text-decoration: none; }
73+
.tweet-text a:hover { text-decoration: underline; }
74+
.tweet-media { margin-top: 10px; border-radius: 12px; border: 1px solid var(--border); overflow: hidden; background: #0f1419; }
75+
.tweet-media img, .tweet-media video { width: 100%; max-height: 380px; object-fit: cover; display: block; }
5676
.tweet-footer { margin-top: 10px; display: flex; align-items: center; justify-content: space-between; }
5777
.delete-btn { padding: 8px 12px; background: #ffe8e8; color: #b00020; }
5878
.badge { width: 18px; height: 18px; border-radius: 50%; display: inline-grid; place-items: center; color: #fff; font-size: 11px; font-weight: 800; }
@@ -97,6 +117,12 @@
97117
<section class="timeline">
98118
<section class="card composer">
99119
<textarea id="t-input" maxlength="280" placeholder="What's happening?"></textarea>
120+
<div class="composer-tools">
121+
<button class="tool-btn" id="media-pick" type="button">Add image/video</button>
122+
<input id="media-input" type="file" accept="image/*,video/*" hidden>
123+
<span class="meta">Markdown + @mentions supported</span>
124+
</div>
125+
<div class="composer-preview" id="composer-preview" hidden></div>
100126
<div class="composer-actions">
101127
<span class="count" id="char-count">280 characters</span>
102128
<button class="tweet-btn" id="t-send" type="button" disabled>Tweet</button>
@@ -111,6 +137,7 @@
111137
import { initializeApp } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-app.js";
112138
import { getAuth, onAuthStateChanged, signOut } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js";
113139
import { getFirestore, collection, addDoc, query, orderBy, onSnapshot, serverTimestamp, doc, getDoc, setDoc, deleteDoc, where, getDocs, limit, writeBatch, increment } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js";
140+
import { getStorage, ref, uploadBytes, getDownloadURL } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-storage.js";
114141

115142
const firebaseConfig = {
116143
apiKey: "AIzaSyCz9fehRDGTBC7EQZehiVMNw7jUSSvzku4",
@@ -125,15 +152,20 @@
125152
const app = initializeApp(firebaseConfig);
126153
const auth = getAuth(app);
127154
const db = getFirestore(app);
155+
const storage = getStorage(app);
128156

129157
const inputEl = document.getElementById("t-input");
130158
const sendEl = document.getElementById("t-send");
131159
const countEl = document.getElementById("char-count");
160+
const mediaPickEl = document.getElementById("media-pick");
161+
const mediaInputEl = document.getElementById("media-input");
162+
const composerPreviewEl = document.getElementById("composer-preview");
132163
const feedEl = document.getElementById("feed");
133164
const statusEl = document.getElementById("status");
134165

135166
let currentUser = null;
136167
let profile = null;
168+
let selectedMedia = null;
137169
const profileCache = new Map();
138170
const likedPostIds = new Set();
139171

@@ -143,6 +175,54 @@
143175
return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\"/g, "&quot;").replace(/'/g, "&#39;");
144176
}
145177

178+
function extractMentions(text) {
179+
const found = new Set();
180+
const regex = /(^|\s)@([a-zA-Z0-9_]{3,30})\b/g;
181+
let match;
182+
while ((match = regex.exec(text || "")) !== null) found.add(match[2].toLowerCase());
183+
return [...found];
184+
}
185+
186+
function renderMarkdown(text) {
187+
let html = esc(text || "");
188+
html = html.replace(/```([\s\S]*?)```/g, (_, code) => `<pre><code>${code}</code></pre>`);
189+
html = html.replace(/^###\s+(.+)$/gm, "<h3>$1</h3>");
190+
html = html.replace(/^##\s+(.+)$/gm, "<h2>$1</h2>");
191+
html = html.replace(/^#\s+(.+)$/gm, "<h1>$1</h1>");
192+
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
193+
html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
194+
html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
195+
html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
196+
html = html.replace(/(^|[^\w])@([a-zA-Z0-9_]{3,30})\b/g, '$1<span class="mention">@$2</span>');
197+
return html.replace(/\n/g, "<br>");
198+
}
199+
200+
function renderComposerPreview() {
201+
if (!selectedMedia) {
202+
composerPreviewEl.hidden = true;
203+
composerPreviewEl.innerHTML = "";
204+
return;
205+
}
206+
const isImage = selectedMedia.file.type.startsWith("image/");
207+
const mediaTag = isImage
208+
? `<img src="${esc(selectedMedia.previewUrl)}" alt="preview">`
209+
: `<video src="${esc(selectedMedia.previewUrl)}" controls></video>`;
210+
composerPreviewEl.innerHTML = `
211+
<div class="preview-row">
212+
<strong>${esc(selectedMedia.file.name)}</strong>
213+
<button class="remove-media" id="remove-media" type="button">Remove</button>
214+
</div>
215+
${mediaTag}
216+
`;
217+
composerPreviewEl.hidden = false;
218+
document.getElementById("remove-media").addEventListener("click", () => {
219+
URL.revokeObjectURL(selectedMedia.previewUrl);
220+
selectedMedia = null;
221+
mediaInputEl.value = "";
222+
renderComposerPreview();
223+
});
224+
}
225+
146226
function badgeHtml(verifiedType, verifiedBy) {
147227
if (!verifiedType) return "";
148228
const parts = String(verifiedType).split(";").map((p) => p.trim()).filter(Boolean);
@@ -243,21 +323,38 @@
243323
async function publishTweet() {
244324
if (!currentUser || !profile) return;
245325
const text = inputEl.value.trim();
246-
if (!text) return;
326+
if (!text && !selectedMedia) return;
247327

248328
sendEl.disabled = true;
249329
statusEl.textContent = "Posting...";
250330
try {
331+
let mediaUrl = "";
332+
let mediaType = "";
333+
if (selectedMedia) {
334+
const safeName = selectedMedia.file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
335+
const mediaRef = ref(storage, `messages/${currentUser.uid}/${Date.now()}_${safeName}`);
336+
await uploadBytes(mediaRef, selectedMedia.file);
337+
mediaUrl = await getDownloadURL(mediaRef);
338+
mediaType = selectedMedia.file.type || "application/octet-stream";
339+
}
340+
251341
await addDoc(collection(db, "messages"), {
252342
authorUid: currentUser.uid,
253343
authorName: profile.displayName,
254344
authorUsername: profile.username,
255345
authorInitials: profile.avatarInitials,
256346
content: text,
347+
mentions: extractMentions(text),
348+
mediaUrl,
349+
mediaType,
257350
likes: 0,
258351
createdAt: serverTimestamp()
259352
});
260353
inputEl.value = "";
354+
if (selectedMedia) URL.revokeObjectURL(selectedMedia.previewUrl);
355+
selectedMedia = null;
356+
mediaInputEl.value = "";
357+
renderComposerPreview();
261358
updateComposerState();
262359
statusEl.textContent = "";
263360
} catch (error) {
@@ -317,7 +414,8 @@
317414
<span class="tweet-handle">@${esc(author.username || item.authorUsername || "node")}</span>
318415
<span class="tweet-time">· ${esc(formatTime(item.createdAt))}</span>
319416
</div>
320-
<div class="tweet-text">${esc(item.content || "")}</div>
417+
<div class="tweet-text">${renderMarkdown(item.content || "")}</div>
418+
${(item.mediaUrl && item.mediaType) ? `<div class="tweet-media">${item.mediaType.startsWith("image/") ? `<img src="${esc(item.mediaUrl)}" alt="Attachment">` : `<video controls src="${esc(item.mediaUrl)}"></video>`}</div>` : ""}
321419
<div class="tweet-footer">
322420
<button class="delete-btn" style="background:${liked ? '#dbf0ff' : '#eef3f8'};color:${liked ? '#1d9bf0' : '#32506a'}" onclick="void(0)" data-like-id="${esc(item.id)}">Like ${Number(item.likes || 0)}</button>
323421
${canDelete ? `<button class="delete-btn" onclick="deleteTweet('${esc(item.id)}')">Delete</button>` : ""}
@@ -341,6 +439,22 @@
341439
}
342440

343441
inputEl.addEventListener("input", updateComposerState);
442+
mediaPickEl.addEventListener("click", () => mediaInputEl.click());
443+
mediaInputEl.addEventListener("change", () => {
444+
const file = mediaInputEl.files?.[0];
445+
if (!file) return;
446+
const maxSize = file.type.startsWith("video/") ? 20 * 1024 * 1024 : 8 * 1024 * 1024;
447+
if (file.size > maxSize) {
448+
statusEl.textContent = file.type.startsWith("video/")
449+
? "Video too large (max 20 MB)."
450+
: "Image too large (max 8 MB).";
451+
mediaInputEl.value = "";
452+
return;
453+
}
454+
if (selectedMedia) URL.revokeObjectURL(selectedMedia.previewUrl);
455+
selectedMedia = { file, previewUrl: URL.createObjectURL(file) };
456+
renderComposerPreview();
457+
});
344458
sendEl.addEventListener("click", publishTweet);
345459

346460
document.getElementById("logout").addEventListener("click", async () => {

0 commit comments

Comments
 (0)