|
40 | 40 | .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; } |
41 | 41 | .composer textarea:focus { border-color: var(--twitter-blue); } |
42 | 42 | .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; } |
43 | 50 | .count { font-size: 13px; color: var(--muted); } |
44 | 51 | .count.warn { color: #d93025; font-weight: 700; } |
45 | 52 | .tweet-btn, .delete-btn { border: none; border-radius: 999px; font-weight: 700; cursor: pointer; } |
|
52 | 59 | .tweet-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 14px; } |
53 | 60 | .tweet-author { font-weight: 800; } |
54 | 61 | .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; } |
56 | 76 | .tweet-footer { margin-top: 10px; display: flex; align-items: center; justify-content: space-between; } |
57 | 77 | .delete-btn { padding: 8px 12px; background: #ffe8e8; color: #b00020; } |
58 | 78 | .badge { width: 18px; height: 18px; border-radius: 50%; display: inline-grid; place-items: center; color: #fff; font-size: 11px; font-weight: 800; } |
|
97 | 117 | <section class="timeline"> |
98 | 118 | <section class="card composer"> |
99 | 119 | <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> |
100 | 126 | <div class="composer-actions"> |
101 | 127 | <span class="count" id="char-count">280 characters</span> |
102 | 128 | <button class="tweet-btn" id="t-send" type="button" disabled>Tweet</button> |
|
111 | 137 | import { initializeApp } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-app.js"; |
112 | 138 | import { getAuth, onAuthStateChanged, signOut } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js"; |
113 | 139 | 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"; |
114 | 141 |
|
115 | 142 | const firebaseConfig = { |
116 | 143 | apiKey: "AIzaSyCz9fehRDGTBC7EQZehiVMNw7jUSSvzku4", |
|
125 | 152 | const app = initializeApp(firebaseConfig); |
126 | 153 | const auth = getAuth(app); |
127 | 154 | const db = getFirestore(app); |
| 155 | + const storage = getStorage(app); |
128 | 156 |
|
129 | 157 | const inputEl = document.getElementById("t-input"); |
130 | 158 | const sendEl = document.getElementById("t-send"); |
131 | 159 | 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"); |
132 | 163 | const feedEl = document.getElementById("feed"); |
133 | 164 | const statusEl = document.getElementById("status"); |
134 | 165 |
|
135 | 166 | let currentUser = null; |
136 | 167 | let profile = null; |
| 168 | + let selectedMedia = null; |
137 | 169 | const profileCache = new Map(); |
138 | 170 | const likedPostIds = new Set(); |
139 | 171 |
|
|
143 | 175 | return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\"/g, """).replace(/'/g, "'"); |
144 | 176 | } |
145 | 177 |
|
| 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 | + |
146 | 226 | function badgeHtml(verifiedType, verifiedBy) { |
147 | 227 | if (!verifiedType) return ""; |
148 | 228 | const parts = String(verifiedType).split(";").map((p) => p.trim()).filter(Boolean); |
|
243 | 323 | async function publishTweet() { |
244 | 324 | if (!currentUser || !profile) return; |
245 | 325 | const text = inputEl.value.trim(); |
246 | | - if (!text) return; |
| 326 | + if (!text && !selectedMedia) return; |
247 | 327 |
|
248 | 328 | sendEl.disabled = true; |
249 | 329 | statusEl.textContent = "Posting..."; |
250 | 330 | 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 | + |
251 | 341 | await addDoc(collection(db, "messages"), { |
252 | 342 | authorUid: currentUser.uid, |
253 | 343 | authorName: profile.displayName, |
254 | 344 | authorUsername: profile.username, |
255 | 345 | authorInitials: profile.avatarInitials, |
256 | 346 | content: text, |
| 347 | + mentions: extractMentions(text), |
| 348 | + mediaUrl, |
| 349 | + mediaType, |
257 | 350 | likes: 0, |
258 | 351 | createdAt: serverTimestamp() |
259 | 352 | }); |
260 | 353 | inputEl.value = ""; |
| 354 | + if (selectedMedia) URL.revokeObjectURL(selectedMedia.previewUrl); |
| 355 | + selectedMedia = null; |
| 356 | + mediaInputEl.value = ""; |
| 357 | + renderComposerPreview(); |
261 | 358 | updateComposerState(); |
262 | 359 | statusEl.textContent = ""; |
263 | 360 | } catch (error) { |
|
317 | 414 | <span class="tweet-handle">@${esc(author.username || item.authorUsername || "node")}</span> |
318 | 415 | <span class="tweet-time">· ${esc(formatTime(item.createdAt))}</span> |
319 | 416 | </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>` : ""} |
321 | 419 | <div class="tweet-footer"> |
322 | 420 | <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> |
323 | 421 | ${canDelete ? `<button class="delete-btn" onclick="deleteTweet('${esc(item.id)}')">Delete</button>` : ""} |
|
341 | 439 | } |
342 | 440 |
|
343 | 441 | 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 | + }); |
344 | 458 | sendEl.addEventListener("click", publishTweet); |
345 | 459 |
|
346 | 460 | document.getElementById("logout").addEventListener("click", async () => { |
|
0 commit comments