38443-vm/assets/js/main.js
2026-02-15 19:48:17 +00:00

1983 lines
100 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

document.addEventListener('DOMContentLoaded', () => {
const fileUpload = document.getElementById('file-upload');
const chatForm = document.getElementById('chat-form');
const chatInput = document.getElementById('chat-input');
const messagesList = document.getElementById('messages-list');
const typingIndicator = document.getElementById('typing-indicator');
function scrollToBottom(force = false) {
if (!messagesList) return;
// Smart scroll: only scroll if user is already at the bottom or if forced (e.g. sending a message)
const threshold = 150; // pixels margin
const isAtBottom = messagesList.scrollHeight - messagesList.scrollTop <= messagesList.clientHeight + threshold;
if (force || isAtBottom) {
messagesList.scrollTo({
top: messagesList.scrollHeight,
behavior: 'smooth'
});
// Backup for non-smooth support or rendering delays
setTimeout(() => {
if (force || messagesList.scrollHeight - messagesList.scrollTop <= messagesList.clientHeight + threshold + 200) {
messagesList.scrollTop = messagesList.scrollHeight;
}
}, 100);
}
}
// Emoji list categorized
const EMOJI_CATEGORIES = {
'Smileys': ['๐Ÿ˜€', '๐Ÿ˜ƒ', '๐Ÿ˜„', '๐Ÿ˜', '๐Ÿ˜†', '๐Ÿ˜…', '๐Ÿคฃ', '๐Ÿ˜‚', '๐Ÿ™‚', '๐Ÿ™ƒ', '๐Ÿ˜‰', '๐Ÿ˜Š', '๐Ÿ˜‡', '๐Ÿฅฐ', '๐Ÿ˜', '๐Ÿคฉ', '๐Ÿ˜˜', '๐Ÿ˜—', '๐Ÿ˜š', '๐Ÿ˜™', '๐Ÿ˜‹', '๐Ÿ˜›', '๐Ÿ˜œ', '๐Ÿคช', '๐Ÿ˜', '๐Ÿค‘', '๐Ÿค—', '๐Ÿคญ', '๐Ÿคซ', '๐Ÿค”', '๐Ÿค', '๐Ÿคจ', '๐Ÿ˜', '๐Ÿ˜‘', '๐Ÿ˜ถ', '๐Ÿ˜', '๐Ÿ˜’', '๐Ÿ™„', '๐Ÿ˜ฌ', '๐Ÿคฅ', '๐Ÿ˜Œ', '๐Ÿ˜”', '๐Ÿ˜ช', '๐Ÿคค', '๐Ÿ˜ด', '๐Ÿ˜ท', '๐Ÿค’', '๐Ÿค•', '๐Ÿคข', '๐Ÿคฎ', '๐Ÿคง', '๐Ÿฅต', '๐Ÿฅถ', '๐Ÿฅด', '๐Ÿ˜ต', '๐Ÿคฏ', '๐Ÿค ', '๐Ÿฅณ', '๐Ÿ˜Ž', '๐Ÿค“', '๐Ÿง', '๐Ÿ˜•', '๐Ÿ˜Ÿ', '๐Ÿ™', 'โ˜น๏ธ', '๐Ÿ˜ฎ', '๐Ÿ˜ฏ', '๐Ÿ˜ฒ', '๐Ÿ˜ณ', '๐Ÿฅบ', '๐Ÿ˜ฆ', '๐Ÿ˜ง', '๐Ÿ˜จ', '๐Ÿ˜ฐ', '๐Ÿ˜ฅ', '๐Ÿ˜ข', '๐Ÿ˜ญ', '๐Ÿ˜ฑ', '๐Ÿ˜–', '๐Ÿ˜ฃ', '๐Ÿ˜ž', '๐Ÿ˜“', '๐Ÿ˜ฉ', '๐Ÿ˜ซ', '๐Ÿฅฑ', '๐Ÿ˜ค', '๐Ÿ˜ก', '๐Ÿ˜ ', '๐Ÿคฌ', '๐Ÿ˜ˆ', '๐Ÿ‘ฟ', '๐Ÿ‘น', '๐Ÿ‘บ', '๐Ÿ’€', 'โ˜ ๏ธ', '๐Ÿ’ฉ', '๐Ÿคก', '๐Ÿ‘น', '๐Ÿ‘บ', '๐Ÿ‘ป', '๐Ÿ‘ฝ', '๐Ÿ‘พ', '๐Ÿค–', '๐Ÿ˜บ', '๐Ÿ˜ธ', '๐Ÿ˜ป', '๐Ÿ˜ผ', '๐Ÿ˜ฝ', '๐Ÿ™€', '๐Ÿ˜ฟ', '๐Ÿ˜พ'],
'People': ['๐Ÿ‘‹', '๐Ÿคš', '๐Ÿ–๏ธ', 'โœ‹', '๐Ÿ––', '๐Ÿ‘Œ', '๐Ÿค', 'โœŒ๏ธ', '๐Ÿคž', '๐ŸคŸ', '๐Ÿค˜', '๐Ÿค™', '๐Ÿ‘ˆ', '๐Ÿ‘‰', '๐Ÿ‘†', '๐Ÿ–•', '๐Ÿ‘‡', 'โ˜๏ธ', '๐Ÿ‘', '๐Ÿ‘Ž', 'โœŠ', '๐Ÿ‘Š', '๐Ÿค›', '๐Ÿคœ', '๐Ÿ‘', '๐Ÿ™Œ', '๐Ÿ‘', '๐Ÿคฒ', '๐Ÿค', '๐Ÿ™', 'โœ๏ธ', '๐Ÿ’…', '๐Ÿคณ', '๐Ÿ’ช', '๐Ÿฆพ', '๐Ÿฆต', '๐Ÿฆฟ', '๐Ÿฆถ', '๐Ÿ‘‚', '๐Ÿฆป', '๐Ÿ‘ƒ', '๐Ÿง ', '๐Ÿฆท', '๐Ÿฆด', '๐Ÿ‘€', '๐Ÿ‘๏ธ', '๐Ÿ‘…', '๐Ÿ‘„', '๐Ÿ‘ถ', '๐Ÿง’', '๐Ÿ‘ฆ', '๐Ÿ‘ง', '๐Ÿง‘', '๐Ÿ‘ฑ', '๐Ÿ‘จ', '๐Ÿง”', '๐Ÿ‘ฉ', '๐Ÿง“', '๐Ÿ‘ด', '๐Ÿ‘ต', '๐Ÿ‘ฎ', '๐Ÿ•ต๏ธ', '๐Ÿ’‚', '๐Ÿ‘ท', '๐Ÿคด', '๐Ÿ‘ธ', '๐Ÿ‘ณ', '๐Ÿ‘ฒ', '๐Ÿง•', '๐Ÿคต', '๐Ÿ‘ฐ', '๐Ÿคฐ', '๐Ÿคฑ', '๐Ÿ‘ผ', '๐ŸŽ…', '๐Ÿคถ', '๐Ÿฆธ', '๐Ÿฆน', '๐Ÿง™', '๐Ÿงš', '๐Ÿง›', '๐Ÿงœ', '๐Ÿง', '๐Ÿงž', '๐ŸงŸ', '๐Ÿ’†', '๐Ÿ’‡', '๐Ÿšถ', '๐Ÿƒ', '๐Ÿ’ƒ', '๐Ÿ•บ', '๐Ÿ•ด๏ธ', '๐Ÿ‘ฏ', '๐Ÿง–', '๐Ÿง—'],
'Animals': ['๐Ÿถ', '๐Ÿฑ', '๐Ÿญ', '๐Ÿน', '๐Ÿฐ', '๐ŸฆŠ', '๐Ÿป', '๐Ÿผ', '๐Ÿจ', '๐Ÿฏ', '๐Ÿฆ', '๐Ÿฎ', '๐Ÿท', '๐Ÿฝ', '๐Ÿธ', '๐Ÿต', '๐Ÿ™ˆ', '๐Ÿ™‰', '๐Ÿ™Š', '๐Ÿ’', '๐Ÿ”', '๐Ÿง', '๐Ÿฆ', '๐Ÿค', '๐Ÿฃ', '๐Ÿฅ', '๐Ÿฆ†', '๐Ÿฆ…', '๐Ÿฆ‰', '๐Ÿฆ‡', '๐Ÿบ', '๐Ÿ—', '๐Ÿด', '๐Ÿฆ„', '๐Ÿ', '๐Ÿ›', '๐Ÿฆ‹', '๐ŸŒ', '๐Ÿž', '๐Ÿœ', '๐ŸฆŸ', '๐Ÿฆ—', '๐Ÿ•ท๏ธ', '๐Ÿ•ธ๏ธ', '่ ', '๐Ÿข', '๐Ÿ', '๐ŸฆŽ', '๐Ÿฆ–', '๐Ÿฆ•', '๐Ÿ™', '๐Ÿฆ‘', '๐Ÿฆ', '๐Ÿฆž', '๐Ÿฆ€', '๐Ÿก', '๐Ÿ ', '๐ŸŸ', '๐Ÿฌ', '๐Ÿณ', '๐Ÿ‹', '๐Ÿฆˆ', '๐ŸŠ', '๐Ÿ…', '๐Ÿ†', '๐Ÿฆ“', '๐Ÿฆ', '๐Ÿฆง', '๐Ÿ˜', '๐Ÿฆ›', '๐Ÿฆ', '๐Ÿช', '๐Ÿซ', '๐Ÿฆ’', '๐Ÿฆ˜', '๐Ÿƒ', '๐Ÿ‚', '๐Ÿ„', '๐ŸŽ', '๐Ÿ–', '๐Ÿ', '๐Ÿ‘', '๐Ÿ', '๐ŸฆŒ', '๐Ÿ•', '๐Ÿฉ', '๐Ÿฆฎ', '๐Ÿˆ', '๐Ÿ“', '๐Ÿฆƒ', '๐Ÿฆš', '๐Ÿฆœ', '๐Ÿฆข', '๐Ÿฆฉ', '๐Ÿ•Š๏ธ', '๐Ÿ‡', '๐Ÿฆ', '๐Ÿฆจ', '๐Ÿฆก', '๐Ÿฆฆ', '๐Ÿฆฅ', '๐Ÿ', '๐Ÿ€', '๐Ÿฟ๏ธ', '๐Ÿฆ”'],
'Nature': ['๐ŸŒต', '๐ŸŽ„', '๐ŸŒฒ', '๐ŸŒณ', '๐ŸŒด', '๐ŸŒฑ', '๐ŸŒฟ', 'โ˜˜๏ธ', '๐Ÿ€', '๐ŸŽ', '๐ŸŽ‹', '๐Ÿƒ', '๐Ÿ‚', '๐Ÿ', '๐Ÿ„', '๐Ÿš', '๐ŸŒพ', '๐Ÿ’', '๐ŸŒท', '๐ŸŒน', '๐Ÿฅ€', '๐ŸŒบ', '๐ŸŒธ', '๐ŸŒผ', '๐ŸŒป', '๐ŸŒž', '๐ŸŒ', '๐ŸŒ›', '๐ŸŒœ', '๐ŸŒš', '๐ŸŒ•', '๐ŸŒ–', '๐ŸŒ—', '๐ŸŒ˜', '๐ŸŒ‘', '๐ŸŒ’', '๐ŸŒ“', '๐ŸŒ”', '๐ŸŒ™', '๐ŸŒŽ', '๐ŸŒ', '๐ŸŒ', '๐Ÿช', '๐Ÿ’ซ', 'โญ๏ธ', '๐ŸŒŸ', 'โœจ', 'โšก๏ธ', 'โ˜„๏ธ', '๐Ÿ’ฅ', '๐Ÿ”ฅ', '๐ŸŒช๏ธ', '๐ŸŒˆ', 'โ˜€๏ธ', '๐ŸŒค๏ธ', 'โ›…๏ธ', '๐ŸŒฅ๏ธ', 'โ˜๏ธ', '๐ŸŒฆ๏ธ', '๐ŸŒง๏ธ', '๐ŸŒจ๏ธ', '๐ŸŒฉ๏ธ', 'โ„๏ธ', 'โ˜ƒ๏ธ', 'โ›„๏ธ', '๐ŸŒฌ๏ธ', '๐Ÿ’จ', '๐Ÿ’ง', '๐Ÿ’ฆ', 'โ˜”๏ธ', 'โ˜‚๏ธ', '๐ŸŒŠ', '๐ŸŒซ๏ธ'],
'Food': ['๐Ÿ', '๐ŸŽ', '๐Ÿ', '๐ŸŠ', '๐Ÿ‹', ' BANANA', '๐Ÿ‰', '๐Ÿ‡', '๐Ÿ“', '๐Ÿˆ', '๐Ÿ’', '๐Ÿ‘', '๐Ÿฅญ', '๐Ÿ', '๐Ÿฅฅ', '๐Ÿฅ', '๐Ÿ…', '๐Ÿ†', '๐Ÿฅ‘', '๐Ÿฅฆ', '๐Ÿฅฌ', '๐Ÿฅ’', '๐ŸŒฝ', '๐Ÿฅ•', '๐Ÿง„', '๐Ÿฅ”', '๐Ÿ ', '๐Ÿฅ', '๐Ÿฅฏ', '๐Ÿž', '๐Ÿฅ–', '๐Ÿฅจ', '๐Ÿง€', '๐Ÿฅš', '๐Ÿณ', '๐Ÿงˆ', '๐Ÿฅž', ' waffle', '๐Ÿฅ“', '๐Ÿฅฉ', '๐Ÿ—', '๐Ÿ–', '๐Ÿฆด', '๐ŸŒญ', '๐Ÿ”', '๐ŸŸ', '๐Ÿ•', '๐Ÿฅช', '๐Ÿฅ™', '๐Ÿง†', '๐ŸŒฎ', '๐ŸŒฏ', '๐Ÿฅ—', '๐Ÿฅ˜', '๐Ÿ', '๐Ÿœ', '๐Ÿฒ', '๐Ÿ›', ' sushi', ' Bento', ' Dumpling', ' Oyster', '๐Ÿค', ' Rice Ball', ' Rice', ' Rice Cracker', '๐Ÿฅ', '๐Ÿฅ ', '๐Ÿฅฎ', '๐Ÿข', '๐Ÿก', '๐Ÿง', '๐Ÿจ', '๐Ÿฆ', '๐Ÿฅง', '๐Ÿง', '๐Ÿฐ', '๐ŸŽ‚', '๐Ÿฎ', '๐Ÿญ', '๐Ÿฌ', '๐Ÿซ', ' popcorn', '๐Ÿฉ', '๐Ÿช', '๐ŸŒฐ', ' peanuts', '๐Ÿฏ', '๐Ÿฅ›', 'โ˜•๏ธ', '๐Ÿต', '๐Ÿฅค', '๐Ÿถ', '๐Ÿบ', '๐Ÿป', '๐Ÿฅ‚', '๐Ÿท', '๐Ÿฅƒ', '๐Ÿธ', '๐Ÿน', '๐Ÿง‰', '๐Ÿพ', '๐ŸงŠ', '๐Ÿฅ„', '๐Ÿด', '๐Ÿฝ๏ธ', '๐Ÿฅฃ', '๐Ÿฅก'],
'Travel': ['๐Ÿš—', '๐Ÿš•', '๐Ÿš™', '๐ŸšŒ', '๐ŸšŽ', '๐ŸŽ๏ธ', '๐Ÿš“', '๐Ÿš‘', '๐Ÿš’', '๐Ÿš', '๐Ÿšš', '๐Ÿš›', '๐Ÿšœ', '๐Ÿ›ต', '๐Ÿšฒ', '๐Ÿ›ด', '๐Ÿš', '๐Ÿ›ฃ๏ธ', '๐Ÿ›ค๏ธ', 'โ›ฝ๏ธ', '๐Ÿšจ', '๐Ÿšฅ', '๐Ÿšฆ', '๐Ÿšง', 'โš“๏ธ', 'โ›ต๏ธ', '๐Ÿšค', '๐Ÿ›ณ๏ธ', 'โ›ด๏ธ', '๐Ÿšข', 'โœˆ๏ธ', '๐Ÿ›ซ', '๐Ÿ›ฌ', '๐Ÿ’บ', '๐Ÿš', '๐ŸšŸ', '๐Ÿš ', '๐Ÿšก', '๐Ÿš€', '๐Ÿ›ธ', '๐Ÿ›ฐ๏ธ', 'โŒ›๏ธ', 'โณ', 'โŒš๏ธ', 'โฐ', 'โฑ๏ธ', 'โฒ๏ธ', '๐Ÿ•ฐ๏ธ', '๐ŸŒก๏ธ', 'โ˜€๏ธ', '๐Ÿช', '๐ŸŒŸ', 'โ˜๏ธ', 'โ›…๏ธ', 'โ›ˆ๏ธ', '๐ŸŒˆ', 'โ›ฐ๏ธ', '๐Ÿ”๏ธ', '๐Ÿ—ป', '๐ŸŒ‹', '๐Ÿœ๏ธ', '๐Ÿ•๏ธ', 'โ›บ๏ธ', '๐Ÿ ', '๐Ÿก', '๐Ÿข', '๐Ÿฃ', '๐Ÿค', '๐Ÿฅ', '๐Ÿฆ', '๐Ÿจ', '๐Ÿฉ', '๐Ÿช', '๐Ÿซ', '๐Ÿฌ', '๐Ÿญ', '๐Ÿฏ', '๐Ÿฐ', '๐Ÿ’’', '๐Ÿ—ผ', '๐Ÿ—ฝ', 'โ›ช๏ธ', '๐Ÿ•Œ', '๐Ÿ•', 'โ›ฉ๏ธ', '๐Ÿ•‹', 'โ›ฒ๏ธ', '๐ŸŒ', '๐ŸŒƒ', '๐Ÿ™๏ธ', '๐ŸŒ„', '๐ŸŒ…', '๐ŸŒ†', '๐ŸŒ‡', '๐ŸŒ‰', '๐ŸŽ ', '๐ŸŽก', '๐ŸŽข', '๐Ÿš‚', '๐Ÿšƒ', '๐Ÿš„', '๐Ÿš…', '๐Ÿš†', '๐Ÿš‡', '๐Ÿšˆ', '๐Ÿš‰', '๐ŸšŠ', '๐Ÿš', '๐Ÿšž', '๐Ÿš‹'],
'Activities': ['โšฝ๏ธ', '๐Ÿ€', '๐Ÿˆ', 'โšพ๏ธ', '๐ŸฅŽ', '๐ŸŽพ', '๐Ÿ', '๐Ÿ‰', '๐ŸŽฑ', '๐Ÿ“', '๐Ÿธ', '๐Ÿฅ…', ' hockey', ' field hockey', ' cricket', 'โ›ณ๏ธ', '๐Ÿน', '๐ŸŽฃ', ' boxing', '๐Ÿฅ‹', ' skateboard', '๐Ÿ›ท', 'โ›ธ๏ธ', '๐ŸฅŒ', '๐ŸŽฟ', 'โ›ท๏ธ', '๐Ÿ‚', '๐Ÿ‹๏ธ', ' fencing', '๐Ÿคผ', ' gymnastics', ' basketball player', '๐Ÿคฝ', ' handball', ' juggle', '๐Ÿง˜', '๐Ÿ‡', ' rowing', ' swimming', '๐Ÿšด', '๐Ÿšต', '๐Ÿง—', '๐ŸŽ–๏ธ', '๐Ÿ†', '๐Ÿ…', '๐Ÿฅ‡', '๐Ÿฅˆ', '๐Ÿฅ‰', '๐ŸŽซ', '๐ŸŽŸ๏ธ', '๐ŸŽญ', '๐ŸŽจ', '๐ŸŽฌ', '๐ŸŽค', '๐ŸŽง', '๐ŸŽผ', '๐ŸŽน', '๐Ÿฅ', '๐ŸŽท', '๐ŸŽบ', '๐ŸŽธ', '๐Ÿช•', '๐ŸŽป', '๐ŸŽฒ', 'โ™Ÿ๏ธ', '๐ŸŽฏ', '๐ŸŽณ', '๐ŸŽฎ', '๐ŸŽฐ', '๐Ÿงฉ'],
'Objects': ['โŒš๏ธ', '๐Ÿ“ฑ', '๐Ÿ“ฒ', '๐Ÿ’ป', 'โŒจ๏ธ', '๐Ÿ–ฑ๏ธ', '๐Ÿ–ฒ๏ธ', '๐Ÿ•น๏ธ', '๐Ÿ—œ๏ธ', '๐Ÿ’ฝ', '๐Ÿ’พ', '๐Ÿ’ฟ', '๐ŸŽž๏ธ', '๐Ÿ“ท', '๐Ÿ“ธ', '๐Ÿ“น', '๐Ÿ“ผ', '๐Ÿ”', '๐Ÿ”Ž', '๐Ÿ•ฏ๏ธ', '๐Ÿ’ก', '๐Ÿ”ฆ', '๐Ÿฎ', '๐Ÿช”', '๐Ÿ“”', '๐Ÿ“•', '๐Ÿ“–', '๐Ÿ“—', '๐Ÿ“˜', '๐Ÿ“™', '๐Ÿ“š', '๐Ÿ““', '๐Ÿ“’', '๐Ÿ“ƒ', '๐Ÿ“œ', '๐Ÿ“„', '๐Ÿ“ฐ', '๐Ÿ—ž๏ธ', '๐Ÿ“‘', '๐Ÿ”–', '๐Ÿท๏ธ', '๐Ÿ’ฐ', '๐Ÿ’ด', '๐Ÿ’ต', '๐Ÿ’ถ', '๐Ÿ’ท', '๐Ÿ’ธ', '๐Ÿ’ณ', '๐Ÿงพ', '๐Ÿ’น', 'โœ‰๏ธ', '๐Ÿ“ง', '๐Ÿ“จ', '๐Ÿ“ฉ', '๐Ÿ“ค', '๐Ÿ“ฅ', '๐Ÿ“ฆ', '๐Ÿ“ซ', '๐Ÿ“ฉ', '๐Ÿ“ฌ', '๐Ÿ“ญ', '๐Ÿ“ฎ', '๐Ÿ—ณ๏ธ', 'โœ๏ธ', 'โœ’๏ธ', '๐Ÿ–‹๏ธ', '๐Ÿ–Š๏ธ', '๐Ÿ–Œ๏ธ', '๐Ÿ–๏ธ', '๐Ÿ“', '๐Ÿ’ผ', '๐Ÿ“', '๐Ÿ“‚', '๐Ÿ—‚๏ธ', '๐Ÿ“…', '๐Ÿ“†', '๐Ÿ—’๏ธ', '๐Ÿ—“๏ธ', '๐Ÿ“‡', '๐Ÿ“ˆ', '๐Ÿ“‰', '๐Ÿ“Š', '๐Ÿ“‹', '๐Ÿ“Œ', '๐Ÿ“', '๐Ÿ“Ž', '๐Ÿ–‡๏ธ', '๐Ÿ“', '๐Ÿ“', 'โœ‚๏ธ', '๐Ÿ—ƒ๏ธ', '๐Ÿ—„๏ธ', '๐Ÿ—‘๏ธ', '๐Ÿ”’', '๐Ÿ”“', '๐Ÿ”', '๐Ÿ”', '๐Ÿ”‘', '๐Ÿ—๏ธ', '๐Ÿ”จ', '๐Ÿช“', 'โ›๏ธ', 'โš’๏ธ', '๐Ÿ› ๏ธ', '๐Ÿ—ก๏ธ', 'โš”๏ธ', '๐Ÿ”ซ', '๐Ÿ›ก๏ธ', '๐Ÿ”ง', '๐Ÿ”ฉ', 'โš™๏ธ', '๐Ÿ—œ๏ธ', 'โš–๏ธ', '๐Ÿฆฏ', '๐Ÿ”—', 'โ›“๏ธ', '๐Ÿงฐ', '๐Ÿงฒ', 'โš—๏ธ', '๐Ÿงช', '๐Ÿงซ', '๐Ÿงฌ', '๐Ÿ”ฌ', '๐Ÿ”ญ', '๐Ÿ“ก', '๐Ÿ’‰', '๐Ÿฉธ', '๐Ÿ’Š', '๐Ÿฉน', '๐Ÿฉบ', '๐Ÿšช', '๐Ÿ›๏ธ', '๐Ÿ›‹๏ธ', '๐Ÿช‘', '๐Ÿšฝ', '๐Ÿšฟ', '๐Ÿ›€', '๐Ÿช’', '๐Ÿงด', '๐Ÿงท', '๐Ÿงน', '๐Ÿงบ', '๐Ÿงป', '๐Ÿงผ', '๐Ÿงฏ', '๐Ÿ›’', '๐Ÿšฌ', 'โšฐ๏ธ', 'โšฑ๏ธ', '๐Ÿ—ฟ'],
'Symbols': ['๐Ÿ’˜', '๐Ÿ’', '๐Ÿ’–', '๐Ÿ’—', '๐Ÿ’“', '๐Ÿ’ž', '๐Ÿ’•', '๐Ÿ’Ÿ', 'โฃ๏ธ', '๐Ÿ’”', 'โค๏ธ', '๐Ÿงก', '๐Ÿ’›', '๐Ÿ’š', '๐Ÿ’™', '๐Ÿ’œ', '๐Ÿ–ค', '๐Ÿค', '๐ŸคŽ', '๐Ÿ’ฏ', '๐Ÿ’ข', '๐Ÿ’ฅ', '๐Ÿ’ซ', '๐Ÿ’ฆ', '๐Ÿ’จ', '๐Ÿ•ณ๏ธ', '๐Ÿ’ฃ', '๐Ÿ’ฌ', '๐Ÿ‘๏ธโ€๐Ÿ—จ๏ธ', '๐Ÿ—จ๏ธ', '๐Ÿ—ฏ๏ธ', '๐Ÿ’ญ', '๐Ÿ’ค', '๐ŸŒ', 'โ™ ๏ธ', 'โ™ฅ๏ธ', 'โ™ฆ๏ธ', 'โ™ฃ๏ธ', '๐Ÿƒ', '๐Ÿ€„๏ธ', '๐ŸŽด', '๐ŸŽญ', '๐Ÿ”‡', '๐Ÿ”ˆ', '๐Ÿ”‰', '๐Ÿ”Š', '๐Ÿ“ข', '๐Ÿ“ฃ', '๐Ÿ“ฏ', '๐Ÿ””', '๐Ÿ”•', '๐ŸŽผ', '๐ŸŽต', '๐ŸŽถ', '๐Ÿ’น', '๐Ÿง', '๐Ÿšฎ', '๐Ÿšฐ', 'โ™ฟ๏ธ', '๐Ÿšน', '๐Ÿšบ', '๐Ÿšป', '๐Ÿšผ', '๐Ÿšพ', '๐Ÿ›‚', '๐Ÿ›ƒ', '๐Ÿ›„', '๐Ÿ›…', 'โš ๏ธ', '๐Ÿšธ', 'โ›”๏ธ', '๐Ÿšซ', '๐Ÿšณ', '๐Ÿšญ', '๐Ÿšฏ', '๐Ÿšฑ', '๐Ÿšท', '๐Ÿ“ต', '๐Ÿ”ž', 'โ˜ข๏ธ', 'โ˜ฃ๏ธ', 'โฌ†๏ธ', 'โ†—๏ธ', 'โžก๏ธ', 'โ†˜๏ธ', 'โฌ‡๏ธ', 'โ†™๏ธ', 'โฌ…๏ธ', 'โ†–๏ธ', 'โ†•๏ธ', 'โ†”๏ธ', 'โ†ฉ๏ธ', 'โ†ช๏ธ', 'โคด๏ธ', 'โคต๏ธ', '๐Ÿ”ƒ', '๐Ÿ”„', '๐Ÿ”™', '๐Ÿ”š', '๐Ÿ”›', '๐Ÿ”œ', '๐Ÿ”', '๐Ÿ›', 'โš›๏ธ', '๐Ÿ•‰๏ธ', 'โœก๏ธ', 'โ˜ธ๏ธ', 'โ˜ฏ๏ธ', 'โœ๏ธ', 'โ˜ฆ๏ธ', 'โ˜ช๏ธ', 'โ˜ฎ๏ธ', '๐Ÿ•Ž', '๐Ÿ”ฏ', 'โ™ˆ๏ธ', 'โ™‰๏ธ', 'โ™Š๏ธ', 'โ™‹๏ธ', 'โ™Œ๏ธ', 'โ™๏ธ', 'โ™Ž๏ธ', 'โ™๏ธ', 'โ™๏ธ', 'โ™‘๏ธ', 'โ™’๏ธ', 'โ™“๏ธ', 'โ›Ž', '๐Ÿ”€', '๐Ÿ”', '๐Ÿ”‚', 'โ–ถ๏ธ', 'โฉ', 'โญ๏ธ', 'โฏ๏ธ', 'โ—€๏ธ', 'โช', 'โฎ๏ธ', '๐Ÿ”ผ', 'โซ', '๐Ÿ”ฝ', 'โฌ', 'โธ๏ธ', 'โน๏ธ', 'โบ๏ธ', 'โ๏ธ', '๐ŸŽฆ', '๐Ÿ”…', '๐Ÿ”†', '๐Ÿ“ถ', '๐Ÿ“ณ', '๐Ÿ“ด', 'โž•', 'โž–', 'โž—', 'โœ–๏ธ', 'โ™พ๏ธ', 'โ€ผ๏ธ', 'โ‰๏ธ', 'โ“', 'โ”', 'โ•', 'โ—๏ธ', 'ใ€ฐ๏ธ', '๐Ÿ’ฑ', '๐Ÿ’ฒ', 'โš•๏ธ', 'โ™ป๏ธ', 'โšœ๏ธ', '๐Ÿ”ฑ', '๐Ÿ“›', '๐Ÿ”ฐ', 'โญ•๏ธ', 'โœ…', 'โ˜‘๏ธ', 'โœ”๏ธ', 'โœ–๏ธ', 'โŒ', 'โŽ', 'โžฐ', 'โžฟ', 'ใ€ฝ๏ธ', 'โœณ๏ธ', 'โœด๏ธ', 'โ‡๏ธ', 'โ€ผ๏ธ', '๐Ÿˆ', '๐Ÿˆ‚๏ธ', '๐Ÿˆท๏ธ', '๐Ÿˆถ', '๐Ÿˆฏ๏ธ', '๐Ÿ‰', '๐Ÿˆน', '๐Ÿˆš๏ธ', '๐Ÿˆฒ', '๐Ÿ‰‘', '๐Ÿˆธ', '๐Ÿˆด', '๐Ÿˆณ', 'ใŠ—๏ธ', 'ใŠ™๏ธ', '๐Ÿˆบ', '๐Ÿˆต', '๐Ÿ”ด', '๐ŸŸ ', '๐ŸŸก', '๐ŸŸข', '๐Ÿ”ต', '๐ŸŸฃ', '๐ŸŸค', 'โšซ๏ธ', 'โšช๏ธ', '๐ŸŸฅ', '๐ŸŸง', '๐ŸŸจ', '๐ŸŸฉ', '๐ŸŸฆ', '๐ŸŸช', '๐ŸŸซ', 'โฌ›๏ธ', 'โฌœ๏ธ'],
'Flags': ['๐Ÿ', '๐Ÿšฉ', '๐ŸŽŒ', '๐Ÿด', '๐Ÿณ๏ธ', '๐Ÿณ๏ธโ€๐ŸŒˆ', '๐Ÿณ๏ธโ€โšง๏ธ', '๐Ÿดโ€โ˜ ๏ธ', '๐Ÿ‡ฆ๐Ÿ‡ซ', '๐Ÿ‡ฆ๐Ÿ‡ฝ', '๐Ÿ‡ฆ๐Ÿ‡ฑ', '๐Ÿ‡ฉ๐Ÿ‡ฟ', '๐Ÿ‡ฆ๐Ÿ‡ฒ', '๐Ÿ‡ฆ๐Ÿ‡บ', '๐Ÿ‡ฆ๐Ÿ‡น', '๐Ÿ‡ฆ๐Ÿ‡ฟ', '๐Ÿ‡ง๐Ÿ‡ช', '๐Ÿ‡ง๐Ÿ‡ท', '๐Ÿ‡จ๐Ÿ‡ฆ', '๐Ÿ‡จ๐Ÿ‡ฑ', '๐Ÿ‡จ๐Ÿ‡ณ', '๐Ÿ‡จ๐Ÿ‡ด', '๐Ÿ‡จ๐Ÿ‡ฟ', '๐Ÿ‡ฉ๐Ÿ‡ฐ', '๐Ÿ‡ช๐Ÿ‡ฌ', '๐Ÿ‡ซ๐Ÿ‡ฎ', '๐Ÿ‡ซ๐Ÿ‡ท', '๐Ÿ‡ฉ๐Ÿ‡ช', '๐Ÿ‡ฌ๐Ÿ‡ท', '๐Ÿ‡ญ๐Ÿ‡ฐ', '๐Ÿ‡ฎ๐Ÿ‡ณ', '๐Ÿ‡ฎ๐Ÿ‡ฉ', '๐Ÿ‡ฎ๐Ÿ‡ช', '๐Ÿ‡ฎ๐Ÿ‡ฑ', '๐Ÿ‡ฎ๐Ÿ‡น', '๐Ÿ‡ฏ๐Ÿ‡ต', '๐Ÿ‡ฐ๐Ÿ‡ท', '๐Ÿ‡ฒ๐Ÿ‡ฝ', '๐Ÿ‡ณ๐Ÿ‡ฑ', '๐Ÿ‡ณ๐Ÿ‡ฟ', '๐Ÿ‡ณ๐Ÿ‡ด', '๐Ÿ‡ต๐Ÿ‡ฐ', '๐Ÿ‡ต๐Ÿ‡ญ', '๐Ÿ‡ต๐Ÿ‡ฑ', '๐Ÿ‡ต๐Ÿ‡น', '๐Ÿ‡ท๐Ÿ‡บ', '๐Ÿ‡ธ๐Ÿ‡ฆ', '๐Ÿ‡ธ๐Ÿ‡ฌ', '๐Ÿ‡ฟ๐Ÿ‡ฆ', '๐Ÿ‡ช๐Ÿ‡ธ', '๐Ÿ‡ธ๐Ÿ‡ช', '๐Ÿ‡จ๐Ÿ‡ญ', '๐Ÿ‡น๐Ÿ‡ญ', '๐Ÿ‡น๐Ÿ‡ท', '๐Ÿ‡บ๐Ÿ‡ฆ', '๐Ÿ‡ฆ๐Ÿ‡ช', '๐Ÿ‡ฌ๐Ÿ‡ง', '๐Ÿ‡บ๐Ÿ‡ธ', '๐Ÿ‡ป๐Ÿ‡ณ']
};
// Global EMOJIS list for reactions (flattened)
const EMOJIS = Object.values(EMOJI_CATEGORIES).flat();
function populateEmojiGrid(category = null, searchTerm = '') {
const roleEmojiGrid = document.getElementById('role-emoji-grid');
if (!roleEmojiGrid) return;
roleEmojiGrid.innerHTML = '';
let emojis = [];
if (searchTerm) {
emojis = EMOJIS.filter(e => e.includes(searchTerm) || searchTerm === '');
// For simple search by name, we'd need names, but since we only have the emoji characters,
// the user might search by the emoji itself or we could just show all if search is empty.
// Actually, without a mapping of emoji to names, searching is limited.
// But I'll implement it anyway so if they paste an emoji it works,
// or I can add common names if I had a mapping.
// Given the constraint, I'll just filter by character for now,
// or maybe the user just wants a way to filter the massive list.
} else {
emojis = EMOJI_CATEGORIES[category] || [];
}
emojis.forEach(emoji => {
const span = document.createElement('span');
span.textContent = emoji;
span.style.cursor = 'pointer';
span.style.fontSize = '20px';
span.style.padding = '5px';
span.style.textAlign = 'center';
span.className = 'rounded role-emoji-item';
span.onclick = () => {
document.getElementById('edit-role-icon').value = emoji;
document.getElementById('selected-role-emoji-preview').textContent = emoji;
};
roleEmojiGrid.appendChild(span);
});
}
function initEmojiCategories() {
const categoriesContainer = document.getElementById('role-emoji-categories');
const searchInput = document.getElementById('role-emoji-search');
if (!categoriesContainer) return;
categoriesContainer.innerHTML = '';
Object.keys(EMOJI_CATEGORIES).forEach((cat, index) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-sm btn-dark text-nowrap px-2 py-1';
btn.style.fontSize = '0.75em';
btn.textContent = cat;
if (index === 0) btn.classList.add('active', 'btn-primary');
btn.onclick = () => {
if (searchInput) searchInput.value = '';
categoriesContainer.querySelectorAll('button').forEach(b => b.classList.remove('active', 'btn-primary'));
btn.classList.add('active', 'btn-primary');
populateEmojiGrid(cat);
};
categoriesContainer.appendChild(btn);
});
if (searchInput) {
searchInput.oninput = () => {
const term = searchInput.value.trim();
if (term) {
categoriesContainer.querySelectorAll('button').forEach(b => b.classList.remove('active', 'btn-primary'));
populateEmojiGrid(null, term);
} else {
const activeCat = categoriesContainer.querySelector('button.active')?.textContent || Object.keys(EMOJI_CATEGORIES)[0];
populateEmojiGrid(activeCat);
}
};
}
// Initial load
populateEmojiGrid(Object.keys(EMOJI_CATEGORIES)[0]);
}
// Call init if elements exist
if (document.getElementById('role-emoji-grid')) {
initEmojiCategories();
}
// Scroll to bottom
scrollToBottom(true);
const currentChannel = new URLSearchParams(window.location.search).get('channel_id') || 1;
const currentThread = new URLSearchParams(window.location.search).get('thread_id');
let typingTimeout;
// Notification Permission
if ("Notification" in window && Notification.permission === "default") {
Notification.requestPermission();
}
// WebSocket for real-time
let ws;
let voiceHandler;
function connectWS() {
try {
ws = new WebSocket('ws://' + window.location.hostname + ':8080');
if (typeof VoiceChannel !== 'undefined') {
voiceHandler = new VoiceChannel(ws);
}
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
// Voice signaling
if (msg.type && msg.type.startsWith('voice_')) {
if (voiceHandler) voiceHandler.handleSignaling(msg);
return;
}
if (msg.type === 'message') {
const data = JSON.parse(msg.data);
if (data.channel_id == currentChannel) {
appendMessage(data);
// Desktop Notifications for mentions
if (data.content.includes(`@${window.currentUsername}`) && data.user_id != window.currentUserId) {
if (Notification.permission === "granted" && !window.isDndMode) {
new Notification(`Mention in #${window.currentChannelName}`, {
body: `${data.username}: ${data.content}`,
icon: data.avatar_url || ''
});
}
}
}
} else if (msg.type === 'typing') {
if (msg.channel_id == currentChannel && msg.user_id != window.currentUserId) {
showTyping(msg.username);
}
} else if (msg.type === 'reaction') {
updateReactionUI(msg.message_id, msg.reactions);
} else if (msg.type === 'message_edit') {
const el = document.querySelector(`.message-item[data-id="${msg.message_id}"] .message-text`);
if (el) el.innerHTML = msg.content.replace(/\n/g, '<br>');
} else if (msg.type === 'message_delete') {
document.querySelector(`.message-item[data-id="${msg.message_id}"]`)?.remove();
} else if (msg.type === 'presence') {
updatePresenceUI(msg.user_id, msg.status);
}
};
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'presence',
user_id: window.currentUserId,
status: 'online'
}));
};
ws.onclose = () => setTimeout(connectWS, 3000);
} catch (e) {
console.warn('WebSocket connection failed.');
}
}
connectWS();
// Polling as fallback for real-time
let lastMessageId = 0;
const findLastMessageId = () => {
const items = document.querySelectorAll('.message-item');
if (items.length > 0) {
lastMessageId = Math.max(...Array.from(items).map(i => parseInt(i.dataset.id) || 0));
}
};
findLastMessageId();
setInterval(async () => {
if (!currentChannel) return;
try {
const resp = await fetch(`api_v1_messages.php?channel_id=${currentChannel}&after_id=${lastMessageId}`);
const data = await resp.json();
if (data.success && data.messages && data.messages.length > 0) {
data.messages.forEach(msg => {
appendMessage(msg);
});
}
} catch (e) { }
}, 1000);
function showTyping(username) {
typingIndicator.textContent = `${username} is typing...`;
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => {
typingIndicator.textContent = '';
}, 3000);
}
chatInput.addEventListener('input', () => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'typing',
channel_id: currentChannel,
user_id: window.currentUserId,
username: window.currentUsername
}));
}
});
chatForm.addEventListener('submit', (e) => {
e.preventDefault();
const content = chatInput.value.trim();
const file = fileUpload.files[0];
if (!content && !file) return;
chatInput.value = '';
const formData = new FormData();
formData.append('content', content);
formData.append('channel_id', currentChannel);
if (currentThread) {
formData.append('thread_id', currentThread);
}
const progressContainer = document.getElementById('upload-progress-container');
const progressBar = document.getElementById('upload-progress-bar');
const progressPercent = document.getElementById('upload-percentage');
const progressFilename = document.getElementById('upload-filename');
if (file) {
formData.append('file', file);
fileUpload.value = ''; // Clear file input
// Show progress bar
progressContainer.style.display = 'block';
progressFilename.textContent = `Uploading: ${file.name}`;
progressBar.style.width = '0%';
progressPercent.textContent = '0%';
}
const xhr = new XMLHttpRequest();
xhr.open('POST', 'api_v1_messages.php', true);
xhr.upload.onprogress = (ev) => {
if (ev.lengthComputable && file) {
const percent = Math.round((ev.loaded / ev.total) * 100);
progressBar.style.width = percent + '%';
progressPercent.textContent = percent + '%';
}
};
xhr.onload = () => {
if (xhr.status === 200) {
const result = JSON.parse(xhr.responseText);
if (result.success) {
appendMessage(result.message);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'message',
data: JSON.stringify({
...result.message,
channel_id: currentChannel
})
}));
}
} else {
alert(result.error || 'Failed to send message');
}
}
progressContainer.style.display = 'none';
};
xhr.onerror = () => {
console.error('XHR Error');
progressContainer.style.display = 'none';
alert('An error occurred during the upload.');
};
xhr.send(formData);
});
// Handle Reaction Clicks
document.addEventListener('click', (e) => {
const badge = e.target.closest('.reaction-badge');
if (badge) {
const msgId = badge.parentElement.dataset.messageId;
const emoji = badge.dataset.emoji;
toggleReaction(msgId, emoji);
return;
}
const addBtn = e.target.closest('.add-reaction-btn');
if (addBtn) {
const msgId = addBtn.parentElement.dataset.messageId;
showEmojiPicker(addBtn, (emoji) => toggleReaction(msgId, emoji));
return;
}
// Close picker if click outside
if (!e.target.closest('.emoji-picker')) {
const picker = document.querySelector('.emoji-picker');
if (picker) picker.remove();
}
});
async function toggleReaction(messageId, emoji) {
try {
const resp = await fetch('api_v1_reactions.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message_id: messageId, emoji: emoji })
});
const result = await resp.json();
if (result.success) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'reaction',
message_id: messageId,
reactions: result.reactions
}));
}
updateReactionUI(messageId, result.reactions);
}
} catch (e) { console.error(e); }
}
function showEmojiPicker(anchor, callback) {
document.querySelector('.emoji-picker')?.remove();
const picker = document.createElement('div');
picker.className = 'emoji-picker p-0 overflow-hidden d-flex flex-column';
picker.style.width = '280px';
picker.style.height = '320px';
picker.style.backgroundColor = 'var(--bg-secondary)';
picker.style.border = '1px solid var(--bg-tertiary)';
picker.style.borderRadius = '8px';
picker.style.boxShadow = '0 8px 24px rgba(0,0,0,0.5)';
picker.style.zIndex = '2000';
const tabs = document.createElement('div');
tabs.className = 'd-flex overflow-auto border-bottom border-secondary p-1 bg-dark';
tabs.style.gap = '2px';
const searchContainer = document.createElement('div');
searchContainer.className = 'p-2 border-bottom border-secondary';
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = 'Search emoji...';
searchInput.className = 'form-control form-control-sm bg-dark border-secondary text-white';
searchContainer.appendChild(searchInput);
const grid = document.createElement('div');
grid.className = 'flex-grow-1 overflow-auto p-2';
grid.style.display = 'grid';
grid.style.gridTemplateColumns = 'repeat(7, 1fr)';
grid.style.gap = '2px';
const renderGrid = (cat = null, term = '') => {
grid.innerHTML = '';
let emojis = [];
if (term) {
emojis = EMOJIS.filter(e => e.includes(term));
} else {
emojis = EMOJI_CATEGORIES[cat] || [];
}
emojis.forEach(emoji => {
const span = document.createElement('span');
span.textContent = emoji;
span.style.cursor = 'pointer';
span.style.fontSize = '20px';
span.style.padding = '5px';
span.style.textAlign = 'center';
span.className = 'rounded role-emoji-item';
span.onclick = () => {
callback(emoji);
picker.remove();
};
grid.appendChild(span);
});
};
searchInput.oninput = () => {
const term = searchInput.value.trim();
if (term) {
tabs.querySelectorAll('button').forEach(b => b.classList.remove('text-primary'));
renderGrid(null, term);
} else {
const activeCat = tabs.querySelector('button.text-primary')?.textContent || Object.keys(EMOJI_CATEGORIES)[0];
renderGrid(activeCat);
}
};
Object.keys(EMOJI_CATEGORIES).forEach((cat, index) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-sm btn-dark text-nowrap px-2 py-1 border-0';
btn.style.fontSize = '0.7em';
btn.textContent = cat;
if (index === 0) btn.classList.add('text-primary');
btn.onclick = (e) => {
e.stopPropagation();
searchInput.value = '';
tabs.querySelectorAll('button').forEach(b => b.classList.remove('text-primary'));
btn.classList.add('text-primary');
renderGrid(cat);
};
tabs.appendChild(btn);
});
picker.appendChild(tabs);
picker.appendChild(searchContainer);
picker.appendChild(grid);
document.body.appendChild(picker);
renderGrid(Object.keys(EMOJI_CATEGORIES)[0]);
const rect = anchor.getBoundingClientRect();
let top = rect.top - picker.offsetHeight - 5;
if (top < 0) top = rect.bottom + 5;
picker.style.position = 'fixed';
picker.style.top = `${top}px`;
picker.style.left = `${Math.min(rect.left, window.innerWidth - 300)}px`;
}
function updateReactionUI(messageId, reactions) {
const container = document.querySelector(`.message-reactions[data-message-id="${messageId}"]`);
if (!container) return;
const addBtn = container.querySelector('.add-reaction-btn');
container.innerHTML = '';
reactions.forEach(r => {
const badge = document.createElement('span');
const userList = r.users.split(',');
const active = userList.includes(String(window.currentUserId));
badge.className = `reaction-badge ${active ? 'active' : ''}`;
badge.dataset.emoji = r.emoji;
badge.innerHTML = `${r.emoji} <span class="count">${r.count}</span>`;
container.appendChild(badge);
});
container.appendChild(addBtn);
}
function updatePresenceUI(userId, status) {
const memberItem = document.querySelector(`.start-dm-btn[data-user-id="${userId}"] .message-avatar`);
if (memberItem) {
let indicator = memberItem.querySelector('.presence-indicator');
if (!indicator) {
indicator = document.createElement('div');
indicator.className = 'presence-indicator';
memberItem.appendChild(indicator);
}
indicator.style.position = 'absolute';
indicator.style.bottom = '0';
indicator.style.right = '0';
indicator.style.width = '10px';
indicator.style.height = '10px';
indicator.style.borderRadius = '50%';
indicator.style.border = '2px solid var(--bg-members)';
indicator.style.backgroundColor = status === 'online' ? '#23a559' : '#80848e';
}
}
// Voice
if (voiceHandler) {
document.querySelectorAll('.voice-item').forEach(item => {
item.addEventListener('click', () => {
const cid = item.dataset.channelId;
if (voiceHandler.currentChannelId == cid) {
voiceHandler.leave();
item.classList.remove('active');
} else {
voiceHandler.join(cid);
document.querySelectorAll('.voice-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
}
});
});
}
// Message Actions (Edit/Delete)
document.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.action-btn.edit');
if (editBtn) {
const msgId = editBtn.dataset.id;
const msgItem = editBtn.closest('.message-item');
const textEl = msgItem.querySelector('.message-text');
const originalContent = textEl.innerText;
const input = document.createElement('input');
input.type = 'text';
input.className = 'form-control bg-dark text-white';
input.value = originalContent;
textEl.innerHTML = '';
textEl.appendChild(input);
input.focus();
input.onkeydown = async (ev) => {
if (ev.key === 'Enter') {
const newContent = input.value.trim();
if (newContent && newContent !== originalContent) {
const resp = await fetch('api_v1_messages.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: msgId, content: newContent })
});
if ((await resp.json()).success) {
textEl.innerHTML = newContent.replace(/\n/g, '<br>');
ws?.send(JSON.stringify({ type: 'message_edit', message_id: msgId, content: newContent }));
}
} else {
textEl.innerHTML = originalContent.replace(/\n/g, '<br>');
}
} else if (ev.key === 'Escape') {
textEl.innerHTML = originalContent.replace(/\n/g, '<br>');
}
};
return;
}
const deleteBtn = e.target.closest('.action-btn.delete');
if (deleteBtn) {
if (!confirm('Delete this message?')) return;
const msgId = deleteBtn.dataset.id;
const resp = await fetch('api_v1_messages.php', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: msgId })
});
if ((await resp.json()).success) {
deleteBtn.closest('.message-item').remove();
ws?.send(JSON.stringify({ type: 'message_delete', message_id: msgId }));
}
return;
}
const pinBtn = e.target.closest('.action-btn.pin');
if (pinBtn) {
const msgId = pinBtn.dataset.id;
const isPinned = pinBtn.dataset.pinned == '1';
const action = isPinned ? 'unpin' : 'pin';
const resp = await fetch('api_v1_messages.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: msgId, action: action })
});
const result = await resp.json();
if (result.success) {
location.reload(); // Simplest way to reflect changes across UI
}
return;
}
const pinnedMessagesBtn = document.getElementById('pinned-messages-btn');
if (e.target.closest('#pinned-messages-btn')) {
const container = document.getElementById('pinned-messages-container');
container.innerHTML = '<div class="p-3 text-center text-muted">Loading pinned messages...</div>';
const modal = new bootstrap.Modal(document.getElementById('pinnedMessagesModal'));
modal.show();
const resp = await fetch(`api_v1_messages.php?channel_id=${currentChannel}&pinned=1`);
const data = await resp.json();
if (data.success && data.messages.length > 0) {
container.innerHTML = '';
data.messages.forEach(msg => {
const div = document.createElement('div');
div.className = 'message-item p-2 border-bottom border-secondary';
div.style.backgroundColor = 'transparent';
const authorStyle = msg.role_color ? `color: ${msg.role_color};` : '';
div.innerHTML = `
<div class="d-flex align-items-start">
<div class="message-avatar" style="width: 32px; height: 32px; margin-right: 10px; ${msg.avatar_url ? `background-image: url('${msg.avatar_url}');` : ''}"></div>
<div style="flex: 1;">
<div class="message-author" style="font-size: 0.85em; ${authorStyle}">
${escapeHTML(msg.username)}
${msg.role_icon ? `<img src="${msg.role_icon}" class="role-icon ms-1" style="width: 12px; height: 12px; vertical-align: middle; object-fit: contain;">` : ''}
<span class="message-time">${msg.time}</span>
</div>
<div class="message-text" style="font-size: 0.9em;">
${escapeHTML(msg.content).replace(/\n/g, '<br>')}
</div>
</div>
</div>
`;
container.appendChild(div);
});
} else {
container.innerHTML = '<div class="p-3 text-center text-muted">No pinned messages in this channel.</div>';
}
return;
}
// Member Menu
const memberItem = e.target.closest('.member-item');
if (memberItem) {
const userId = memberItem.dataset.userId;
const username = memberItem.dataset.username;
const avatar = memberItem.dataset.avatar;
// Create or show member menu
document.querySelector('.member-context-menu')?.remove();
const menu = document.createElement('div');
menu.className = 'member-context-menu bg-dark border border-secondary rounded p-2';
menu.style.position = 'fixed';
menu.style.zIndex = '1000';
menu.style.boxShadow = '0 4px 12px rgba(0,0,0,0.5)';
menu.style.minWidth = '150px';
const rect = memberItem.getBoundingClientRect();
menu.style.top = `${rect.top}px`;
menu.style.left = `${rect.left - 160}px`;
menu.innerHTML = `
<div class="mb-2 p-1 border-bottom border-secondary d-flex align-items-center">
<div class="message-avatar me-2" style="width: 24px; height: 24px; ${avatar ? `background-image: url('${avatar}');` : ''}"></div>
<span class="small fw-bold">${escapeHTML(username)}</span>
</div>
<button class="btn btn-sm btn-dark w-100 text-start mb-1 member-menu-action" data-action="message">Message</button>
${(window.isServerOwner || window.canManageServer) ? `<button class="btn btn-sm btn-dark w-100 text-start member-menu-action" data-action="edit-roles">ร‰diter son rรดle</button>` : ''}
`;
document.body.appendChild(menu);
// Close menu on click outside
const closeMenu = (e) => {
if (!menu.contains(e.target)) {
menu.remove();
document.removeEventListener('mousedown', closeMenu);
}
};
document.addEventListener('mousedown', closeMenu);
menu.querySelectorAll('.member-menu-action').forEach(btn => {
btn.onclick = async () => {
const action = btn.dataset.action;
if (action === 'message') {
const formData = new FormData();
formData.append('user_id', userId);
const resp = await fetch('api_v1_dms.php', { method: 'POST', body: formData });
const result = await resp.json();
if (result.success) {
window.location.href = `?server_id=dms&channel_id=${result.channel_id}`;
}
} else if (action === 'edit-roles') {
openEditUserRolesModal(userId, username, avatar);
}
menu.remove();
};
});
}
});
// Global Search
const searchInput = document.getElementById('global-search');
const searchType = document.getElementById('search-type');
const searchResults = document.getElementById('search-results');
searchInput?.addEventListener('input', async () => {
const q = searchInput.value.trim();
const type = searchType.value;
if (q.length < 2) {
searchResults.style.display = 'none';
return;
}
const resp = await fetch(`api_v1_search.php?q=${encodeURIComponent(q)}&type=${type}&channel_id=${currentChannel}`);
const data = await resp.json();
if (data.success && data.results.length > 0) {
searchResults.innerHTML = '';
data.results.forEach(res => {
const item = document.createElement('div');
item.className = 'search-result-item d-flex align-items-center gap-2';
if (type === 'users') {
item.innerHTML = `
<div class="message-avatar" style="width: 24px; height: 24px; ${res.avatar_url ? `background-image: url('${res.avatar_url}');` : ''}"></div>
<div class="flex-grow-1">
<div class="search-result-author">${res.username}</div>
<div class="small text-muted" style="font-size: 0.7em;">Click to start conversation</div>
</div>
`;
item.onclick = () => {
const formData = new FormData();
formData.append('user_id', res.id);
fetch('api_v1_dms.php', { method: 'POST', body: formData })
.then(r => r.json())
.then(resDM => {
if (resDM.success) window.location.href = `?server_id=dms&channel_id=${resDM.channel_id}`;
});
};
} else {
item.innerHTML = `
<div class="flex-grow-1">
<div class="search-result-author">${res.username}</div>
<div class="search-result-text">${res.content}</div>
</div>
`;
}
searchResults.appendChild(item);
});
searchResults.style.display = 'block';
} else {
searchResults.innerHTML = '<div class="p-2 text-muted">No results found</div>';
searchResults.style.display = 'block';
}
});
// Channel Permissions Management
const channelPermissionsTabBtn = document.getElementById('channel-permissions-tab-btn');
const channelPermissionsList = document.getElementById('channel-permissions-list');
const addPermRoleList = document.getElementById('add-permission-role-list');
channelPermissionsTabBtn?.addEventListener('click', async () => {
const channelId = document.getElementById('edit-channel-id').value;
loadChannelPermissions(channelId);
loadRolesForPermissions(channelId);
});
async function loadChannelPermissions(channelId) {
channelPermissionsList.innerHTML = '<div class="text-center p-3 text-muted small">Loading permissions...</div>';
const resp = await fetch(`api_v1_channel_permissions.php?channel_id=${channelId}`);
const data = await resp.json();
if (data.success) {
renderChannelPermissions(channelId, data.permissions);
}
}
async function loadRolesForPermissions(channelId) {
addPermRoleList.innerHTML = '';
const resp = await fetch(`api_v1_roles.php?server_id=${activeServerId}`);
const data = await resp.json();
if (data.success) {
data.roles.forEach(role => {
const li = document.createElement('li');
li.innerHTML = `<a class="dropdown-item d-flex align-items-center gap-2" href="#">
<div style="width: 10px; height: 10px; border-radius: 50%; background-color: ${role.color};"></div>
${role.name}
</a>`;
li.onclick = async (e) => {
e.preventDefault();
await fetch('api_v1_channel_permissions.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel_id: channelId, role_id: role.id, allow: 0, deny: 0 })
});
loadChannelPermissions(channelId);
};
addPermRoleList.appendChild(li);
});
}
}
function renderChannelPermissions(channelId, permissions) {
channelPermissionsList.innerHTML = '';
if (permissions.length === 0) {
channelPermissionsList.innerHTML = '<div class="text-center p-3 text-muted small">No role overrides.</div>';
return;
}
permissions.forEach(p => {
const item = document.createElement('div');
item.className = 'list-group-item bg-transparent text-white border-secondary p-2';
item.innerHTML = `
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="d-flex align-items-center">
<div style="width: 10px; height: 10px; border-radius: 50%; background-color: ${p.role_color}; margin-right: 8px;"></div>
<span class="small fw-bold">${p.role_name}</span>
</div>
<button class="btn btn-sm text-danger remove-perm-btn" data-role-id="${p.role_id}">ร—</button>
</div>
<div class="d-flex gap-2">
<select class="form-select form-select-sm bg-dark text-white border-secondary perm-select" data-role-id="${p.role_id}">
<option value="allow" ${p.allow_permissions ? 'selected' : ''}>Allow Sending Messages</option>
<option value="deny" ${p.deny_permissions ? 'selected' : ''}>Deny Sending Messages</option>
<option value="neutral" ${!p.allow_permissions && !p.deny_permissions ? 'selected' : ''}>Neutral</option>
</select>
</div>
`;
channelPermissionsList.appendChild(item);
});
}
channelPermissionsList?.addEventListener('click', async (e) => {
const channelId = document.getElementById('edit-channel-id').value;
if (e.target.classList.contains('remove-perm-btn')) {
const roleId = e.target.dataset.roleId;
await fetch('api_v1_channel_permissions.php', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel_id: channelId, role_id: roleId })
});
loadChannelPermissions(channelId);
}
});
channelPermissionsList?.addEventListener('change', async (e) => {
if (e.target.classList.contains('perm-select')) {
const channelId = document.getElementById('edit-channel-id').value;
const roleId = e.target.dataset.roleId;
const val = e.target.value;
let allow = 0, deny = 0;
if (val === 'allow') allow = 1;
if (val === 'deny') deny = 1;
await fetch('api_v1_channel_permissions.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel_id: channelId, role_id: roleId, allow, deny })
});
}
});
document.addEventListener('click', async (e) => {
if (!e.target.closest('.search-container')) {
searchResults.style.display = 'none';
}
if (e.target.classList.contains('move-rule-btn')) {
const id = e.target.dataset.id;
const dir = e.target.dataset.dir;
const resp = await fetch('api_v1_rules.php', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, dir })
});
if ((await resp.json()).success) location.reload();
}
});
// Roles Management
const channelSettingsBtns = document.querySelectorAll('.channel-settings-btn');
channelSettingsBtns.forEach(btn => {
btn.addEventListener('click', () => {
const modal = document.getElementById('editChannelModal');
const channelId = btn.dataset.id;
const channelType = btn.dataset.type || 'chat';
modal.querySelector('#edit-channel-id').value = channelId;
modal.querySelector('#edit-channel-name').value = btn.dataset.name;
modal.querySelector('#edit-channel-type').value = channelType;
modal.querySelector('#edit-channel-files').checked = btn.dataset.files == '1';
modal.querySelector('#edit-channel-limit').value = btn.dataset.limit || '';
modal.querySelector('#edit-channel-status').value = btn.dataset.status || '';
modal.querySelector('#edit-channel-icon').value = btn.dataset.icon || '';
modal.querySelector('#delete-channel-id').value = channelId;
// Show/Hide RSS tab
const rssTabNav = document.getElementById('rss-tab-nav');
const statusContainer = document.getElementById('edit-channel-status-container');
if (channelType === 'announcement') {
rssTabNav.style.display = 'block';
} else {
rssTabNav.style.display = 'none';
// Switch to General tab if we were on RSS
if (document.getElementById('rss-tab-btn').classList.contains('active')) {
bootstrap.Tab.getInstance(modal.querySelector('.nav-link.active')).hide();
bootstrap.Tab.getOrCreateInstance(modal.querySelector('[data-bs-target="#edit-channel-general"]')).show();
}
}
if (channelType === 'voice') {
statusContainer.style.display = 'block';
} else {
statusContainer.style.display = 'none';
}
});
});
// RSS Management
const editChannelType = document.getElementById('edit-channel-type');
editChannelType?.addEventListener('change', () => {
const type = editChannelType.value;
const rssTabNav = document.getElementById('rss-tab-nav');
const statusContainer = document.getElementById('edit-channel-status-container');
rssTabNav.style.display = (type === 'announcement') ? 'block' : 'none';
statusContainer.style.display = (type === 'voice') ? 'block' : 'none';
});
// RSS Management
const rssTabBtn = document.getElementById('rss-tab-btn');
const rssFeedsList = document.getElementById('rss-feeds-list');
const addRssBtn = document.getElementById('add-rss-btn');
const syncRssBtn = document.getElementById('sync-rss-btn');
rssTabBtn?.addEventListener('click', loadRssFeeds);
async function loadRssFeeds() {
const channelId = document.getElementById('edit-channel-id').value;
rssFeedsList.innerHTML = '<div class="text-center p-3 text-muted small">Loading feeds...</div>';
try {
const resp = await fetch(`api_v1_rss.php?channel_id=${channelId}`);
const data = await resp.json();
if (data.success) {
renderRssFeeds(data.feeds);
}
} catch (e) { console.error(e); }
}
function renderRssFeeds(feeds) {
rssFeedsList.innerHTML = '';
if (feeds.length === 0) {
rssFeedsList.innerHTML = '<div class="text-center p-3 text-muted small">No RSS feeds configured.</div>';
return;
}
feeds.forEach(feed => {
const item = document.createElement('div');
item.className = 'list-group-item bg-transparent text-white border-secondary p-2 mb-1';
item.innerHTML = `
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="small text-truncate" style="max-width: 80%;">${feed.url}</span>
<button class="btn btn-sm text-danger delete-rss-btn" data-id="${feed.id}">ร—</button>
</div>
<div class="small text-muted" style="font-size: 0.7em;">Last fetched: ${feed.last_fetched_at || 'Never'}</div>
`;
rssFeedsList.appendChild(item);
});
}
addRssBtn?.addEventListener('click', async () => {
const channelId = document.getElementById('edit-channel-id').value;
const url = document.getElementById('new-rss-url').value.trim();
if (!url) return;
const formData = new FormData();
formData.append('action', 'add');
formData.append('channel_id', channelId);
formData.append('url', url);
const resp = await fetch('api_v1_rss.php', { method: 'POST', body: formData });
if ((await resp.json()).success) {
document.getElementById('new-rss-url').value = '';
loadRssFeeds();
}
});
syncRssBtn?.addEventListener('click', async () => {
const channelId = document.getElementById('edit-channel-id').value;
syncRssBtn.disabled = true;
syncRssBtn.textContent = 'Syncing...';
const formData = new FormData();
formData.append('action', 'sync');
formData.append('channel_id', channelId);
try {
const resp = await fetch('api_v1_rss.php', { method: 'POST', body: formData });
const result = await resp.json();
if (result.success) {
alert(`Sync complete! Found ${result.new_items} new items.`);
loadRssFeeds();
}
} catch (e) { console.error(e); }
syncRssBtn.disabled = false;
syncRssBtn.textContent = 'Sync Now';
});
rssFeedsList?.addEventListener('click', async (e) => {
if (e.target.classList.contains('delete-rss-btn')) {
const channelId = document.getElementById('edit-channel-id').value;
const feedId = e.target.dataset.id;
const formData = new FormData();
formData.append('action', 'delete');
formData.append('channel_id', channelId);
formData.append('feed_id', feedId);
await fetch('api_v1_rss.php', { method: 'POST', body: formData });
loadRssFeeds();
}
});
// Clear Channel History
const clearHistoryBtn = document.getElementById('clear-channel-history-btn');
clearHistoryBtn?.addEventListener('click', async () => {
const channelId = document.getElementById('edit-channel-id').value;
if (!confirm('Voulez-vous vraiment vider tout l\'historique de ce salon ? Cette action est irrรฉversible.')) return;
try {
const formData = new FormData();
formData.append('channel_id', channelId);
const resp = await fetch('api_v1_clear_channel.php', {
method: 'POST',
body: formData
});
const result = await resp.json();
if (result.success) {
location.reload();
} else {
alert(result.error || 'Erreur lors du nettoyage de l\'historique');
}
} catch (e) { console.error(e); }
});
// Roles Management
const rolesTabBtn = document.getElementById('roles-tab-btn');
const rolesList = document.getElementById('roles-list');
const addRoleBtn = document.getElementById('add-role-btn');
const membersTabBtn = document.getElementById('members-tab-btn');
const membersList = document.getElementById('server-members-list');
const activeServerId = window.activeServerId || new URLSearchParams(window.location.search).get('server_id') || 1;
let serverRoles = [];
let serverPermissions = [];
rolesTabBtn?.addEventListener('click', loadRoles);
membersTabBtn?.addEventListener('click', loadRoles); // Both tabs need roles data
async function loadRoles() {
if (rolesList) rolesList.innerHTML = '<div class="text-center p-3 text-muted">Loading...</div>';
if (membersList) membersList.innerHTML = '<div class="text-center p-3 text-muted">Loading...</div>';
try {
const resp = await fetch(`api_v1_roles.php?server_id=${activeServerId}`);
const data = await resp.json();
if (data.success) {
serverRoles = data.roles;
serverPermissions = data.permissions_list;
if (rolesList) renderRoles(data.roles);
if (membersList) renderMembers(data.members);
updateGlobalUI(data.members);
}
} catch (e) { console.error(e); }
}
function renderRoleIconJS(icon, size = '12px') {
if (!icon) return '';
const isUrl = icon.startsWith('http') || icon.startsWith('/');
if (isUrl) {
return `<img src="${escapeHTML(icon)}" class="role-icon ms-1" style="width: ${size}; height: ${size}; vertical-align: middle; object-fit: contain;">`;
} else {
return `<span class="ms-1" style="font-size: ${size}; vertical-align: middle;">${escapeHTML(icon)}</span>`;
}
}
function updateGlobalUI(members) {
// Update members sidebar
const sidebar = document.querySelector('.members-sidebar');
if (sidebar) {
const countEl = sidebar.querySelector('div[style*="text-transform: uppercase"]');
if (countEl) countEl.textContent = `Members โ€” ${members.length}`;
// We need to keep the "Members - X" div and replace everything else
const header = sidebar.firstElementChild;
sidebar.innerHTML = '';
sidebar.appendChild(header);
members.forEach(m => {
const item = document.createElement('div');
item.className = 'channel-item member-item';
item.dataset.userId = m.id;
item.dataset.username = m.username;
item.dataset.avatar = m.avatar_url || '';
item.style.color = 'var(--text-primary)';
item.style.marginBottom = '8px';
item.style.cursor = 'pointer';
const roleIconHtml = renderRoleIconJS(m.role_icon, '12px');
const avatarBg = m.avatar_url ? `background-image: url('${m.avatar_url}');` : '';
const statusColor = m.status === 'online' ? '#23a559' : '#80848e';
item.innerHTML = `
<div class="message-avatar" style="width: 32px; height: 32px; background-color: ${statusColor}; position: relative; ${avatarBg}">
${m.status === 'online' ? `<div style="position: absolute; bottom: 0; right: 0; width: 10px; height: 10px; background-color: #23a559; border-radius: 50%; border: 2px solid var(--bg-members);"></div>` : ''}
</div>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${m.role_color ? `color: ${m.role_color};` : ''}">
${escapeHTML(m.username)}
${roleIconHtml}
</span>
`;
sidebar.appendChild(item);
});
}
// Update chat colors
document.querySelectorAll('.message-author').forEach(authorEl => {
const username = authorEl.childNodes[0].textContent.trim();
const member = members.find(m => m.username === username);
if (member) {
authorEl.style.color = member.role_color || 'inherit';
// Try to update icon if it exists or add it if it doesn't
let iconEl = authorEl.querySelector('.role-icon, span.ms-1');
const newIconHtml = renderRoleIconJS(member.role_icon, '12px');
if (newIconHtml) {
if (iconEl) {
const temp = document.createElement('div');
temp.innerHTML = newIconHtml;
iconEl.replaceWith(temp.firstChild);
} else {
const temp = document.createElement('div');
temp.innerHTML = newIconHtml;
// Insert after the text node
authorEl.insertBefore(temp.firstChild, authorEl.childNodes[1]);
}
} else if (iconEl) {
iconEl.remove();
}
}
});
}
function renderRoles(roles) {
rolesList.innerHTML = '';
if (roles.length === 0) {
rolesList.innerHTML = '<div class="text-center p-3 text-muted">No roles created yet.</div>';
}
roles.forEach(role => {
const item = document.createElement('div');
item.className = 'list-group-item bg-transparent text-white border-secondary d-flex justify-content-between align-items-center p-2 mb-1 rounded role-sortable-item';
item.dataset.id = role.id;
const isUrl = role.icon_url && (role.icon_url.startsWith('http') || role.icon_url.startsWith('/'));
item.innerHTML = `
<div class="d-flex align-items-center">
<div class="role-drag-handle me-3" style="cursor: grab; opacity: 0.5;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="5" x2="8" y2="5.01"></line><line x1="16" y1="5" x2="16" y2="5.01"></line><line x1="8" y1="12" x2="8" y2="12.01"></line><line x1="16" y1="12" x2="16" y2="12.01"></line><line x1="8" y1="19" x2="8" y2="19.01"></line><line x1="16" y1="19" x2="16" y2="19.01"></line></svg>
</div>
<div style="width: 14px; height: 14px; border-radius: 50%; background-color: ${role.color}; margin-right: 12px; box-shadow: 0 0 5px ${role.color}88;"></div>
<span class="fw-medium">${role.name}</span>
${role.icon_url ? (isUrl ? `<img src="${role.icon_url}" class="ms-1" style="width: 12px; height: 12px; object-fit: contain;">` : `<span class="ms-1" style="font-size: 12px;">${role.icon_url}</span>`) : ''}
</div>
<div>
<button class="btn btn-sm btn-outline-light edit-role-btn-v2" data-id="${role.id}" data-name="${role.name}" data-color="${role.color}" data-perms="${role.permissions}" data-icon="${role.icon_url || ''}">Edit</button>
<button class="btn btn-sm btn-outline-danger delete-role-btn" data-id="${role.id}">ร—</button>
</div>
`;
rolesList.appendChild(item);
});
// Initialize Sortable for roles
if (typeof Sortable !== 'undefined' && rolesList) {
new Sortable(rolesList, {
animation: 150,
handle: '.role-drag-handle',
ghostClass: 'sortable-ghost',
onEnd: () => saveRolePositions()
});
}
}
async function saveRolePositions() {
const orders = [];
const items = rolesList.querySelectorAll('.role-sortable-item');
// Invert the order because we ORDER BY position DESC in SQL
let position = items.length - 1;
items.forEach(item => {
orders.push({
id: item.dataset.id,
position: position--
});
});
try {
await fetch('api_v1_roles.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'reorder',
server_id: activeServerId,
orders: orders
})
});
} catch (e) { console.error(e); }
}
function renderMembers(members) {
membersList.innerHTML = '';
members.forEach(member => {
const item = document.createElement('div');
item.className = 'list-group-item bg-transparent text-white border-secondary d-flex justify-content-between align-items-center p-2 mb-2 rounded bg-dark';
const isIconUrl = member.role_icon && (member.role_icon.startsWith('http') || member.role_icon.startsWith('/'));
const roleIconHtml = member.role_icon ? (isIconUrl ? `<img src="${member.role_icon}" class="role-icon ms-1" style="width: 12px; height: 12px; vertical-align: middle; object-fit: contain;">` : `<span class="ms-1" style="font-size: 12px; vertical-align: middle;">${member.role_icon}</span>`) : '';
item.innerHTML = `
<div class="d-flex align-items-center flex-grow-1">
<div class="message-avatar me-2" style="width: 32px; height: 32px; ${member.avatar_url ? `background-image: url('${member.avatar_url}');` : ''}"></div>
<div class="flex-grow-1">
<div class="fw-bold small" style="color: ${member.role_color || 'inherit'}">
${escapeHTML(member.username)}
${roleIconHtml}
</div>
<div class="text-muted small">
${member.role_names ? member.role_names.split(',').join(', ') : 'No roles'}
</div>
</div>
</div>
${(window.isServerOwner || window.canManageServer) ? `
<button class="btn btn-sm btn-outline-light edit-user-roles-settings-btn" data-id="${member.id}" data-username="${member.username}" data-avatar="${member.avatar_url || ''}">Roles</button>
` : ''}
`;
membersList.appendChild(item);
});
}
// Add listener for the button in members list tab
membersList?.addEventListener('click', (e) => {
const btn = e.target.closest('.edit-user-roles-settings-btn');
if (btn) {
openEditUserRolesModal(btn.dataset.id, btn.dataset.username, btn.dataset.avatar);
}
});
// Role Editing Modal Logic
rolesList?.addEventListener('click', (e) => {
if (e.target.classList.contains('edit-role-btn-v2')) {
const role = e.target.dataset;
document.getElementById('edit-role-id').value = role.id;
document.getElementById('edit-role-name').value = role.name;
document.getElementById('edit-role-color').value = role.color;
document.getElementById('edit-role-icon').value = role.icon;
document.getElementById('selected-role-emoji-preview').textContent = role.icon || '';
const permsContainer = document.getElementById('role-permissions-checkboxes');
permsContainer.innerHTML = '';
const currentPerms = parseInt(role.perms);
serverPermissions.forEach(p => {
const isChecked = (currentPerms & p.value) === p.value;
permsContainer.innerHTML += `
<div class="form-check mb-1">
<input class="form-check-input perm-check" type="checkbox" value="${p.value}" id="perm-${p.value}" ${isChecked ? 'checked' : ''}>
<label class="form-check-label text-white small" for="perm-${p.value}">${p.name}</label>
</div>
`;
});
const modal = new bootstrap.Modal(document.getElementById('roleEditorModal'));
modal.show();
}
});
document.getElementById('save-role-btn')?.addEventListener('click', async (e) => {
const btn = e.target;
const originalText = btn.textContent;
const id = document.getElementById('edit-role-id').value;
const name = document.getElementById('edit-role-name').value;
const color = document.getElementById('edit-role-color').value;
const icon_url = document.getElementById('edit-role-icon').value;
let permissions = 0;
document.querySelectorAll('.perm-check:checked').forEach(cb => {
permissions |= parseInt(cb.value);
});
try {
const resp = await fetch('api_v1_roles.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'update', server_id: activeServerId, id, name, color, icon_url, permissions })
});
const data = await resp.json();
if (data.success) {
btn.textContent = 'Saved โœ…';
btn.classList.replace('btn-primary', 'btn-success');
setTimeout(() => {
btn.textContent = originalText;
btn.classList.replace('btn-success', 'btn-primary');
}, 2000);
loadRoles();
}
} catch (e) { console.error(e); }
});
async function openEditUserRolesModal(userId, username, avatar) {
const modal = document.getElementById('editUserRolesModal');
document.getElementById('edit-user-roles-user-id').value = userId;
document.getElementById('edit-user-roles-username').textContent = username;
const avatarEl = document.getElementById('edit-user-roles-avatar');
avatarEl.style.backgroundImage = avatar ? `url('${avatar}')` : 'none';
const list = document.getElementById('user-roles-selection-list');
list.innerHTML = '<div class="text-center p-3 text-muted">Loading roles...</div>';
const bsModal = new bootstrap.Modal(modal);
bsModal.show();
try {
// We need to fetch roles and the current user's roles
// We can reuse loadRoles or make a specific call
const resp = await fetch(`api_v1_roles.php?server_id=${activeServerId}`);
const data = await resp.json();
if (data.success) {
const member = data.members.find(m => m.id == userId);
const assignedRoles = member && member.role_ids ? member.role_ids.split(',') : [];
list.innerHTML = '';
// Sort roles by position descending for display
data.roles.sort((a, b) => b.position - a.position).forEach(role => {
const isChecked = assignedRoles.includes(role.id.toString());
const item = document.createElement('div');
item.className = 'list-group-item bg-dark text-white border-secondary p-2 d-flex align-items-center';
item.innerHTML = `
<input class="form-check-input me-3 user-role-checkbox" type="checkbox" value="${role.id}" id="user-role-${role.id}" ${isChecked ? 'checked' : ''}>
<label class="form-check-label flex-grow-1" for="user-role-${role.id}" style="color: ${role.color}; cursor: pointer;">
${role.name}
</label>
`;
list.appendChild(item);
});
if (data.roles.length === 0) {
list.innerHTML = '<div class="text-center p-3 text-muted">No roles defined for this server.</div>';
}
}
} catch (e) { console.error(e); }
}
document.getElementById('save-user-roles-btn')?.addEventListener('click', async (e) => {
const btn = e.target;
const originalText = btn.textContent;
const userId = document.getElementById('edit-user-roles-user-id').value;
const roleIds = Array.from(document.querySelectorAll('.user-role-checkbox:checked')).map(cb => cb.value);
try {
const resp = await fetch('api_v1_roles.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'set_user_roles',
server_id: activeServerId,
user_id: userId,
role_ids: roleIds
})
});
const data = await resp.json();
if (data.success) {
btn.textContent = 'Saved โœ…';
btn.classList.replace('btn-primary', 'btn-success');
setTimeout(() => {
btn.textContent = originalText;
btn.classList.replace('btn-success', 'btn-primary');
}, 2000);
loadRoles();
} else {
alert(data.error || 'Failed to update roles');
}
} catch (e) { console.error(e); }
});
addRoleBtn?.addEventListener('click', async () => {
const name = prompt('Role name:');
if (!name) return;
const color = '#99aab5';
try {
const resp = await fetch('api_v1_roles.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'create', server_id: activeServerId, name, color, permissions: 0 })
});
if ((await resp.json()).success) loadRoles();
} catch (e) { console.error(e); }
});
rolesList?.addEventListener('click', async (e) => {
if (e.target.classList.contains('delete-role-btn')) {
if (!confirm('Delete this role?')) return;
const roleId = e.target.dataset.id;
const resp = await fetch('api_v1_roles.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete', server_id: activeServerId, id: roleId })
});
if ((await resp.json()).success) loadRoles();
}
});
// Webhooks Management
const webhooksTabBtn = document.getElementById('webhooks-tab-btn');
const webhooksList = document.getElementById('webhooks-list');
const addWebhookBtn = document.getElementById('add-webhook-btn');
webhooksTabBtn?.addEventListener('click', loadWebhooks);
async function loadWebhooks() {
webhooksList.innerHTML = '<div class="text-center p-3 text-muted">Loading webhooks...</div>';
try {
const resp = await fetch(`api_v1_webhook.php?server_id=${activeServerId}`);
const data = await resp.json();
if (data.success) {
renderWebhooks(data.webhooks);
}
} catch (e) { console.error(e); }
}
function renderWebhooks(webhooks) {
webhooksList.innerHTML = '';
if (webhooks.length === 0) {
webhooksList.innerHTML = '<div class="text-center p-3 text-muted">No webhooks found.</div>';
return;
}
webhooks.forEach(wh => {
const item = document.createElement('div');
item.className = 'list-group-item bg-transparent text-white border-secondary p-2 mb-2';
const url = `${window.location.origin}/api_v1_webhook.php?token=${wh.token}`;
item.innerHTML = `
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="fw-bold">${wh.name}</span>
<button class="btn btn-sm btn-outline-danger delete-webhook-btn" data-id="${wh.id}">ร—</button>
</div>
<div class="small text-muted mb-2">Channel: #${wh.channel_name}</div>
<div class="input-group input-group-sm">
<input type="text" class="form-control bg-dark text-white border-secondary" value="${url}" readonly>
<button class="btn btn-outline-secondary" type="button" onclick="navigator.clipboard.writeText('${url}')">Copy</button>
</div>
`;
webhooksList.appendChild(item);
});
}
addWebhookBtn?.addEventListener('click', async () => {
const name = prompt('Webhook name:', 'Bot Name');
if (!name) return;
// Fetch channels for this server to let user pick one
const respChannels = await fetch(`api_v1_channels.php?server_id=${activeServerId}`);
const dataChannels = await respChannels.json();
if (!dataChannels.length) return alert('Create a channel first.');
const channelId = prompt('Enter Channel ID:\n' + dataChannels.map(c => `${c.id}: #${c.name}`).join('\n'));
if (!channelId) return;
try {
const resp = await fetch('api_v1_webhook.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel_id: channelId, name: name })
});
if ((await resp.json()).success) loadWebhooks();
} catch (e) { console.error(e); }
});
webhooksList?.addEventListener('click', async (e) => {
if (e.target.classList.contains('delete-webhook-btn')) {
if (!confirm('Delete this webhook?')) return;
const whId = e.target.dataset.id;
const resp = await fetch('api_v1_webhook.php', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: whId })
});
if ((await resp.json()).success) loadWebhooks();
}
});
// Stats Management
const statsTabBtn = document.getElementById('stats-tab-btn');
statsTabBtn?.addEventListener('click', loadStats);
async function loadStats() {
try {
const resp = await fetch(`api_v1_stats.php?server_id=${activeServerId}`);
const data = await resp.json();
if (data.success) {
document.getElementById('stat-members').textContent = data.stats.total_members;
document.getElementById('stat-messages').textContent = data.stats.total_messages;
const topUsersList = document.getElementById('top-users-list');
topUsersList.innerHTML = '';
data.stats.top_users.forEach(user => {
const item = document.createElement('div');
item.className = 'd-flex justify-content-between align-items-center mb-1 p-2 bg-dark rounded';
item.innerHTML = `<span>${user.username}</span><span class="badge bg-primary">${user.message_count} msgs</span>`;
topUsersList.appendChild(item);
});
const activity = document.getElementById('activity-chart-placeholder');
activity.innerHTML = '';
data.stats.history.forEach(day => {
const bar = document.createElement('div');
bar.className = 'd-flex align-items-center mb-1';
const percent = Math.min(100, (day.count / 100) * 100); // Normalize to 100 for visual
bar.innerHTML = `
<div style="width: 80px;" class="small">${day.date}</div>
<div class="flex-grow-1 mx-2" style="height: 10px; background: #1e1f22; border-radius: 5px;">
<div style="width: ${percent}%; height: 100%; background: var(--blurple); border-radius: 5px;"></div>
</div>
<div style="width: 30px;" class="small text-end">${day.count}</div>
`;
activity.appendChild(bar);
});
if (data.stats.history.length === 0) {
activity.innerHTML = '<div class="text-muted">No activity in the last 7 days.</div>';
}
}
} catch (e) { console.error(e); }
}
// Server Settings
const searchServerIconBtn = document.getElementById('search-server-icon-btn');
const serverIconResults = document.getElementById('server-icon-search-results');
const serverIconPreview = document.getElementById('server-icon-preview');
const serverIconUrlInput = document.getElementById('server-icon-url');
searchServerIconBtn?.addEventListener('click', async () => {
const query = prompt('Search for a server icon:', 'abstract');
if (!query) return;
serverIconResults.innerHTML = '<div class="text-muted small">Searching...</div>';
try {
const resp = await fetch(`api/pexels.php?action=search&query=${encodeURIComponent(query)}`);
const data = await resp.json();
serverIconResults.innerHTML = '';
data.forEach(photo => {
const img = document.createElement('img');
img.src = photo.url;
img.className = 'avatar-pick';
img.style.width = '50px';
img.style.height = '50px';
img.onclick = () => {
serverIconUrlInput.value = photo.url;
serverIconPreview.style.backgroundImage = `url('${photo.url}')`;
serverIconResults.innerHTML = '';
};
serverIconResults.appendChild(img);
});
} catch (e) {
serverIconResults.innerHTML = '<div class="text-danger small">Error fetching icons</div>';
}
});
// Forum: New Thread
const newThreadBtn = document.getElementById('new-thread-btn');
const newThreadModal = document.getElementById('newThreadModal') ? new bootstrap.Modal(document.getElementById('newThreadModal')) : null;
let selectedTagIds = [];
newThreadBtn?.addEventListener('click', async () => {
if (!newThreadModal) return;
// Load tags for this channel
const tagsList = document.getElementById('new-thread-tags-list');
tagsList.innerHTML = '<div class="text-muted small">Loading tags...</div>';
selectedTagIds = [];
try {
const resp = await fetch(`api_v1_tags.php?channel_id=${currentChannel}`);
const data = await resp.json();
tagsList.innerHTML = '';
if (data.success && data.tags.length > 0) {
data.tags.forEach(tag => {
const span = document.createElement('span');
span.className = 'badge rounded-pill p-2 border border-secondary';
span.style.cursor = 'pointer';
span.style.backgroundColor = 'transparent';
span.dataset.id = tag.id;
span.dataset.color = tag.color;
span.textContent = tag.name;
span.onclick = () => {
if (selectedTagIds.includes(tag.id)) {
selectedTagIds = selectedTagIds.filter(id => id !== tag.id);
span.style.backgroundColor = 'transparent';
} else {
selectedTagIds.push(tag.id);
span.style.backgroundColor = tag.color;
}
};
tagsList.appendChild(span);
});
} else {
tagsList.innerHTML = '<div class="text-muted small">No tags available.</div>';
}
} catch (e) { console.error(e); }
newThreadModal.show();
});
document.getElementById('submit-new-thread-btn')?.addEventListener('click', async () => {
const title = document.getElementById('new-thread-title').value.trim();
if (!title) return;
try {
const formData = new FormData();
formData.append('channel_id', currentChannel);
formData.append('title', title);
formData.append('tag_ids', selectedTagIds.join(','));
const resp = await fetch('api_v1_threads.php', { method: 'POST', body: formData });
const result = await resp.json();
if (result.success) {
window.location.href = `?server_id=${activeServerId}&channel_id=${currentChannel}&thread_id=${result.thread_id}`;
} else {
alert(result.error || 'Failed to create thread');
}
} catch (e) { console.error(e); }
});
// Forum: Mark as Solution
document.addEventListener('click', async (e) => {
const solBtn = e.target.closest('.action-btn.mark-solution');
if (solBtn) {
const threadId = solBtn.dataset.threadId;
const messageId = solBtn.classList.contains('active') ? null : solBtn.dataset.messageId;
try {
const resp = await fetch('api_v1_threads.php?action=solve', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ thread_id: threadId, message_id: messageId })
});
const result = await resp.json();
if (result.success) {
location.reload();
} else {
alert(result.error || 'Failed to update solution');
}
} catch (e) { console.error(e); }
}
});
// Forum: Manage Tags
const manageTagsBtn = document.getElementById('manage-tags-btn');
const manageTagsModal = document.getElementById('manageTagsModal') ? new bootstrap.Modal(document.getElementById('manageTagsModal')) : null;
manageTagsBtn?.addEventListener('click', async () => {
if (!manageTagsModal) return;
loadForumAdminTags();
manageTagsModal.show();
});
async function loadForumAdminTags() {
const list = document.getElementById('forum-tags-admin-list');
list.innerHTML = '<div class="text-center p-3 text-muted small">Loading tags...</div>';
try {
const resp = await fetch(`api_v1_tags.php?channel_id=${currentChannel}`);
const data = await resp.json();
list.innerHTML = '';
if (data.success && data.tags.length > 0) {
data.tags.forEach(tag => {
const div = document.createElement('div');
div.className = 'd-flex justify-content-between align-items-center mb-2 p-2 bg-dark rounded';
div.innerHTML = `
<div class="d-flex align-items-center">
<div style="width: 12px; height: 12px; border-radius: 50%; background-color: ${tag.color}; margin-right: 8px;"></div>
<span>${tag.name}</span>
</div>
<button class="btn btn-sm text-danger delete-forum-tag-btn" data-id="${tag.id}">ร—</button>
`;
list.appendChild(div);
});
} else {
list.innerHTML = '<div class="text-center p-3 text-muted small">No tags created yet.</div>';
}
} catch (e) { console.error(e); }
}
document.getElementById('add-forum-tag-btn')?.addEventListener('click', async () => {
const name = document.getElementById('new-tag-name').value.trim();
const color = document.getElementById('new-tag-color').value;
if (!name) return;
try {
const resp = await fetch('api_v1_tags.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'create', channel_id: currentChannel, name, color })
});
if ((await resp.json()).success) {
document.getElementById('new-tag-name').value = '';
loadForumAdminTags();
}
} catch (e) { console.error(e); }
});
document.getElementById('forum-tags-admin-list')?.addEventListener('click', async (e) => {
if (e.target.classList.contains('delete-forum-tag-btn')) {
const tagId = e.target.dataset.id;
try {
const resp = await fetch('api_v1_tags.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete', channel_id: currentChannel, tag_id: tagId })
});
if ((await resp.json()).success) loadForumAdminTags();
} catch (e) { console.error(e); }
}
});
// Rules: Add Rule
const addRuleBtn = document.getElementById('add-rule-btn');
addRuleBtn?.addEventListener('click', async () => {
const content = prompt('Rule Content:');
if (!content) return;
try {
const formData = new FormData();
formData.append('channel_id', currentChannel);
formData.append('content', content);
const resp = await fetch('api_v1_rules.php', { method: 'POST', body: formData });
const result = await resp.json();
if (result.success) {
location.reload();
} else {
alert(result.error || 'Failed to add rule');
}
} catch (e) { console.error(e); }
});
// Rules: Delete/Edit
document.addEventListener('click', async (e) => {
if (e.target.classList.contains('delete-rule-btn')) {
if (!confirm('Delete this rule?')) return;
const id = e.target.dataset.id;
const resp = await fetch(`api_v1_rules.php?id=${id}`, { method: 'DELETE' });
if ((await resp.json()).success) location.reload();
}
if (e.target.classList.contains('edit-rule-btn')) {
const id = e.target.dataset.id;
const oldContent = e.target.closest('.rule-item').querySelector('.rule-content').innerText;
const newContent = prompt('Edit Rule:', oldContent);
if (!newContent || newContent === oldContent) return;
const resp = await fetch('api_v1_rules.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, content: newContent })
});
if ((await resp.json()).success) location.reload();
}
});
// Channel Selection Type
const addChannelBtns = document.querySelectorAll('.add-channel-btn');
addChannelBtns.forEach(btn => {
btn.addEventListener('click', () => {
const type = btn.dataset.type;
const select = document.getElementById('channel-type-select');
if (select) {
select.value = type === 'voice' ? 'voice' : 'chat';
}
});
});
// User Settings - Avatar Search
const avatarSearchBtn = document.getElementById('search-avatar-btn');
const avatarSearchQuery = document.getElementById('avatar-search-query');
const avatarResults = document.getElementById('avatar-results');
const avatarPreview = document.getElementById('settings-avatar-preview');
const avatarUrlInput = document.getElementById('settings-avatar-url');
avatarSearchBtn?.addEventListener('click', async () => {
const q = avatarSearchQuery.value.trim();
if (!q) return;
avatarResults.innerHTML = '<div class="text-muted small">Searching...</div>';
try {
const resp = await fetch(`api/pexels.php?action=search&query=${encodeURIComponent(q)}`);
const data = await resp.json();
avatarResults.innerHTML = '';
data.forEach(photo => {
const img = document.createElement('img');
img.src = photo.url;
img.className = 'avatar-pick';
img.style.width = '60px';
img.style.height = '60px';
img.style.cursor = 'pointer';
img.onclick = () => {
avatarUrlInput.value = photo.url;
avatarPreview.style.backgroundImage = `url('${photo.url}')`;
};
avatarResults.appendChild(img);
});
} catch (e) { console.error(e); }
});
// Toggle members sidebar
const toggleMembersBtn = document.getElementById('toggle-members-btn');
const membersSidebar = document.querySelector('.members-sidebar');
if (toggleMembersBtn && membersSidebar) {
toggleMembersBtn.addEventListener('click', () => {
if (window.innerWidth > 992) {
membersSidebar.classList.toggle('hidden');
} else {
membersSidebar.classList.toggle('show');
}
});
}
// User Settings - Save
const saveSettingsBtn = document.getElementById('save-settings-btn');
saveSettingsBtn?.addEventListener('click', async () => {
const form = document.getElementById('user-settings-form');
const formData = new FormData(form);
const dndMode = document.getElementById('dnd-switch').checked ? '1' : '0';
formData.append('dnd_mode', dndMode);
const theme = form.querySelector('input[name="theme"]:checked').value;
document.body.setAttribute('data-theme', theme);
const resp = await fetch('api_v1_user.php', {
method: 'POST',
body: formData
});
const result = await resp.json();
if (result.success) {
location.reload();
} else {
alert(result.error || 'Failed to save settings');
}
});
function escapeHTML(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function appendMessage(msg) {
if (!msg || !msg.id) return;
if (document.querySelector(`.message-item[data-id="${msg.id}"]`)) return;
// Auto-populate metadata for video platforms if missing
const dmRegexForMeta = /(?:https?:\/\/)?(?:www\.)?(?:dailymotion\.com\/video\/|dai\.ly\/)([a-zA-Z0-9]+)/;
const dmMatchForMeta = msg.content.match(dmRegexForMeta);
if (dmMatchForMeta && !msg.metadata) {
msg.metadata = {
title: 'Dailymotion Video',
url: dmMatchForMeta[0],
image: `https://www.dailymotion.com/thumbnail/video/${dmMatchForMeta[1]}`,
site_name: 'Dailymotion'
};
}
const messagesList = document.getElementById('messages-list');
const div = document.createElement('div');
div.className = 'message-item';
div.dataset.id = msg.id;
if (parseInt(msg.id) > lastMessageId) {
lastMessageId = parseInt(msg.id);
}
const avatarStyle = msg.avatar_url ? `background-image: url('${msg.avatar_url}');` : '';
let attachmentHtml = '';
if (msg.attachment_url) {
const ext = msg.attachment_url.split('.').pop().toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) {
attachmentHtml = `<div class="message-attachment mt-2"><img src="${msg.attachment_url}" class="img-fluid rounded message-img-preview" alt="Attachment" style="max-height: 300px; cursor: pointer;" onclick="window.open(this.src)"></div>`;
} else {
attachmentHtml = `<div class="message-attachment mt-2"><a href="${msg.attachment_url}" target="_blank" class="attachment-link d-inline-flex align-items-center p-2 rounded bg-dark text-white text-decoration-none"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="me-2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>${msg.attachment_url.split('/').pop()}</a></div>`;
}
}
let embedHtml = '';
if (msg.metadata) {
const meta = typeof msg.metadata === 'string' ? JSON.parse(msg.metadata) : msg.metadata;
embedHtml = `
<div class="rich-embed mt-2 p-3 rounded" style="background: rgba(0,0,0,0.1); border-left: 4px solid var(--blurple); max-width: 520px;">
${meta.site_name ? `<div class="embed-site-name mb-1" style="font-size: 0.75em; color: var(--text-muted); text-transform: uppercase; font-weight: bold;">${escapeHTML(meta.site_name)}</div>` : ''}
${meta.title ? `<a href="${meta.url}" target="_blank" class="embed-title d-block mb-1 text-decoration-none" style="font-weight: 600; color: #00a8fc;">${escapeHTML(meta.title)}</a>` : ''}
${meta.description ? `<div class="embed-description mb-2" style="font-size: 0.9em; color: var(--text-normal);">${escapeHTML(meta.description)}</div>` : ''}
${meta.image ? `<div class="embed-image"><img src="${meta.image}" class="rounded" style="max-width: 100%; max-height: 300px; object-fit: contain;"></div>` : ''}
</div>
`;
}
const isMe = msg.user_id == window.currentUserId || msg.username == window.currentUsername;
const hasManageRights = window.canManageChannels || window.isServerOwner || false;
const pinHtml = `
<span class="action-btn pin ${msg.is_pinned ? 'active' : ''}" title="${msg.is_pinned ? 'Unpin' : 'Pin'}" data-id="${msg.id}" data-pinned="${msg.is_pinned ? '1' : '0'}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3"></circle></svg>
</span>
`;
const actionsHtml = (isMe || hasManageRights) ? `
<div class="message-actions-menu">
${pinHtml}
${isMe ? `
<span class="action-btn edit" title="Edit" data-id="${msg.id}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
</span>
<span class="action-btn delete" title="Delete" data-id="${msg.id}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
</span>
` : ''}
</div>
` : '';
const pinnedBadge = msg.is_pinned ? `
<span class="pinned-badge ms-2" title="Pinned Message">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path></svg>
Pinned
</span>
` : '';
const mentionRegex = new RegExp(`@${window.currentUsername}\\b`, 'g');
if (msg.content.match(mentionRegex)) {
div.classList.add('mentioned');
}
if (msg.is_pinned) div.classList.add('pinned');
const ytRegex = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
const dmRegex = /(?:https?:\/\/)?(?:www\.)?(?:dailymotion\.com\/video\/|dai\.ly\/)([a-zA-Z0-9]+)/;
const vimeoRegex = /(?:https?:\/\/)?(?:www\.)?vimeo\.com\/(\d+)/;
const ytMatch = msg.content.match(ytRegex);
const dmMatch = msg.content.match(dmRegex);
const vimeoMatch = msg.content.match(vimeoRegex);
let videoHtml = '';
if (ytMatch && ytMatch[1]) {
videoHtml = `<div class="video-embed mt-2"><iframe width="100%" height="315" src="https://www.youtube.com/embed/${ytMatch[1]}?autoplay=0" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen style="border-radius: 8px; max-width: 560px;"></iframe></div>`;
} else if (dmMatch && dmMatch[1]) {
videoHtml = `<div class="video-embed mt-2"><iframe width="100%" height="315" src="https://www.dailymotion.com/embed/video/${dmMatch[1]}?autoplay=0&queue-enable=0&mute=0" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen style="border-radius: 8px; max-width: 560px;"></iframe></div>`;
} else if (vimeoMatch && vimeoMatch[1]) {
videoHtml = `<div class="video-embed mt-2"><iframe width="100%" height="315" src="https://player.vimeo.com/video/${vimeoMatch[1]}?autoplay=0" frameborder="0" allow="fullscreen; picture-in-picture" allowfullscreen style="border-radius: 8px; max-width: 560px;"></iframe></div>`;
}
const authorStyle = msg.role_color ? `color: ${msg.role_color};` : '';
const isRoleIconUrl = msg.role_icon && (msg.role_icon.startsWith('http') || msg.role_icon.startsWith('/'));
const roleIcon = msg.role_icon ? (isRoleIconUrl ? `<img src="${msg.role_icon}" class="role-icon ms-1" style="width: 12px; height: 12px; vertical-align: middle; object-fit: contain;">` : `<span class="ms-1" style="font-size: 12px; vertical-align: middle;">${msg.role_icon}</span>`) : '';
div.innerHTML = `
<div class="message-avatar" style="${avatarStyle}"></div>
<div class="message-content">
<div class="message-header">
<span class="message-author" style="${authorStyle}">${escapeHTML(msg.username)}</span>
${roleIcon}
<span class="message-time">${msg.time}</span>
${pinnedBadge}
</div>
<div class="message-text">
${escapeHTML(msg.content).replace(/\n/g, '<br>').replace(mentionRegex, `<span class="mention">@${window.currentUsername}</span>`)}
${attachmentHtml}
${videoHtml}
${embedHtml}
</div>
<div class="message-reactions mt-1" data-message-id="${msg.id}">
<span class="add-reaction-btn" title="Add Reaction">+</span>
</div>
</div>
${actionsHtml}
`;
messagesList.appendChild(div);
scrollToBottom(isMe);
// Ensure we scroll again when images/videos load
div.querySelectorAll('img, iframe').forEach(el => {
el.addEventListener('load', () => scrollToBottom(isMe));
});
}
});