Merge branch 'main' into bugfix/opacity-for-last-message

This commit is contained in:
BordedDev 2025-05-23 15:34:30 +02:00
commit 2c506db4e4
26 changed files with 1940 additions and 1763 deletions

View File

@ -3,6 +3,7 @@ import logging
import pathlib import pathlib
import time import time
import uuid import uuid
from datetime import datetime
from snek import snode from snek import snode
from snek.view.threads import ThreadsView from snek.view.threads import ThreadsView
import json import json
@ -96,6 +97,7 @@ class Application(BaseApplication):
self.jinja2_env.add_extension(LinkifyExtension) self.jinja2_env.add_extension(LinkifyExtension)
self.jinja2_env.add_extension(PythonExtension) self.jinja2_env.add_extension(PythonExtension)
self.jinja2_env.add_extension(EmojiExtension) self.jinja2_env.add_extension(EmojiExtension)
self.time_start = datetime.now()
self.ssh_host = "0.0.0.0" self.ssh_host = "0.0.0.0"
self.ssh_port = 2242 self.ssh_port = 2242
self.setup_router() self.setup_router()
@ -112,7 +114,34 @@ class Application(BaseApplication):
self.on_startup.append(self.start_user_availability_service) self.on_startup.append(self.start_user_availability_service)
self.on_startup.append(self.start_ssh_server) self.on_startup.append(self.start_ssh_server)
self.on_startup.append(self.prepare_database) self.on_startup.append(self.prepare_database)
@property
def uptime_seconds(self):
return (datetime.now() - self.time_start).total_seconds()
@property
def uptime(self):
return self._format_uptime(self.uptime_seconds)
def _format_uptime(self,seconds):
seconds = int(seconds)
days, seconds = divmod(seconds, 86400)
hours, seconds = divmod(seconds, 3600)
minutes, seconds = divmod(seconds, 60)
parts = []
if days > 0:
parts.append(f"{days} day{'s' if days != 1 else ''}")
if hours > 0:
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
if minutes > 0:
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
if seconds > 0 or not parts:
parts.append(f"{seconds} second{'s' if seconds != 1 else ''}")
return ", ".join(parts)
async def start_user_availability_service(self, app): async def start_user_availability_service(self, app):
app.user_availability_service_task = asyncio.create_task(app.services.socket.user_availability_service()) app.user_availability_service_task = asyncio.create_task(app.services.socket.user_availability_service())
async def snode_sync(self, app): async def snode_sync(self, app):

View File

@ -1,250 +1,261 @@
// Written by retoor@molodetz.nl // Written by retoor@molodetz.nl
// This project implements a client-server communication system using WebSockets and REST APIs. // This project implements a client-server communication system using WebSockets and REST APIs.
// It features a chat system, a notification sound system, and interaction with server endpoints. // It features a chat system, a notification sound system, and interaction with server endpoints.
// No additional imports were used beyond standard JavaScript objects and constructors. // No additional imports were used beyond standard JavaScript objects and constructors.
// MIT License // MIT License
import { Schedule } from './schedule.js'; import { Schedule } from "./schedule.js";
import { EventHandler } from "./event-handler.js"; import { EventHandler } from "./event-handler.js";
import { Socket } from "./socket.js"; import { Socket } from "./socket.js";
export class RESTClient { export class RESTClient {
debug = false; debug = false;
async get(url, params = {}) { async get(url, params = {}) {
const encodedParams = new URLSearchParams(params); const encodedParams = new URLSearchParams(params);
if (encodedParams) url += '?' + encodedParams; if (encodedParams) url += "?" + encodedParams;
const response = await fetch(url, { const response = await fetch(url, {
method: 'GET', method: "GET",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
}); });
const result = await response.json(); const result = await response.json();
if (this.debug) { if (this.debug) {
console.debug({ url, params, result }); console.debug({ url, params, result });
}
return result;
} }
return result;
}
async post(url, data) { async post(url, data) {
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
const result = await response.json(); const result = await response.json();
if (this.debug) { if (this.debug) {
console.debug({ url, data, result }); console.debug({ url, data, result });
}
return result;
} }
return result;
}
} }
export class Chat extends EventHandler { export class Chat extends EventHandler {
constructor() { constructor() {
super(); super();
this._url = window.location.hostname === 'localhost' ? 'ws://localhost/chat.ws' : 'wss://' + window.location.hostname + '/chat.ws'; this._url =
this._socket = null; window.location.hostname === "localhost"
this._waitConnect = null; ? "ws://localhost/chat.ws"
this._promises = {}; : "wss://" + window.location.hostname + "/chat.ws";
this._socket = null;
this._waitConnect = null;
this._promises = {};
}
connect() {
if (this._waitConnect) {
return this._waitConnect;
} }
return new Promise((resolve) => {
this._waitConnect = resolve;
console.debug("Connecting..");
try {
this._socket = new WebSocket(this._url);
} catch (e) {
console.warn(e);
setTimeout(() => {
this.ensureConnection();
}, 1000);
}
connect() { this._socket.onconnect = () => {
if (this._waitConnect) { this._connected();
return this._waitConnect; this._waitSocket();
} };
return new Promise((resolve) => { });
this._waitConnect = resolve; }
console.debug("Connecting..");
try { generateUniqueId() {
this._socket = new WebSocket(this._url); return "id-" + Math.random().toString(36).substr(2, 9);
} catch (e) { }
console.warn(e);
setTimeout(() => {
this.ensureConnection();
}, 1000);
}
this._socket.onconnect = () => { call(method, ...args) {
this._connected(); return new Promise((resolve, reject) => {
this._waitSocket(); try {
}; const command = { method, args, message_id: this.generateUniqueId() };
}); this._promises[command.message_id] = resolve;
} this._socket.send(JSON.stringify(command));
} catch (e) {
reject(e);
}
});
}
generateUniqueId() { _connected() {
return 'id-' + Math.random().toString(36).substr(2, 9); this._socket.onmessage = (event) => {
} const message = JSON.parse(event.data);
if (message.message_id && this._promises[message.message_id]) {
this._promises[message.message_id](message);
delete this._promises[message.message_id];
} else {
this.emit("message", message);
}
};
this._socket.onclose = () => {
this._waitSocket = null;
this._socket = null;
this.emit("close");
};
}
call(method, ...args) { async privmsg(room, text) {
return new Promise((resolve, reject) => { await rest.post("/api/privmsg", {
try { room,
const command = { method, args, message_id: this.generateUniqueId() }; text,
this._promises[command.message_id] = resolve; });
this._socket.send(JSON.stringify(command)); }
} catch (e) {
reject(e);
}
});
}
_connected() {
this._socket.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.message_id && this._promises[message.message_id]) {
this._promises[message.message_id](message);
delete this._promises[message.message_id];
} else {
this.emit("message", message);
}
};
this._socket.onclose = () => {
this._waitSocket = null;
this._socket = null;
this.emit('close');
};
}
async privmsg(room, text) {
await rest.post("/api/privmsg", {
room,
text,
});
}
} }
export class NotificationAudio { export class NotificationAudio {
constructor(timeout = 500) { constructor(timeout = 500) {
this.schedule = new Schedule(timeout); this.schedule = new Schedule(timeout);
} }
sounds = { sounds = {
"message": "/audio/soundfx.d_beep3.mp3", message: "/audio/soundfx.d_beep3.mp3",
"mention": "/audio/750607__deadrobotmusic__notification-sound-1.wav", mention: "/audio/750607__deadrobotmusic__notification-sound-1.wav",
"messageOtherChannel": "/audio/750608__deadrobotmusic__notification-sound-2.wav", messageOtherChannel:
"ping": "/audio/750609__deadrobotmusic__notification-sound-3.wav", "/audio/750608__deadrobotmusic__notification-sound-2.wav",
} ping: "/audio/750609__deadrobotmusic__notification-sound-3.wav",
};
play(soundIndex = 0) { play(soundIndex = 0) {
this.schedule.delay(() => { this.schedule.delay(() => {
new Audio(this.sounds[soundIndex]).play() new Audio(this.sounds[soundIndex])
.then(() => { .play()
console.debug("Gave sound notification"); .then(() => {
}) console.debug("Gave sound notification");
.catch(error => { })
console.error("Notification failed:", error); .catch((error) => {
}); console.error("Notification failed:", error);
}); });
} });
}
} }
export class App extends EventHandler { export class App extends EventHandler {
rest = new RESTClient(); rest = new RESTClient();
ws = null; ws = null;
rpc = null; rpc = null;
audio = null; audio = null;
user = {}; user = {};
typeLock = null; typeLock = null;
typeListener = null typeListener = null;
typeEventChannelUid = null typeEventChannelUid = null;
async set_typing(channel_uid){ async set_typing(channel_uid) {
this.typeEventChannel_uid = channel_uid this.typeEventChannel_uid = channel_uid;
}
async ping(...args) {
if (this.is_pinging) return false;
this.is_pinging = true;
await this.rpc.ping(...args);
this.is_pinging = false;
}
async forcePing(...arg) {
await this.rpc.ping(...args);
}
starField = null
constructor() {
super();
this.ws = new Socket();
this.rpc = this.ws.client;
this.audio = new NotificationAudio(500);
this.is_pinging = false;
this.ping_interval = setInterval(() => {
this.ping("active");
}, 15000);
this.typeEventChannelUid = null;
this.typeListener = setInterval(() => {
if (this.typeEventChannelUid) {
this.rpc.set_typing(this.typeEventChannelUid);
this.typeEventChannelUid = null;
}
});
const me = this;
this.ws.addEventListener("connected", (data) => {
this.ping("online");
});
this.ws.addEventListener("reconnecting", (data) => {
this.starField?.showNotify("Connecting..","#CC0000")
})
this.ws.addEventListener("channel-message", (data) => {
me.emit("channel-message", data);
});
this.ws.addEventListener("event", (data) => {
console.info("aaaa");
});
this.rpc.getUser(null).then((user) => {
me.user = user;
});
}
playSound(index) {
this.audio.play(index);
}
timeDescription(isoDate) {
const date = new Date(isoDate);
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;
return timeStr;
}
timeAgo(date1, date2) {
const diffMs = Math.abs(date2 - date1);
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const hours = Math.floor(
(diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60),
);
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
if (days) {
return `${days} ${days > 1 ? "days" : "day"} ago`;
} }
if (hours) {
async ping(...args) { return `${hours} ${hours > 1 ? "hours" : "hour"} ago`;
if (this.is_pinging) return false
this.is_pinging = true
await this.rpc.ping(...args);
this.is_pinging = false
} }
if (minutes) {
async forcePing(...arg) { return `${minutes} ${minutes > 1 ? "minutes" : "minute"} ago`;
await this.rpc.ping(...args);
} }
return "just now";
}
constructor() { async benchMark(times = 100, message = "Benchmark Message") {
super(); const promises = [];
this.ws = new Socket(); const me = this;
this.rpc = this.ws.client; for (let i = 0; i < times; i++) {
this.audio = new NotificationAudio(500); promises.push(
this.is_pinging = false this.rpc.getChannels().then((channels) => {
this.ping_interval = setInterval(() => { channels.forEach((channel) => {
this.ping("active") me.rpc.sendMessage(channel.uid, `${message} ${i}`);
}, 15000) });
this.typeEventChannelUid = null }),
this.typeListener = setInterval(()=>{ );
if(this.typeEventChannelUid){
this.rpc.set_typing(this.typeEventChannelUid)
this.typeEventChannelUid = null
}
})
const me = this
this.ws.addEventListener("connected", (data) => {
this.ping("online")
})
this.ws.addEventListener("channel-message", (data) => {
me.emit("channel-message", data);
});
this.ws.addEventListener("event",(data)=>{
console.info("aaaa")
})
this.rpc.getUser(null).then(user => {
me.user = user;
});
}
playSound(index) {
this.audio.play(index);
}
timeDescription(isoDate) {
const date = new Date(isoDate);
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;
return timeStr;
}
timeAgo(date1, date2) {
const diffMs = Math.abs(date2 - date1);
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
if (days) {
return `${days} ${days > 1 ? 'days' : 'day'} ago`;
}
if (hours) {
return `${hours} ${hours > 1 ? 'hours' : 'hour'} ago`;
}
if (minutes) {
return `${minutes} ${minutes > 1 ? 'minutes' : 'minute'} ago`;
}
return 'just now';
}
async benchMark(times = 100, message = "Benchmark Message") {
const promises = [];
const me = this;
for (let i = 0; i < times; i++) {
promises.push(this.rpc.getChannels().then(channels => {
channels.forEach(channel => {
me.rpc.sendMessage(channel.uid, `${message} ${i}`);
});
}));
}
} }
}
} }
export const app = new App(); export const app = new App();

View File

@ -1,235 +1,250 @@
import { app } from "../app.js";
import { app } from '../app.js';
class ChatInputComponent extends HTMLElement { class ChatInputComponent extends HTMLElement {
autoCompletions = { autoCompletions = {
'example 1': () => { "example 1": () => {},
"example 2": () => {},
};
}, constructor() {
'example 2': () => { super();
this.lastUpdateEvent = new Date();
this.textarea = document.createElement("textarea");
this._value = "";
this.value = this.getAttribute("value") || "";
this.previousValue = this.value;
this.lastChange = new Date();
this.changed = false;
}
get value() {
return this._value;
}
set value(value) {
this._value = value || "";
this.textarea.value = this._value;
}
resolveAutoComplete() {
let count = 0;
let value = null;
Object.keys(this.autoCompletions).forEach((key) => {
if (key.startsWith(this.value)) {
count++;
value = key;
}
});
if (count == 1) return value;
return null;
}
isActive() {
return document.activeElement === this.textarea;
}
focus() {
this.textarea.focus();
}
async connectedCallback() {
this.user = null
app.rpc.getUser(null).then((user) => {
this.user=user
})
this.liveType = this.getAttribute("live-type") === "true";
this.liveTypeInterval =
parseInt(this.getAttribute("live-type-interval")) || 3;
this.channelUid = this.getAttribute("channel");
this.messageUid = null;
this.classList.add("chat-input");
this.textarea.setAttribute("placeholder", "Type a message...");
this.textarea.setAttribute("rows", "2");
this.appendChild(this.textarea);
this.uploadButton = document.createElement("upload-button");
this.uploadButton.setAttribute("channel", this.channelUid);
this.uploadButton.addEventListener("upload", (e) => {
this.dispatchEvent(new CustomEvent("upload", e));
});
this.uploadButton.addEventListener("uploaded", (e) => {
this.dispatchEvent(new CustomEvent("uploaded", e));
});
this.appendChild(this.uploadButton);
this.textarea.addEventListener("keyup", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
this.value = "";
e.target.value = "";
return;
}
this.value = e.target.value;
this.changed = true;
this.update();
});
this.textarea.addEventListener("keydown", (e) => {
this.value = e.target.value;
if (e.key === "Tab") {
e.preventDefault();
let autoCompletion = this.resolveAutoComplete();
if (autoCompletion) {
e.target.value = autoCompletion;
this.value = autoCompletion;
return;
} }
} }
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
constructor() { const message = e.target.value;
super();
this.lastUpdateEvent = new Date();
this.textarea = document.createElement("textarea");
this._value = "";
this.value = this.getAttribute("value") || "";
this.previousValue = this.value;
this.lastChange = new Date();
this.changed = false;
}
get value() {
return this._value;
}
set value(value) {
this._value = value || "";
this.textarea.value = this._value;
}
resolveAutoComplete() {
let count = 0;
let value = null;
Object.keys(this.autoCompletions).forEach((key) => {
if (key.startsWith(this.value)) {
count++;
value = key;
}
});
if (count == 1)
return value;
return null;
}
isActive() {
return document.activeElement === this.textarea;
}
focus() {
this.textarea.focus();
}
async connectedCallback() {
this.user = await app.rpc.getUser(null);
this.liveType = this.getAttribute("live-type") === "true";
this.liveTypeInterval = parseInt(this.getAttribute("live-type-interval")) || 3;
this.channelUid = this.getAttribute("channel");
this.messageUid = null; this.messageUid = null;
this.value = "";
this.previousValue = "";
this.classList.add("chat-input"); if (!message) {
return;
}
this.textarea.setAttribute("placeholder", "Type a message..."); let autoCompletion = this.autoCompletions[message];
this.textarea.setAttribute("rows", "2"); if (autoCompletion) {
this.value = "";
this.previousValue = "";
e.target.value = "";
autoCompletion();
return;
}
this.appendChild(this.textarea); e.target.value = "";
this.value = "";
this.uploadButton = document.createElement("upload-button"); this.messageUid = null;
this.uploadButton.setAttribute("channel", this.channelUid); this.sendMessage(this.channelUid, message).then((uid) => {
this.uploadButton.addEventListener("upload", (e) => { this.messageUid = uid;
this.dispatchEvent(new CustomEvent("upload", e));
});
this.uploadButton.addEventListener("uploaded", (e) => {
this.dispatchEvent(new CustomEvent("uploaded", e));
}); });
}
});
this.appendChild(this.uploadButton); this.changeInterval = setInterval(() => {
if (!this.liveType) {
return;
}
if (this.value !== this.previousValue) {
if (
this.trackSecondsBetweenEvents(this.lastChange, new Date()) >=
this.liveTypeInterval
) {
this.value = "";
this.previousValue = "";
}
this.lastChange = new Date();
}
this.update();
}, 300);
this.textarea.addEventListener("keyup", (e) => { this.addEventListener("upload", (e) => {
if(e.key === 'Enter' && !e.shiftKey) { this.focus();
this.value = '' });
e.target.value = ''; this.addEventListener("uploaded", function (e) {
return let message = "";
} e.detail.files.forEach((file) => {
this.value = e.target.value; message += `[${file.name}](/channel/attachment/${file.relative_url})`;
this.changed = true; });
this.update(); app.rpc.sendMessage(this.channelUid, message);
}); });
setTimeout(()=>{
this.focus();
},1000)
}
this.textarea.addEventListener("keydown", (e) => { trackSecondsBetweenEvents(event1Time, event2Time) {
this.value = e.target.value; const millisecondsDifference = event2Time.getTime() - event1Time.getTime();
if (e.key === "Tab") { return millisecondsDifference / 1000;
e.preventDefault(); }
let autoCompletion = this.resolveAutoComplete();
if (autoCompletion) {
e.target.value = autoCompletion;
this.value = autoCompletion;
return;
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const message = e.target.value; newMessage() {
this.messageUid = null; if (!this.messageUid) {
this.value = ''; this.messageUid = "?";
this.previousValue = '';
if (!message) {
return;
}
let autoCompletion = this.autoCompletions[message];
if (autoCompletion) {
this.value = '';
this.previousValue = '';
e.target.value = '';
autoCompletion();
return;
}
e.target.value = '';
this.value = '';
this.messageUid = null;
this.sendMessage(this.channelUid, message).then((uid) => {
this.messageUid = uid;
});
}
});
this.changeInterval = setInterval(() => {
if (!this.liveType) {
return;
}
if (this.value !== this.previousValue) {
if (this.trackSecondsBetweenEvents(this.lastChange, new Date()) >= this.liveTypeInterval) {
this.value = '';
this.previousValue = '';
}
this.lastChange = new Date();
}
this.update();
}, 300);
this.addEventListener("upload", (e) => {
this.focus();
});
this.addEventListener("uploaded", function (e) {
let message = "";
e.detail.files.forEach((file) => {
message += `[${file.name}](/channel/attachment/${file.relative_url})`;
});
app.rpc.sendMessage(this.channelUid, message);
});
} }
trackSecondsBetweenEvents(event1Time, event2Time) { this.sendMessage(this.channelUid, this.value).then((uid) => {
const millisecondsDifference = event2Time.getTime() - event1Time.getTime(); this.messageUid = uid;
return millisecondsDifference / 1000; });
}
updateMessage() {
if (this.value[0] == "/") {
return false;
}
if (!this.messageUid) {
this.newMessage();
return false;
}
if (this.messageUid === "?") {
return false;
}
if (
typeof app !== "undefined" &&
app.rpc &&
typeof app.rpc.updateMessageText === "function"
) {
app.rpc.updateMessageText(this.messageUid, this.value);
}
}
updateStatus() {
if (this.liveType) {
return;
}
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) > 1) {
this.lastUpdateEvent = new Date();
if (
typeof app !== "undefined" &&
app.rpc &&
typeof app.rpc.set_typing === "function"
) {
app.rpc.set_typing(this.channelUid, this.user.color);
}
}
}
update() {
const expired =
this.trackSecondsBetweenEvents(this.lastChange, new Date()) >=
this.liveTypeInterval;
const changed = this.value !== this.previousValue;
if (changed || expired) {
this.lastChange = new Date();
this.updateStatus();
} }
newMessage() { this.previousValue = this.value;
if (!this.messageUid) {
this.messageUid = '?';
}
this.sendMessage(this.channelUid, this.value).then((uid) => { if (this.liveType && expired) {
this.messageUid = uid; this.value = "";
}); this.previousValue = "";
this.messageUid = null;
return;
} }
updateMessage() { if (changed) {
if (this.value[0] == "/") { if (this.liveType) {
return false; this.updateMessage();
} }
if (!this.messageUid) {
this.newMessage();
return false;
}
if (this.messageUid === '?') {
return false;
}
if (typeof app !== "undefined" && app.rpc && typeof app.rpc.updateMessageText === "function") {
app.rpc.updateMessageText(this.messageUid, this.value);
}
} }
}
updateStatus() { async sendMessage(channelUid, value) {
if (this.liveType) { if (!value.trim()) {
return; return null;
}
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) > 1) {
this.lastUpdateEvent = new Date();
if (typeof app !== "undefined" && app.rpc && typeof app.rpc.set_typing === "function") {
app.rpc.set_typing(this.channelUid,this.user.color);
}
}
}
update() {
const expired = this.trackSecondsBetweenEvents(this.lastChange, new Date()) >= this.liveTypeInterval;
const changed = (this.value !== this.previousValue);
if (changed || expired) {
this.lastChange = new Date();
this.updateStatus();
}
this.previousValue = this.value;
if (this.liveType && expired) {
this.value = "";
this.previousValue = "";
this.messageUid = null;
return;
}
if (changed) {
if (this.liveType) {
this.updateMessage();
}
}
}
async sendMessage(channelUid, value) {
if (!value.trim()) {
return null;
}
return await app.rpc.sendMessage(channelUid, value);
} }
return await app.rpc.sendMessage(channelUid, value);
}
} }
customElements.define('chat-input', ChatInputComponent); customElements.define("chat-input", ChatInputComponent);

View File

@ -6,77 +6,77 @@
// The MIT License (MIT) // The MIT License (MIT)
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
// //
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. // The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
// //
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
class ChatWindowElement extends HTMLElement { class ChatWindowElement extends HTMLElement {
receivedHistory = false; receivedHistory = false;
channel = null channel = null;
constructor() { constructor() {
super(); super();
this.attachShadow({ mode: 'open' }); this.attachShadow({ mode: "open" });
this.component = document.createElement('section'); this.component = document.createElement("section");
this.app = app; this.app = app;
this.shadowRoot.appendChild(this.component); this.shadowRoot.appendChild(this.component);
} }
get user() { get user() {
return this.app.user; return this.app.user;
} }
async connectedCallback() { async connectedCallback() {
const link = document.createElement('link'); const link = document.createElement("link");
link.rel = 'stylesheet'; link.rel = "stylesheet";
link.href = '/base.css'; link.href = "/base.css";
this.component.appendChild(link); this.component.appendChild(link);
this.component.classList.add("chat-area"); this.component.classList.add("chat-area");
this.container = document.createElement("section");
this.container.classList.add("chat-area", "chat-window");
const chatHeader = document.createElement("div"); this.container = document.createElement("section");
chatHeader.classList.add("chat-header"); this.container.classList.add("chat-area", "chat-window");
const chatTitle = document.createElement('h2'); const chatHeader = document.createElement("div");
chatTitle.classList.add("chat-title"); chatHeader.classList.add("chat-header");
chatTitle.classList.add("no-select");
chatTitle.innerText = "Loading...";
chatHeader.appendChild(chatTitle);
this.container.appendChild(chatHeader);
const channels = await app.rpc.getChannels(); const chatTitle = document.createElement("h2");
const channel = channels[0]; chatTitle.classList.add("chat-title");
this.channel = channel; chatTitle.classList.add("no-select");
chatTitle.innerText = channel.name; chatTitle.innerText = "Loading...";
chatHeader.appendChild(chatTitle);
this.container.appendChild(chatHeader);
const channelElement = document.createElement('message-list'); const channels = await app.rpc.getChannels();
channelElement.setAttribute("channel", channel.uid); const channel = channels[0];
this.container.appendChild(channelElement); this.channel = channel;
chatTitle.innerText = channel.name;
const chatInput = document.createElement('chat-input'); const channelElement = document.createElement("message-list");
chatInput.chatWindow = this; channelElement.setAttribute("channel", channel.uid);
chatInput.addEventListener("submit", (e) => { this.container.appendChild(channelElement);
app.rpc.sendMessage(channel.uid, e.detail);
});
this.container.appendChild(chatInput);
this.component.appendChild(this.container); const chatInput = document.createElement("chat-input");
chatInput.chatWindow = this;
chatInput.addEventListener("submit", (e) => {
app.rpc.sendMessage(channel.uid, e.detail);
});
this.container.appendChild(chatInput);
const messages = await app.rpc.getMessages(channel.uid); this.component.appendChild(this.container);
messages.forEach(message => {
if (!message['user_nick']) return;
channelElement.addMessage(message);
});
const me = this; const messages = await app.rpc.getMessages(channel.uid);
channelElement.addEventListener("message", (message) => { messages.forEach((message) => {
if (me.user.uid !== message.detail.user_uid) app.playSound(0); if (!message["user_nick"]) return;
channelElement.addMessage(message);
message.detail.element.scrollIntoView({"block": "end"}); });
});
} const me = this;
channelElement.addEventListener("message", (message) => {
if (me.user.uid !== message.detail.user_uid) app.playSound(0);
message.detail.element.scrollIntoView({ block: "end" });
});
}
} }
customElements.define('chat-window', ChatWindowElement); customElements.define("chat-window", ChatWindowElement);

View File

@ -0,0 +1,150 @@
class DumbTerminal extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
:host {
--terminal-bg: #111;
--terminal-fg: #0f0;
--terminal-accent: #0ff;
--terminal-font: monospace;
display: block;
background: var(--terminal-bg);
color: var(--terminal-fg);
font-family: var(--terminal-font);
padding: 1rem;
border-radius: 8px;
overflow-y: auto;
max-height: 500px;
}
.output {
white-space: pre-wrap;
margin-bottom: 1em;
}
.input-line {
display: flex;
}
.prompt {
color: var(--terminal-accent);
margin-right: 0.5em;
}
input {
background: transparent;
border: none;
color: var(--terminal-fg);
outline: none;
width: 100%;
font-family: inherit;
font-size: inherit;
}
dialog {
border: none;
background: transparent;
}
.dialog-backdrop {
background: rgba(0, 0, 0, 0.8);
padding: 2rem;
}
</style>
<div class="output" id="output"></div>
<div class="input-line">
<span class="prompt">&gt;</span>
<input type="text" id="input" autocomplete="off" autofocus />
</div>
`;
this.outputEl = this.shadowRoot.getElementById("output");
this.inputEl = this.shadowRoot.getElementById("input");
this.history = [];
this.historyIndex = -1;
this.inputEl.addEventListener("keydown", (e) => this.onKeyDown(e));
}
onKeyDown(event) {
const value = this.inputEl.value;
switch (event.key) {
case "Enter":
this.executeCommand(value);
this.history.push(value);
this.historyIndex = this.history.length;
this.inputEl.value = "";
break;
case "ArrowUp":
if (this.historyIndex > 0) {
this.historyIndex--;
this.inputEl.value = this.history[this.historyIndex];
}
break;
case "ArrowDown":
if (this.historyIndex < this.history.length - 1) {
this.historyIndex++;
this.inputEl.value = this.history[this.historyIndex];
} else {
this.historyIndex = this.history.length;
this.inputEl.value = "";
}
break;
}
}
executeCommand(command) {
const outputLine = document.createElement("div");
outputLine.textContent = `> ${command}`;
this.outputEl.appendChild(outputLine);
const resultLine = document.createElement("div");
resultLine.textContent = this.mockExecute(command);
this.outputEl.appendChild(resultLine);
this.outputEl.scrollTop = this.outputEl.scrollHeight;
}
mockExecute(command) {
switch (command.trim()) {
case "help":
return "Available commands: help, clear, date";
case "date":
return new Date().toString();
case "clear":
this.outputEl.innerHTML = "";
return "";
default:
return `Unknown command: ${command}`;
}
}
/**
* Static method to create a modal dialog with the terminal
* @returns {HTMLDialogElement}
*/
static createModal() {
const dialog = document.createElement("dialog");
dialog.innerHTML = `
<div class="dialog-backdrop">
<web-terminal></web-terminal>
</div>
`;
document.body.appendChild(dialog);
dialog.showModal();
return dialog;
}
}
customElements.define("web-terminal", WebTerminal);

View File

@ -1,16 +1,15 @@
export class EventHandler { export class EventHandler {
constructor() { constructor() {
this.subscribers = {}; this.subscribers = {};
} }
addEventListener(type, handler) { addEventListener(type, handler) {
if (!this.subscribers[type]) this.subscribers[type] = []; if (!this.subscribers[type]) this.subscribers[type] = [];
this.subscribers[type].push(handler); this.subscribers[type].push(handler);
} }
emit(type, ...data) { emit(type, ...data) {
if (this.subscribers[type]) this.subscribers[type].forEach(handler => handler(...data)); if (this.subscribers[type])
} this.subscribers[type].forEach((handler) => handler(...data));
} }
}

View File

@ -2,28 +2,25 @@
// This JavaScript class defines a custom HTML element <fancy-button>, which creates a styled, clickable button element with customizable size, text, and URL redirect functionality. // This JavaScript class defines a custom HTML element <fancy-button>, which creates a styled, clickable button element with customizable size, text, and URL redirect functionality.
// MIT License // MIT License
class FancyButton extends HTMLElement { class FancyButton extends HTMLElement {
constructor() { constructor() {
super(); super();
this.attachShadow({ mode: 'open' }); this.attachShadow({ mode: "open" });
this.url = null; this.url = null;
this.type = "button"; this.type = "button";
this.value = null; this.value = null;
} }
connectedCallback() { connectedCallback() {
this.container = document.createElement('span'); this.container = document.createElement("span");
let size = this.getAttribute('size'); let size = this.getAttribute("size");
console.info({ GG: size }); console.info({ GG: size });
size = size === 'auto' ? '1%' : '33%'; size = size === "auto" ? "1%" : "33%";
this.styleElement = document.createElement("style"); this.styleElement = document.createElement("style");
this.styleElement.innerHTML = ` this.styleElement.innerHTML = `
:root { :root {
width: 100%; width: 100%;
--width: 100%; --width: 100%;
@ -49,29 +46,30 @@ class FancyButton extends HTMLElement {
} }
`; `;
this.container.appendChild(this.styleElement); this.container.appendChild(this.styleElement);
this.buttonElement = document.createElement('button'); this.buttonElement = document.createElement("button");
this.container.appendChild(this.buttonElement); this.container.appendChild(this.buttonElement);
this.shadowRoot.appendChild(this.container); this.shadowRoot.appendChild(this.container);
this.url = this.getAttribute('url'); this.url = this.getAttribute("url");
this.value = this.getAttribute('value'); this.value = this.getAttribute("value");
this.buttonElement.appendChild(document.createTextNode(this.getAttribute("text"))); this.buttonElement.appendChild(
this.buttonElement.addEventListener("click", () => { document.createTextNode(this.getAttribute("text")),
if(this.url == 'submit'){ );
this.closest('form').submit() this.buttonElement.addEventListener("click", () => {
return if (this.url == "submit") {
} this.closest("form").submit();
return;
if (this.url === "/back" || this.url === "/back/") { }
window.history.back();
} else if (this.url) { if (this.url === "/back" || this.url === "/back/") {
window.location = this.url; window.history.back();
} } else if (this.url) {
}); window.location = this.url;
} }
});
}
} }
customElements.define("fancy-button", FancyButton); customElements.define("fancy-button", FancyButton);

View File

@ -3,13 +3,13 @@ class FileBrowser extends HTMLElement {
constructor() { constructor() {
super(); super();
this.attachShadow({ mode: "open" }); this.attachShadow({ mode: "open" });
this.path = ""; // current virtual path ("" = ROOT) this.path = ""; // current virtual path ("" = ROOT)
this.offset = 0; // pagination offset this.offset = 0; // pagination offset
this.limit = 40; // items per request this.limit = 40; // items per request
} }
connectedCallback() { connectedCallback() {
this.path = this.getAttribute("path") || ""; this.path = this.getAttribute("path") || "";
this.renderShell(); this.renderShell();
this.load(); this.load();
} }
@ -40,19 +40,30 @@ class FileBrowser extends HTMLElement {
<button id="next">Next</button> <button id="next">Next</button>
</nav> </nav>
`; `;
this.shadowRoot.getElementById("up").addEventListener("click", () => this.goUp()); this.shadowRoot
.getElementById("up")
.addEventListener("click", () => this.goUp());
this.shadowRoot.getElementById("prev").addEventListener("click", () => { this.shadowRoot.getElementById("prev").addEventListener("click", () => {
if (this.offset > 0) { this.offset -= this.limit; this.load(); } if (this.offset > 0) {
this.offset -= this.limit;
this.load();
}
}); });
this.shadowRoot.getElementById("next").addEventListener("click", () => { this.shadowRoot.getElementById("next").addEventListener("click", () => {
this.offset += this.limit; this.load(); this.offset += this.limit;
this.load();
}); });
} }
// ---------- Networking ---------------------------------------------- // ---------- Networking ----------------------------------------------
async load() { async load() {
const r = await fetch(`/drive.json?path=${encodeURIComponent(this.path)}&offset=${this.offset}&limit=${this.limit}`); const r = await fetch(
if (!r.ok) { console.error(await r.text()); return; } `/drive.json?path=${encodeURIComponent(this.path)}&offset=${this.offset}&limit=${this.limit}`,
);
if (!r.ok) {
console.error(await r.text());
return;
}
const data = await r.json(); const data = await r.json();
this.renderTiles(data.items); this.renderTiles(data.items);
this.updateNav(data.pagination); this.updateNav(data.pagination);
@ -62,13 +73,17 @@ class FileBrowser extends HTMLElement {
renderTiles(items) { renderTiles(items) {
const grid = this.shadowRoot.getElementById("grid"); const grid = this.shadowRoot.getElementById("grid");
grid.innerHTML = ""; grid.innerHTML = "";
items.forEach(item => { items.forEach((item) => {
const tile = document.createElement("div"); const tile = document.createElement("div");
tile.className = "tile"; tile.className = "tile";
if (item.type === "directory") { if (item.type === "directory") {
tile.innerHTML = `<div class="icon">📂</div><div>${item.name}</div>`; tile.innerHTML = `<div class="icon">📂</div><div>${item.name}</div>`;
tile.addEventListener("click", () => { this.path = item.path; this.offset = 0; this.load(); }); tile.addEventListener("click", () => {
this.path = item.path;
this.offset = 0;
this.load();
});
} else { } else {
if (item.mimetype?.startsWith("image/")) { if (item.mimetype?.startsWith("image/")) {
tile.innerHTML = `<img class="thumb" src="${item.url}" alt="${item.name}"><div>${item.name}</div>`; tile.innerHTML = `<img class="thumb" src="${item.url}" alt="${item.name}"><div>${item.name}</div>`;
@ -87,7 +102,7 @@ class FileBrowser extends HTMLElement {
this.shadowRoot.getElementById("crumb").textContent = `/${this.path}`; this.shadowRoot.getElementById("crumb").textContent = `/${this.path}`;
this.shadowRoot.getElementById("prev").disabled = offset === 0; this.shadowRoot.getElementById("prev").disabled = offset === 0;
this.shadowRoot.getElementById("next").disabled = offset + limit >= total; this.shadowRoot.getElementById("next").disabled = offset + limit >= total;
this.shadowRoot.getElementById("up").disabled = this.path === ""; this.shadowRoot.getElementById("up").disabled = this.path === "";
} }
goUp() { goUp() {

View File

@ -40,7 +40,7 @@ class GenericField extends HTMLElement {
} }
set value(val) { set value(val) {
val = val ?? ''; val = val ?? "";
this.inputElement.value = val; this.inputElement.value = val;
this.inputElement.setAttribute("value", val); this.inputElement.setAttribute("value", val);
} }
@ -62,9 +62,9 @@ class GenericField extends HTMLElement {
constructor() { constructor() {
super(); super();
this.attachShadow({mode: 'open'}); this.attachShadow({ mode: "open" });
this.container = document.createElement('div'); this.container = document.createElement("div");
this.styleElement = document.createElement('style'); this.styleElement = document.createElement("style");
this.styleElement.innerHTML = ` this.styleElement.innerHTML = `
h1 { h1 {
@ -174,7 +174,7 @@ class GenericField extends HTMLElement {
if (this.inputElement == null && this.field) { if (this.inputElement == null && this.field) {
this.inputElement = document.createElement(this.field.tag); this.inputElement = document.createElement(this.field.tag);
if (this.field.tag === 'button' && this.field.value === "submit") { if (this.field.tag === "button" && this.field.value === "submit") {
this.action = this.field.value; this.action = this.field.value;
} }
this.inputElement.name = this.field.name; this.inputElement.name = this.field.name;
@ -182,26 +182,39 @@ class GenericField extends HTMLElement {
const me = this; const me = this;
this.inputElement.addEventListener("keyup", (e) => { this.inputElement.addEventListener("keyup", (e) => {
if (e.key === 'Enter') { if (e.key === "Enter") {
const event = new CustomEvent("change", {detail: me, bubbles: true}); const event = new CustomEvent("change", {
detail: me,
bubbles: true,
});
me.dispatchEvent(event); me.dispatchEvent(event);
me.dispatchEvent(new Event("submit")); me.dispatchEvent(new Event("submit"));
} else if (me.field.value !== e.target.value) { } else if (me.field.value !== e.target.value) {
const event = new CustomEvent("change", {detail: me, bubbles: true}); const event = new CustomEvent("change", {
detail: me,
bubbles: true,
});
me.dispatchEvent(event); me.dispatchEvent(event);
} }
}); });
this.inputElement.addEventListener("click", (e) => { this.inputElement.addEventListener("click", (e) => {
const event = new CustomEvent("click", {detail: me, bubbles: true}); const event = new CustomEvent("click", { detail: me, bubbles: true });
me.dispatchEvent(event); me.dispatchEvent(event);
}); });
this.inputElement.addEventListener("blur", (e) => { this.inputElement.addEventListener(
const event = new CustomEvent("change", {detail: me, bubbles: true}); "blur",
me.dispatchEvent(event); (e) => {
}, true); const event = new CustomEvent("change", {
detail: me,
bubbles: true,
});
me.dispatchEvent(event);
},
true,
);
this.container.appendChild(this.inputElement); this.container.appendChild(this.inputElement);
} }
@ -210,8 +223,8 @@ class GenericField extends HTMLElement {
return; return;
} }
this.inputElement.setAttribute("type", this.field.type ?? 'input'); this.inputElement.setAttribute("type", this.field.type ?? "input");
this.inputElement.setAttribute("name", this.field.name ?? ''); this.inputElement.setAttribute("name", this.field.name ?? "");
if (this.field.text != null) { if (this.field.text != null) {
this.inputElement.innerText = this.field.text; this.inputElement.innerText = this.field.text;
@ -239,14 +252,14 @@ class GenericField extends HTMLElement {
this.inputElement.removeAttribute("required"); this.inputElement.removeAttribute("required");
} }
if (!this.footerElement) { if (!this.footerElement) {
this.footerElement = document.createElement('div'); this.footerElement = document.createElement("div");
this.footerElement.style.clear = 'both'; this.footerElement.style.clear = "both";
this.container.appendChild(this.footerElement); this.container.appendChild(this.footerElement);
} }
} }
} }
customElements.define('generic-field', GenericField); customElements.define("generic-field", GenericField);
class GenericForm extends HTMLElement { class GenericForm extends HTMLElement {
fields = {}; fields = {};
@ -254,7 +267,7 @@ class GenericForm extends HTMLElement {
constructor() { constructor() {
super(); super();
this.attachShadow({mode: 'open'}); this.attachShadow({ mode: "open" });
this.styleElement = document.createElement("style"); this.styleElement = document.createElement("style");
this.styleElement.innerHTML = ` this.styleElement.innerHTML = `
@ -281,27 +294,29 @@ class GenericForm extends HTMLElement {
} }
}`; }`;
this.container = document.createElement('div'); this.container = document.createElement("div");
this.container.appendChild(this.styleElement); this.container.appendChild(this.styleElement);
this.container.classList.add("generic-form-container"); this.container.classList.add("generic-form-container");
this.shadowRoot.appendChild(this.container); this.shadowRoot.appendChild(this.container);
} }
connectedCallback() { connectedCallback() {
const preloadedForm = this.getAttribute('preloaded-structure'); const preloadedForm = this.getAttribute("preloaded-structure");
if (preloadedForm) { if (preloadedForm) {
try { try {
const form = JSON.parse(preloadedForm); const form = JSON.parse(preloadedForm);
this.constructForm(form) this.constructForm(form);
} catch (error) { } catch (error) {
console.error(error, preloadedForm); console.error(error, preloadedForm);
} }
} }
const url = this.getAttribute('url'); const url = this.getAttribute("url");
if (url) { if (url) {
const fullUrl = url.startsWith("/") ? window.location.origin + url : new URL(window.location.origin + "/http-get"); const fullUrl = url.startsWith("/")
? window.location.origin + url
: new URL(window.location.origin + "/http-get");
if (!url.startsWith("/")) { if (!url.startsWith("/")) {
fullUrl.searchParams.set('url', url); fullUrl.searchParams.set("url", url);
} }
this.loadForm(fullUrl.toString()); this.loadForm(fullUrl.toString());
} else { } else {
@ -318,10 +333,10 @@ class GenericForm extends HTMLElement {
let hasAutoFocus = Object.keys(this.fields).length !== 0; let hasAutoFocus = Object.keys(this.fields).length !== 0;
fields.sort((a, b) => a.index - b.index); fields.sort((a, b) => a.index - b.index);
fields.forEach(field => { fields.forEach((field) => {
const updatingField = field.name in this.fields const updatingField = field.name in this.fields;
this.fields[field.name] ??= document.createElement('generic-field'); this.fields[field.name] ??= document.createElement("generic-field");
const fieldElement = this.fields[field.name]; const fieldElement = this.fields[field.name];
@ -362,7 +377,7 @@ class GenericForm extends HTMLElement {
window.location.pathname = saveResult.redirect_url; window.location.pathname = saveResult.redirect_url;
} }
} }
}) });
} }
}); });
} catch (error) { } catch (error) {
@ -374,7 +389,9 @@ class GenericForm extends HTMLElement {
try { try {
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`); throw new Error(
`Failed to fetch: ${response.status} ${response.statusText}`,
);
} }
await this.constructForm(await response.json()); await this.constructForm(await response.json());
@ -387,15 +404,15 @@ class GenericForm extends HTMLElement {
const url = this.getAttribute("url"); const url = this.getAttribute("url");
let response = await fetch(url, { let response = await fetch(url, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify({"action": "validate", "form": this.form}) body: JSON.stringify({ action: "validate", form: this.form }),
}); });
const form = await response.json(); const form = await response.json();
Object.values(form.fields).forEach(field => { Object.values(form.fields).forEach((field) => {
if (!this.form.fields[field.name]) { if (!this.form.fields[field.name]) {
return; return;
} }
@ -409,23 +426,23 @@ class GenericForm extends HTMLElement {
this.fields[field.name].setAttribute("field", field); this.fields[field.name].setAttribute("field", field);
this.fields[field.name].updateAttributes(); this.fields[field.name].updateAttributes();
}); });
Object.values(form.fields).forEach(field => { Object.values(form.fields).forEach((field) => {
this.fields[field.name].setErrors(field.errors); this.fields[field.name].setErrors(field.errors);
}); });
return form['is_valid']; return form["is_valid"];
} }
async submit() { async submit() {
const url = this.getAttribute("url"); const url = this.getAttribute("url");
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify({"action": "submit", "form": this.form}) body: JSON.stringify({ action: "submit", form: this.form }),
}); });
return await response.json(); return await response.json();
} }
} }
customElements.define('generic-form', GenericForm); customElements.define("generic-form", GenericForm);

View File

@ -7,48 +7,50 @@
// MIT License // MIT License
class HTMLFrame extends HTMLElement { class HTMLFrame extends HTMLElement {
constructor() { constructor() {
super(); super();
this.attachShadow({ mode: 'open' }); this.attachShadow({ mode: "open" });
this.container = document.createElement('div'); this.container = document.createElement("div");
this.shadowRoot.appendChild(this.container); this.shadowRoot.appendChild(this.container);
} }
connectedCallback() { connectedCallback() {
this.container.classList.add("html_frame"); this.container.classList.add("html_frame");
let url = this.getAttribute('url'); let url = this.getAttribute("url");
if (!url.startsWith("https")) { if (!url.startsWith("https")) {
url = "https://" + url; url = "https://" + url;
}
if (url) {
const fullUrl = url.startsWith("/") ? window.location.origin + url : new URL(window.location.origin + "/http-get");
if (!url.startsWith("/")) {
fullUrl.searchParams.set('url', url);
}
this.loadAndRender(fullUrl.toString());
} else {
this.container.textContent = "No source URL!";
}
} }
if (url) {
const fullUrl = url.startsWith("/")
? window.location.origin + url
: new URL(window.location.origin + "/http-get");
if (!url.startsWith("/")) {
fullUrl.searchParams.set("url", url);
}
this.loadAndRender(fullUrl.toString());
} else {
this.container.textContent = "No source URL!";
}
}
async loadAndRender(url) { async loadAndRender(url) {
try { try {
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw new Error(`Error: ${response.status} ${response.statusText}`); throw new Error(`Error: ${response.status} ${response.statusText}`);
} }
const html = await response.text(); const html = await response.text();
if (url.endsWith(".md")) { if (url.endsWith(".md")) {
const markdownElement = document.createElement('div'); const markdownElement = document.createElement("div");
markdownElement.innerHTML = html; markdownElement.innerHTML = html;
this.outerHTML = html; this.outerHTML = html;
} else { } else {
this.container.innerHTML = html; this.container.innerHTML = html;
} }
} catch (error) { } catch (error) {
this.container.textContent = `Error: ${error.message}`; this.container.textContent = `Error: ${error.message}`;
}
} }
}
} }
customElements.define('html-frame', HTMLFrame); customElements.define("html-frame", HTMLFrame);

View File

@ -12,22 +12,22 @@
class HTMLFrame extends HTMLElement { class HTMLFrame extends HTMLElement {
constructor() { constructor() {
super(); super();
this.attachShadow({ mode: 'open' }); this.attachShadow({ mode: "open" });
this.container = document.createElement('div'); this.container = document.createElement("div");
this.shadowRoot.appendChild(this.container); this.shadowRoot.appendChild(this.container);
} }
connectedCallback() { connectedCallback() {
this.container.classList.add('html_frame'); this.container.classList.add("html_frame");
const url = this.getAttribute('url'); const url = this.getAttribute("url");
if (url) { if (url) {
const fullUrl = url.startsWith('/') const fullUrl = url.startsWith("/")
? window.location.origin + url ? window.location.origin + url
: new URL(window.location.origin + '/http-get'); : new URL(window.location.origin + "/http-get");
if (!url.startsWith('/')) fullUrl.searchParams.set('url', url); if (!url.startsWith("/")) fullUrl.searchParams.set("url", url);
this.loadAndRender(fullUrl.toString()); this.loadAndRender(fullUrl.toString());
} else { } else {
this.container.textContent = 'No source URL!'; this.container.textContent = "No source URL!";
} }
} }
@ -45,4 +45,4 @@ class HTMLFrame extends HTMLElement {
} }
} }
customElements.define('markdown-frame', HTMLFrame); customElements.define("markdown-frame", HTMLFrame);

View File

@ -4,7 +4,6 @@
// No external libraries or dependencies are used other than standard web components. // No external libraries or dependencies are used other than standard web components.
// MIT License // MIT License
// //
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
@ -13,20 +12,19 @@
// //
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
class TileGridElement extends HTMLElement { class TileGridElement extends HTMLElement {
constructor() { constructor() {
super(); super();
this.attachShadow({ mode: 'open' }); this.attachShadow({ mode: "open" });
this.gridId = this.getAttribute('grid'); this.gridId = this.getAttribute("grid");
this.component = document.createElement('div'); this.component = document.createElement("div");
this.shadowRoot.appendChild(this.component); this.shadowRoot.appendChild(this.component);
} }
connectedCallback() { connectedCallback() {
console.log('connected'); console.log("connected");
this.styleElement = document.createElement('style'); this.styleElement = document.createElement("style");
this.styleElement.textContent = ` this.styleElement.textContent = `
.grid { .grid {
padding: 10px; padding: 10px;
display: flex; display: flex;
@ -47,53 +45,53 @@ class TileGridElement extends HTMLElement {
transform: scale(1.1); transform: scale(1.1);
} }
`; `;
this.component.appendChild(this.styleElement); this.component.appendChild(this.styleElement);
this.container = document.createElement('div'); this.container = document.createElement("div");
this.container.classList.add('gallery'); this.container.classList.add("gallery");
this.component.appendChild(this.container); this.component.appendChild(this.container);
} }
addImage(src) { addImage(src) {
const item = document.createElement('img'); const item = document.createElement("img");
item.src = src; item.src = src;
item.classList.add('tile'); item.classList.add("tile");
item.style.width = '100px'; item.style.width = "100px";
item.style.height = '100px'; item.style.height = "100px";
this.container.appendChild(item); this.container.appendChild(item);
} }
addImages(srcs) { addImages(srcs) {
srcs.forEach(src => this.addImage(src)); srcs.forEach((src) => this.addImage(src));
} }
addElement(element) { addElement(element) {
element.classList.add('tile'); element.classList.add("tile");
this.container.appendChild(element); this.container.appendChild(element);
} }
} }
class UploadButton extends HTMLElement { class UploadButton extends HTMLElement {
constructor() { constructor() {
super(); super();
this.attachShadow({ mode: 'open' }); this.attachShadow({ mode: "open" });
this.component = document.createElement('div'); this.component = document.createElement("div");
this.shadowRoot.appendChild(this.component); this.shadowRoot.appendChild(this.component);
window.u = this; window.u = this;
} }
get gridSelector() { get gridSelector() {
return this.getAttribute('grid'); return this.getAttribute("grid");
} }
grid = null; grid = null;
addImages(urls) { addImages(urls) {
this.grid.addImages(urls); this.grid.addImages(urls);
} }
connectedCallback() { connectedCallback() {
console.log('connected'); console.log("connected");
this.styleElement = document.createElement('style'); this.styleElement = document.createElement("style");
this.styleElement.textContent = ` this.styleElement.textContent = `
.upload-button { .upload-button {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -115,61 +113,61 @@ class UploadButton extends HTMLElement {
background-color: #999; background-color: #999;
} }
`; `;
this.shadowRoot.appendChild(this.styleElement); this.shadowRoot.appendChild(this.styleElement);
this.container = document.createElement('div'); this.container = document.createElement("div");
this.container.classList.add('upload-button'); this.container.classList.add("upload-button");
this.shadowRoot.appendChild(this.container); this.shadowRoot.appendChild(this.container);
const input = document.createElement('input'); const input = document.createElement("input");
input.type = 'file'; input.type = "file";
input.accept = 'image/*'; input.accept = "image/*";
input.multiple = true; input.multiple = true;
input.addEventListener('change', (e) => { input.addEventListener("change", (e) => {
const files = e.target.files; const files = e.target.files;
const urls = []; const urls = [];
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
urls.push(e.target.result); urls.push(e.target.result);
if (urls.length === files.length) { if (urls.length === files.length) {
this.addImages(urls); this.addImages(urls);
} }
}; };
reader.readAsDataURL(files[i]); reader.readAsDataURL(files[i]);
} }
}); });
const label = document.createElement('label'); const label = document.createElement("label");
label.textContent = 'Upload Images'; label.textContent = "Upload Images";
label.appendChild(input); label.appendChild(input);
this.container.appendChild(label); this.container.appendChild(label);
} }
} }
customElements.define('upload-button', UploadButton); customElements.define("upload-button", UploadButton);
customElements.define('tile-grid', TileGridElement); customElements.define("tile-grid", TileGridElement);
class MeniaUploadElement extends HTMLElement { class MeniaUploadElement extends HTMLElement {
constructor(){ constructor() {
super(); super();
this.attachShadow({ mode: 'open' }); this.attachShadow({ mode: "open" });
this.component = document.createElement("div"); this.component = document.createElement("div");
alert('aaaa'); alert("aaaa");
this.shadowRoot.appendChild(this.component); this.shadowRoot.appendChild(this.component);
} }
connectedCallback() { connectedCallback() {
this.container = document.createElement("div"); this.container = document.createElement("div");
this.component.style.height = '100%'; this.component.style.height = "100%";
this.component.style.backgroundColor = 'blue'; this.component.style.backgroundColor = "blue";
this.shadowRoot.appendChild(this.container); this.shadowRoot.appendChild(this.container);
this.tileElement = document.createElement("tile-grid"); this.tileElement = document.createElement("tile-grid");
this.tileElement.style.backgroundColor = 'red'; this.tileElement.style.backgroundColor = "red";
this.tileElement.style.height = '100%'; this.tileElement.style.height = "100%";
this.component.appendChild(this.tileElement); this.component.appendChild(this.tileElement);
this.uploadButton = document.createElement('upload-button'); this.uploadButton = document.createElement("upload-button");
this.component.appendChild(this.uploadButton); this.component.appendChild(this.uploadButton);
} }
} }
customElements.define('menia-upload', MeniaUploadElement); customElements.define("menia-upload", MeniaUploadElement);

View File

@ -3,7 +3,7 @@
// This JavaScript source code defines a custom HTML element named "message-list-manager" to manage a list of message lists for different channels obtained asynchronously. // This JavaScript source code defines a custom HTML element named "message-list-manager" to manage a list of message lists for different channels obtained asynchronously.
// //
// //
// MIT License // MIT License
// Permission is hereby granted, free of charge, to any person obtaining a copy // Permission is hereby granted, free of charge, to any person obtaining a copy
@ -22,23 +22,22 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE. // SOFTWARE.
class MessageListManagerElement extends HTMLElement { class MessageListManagerElement extends HTMLElement {
constructor() { constructor() {
super(); super();
this.attachShadow({ mode: 'open' }); this.attachShadow({ mode: "open" });
this.container = document.createElement("div"); this.container = document.createElement("div");
this.shadowRoot.appendChild(this.container); this.shadowRoot.appendChild(this.container);
} }
async connectedCallback() { async connectedCallback() {
const channels = await app.rpc.getChannels(); const channels = await app.rpc.getChannels();
channels.forEach(channel => { channels.forEach((channel) => {
const messageList = document.createElement("message-list"); const messageList = document.createElement("message-list");
messageList.setAttribute("channel", channel.uid); messageList.setAttribute("channel", channel.uid);
this.container.appendChild(messageList); this.container.appendChild(messageList);
}); });
} }
} }
customElements.define("message-list-manager", MessageListManagerElement); customElements.define("message-list-manager", MessageListManagerElement);

View File

@ -5,226 +5,71 @@
// The code seems to rely on some external dependencies like 'models.Message', 'app', and 'Schedule'. These should be imported or defined elsewhere in your application. // The code seems to rely on some external dependencies like 'models.Message', 'app', and 'Schedule'. These should be imported or defined elsewhere in your application.
// MIT License: This is free software. Permission is granted to use, copy, modify, and/or distribute this software for any purpose with or without fee. The software is provided "as is" without any warranty. // MIT License: This is free software. Permission is granted to use, copy, modify, and/or distribute this software for any purpose with or without fee. The software is provided "as is" without any warranty.
import {app} from '../app.js' import { app } from "../app.js";
class MessageList extends HTMLElement { class MessageList extends HTMLElement {
constructor() { constructor() {
super(); super();
app.ws.addEventListener("update_message_text",(data)=>{ app.ws.addEventListener("update_message_text", (data) => {
this.updateMessageText(data.data.uid,data.data) this.updateMessageText(data.uid, data);
}) });
app.ws.addEventListener("set_typing",(data)=>{ app.ws.addEventListener("set_typing", (data) => {
this.triggerGlow(data.data.user_uid) this.triggerGlow(data.user_uid,data.color);
});
}) this.items = [];
}
scrollToBottom(force) {
console.info("Scrolling down")
// if (force) {
this.scrollTop = this.scrollHeight;
this.items = []; this.querySelector(".message-list-bottom").scrollIntoView();
setTimeout(() => {
this.scrollTop = this.scrollHeight;
this.querySelector(".message-list-bottom").scrollIntoView();
},200)
// }
}
updateMessageText(uid, message) {
const messageDiv = this.querySelector('div[data-uid="' + uid + '"]');
if (!messageDiv) {
return;
}
const receivedHtml = document.createElement("div");
receivedHtml.innerHTML = message.html;
const html = receivedHtml.querySelector(".text").innerHTML;
const textElement = messageDiv.querySelector(".text");
textElement.innerHTML = html;
textElement.style.display = message.text == "" ? "none" : "block";
}
triggerGlow(uid,color) {
app.starField.glowColor(color)
let lastElement = null;
this.querySelectorAll(".avatar").forEach((el) => {
const div = el.closest("a");
if (el.href.indexOf(uid) != -1) {
lastElement = el;
} }
updateMessageText(uid,message){ });
const messageDiv = this.querySelector("div[data-uid=\""+uid+"\"]") if (lastElement) {
lastElement.classList.add("glow");
if(!messageDiv){ setTimeout(() => {
return lastElement.classList.remove("glow");
} }, 1000);
const receivedHtml = document.createElement("div")
receivedHtml.innerHTML = message.html
const html = receivedHtml.querySelector(".text").innerHTML
const textElement = messageDiv.querySelector(".text")
textElement.innerHTML = html
textElement.style.display = message.text == '' ? 'none' : 'block'
}
triggerGlow(uid) {
let lastElement = null;
this.querySelectorAll(".avatar").forEach((el)=>{
const div = el.closest('a');
if(el.href.indexOf(uid)!=-1){
lastElement = el
}
})
if(lastElement){
lastElement.classList.add("glow")
setTimeout(()=>{
lastElement.classList.remove("glow")
},1000)
}
}
set data(items) {
this.items = items;
this.render();
}
render() {
this.innerHTML = '';
//this.insertAdjacentHTML("beforeend", html);
}
} }
}
customElements.define('message-list', MessageList); set data(items) {
this.items = items;
this.render();
}
render() {
this.innerHTML = "";
class MessageListElementOLD extends HTMLElement { //this.insertAdjacentHTML("beforeend", html);
static get observedAttributes() { }
return ["messages"];
}
messages = [];
room = null;
url = null;
container = null;
messageEventSchedule = null;
observer = null;
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.component = document.createElement('div');
this.shadowRoot.appendChild(this.component);
}
linkifyText(text) {
const urlRegex = /https?:\/\/[^\s]+/g;
return text.replace(urlRegex, (url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`);
}
timeAgo(date1, date2) {
const diffMs = Math.abs(date2 - date1);
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
if (days) {
return `${days} ${days > 1 ? 'days' : 'day'} ago`;
}
if (hours) {
return `${hours} ${hours > 1 ? 'hours' : 'hour'} ago`;
}
if (minutes) {
return `${minutes} ${minutes > 1 ? 'minutes' : 'minute'} ago`;
}
return 'just now';
}
timeDescription(isoDate) {
const date = new Date(isoDate);
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;
return timeStr;
}
createElement(message) {
const element = document.createElement("div");
element.dataset.uid = message.uid;
element.dataset.color = message.color;
element.dataset.channel_uid = message.channel_uid;
element.dataset.user_nick = message.user_nick;
element.dataset.created_at = message.created_at;
element.dataset.user_uid = message.user_uid;
element.dataset.message = message.message;
element.classList.add("message");
if (!this.messages.length || this.messages[this.messages.length - 1].user_uid != message.user_uid) {
element.classList.add("switch-user");
}
const avatar = document.createElement("div");
avatar.classList.add("avatar");
avatar.classList.add("no-select");
avatar.style.backgroundColor = message.color;
avatar.style.color = "black";
avatar.innerText = message.user_nick[0];
const messageContent = document.createElement("div");
messageContent.classList.add("message-content");
const author = document.createElement("div");
author.classList.add("author");
author.style.color = message.color;
author.textContent = message.user_nick;
const text = document.createElement("div");
text.classList.add("text");
if (message.html) text.innerHTML = message.html;
const time = document.createElement("div");
time.classList.add("time");
time.dataset.created_at = message.created_at;
time.textContent = this.timeDescription(message.created_at);
messageContent.appendChild(author);
messageContent.appendChild(text);
messageContent.appendChild(time);
element.appendChild(avatar);
element.appendChild(messageContent);
message.element = element;
return element;
}
addMessage(message) {
const obj = new models.Message(
message.uid,
message.channel_uid,
message.user_uid,
message.user_nick,
message.color,
message.message,
message.html,
message.created_at,
message.updated_at
);
const element = this.createElement(obj);
this.messages.push(obj);
this.container.appendChild(element);
this.messageEventSchedule.delay(() => {
this.dispatchEvent(new CustomEvent("message", { detail: obj, bubbles: true }));
});
return obj;
}
scrollBottom() {
this.container.scrollTop = this.container.scrollHeight;
}
connectedCallback() {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/base.css';
this.component.appendChild(link);
this.component.classList.add("chat-messages");
this.container = document.createElement('div');
this.component.appendChild(this.container);
this.messageEventSchedule = new Schedule(500);
this.messages = [];
this.channel_uid = this.getAttribute("channel");
app.addEventListener(this.channel_uid, (data) => {
this.addMessage(data);
});
this.dispatchEvent(new CustomEvent("rendered", { detail: this, bubbles: true }));
this.timeUpdateInterval = setInterval(() => {
this.messages.forEach((message) => {
const newText = this.timeDescription(message.created_at);
if (newText != message.element.innerText) {
message.element.querySelector(".time").innerText = newText;
}
});
}, 30000);
}
} }
//customElements.define('message-list', MessageListElement); customElements.define("message-list", MessageList);

View File

@ -7,20 +7,30 @@
// MIT License // MIT License
class MessageModel { class MessageModel {
constructor(uid, channel_uid, user_uid, user_nick, color, message, html, created_at, updated_at) { constructor(
this.uid = uid uid,
this.message = message channel_uid,
this.html = html user_uid,
this.user_uid = user_uid user_nick,
this.user_nick = user_nick color,
this.color = color message,
this.channel_uid = channel_uid html,
this.created_at = created_at created_at,
this.updated_at = updated_at updated_at,
this.element = null ) {
} this.uid = uid;
this.message = message;
this.html = html;
this.user_uid = user_uid;
this.user_nick = user_nick;
this.color = color;
this.channel_uid = channel_uid;
this.created_at = created_at;
this.updated_at = updated_at;
this.element = null;
}
} }
const models = { const models = {
Message: MessageModel Message: MessageModel,
} };

View File

@ -1 +0,0 @@

View File

@ -1,30 +1,34 @@
this.onpush = (event) => { this.onpush = (event) => {
console.log(event.data); console.log(event.data);
// From here we can write the data to IndexedDB, send it to any open // From here we can write the data to IndexedDB, send it to any open
// windows, display a notification, etc. // windows, display a notification, etc.
}; };
navigator.serviceWorker navigator.serviceWorker
.register("/service-worker.js") .register("/service-worker.js")
.then((serviceWorkerRegistration) => { .then((serviceWorkerRegistration) => {
serviceWorkerRegistration.pushManager.subscribe().then( serviceWorkerRegistration.pushManager.subscribe().then(
(pushSubscription) => { (pushSubscription) => {
const subscriptionObject = { const subscriptionObject = {
endpoint: pushSubscription.endpoint, endpoint: pushSubscription.endpoint,
keys: { keys: {
p256dh: pushSubscription.getKey('p256dh'), p256dh: pushSubscription.getKey("p256dh"),
auth: pushSubscription.getKey('auth'), auth: pushSubscription.getKey("auth"),
}, },
encoding: PushManager.supportedContentEncodings, encoding: PushManager.supportedContentEncodings,
/* other app-specific data, such as user identity */ /* other app-specific data, such as user identity */
}; };
console.log(pushSubscription.endpoint, pushSubscription, subscriptionObject); console.log(
// The push subscription details needed by the application pushSubscription.endpoint,
// server are now available, and can be sent to it using, pushSubscription,
// for example, the fetch() API. subscriptionObject,
}, );
(error) => { // The push subscription details needed by the application
console.error(error); // server are now available, and can be sent to it using,
}, // for example, the fetch() API.
); },
}); (error) => {
console.error(error);
},
);
});

View File

@ -1,42 +1,178 @@
:root {
--star-color: white;
--background-color: black;
}
.star { body.day {
--star-color: #444;
--background-color: #e6f0ff;
}
body.night {
--star-color: white;
--background-color: black;
}
body {
margin: 0;
overflow: hidden;
background-color: var(--background-color);
transition: background-color 0.5s;
}
.star {
position: absolute;
border-radius: 50%;
background-color: var(--star-color);
animation: twinkle 2s infinite ease-in-out;
}
@keyframes twinkle {
0%, 100% { opacity: 0.8; transform: scale(1); }
50% { opacity: 1; transform: scale(1.2); }
}
#themeToggle {
position: absolute;
top: 10px;
left: 10px;
padding: 8px 12px;
font-size: 14px;
z-index: 1000;
}
.star.special {
box-shadow: 0 0 10px 3px gold;
transform: scale(1.4);
z-index: 10;
}
.star-tooltip {
position: absolute; position: absolute;
width: 2px; font-size: 12px;
height: 2px; color: white;
background: var(--star-color, #fff);
border-radius: 50%;
opacity: 0;
transition: background 0.5s ease;
animation: twinkle ease-in-out infinite;
}
@keyframes twinkle {
0%, 100% { opacity: 0; }
50% { opacity: 1; }
}
@keyframes star-glow-frames {
0% {
box-shadow: 0 0 5px --star-color;
}
50% {
box-shadow: 0 0 20px --star-color, 0 0 30px --star-color;
}
100% {
box-shadow: 0 0 5px --star-color;
}
}
.star-glow {
animation: star-glow-frames 1s;
}
.content {
position: relative;
z-index: 1;
color: var(--star-content-color, #eee);
font-family: sans-serif; font-family: sans-serif;
text-align: center; pointer-events: none;
top: 40%; z-index: 9999;
transform: translateY(-40%); white-space: nowrap;
text-shadow: 1px 1px 2px black;
display: none;
padding: 2px 6px;
} }
.star-popup {
position: absolute;
max-width: 300px;
color: #fff;
font-family: sans-serif;
font-size: 14px;
z-index: 10000;
text-shadow: 1px 1px 3px black;
display: none;
padding: 10px;
border-radius: 12px;
}
.star:hover {
cursor: pointer;
}
.star-popup {
position: absolute;
max-width: 300px;
background: white;
color: black;
padding: 15px;
border-radius: 12px;
box-shadow: 0 8px 20px rgba(0,0,0,0.3);
z-index: 10000;
font-family: sans-serif;
font-size: 14px;
display: none;
}
.star-popup h3 {
margin: 0 0 5px;
font-size: 16px;
}
.star-popup button {
margin-top: 10px;
}
.demo-overlay {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 3em;
color: white;
font-family: 'Segoe UI', sans-serif;
font-weight: 300;
text-align: center;
text-shadow: 0 0 20px rgba(0,0,0,0.8);
z-index: 9999;
opacity: 0;
transition: opacity 0.6s ease;
max-width: 80vw;
pointer-events: none;
}
@keyframes demoFadeIn {
from {
opacity: 0;
transform: translate(-50%, -60%) scale(0.95);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes demoPulse {
0% {
box-shadow: 0 0 0 rgba(255, 255, 150, 0);
transform: scale(1);
}
30% {
box-shadow: 0 0 30px 15px rgba(255, 255, 150, 0.9);
transform: scale(1.05);
}
100% {
box-shadow: 0 0 0 rgba(255, 255, 150, 0);
transform: scale(1);
}
}
.demo-highlight {
animation: demoPulse 1.5s ease-out;
font-weight: bold;
position: relative;
z-index: 9999;
}
.star-notify-container {
position: fixed;
top: 50px;
right: 20px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
z-index: 9999;
pointer-events: none;
}
.star-notify {
opacity: 0;
background: transparent;
padding: 5px 10px;
color: white;
font-weight: 300;
text-shadow: 0 0 10px rgba(0,0,0,0.7);
transition: opacity 0.5s ease, transform 0.5s ease;
transform: translateY(-10px);
font-family: 'Segoe UI', sans-serif;
}

View File

@ -51,4 +51,4 @@ export class Schedule {
me.timeOutCount = 0; me.timeOutCount = 0;
}, this.msDelay); }, this.msDelay);
} }
} }

View File

@ -1,33 +1,34 @@
async function requestNotificationPermission() { async function requestNotificationPermission() {
const permission = await Notification.requestPermission(); const permission = await Notification.requestPermission();
return permission === 'granted'; return permission === "granted";
} }
// Subscribe to Push Notifications // Subscribe to Push Notifications
async function subscribeUser() { async function subscribeUser() {
const registration = await navigator.serviceWorker.register('/service-worker.js'); const registration =
await navigator.serviceWorker.register("/service-worker.js");
const subscription = await registration.pushManager.subscribe({ const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY) applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY),
}); });
// Send subscription to your backend // Send subscription to your backend
await fetch('/subscribe', { await fetch("/subscribe", {
method: 'POST', method: "POST",
body: JSON.stringify(subscription), body: JSON.stringify(subscription),
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
} },
}); });
} }
// Service Worker (service-worker.js) // Service Worker (service-worker.js)
self.addEventListener('push', event => { self.addEventListener("push", (event) => {
const data = event.data.json(); const data = event.data.json();
self.registration.showNotification(data.title, { self.registration.showNotification(data.title, {
body: data.message, body: data.message,
icon: data.icon icon: data.icon,
}); });
}); });

View File

@ -1,142 +1,144 @@
import {EventHandler} from "./event-handler.js"; import { EventHandler } from "./event-handler.js";
export class Socket extends EventHandler { export class Socket extends EventHandler {
/** /**
* @type {URL} * @type {URL}
*/ */
url url;
/** /**
* @type {WebSocket|null} * @type {WebSocket|null}
*/ */
ws = null ws = null;
/** /**
* @type {null|PromiseWithResolvers<Socket>&{resolved?:boolean}} * @type {null|PromiseWithResolvers<Socket>&{resolved?:boolean}}
*/ */
connection = null connection = null;
shouldReconnect = true; shouldReconnect = true;
get isConnected() { get isConnected() {
return this.ws && this.ws.readyState === WebSocket.OPEN; return this.ws && this.ws.readyState === WebSocket.OPEN;
}
get isConnecting() {
return this.ws && this.ws.readyState === WebSocket.CONNECTING;
}
constructor() {
super();
this.url = new URL("/rpc.ws", window.location.origin);
this.url.protocol = this.url.protocol.replace("http", "ws");
this.connect();
}
connect() {
if (this.ws) {
return this.connection.promise;
} }
get isConnecting() { if (!this.connection || this.connection.resolved) {
return this.ws && this.ws.readyState === WebSocket.CONNECTING; this.connection = Promise.withResolvers();
} }
constructor() { this.ws = new WebSocket(this.url);
super(); this.ws.addEventListener("open", () => {
this.connection.resolved = true;
this.connection.resolve(this);
this.emit("connected");
});
this.url = new URL('/rpc.ws', window.location.origin); this.ws.addEventListener("close", () => {
this.url.protocol = this.url.protocol.replace('http', 'ws'); console.log("Connection closed");
this.disconnect();
this.connect() });
} this.ws.addEventListener("error", (e) => {
console.error("Connection error", e);
connect() { this.disconnect();
if (this.ws) { });
return this.connection.promise; this.ws.addEventListener("message", (e) => {
if (e.data instanceof Blob || e.data instanceof ArrayBuffer) {
console.error("Binary data not supported");
} else {
try {
this.onData(JSON.parse(e.data));
} catch (e) {
console.error("Failed to parse message", e);
} }
}
});
}
if (!this.connection || this.connection.resolved) { onData(data) {
this.connection = Promise.withResolvers() if (data.success !== undefined && !data.success) {
} console.error(data);
this.ws = new WebSocket(this.url);
this.ws.addEventListener("open", () => {
this.connection.resolved = true;
this.connection.resolve(this);
this.emit("connected");
});
this.ws.addEventListener("close", () => {
console.log("Connection closed");
this.disconnect()
})
this.ws.addEventListener("error", (e) => {
console.error("Connection error", e);
this.disconnect()
})
this.ws.addEventListener("message", (e) => {
if (e.data instanceof Blob || e.data instanceof ArrayBuffer) {
console.error("Binary data not supported");
} else {
try {
this.onData(JSON.parse(e.data));
} catch (e) {
console.error("Failed to parse message", e);
}
}
})
} }
if (data.callId) {
this.emit(data.callId, data.data);
onData(data) {
if (data.success !== undefined && !data.success) {
console.error(data);
}
if (data.callId) {
this.emit(data.callId, data.data);
}
if (data.channel_uid) {
this.emit(data.channel_uid, data.data);
if(!data['event'])
this.emit("channel-message", data);
}
this.emit("data", data.data)
if(data['event']){
this.emit(data.event, data)
}
} }
if (data.channel_uid) {
disconnect() { this.emit(data.channel_uid, data.data);
this.ws?.close(); if (!data["event"]) this.emit("channel-message", data);
this.ws = null;
if (this.shouldReconnect) setTimeout(() => {
console.log("Reconnecting");
return this.connect();
}, 0);
} }
this.emit("data", data.data);
if (data["event"]) {
_camelToSnake(str) { this.emit(data.event, data.data);
return str.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
} }
}
get client() { disconnect() {
const me = this; this.ws?.close();
return new Proxy({}, { this.ws = null;
get(_, prop) {
return (...args) => {
const functionName = me._camelToSnake(prop);
return me.call(functionName, ...args);
};
},
});
}
generateCallId() { if (this.shouldReconnect)
return self.crypto.randomUUID(); setTimeout(() => {
} console.log("Reconnecting");
this.emit("reconnecting");
return this.connect();
}, 0);
}
async sendJson(data) { _camelToSnake(str) {
await this.connect().then(api => { return str.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
api.ws.send(JSON.stringify(data)); }
});
}
async call(method, ...args) { get client() {
const call = { const me = this;
callId: this.generateCallId(), return new Proxy(
method, {},
args, {
}; get(_, prop) {
const me = this return (...args) => {
return new Promise((resolve) => { const functionName = me._camelToSnake(prop);
me.addEventListener(call.callId, data => resolve(data)); return me.call(functionName, ...args);
me.sendJson(call); };
}); },
} },
);
}
generateCallId() {
return self.crypto.randomUUID();
}
async sendJson(data) {
await this.connect().then((api) => {
api.ws.send(JSON.stringify(data));
});
}
async call(method, ...args) {
const call = {
callId: this.generateCallId(),
method,
args,
};
const me = this;
return new Promise((resolve) => {
me.addEventListener(call.callId, (data) => resolve(data));
me.sendJson(call);
});
}
} }

View File

@ -2,61 +2,62 @@
// This class defines a custom HTML element for an upload button with integrated file upload functionality using XMLHttpRequest. // This class defines a custom HTML element for an upload button with integrated file upload functionality using XMLHttpRequest.
// MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: // MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
class UploadButtonElement extends HTMLElement { class UploadButtonElement extends HTMLElement {
constructor() { constructor() {
super(); super();
this.attachShadow({ mode: 'open' }); this.attachShadow({ mode: "open" });
}
chatInput = null;
async uploadFiles() {
const fileInput = this.container.querySelector(".file-input");
const uploadButton = this.container.querySelector(".upload-button");
if (!fileInput.files.length) {
return;
} }
chatInput = null
async uploadFiles() {
const fileInput = this.container.querySelector('.file-input');
const uploadButton = this.container.querySelector('.upload-button');
if (!fileInput.files.length) { const files = fileInput.files;
return; const formData = new FormData();
} for (let i = 0; i < files.length; i++) {
formData.append("files[]", files[i]);
const files = fileInput.files;
const formData = new FormData();
for (let i = 0; i < files.length; i++) {
formData.append('files[]', files[i]);
}
const request = new XMLHttpRequest();
request.responseType = 'json';
request.open('POST', `/channel/${this.channelUid}/attachment.bin`, true);
request.upload.onprogress = function (event) {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
uploadButton.innerText = `${Math.round(percentComplete)}%`;
}
};
const me = this
request.onload = function () {
if (request.status === 200) {
me.dispatchEvent(new CustomEvent('uploaded', { detail: request.response }));
uploadButton.innerHTML = '📤';
} else {
alert('Upload failed');
}
};
request.onerror = function () {
alert('Error while uploading.');
};
request.send(formData);
const uploadEvent = new Event('upload',{});
this.dispatchEvent(uploadEvent);
} }
channelUid = null const request = new XMLHttpRequest();
connectedCallback() {
this.styleElement = document.createElement('style'); request.responseType = "json";
this.styleElement.innerHTML = ` request.open("POST", `/channel/${this.channelUid}/attachment.bin`, true);
request.upload.onprogress = function (event) {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
uploadButton.innerText = `${Math.round(percentComplete)}%`;
}
};
const me = this;
request.onload = function () {
if (request.status === 200) {
me.dispatchEvent(
new CustomEvent("uploaded", { detail: request.response }),
);
uploadButton.innerHTML = "📤";
} else {
alert("Upload failed");
}
};
request.onerror = function () {
alert("Error while uploading.");
};
request.send(formData);
const uploadEvent = new Event("upload", {});
this.dispatchEvent(uploadEvent);
}
channelUid = null;
connectedCallback() {
this.styleElement = document.createElement("style");
this.styleElement.innerHTML = `
body { body {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
display: flex; display: flex;
@ -97,9 +98,9 @@ class UploadButtonElement extends HTMLElement {
display: none; display: none;
} }
`; `;
this.shadowRoot.appendChild(this.styleElement); this.shadowRoot.appendChild(this.styleElement);
this.container = document.createElement('div'); this.container = document.createElement("div");
this.container.innerHTML = ` this.container.innerHTML = `
<div class="upload-container"> <div class="upload-container">
<button class="upload-button"> <button class="upload-button">
📤 📤
@ -107,17 +108,17 @@ class UploadButtonElement extends HTMLElement {
<input class="hidden-input file-input" type="file" multiple /> <input class="hidden-input file-input" type="file" multiple />
</div> </div>
`; `;
this.shadowRoot.appendChild(this.container); this.shadowRoot.appendChild(this.container);
this.channelUid = this.getAttribute('channel'); this.channelUid = this.getAttribute("channel");
this.uploadButton = this.container.querySelector('.upload-button'); this.uploadButton = this.container.querySelector(".upload-button");
this.fileInput = this.container.querySelector('.hidden-input'); this.fileInput = this.container.querySelector(".hidden-input");
this.uploadButton.addEventListener('click', () => { this.uploadButton.addEventListener("click", () => {
this.fileInput.click(); this.fileInput.click();
}); });
this.fileInput.addEventListener('change', () => { this.fileInput.addEventListener("change", () => {
this.uploadFiles(); this.uploadFiles();
}); });
} }
} }
customElements.define('upload-button', UploadButtonElement); customElements.define("upload-button", UploadButtonElement);

View File

@ -1,36 +1,36 @@
class UserList extends HTMLElement { class UserList extends HTMLElement {
constructor() { constructor() {
super(); super();
this.users = []; this.users = [];
} }
set data(userArray) { set data(userArray) {
this.users = userArray; this.users = userArray;
this.render(); this.render();
} }
formatRelativeTime(timestamp) { formatRelativeTime(timestamp) {
const now = new Date(); const now = new Date();
const msgTime = new Date(timestamp); const msgTime = new Date(timestamp);
const diffMs = now - msgTime; const diffMs = now - msgTime;
const minutes = Math.floor(diffMs / 60000); const minutes = Math.floor(diffMs / 60000);
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
if (days > 0) { if (days > 0) {
return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${days} day${days > 1 ? 's' : ''} ago`; return `${msgTime.getHours().toString().padStart(2, "0")}:${msgTime.getMinutes().toString().padStart(2, "0")}, ${days} day${days > 1 ? "s" : ""} ago`;
} else if (hours > 0) { } else if (hours > 0) {
return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${hours} hour${hours > 1 ? 's' : ''} ago`; return `${msgTime.getHours().toString().padStart(2, "0")}:${msgTime.getMinutes().toString().padStart(2, "0")}, ${hours} hour${hours > 1 ? "s" : ""} ago`;
} else { } else {
return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${minutes} min ago`; return `${msgTime.getHours().toString().padStart(2, "0")}:${msgTime.getMinutes().toString().padStart(2, "0")}, ${minutes} min ago`;
} }
} }
render() { render() {
this.innerHTML = ''; this.innerHTML = "";
this.users.forEach(user => { this.users.forEach((user) => {
const html = ` const html = `
<div class="user-list__item" <div class="user-list__item"
data-uid="${user.uid}" data-uid="${user.uid}"
data-color="${user.color}" data-color="${user.color}"
@ -51,9 +51,9 @@
</div> </div>
</div> </div>
`; `;
this.insertAdjacentHTML("beforeend", html); this.insertAdjacentHTML("beforeend", html);
}); });
} }
} }
customElements.define('user-list', UserList); customElements.define("user-list", UserList);

View File

@ -1,504 +1,392 @@
<div id="star-tooltip" class="star-tooltip"></div>
<div id="star-popup" class="star-popup"></div>
<script type="module"> <script type="module">
import { app } from "/app.js"; import { app } from "/app.js";
const STAR_COUNT = 200;
const body = document.body;
function getStarPosition(star) {
const leftPercent = parseFloat(star.style.left);
const topPercent = parseFloat(star.style.top);
let position;
if (topPercent < 40 && leftPercent >= 40 && leftPercent <= 60) {
position = 'North';
} else if (topPercent > 60 && leftPercent >= 40 && leftPercent <= 60) {
position = 'South';
} else if (leftPercent < 40 && topPercent >= 40 && topPercent <= 60) {
position = 'West';
} else if (leftPercent > 60 && topPercent >= 40 && topPercent <= 60) {
position = 'East';
} else if (topPercent >= 40 && topPercent <= 60 && leftPercent >= 40 && leftPercent <= 60) {
position = 'Center';
} else {
position = 'Corner or Edge';
}
return position
}
let stars = {}
window.stars = stars
function createStar() {
const star = document.createElement('div');
star.classList.add('star');
star.style.left = `${Math.random() * 100}%`;
star.style.top = `${Math.random() * 100}%`;
star.shuffle = () => {
star.style.left = `${Math.random() * 100}%`;
star.style.top = `${Math.random() * 100}%`;
star.position = getStarPosition(star)
}
star.position = getStarPosition(star)
function moveStarToPosition(star, position) {
let top, left;
switch (position) {
case 'North':
top = `${Math.random() * 20}%`;
left = `${40 + Math.random() * 20}%`;
break;
case 'South':
top = `${80 + Math.random() * 10}%`;
left = `${40 + Math.random() * 20}%`;
break;
case 'West':
top = `${40 + Math.random() * 20}%`;
left = `${Math.random() * 20}%`;
break;
case 'East':
top = `${40 + Math.random() * 20}%`;
left = `${80 + Math.random() * 10}%`;
break;
case 'Center':
top = `${45 + Math.random() * 10}%`;
left = `${45 + Math.random() * 10}%`;
break;
default: // 'Corner or Edge' fallback
top = `${Math.random() * 100}%`;
left = `${Math.random() * 100}%`;
break;
}
star.style.top = top;
star.style.left = left;
star.position = getStarPosition(star)
}
if(!stars[star.position])
stars[star.position] = []
stars[star.position].push(star)
const size = Math.random() * 2 + 1;
star.style.width = `${size}px`;
star.style.height = `${size}px`;
const duration = Math.random() * 3 + 2;
const delay = Math.random() * 5;
star.style.animationDuration = `${duration}s`;
star.style.animationDelay = `${delay}s`;
body.appendChild(star);
}
Array.from({ length: STAR_COUNT }, createStar);
function lightenColor(hex, percent) {
const num = parseInt(hex.replace("#", ""), 16);
let r = (num >> 16) + Math.round(255 * percent / 100);
let g = ((num >> 8) & 0x00FF) + Math.round(255 * percent / 100);
let b = (num & 0x0000FF) + Math.round(255 * percent / 100);
r = Math.min(255, r);
g = Math.min(255, g);
b = Math.min(255, b);
return `#${(1 << 24 | r << 16 | g << 8 | b).toString(16).slice(1)}`;
}
const originalColor = document.documentElement.style.getPropertyValue("--star-color").trim();
function glowCSSVariable(varName, glowColor, duration = 500) {
const root = document.documentElement;
//igetComputedStyle(root).getPropertyValue(varName).trim();
glowColor = lightenColor(glowColor, 10);
root.style.setProperty(varName, glowColor);
setTimeout(() => {
root.style.setProperty(varName, originalColor);
}, duration);
}
function updateStarColorDelayed(color) {
glowCSSVariable('--star-color', color, 2500);
}
app.updateStarColor = updateStarColorDelayed;
app.ws.addEventListener("set_typing", (data) => {
updateStarColorDelayed(data.data.color);
});
window.createAvatar = () => {
let avatar = document.createElement("avatar-face")
document.querySelector("main").appendChild(avatar)
return avatar
}
class AvatarFace extends HTMLElement {
static get observedAttributes(){
return ['emotion','face-color','eye-color','text','balloon-color','text-color'];
}
constructor(){
super();
this._shadow = this.attachShadow({mode:'open'});
this._shadow.innerHTML = `
<style>
:host { display:block; position:relative; }
canvas { width:100%; height:100%; display:block; }
</style>
<canvas></canvas>
`;
this._c = this._shadow.querySelector('canvas');
this._ctx = this._c.getContext('2d');
// state
this._mouse = {x:0,y:0};
this._blinkTimer = 0;
this._blinking = false;
this._lastTime = 0;
// defaults
this._emotion = 'neutral';
this._faceColor = '#ffdfba';
this._eyeColor = '#000';
this._text = '';
this._balloonColor = '#fff';
this._textColor = '#000';
}
attributeChangedCallback(name,_old,newV){
if (name==='emotion') this._emotion = newV||'neutral';
else if (name==='face-color') this._faceColor = newV||'#ffdfba';
else if (name==='eye-color') this._eyeColor = newV||'#000';
else if (name==='text') this._text = newV||'';
else if (name==='balloon-color')this._balloonColor = newV||'#fff';
else if (name==='text-color') this._textColor = newV||'#000';
}
connectedCallback(){
// watch size so canvas buffer matches display
this._ro = new ResizeObserver(entries=>{
for(const ent of entries){
const w = ent.contentRect.width;
const h = ent.contentRect.height;
const dpr = devicePixelRatio||1;
this._c.width = w*dpr;
this._c.height = h*dpr;
this._ctx.scale(dpr,dpr);
}
});
this._ro.observe(this);
// track mouse so eyes follow
this._shadow.addEventListener('mousemove', e=>{
const r = this._c.getBoundingClientRect();
this._mouse.x = e.clientX - r.left;
this._mouse.y = e.clientY - r.top;
});
this._lastTime = performance.now();
this._raf = requestAnimationFrame(t=>this._loop(t));
}
disconnectedCallback(){
cancelAnimationFrame(this._raf);
this._ro.disconnect();
}
_updateBlink(dt){
this._blinkTimer -= dt;
if (this._blinkTimer<=0){
this._blinking = !this._blinking;
this._blinkTimer = this._blinking
? 0.1
: 2 + Math.random()*3;
}
}
_roundRect(x,y,w,h,r){
const ctx = this._ctx;
ctx.beginPath();
ctx.moveTo(x+r,y);
ctx.lineTo(x+w-r,y);
ctx.quadraticCurveTo(x+w,y, x+w,y+r);
ctx.lineTo(x+w,y+h-r);
ctx.quadraticCurveTo(x+w,y+h, x+w-r,y+h);
ctx.lineTo(x+r,y+h);
ctx.quadraticCurveTo(x,y+h, x,y+h-r);
ctx.lineTo(x,y+r);
ctx.quadraticCurveTo(x,y, x+r,y);
ctx.closePath();
}
_draw(ts){
const ctx = this._ctx;
const W = this._c.clientWidth;
const H = this._c.clientHeight;
ctx.clearRect(0,0,W,H);
// HEAD + BOB
const cx = W/2;
const cy = H/2 + Math.sin(ts*0.002)*8;
const R = Math.min(W,H)*0.25;
// SPEECH BALLOON
if (this._text){
const pad = 6;
ctx.font = `${R*0.15}px sans-serif`;
const m = ctx.measureText(this._text);
const tw = m.width, th = R*0.18;
const bw = tw + pad*2, bh = th + pad*2;
const bx = cx - bw/2, by = cy - R - bh - 10;
// bubble
ctx.fillStyle = this._balloonColor;
this._roundRect(bx,by,bw,bh,6);
ctx.fill();
ctx.strokeStyle = '#888';
ctx.lineWidth = 1.2;
ctx.stroke();
// tail
ctx.beginPath();
ctx.moveTo(cx-6, by+bh);
ctx.lineTo(cx+6, by+bh);
ctx.lineTo(cx, cy-R+4);
ctx.closePath();
ctx.fill();
ctx.stroke();
// text
ctx.fillStyle = this._textColor;
ctx.textBaseline = 'top';
ctx.fillText(this._text, bx+pad, by+pad);
}
// FACE
ctx.fillStyle = this._faceColor;
ctx.beginPath();
ctx.arc(cx,cy,R,0,2*Math.PI);
ctx.fill();
// EYES
const eyeY = cy - R*0.2;
const eyeX = R*0.4;
const eyeR= R*0.12;
const pupR= eyeR*0.5;
for(let i=0;i<2;i++){
const ex = cx + (i? eyeX:-eyeX);
const ey = eyeY;
// eyeball
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(ex,ey,eyeR,0,2*Math.PI);
ctx.fill();
// pupil follows
let dx = this._mouse.x - ex;
let dy = this._mouse.y - ey;
const d = Math.hypot(dx,dy);
const max = eyeR - pupR - 2;
if (d>max){ dx=dx/d*max; dy=dy/d*max; }
if (this._blinking){
ctx.strokeStyle='#000';
ctx.lineWidth=3;
ctx.beginPath();
ctx.moveTo(ex-eyeR,ey);
ctx.lineTo(ex+eyeR,ey);
ctx.stroke();
} else {
ctx.fillStyle = this._eyeColor;
ctx.beginPath();
ctx.arc(ex+dx,ey+dy,pupR,0,2*Math.PI);
ctx.fill();
}
}
// ANGRY BROWS
if (this._emotion==='angry'){
ctx.strokeStyle='#000';
ctx.lineWidth=4;
[[-eyeX,1],[ eyeX,-1]].forEach(([off,dir])=>{
const sx = cx+off - eyeR;
const sy = eyeY - eyeR*1.3;
const ex = cx+off + eyeR;
const ey2= sy + dir*6;
ctx.beginPath();
ctx.moveTo(sx,sy);
ctx.lineTo(ex,ey2);
ctx.stroke();
});
}
// MOUTH by emotion
const mw = R*0.6;
const my = cy + R*0.25;
ctx.strokeStyle='#a33';
ctx.lineWidth=4;
if (this._emotion==='surprised'){
ctx.fillStyle='#a33';
ctx.beginPath();
ctx.arc(cx,my,mw*0.3,0,2*Math.PI);
ctx.fill();
}
else if (this._emotion==='sad'){
ctx.beginPath();
ctx.arc(cx,my,mw/2,1.15*Math.PI,1.85*Math.PI,true);
ctx.stroke();
}
else if (this._emotion==='angry'){
ctx.beginPath();
ctx.moveTo(cx-mw/2,my+2);
ctx.lineTo(cx+mw/2,my-2);
ctx.stroke();
}
else {
const s = this._emotion==='happy'? 0.15*Math.PI:0.2*Math.PI;
const e = this._emotion==='happy'? 0.85*Math.PI:0.8*Math.PI;
ctx.beginPath();
ctx.arc(cx,my,mw/2,s,e);
ctx.stroke();
}
}
_loop(ts){
const dt = (ts - this._lastTime)/1000;
this._lastTime = ts;
this._updateBlink(dt);
this._draw(ts);
this._raf = requestAnimationFrame(t=>this._loop(t));
}
}
customElements.define('avatar-face', AvatarFace);
class AvatarReplacer {
constructor(target, opts={}){
this.target = target;
// record original inline styles so we can restore
this._oldVis = target.style.visibility || '';
this._oldPos = target.style.position || '';
// hide the target
target.style.visibility = 'hidden';
// measure
const rect = target.getBoundingClientRect();
// create avatar
this.avatar = document.createElement('avatar-face');
// copy all supported opts into attributes
['emotion','faceColor','eyeColor','text','balloonColor','textColor']
.forEach(k => {
const attr = k.replace(/[A-Z]/g,m=>'-'+m.toLowerCase());
if (opts[k] != null) this.avatar.setAttribute(attr, opts[k]);
});
// position absolutely
const scrollX = window.pageXOffset;
const scrollY = window.pageYOffset;
Object.assign(this.avatar.style, {
position: 'absolute',
left: (rect.left + scrollX) + 'px',
top: (rect.top + scrollY) + 'px',
width: rect.width + 'px',
height: rect.height + 'px',
zIndex: 9999
});
document.body.appendChild(this.avatar);
}
detach(){
// remove avatar and restore target
if (this.avatar && this.avatar.parentNode) {
this.avatar.parentNode.removeChild(this.avatar);
this.avatar = null;
}
this.target.style.visibility = this._oldVis;
this.target.style.position = this._oldPos;
}
// static convenience method
static attach(target, opts){
return new AvatarReplacer(target, opts);
}
}
/*
// DEMO wiring
const btnGo = document.getElementById('go');
const btnReset = document.getElementById('reset');
let repl1, repl2;
btnGo.addEventListener('click', ()=>{
// replace #one with a happy avatar saying "Hi!"
repl1 = AvatarReplacer.attach(
document.getElementById('one'),
{emotion:'happy', text:'Hi!', balloonColor:'#fffbdd', textColor:'#333'}
);
// replace #two with a surprised avatar
repl2 = AvatarReplacer.attach(
document.getElementById('two'),
{emotion:'surprised', faceColor:'#eeffcc', text:'Wow!', balloonColor:'#ddffdd'}
);
});
btnReset.addEventListener('click', ()=>{
if (repl1) repl1.detach();
if (repl2) repl2.detach();
});
*/
/*
class StarField { class StarField {
constructor(container = document.body, options = {}) { constructor({ count = 200, container = document.body } = {}) {
this.container = container; this.container = container;
this.starCount = count;
this.stars = []; this.stars = [];
this.setOptions(options); this.positionMap = {};
this.originalColor = getComputedStyle(document.documentElement).getPropertyValue("--star-color").trim();
this._createStars();
window.stars = this.positionMap;
} }
setOptions({ _getStarPosition(star) {
starCount = 200, const left = parseFloat(star.style.left);
minSize = 1, const top = parseFloat(star.style.top);
maxSize = 3, if (top < 40 && left >= 40 && left <= 60) return "North";
speed = 5, if (top > 60 && left >= 40 && left <= 60) return "South";
color = "white" if (left < 40 && top >= 40 && top <= 60) return "West";
}) { if (left > 60 && top >= 40 && top <= 60) return "East";
this.options = { starCount, minSize, maxSize, speed, color }; if (top >= 40 && top <= 60 && left >= 40 && left <= 60) return "Center";
return "Corner or Edge";
} }
clear() { _createStars() {
this.stars.forEach(star => star.remove()); for (let i = 0; i < this.starCount; i++) {
this.stars = [];
}
generate() {
this.clear();
const { starCount, minSize, maxSize, speed, color } = this.options;
for (let i = 0; i < starCount; i++) {
const star = document.createElement("div"); const star = document.createElement("div");
star.classList.add("star"); star.classList.add("star");
const size = Math.random() * (maxSize - minSize) + minSize; this._randomizeStar(star);
this._placeStar(star);
Object.assign(star.style, {
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
width: `${size}px`,
height: `${size}px`,
backgroundColor: color,
position: "absolute",
borderRadius: "50%",
opacity: "0.8",
animation: `twinkle ${speed}s ease-in-out infinite`,
});
this.container.appendChild(star); this.container.appendChild(star);
this.stars.push(star); this.stars.push(star);
} }
} }
_randomizeStar(star) {
star.style.left = `${Math.random() * 100}%`;
star.style.top = `${Math.random() * 100}%`;
star.style.width = `${Math.random() * 2 + 1}px`;
star.style.height = `${Math.random() * 2 + 1}px`;
star.style.animationDelay = `${Math.random() * 2}s`;
star.style.position = "absolute";
star.style.transition = "top 1s ease, left 1s ease, opacity 1s ease";
star.shuffle = () => this._randomizeStar(star);
star.position = this._getStarPosition(star);
}
_placeStar(star) {
const pos = star.position;
if (!this.positionMap[pos]) this.positionMap[pos] = [];
this.positionMap[pos].push(star);
}
shuffleAll(duration = 1000) {
this.stars.forEach(star => {
star.style.transition = `top ${duration}ms ease, left ${duration}ms ease, opacity ${duration}ms ease`;
star.style.filter = "drop-shadow(0 0 2px white)";
const left = Math.random() * 100;
const top = Math.random() * 100;
star.style.left = `${left}%`;
star.style.top = `${top}%`;
setTimeout(() => {
star.style.filter = "";
star.position = this._getStarPosition(star);
}, duration);
});
}
glowColor(tempColor, duration = 2500) {
const lighten = (hex, percent) => {
const num = parseInt(hex.replace("#", ""), 16);
const r = Math.min(255, (num >> 16) + Math.round(255 * percent / 100));
const g = Math.min(255, ((num >> 8) & 0xff) + Math.round(255 * percent / 100));
const b = Math.min(255, (num & 0xff) + Math.round(255 * percent / 100));
return `#${(1 << 24 | r << 16 | g << 8 | b).toString(16).slice(1)}`;
};
const glow = lighten(tempColor, 10);
document.documentElement.style.setProperty("--star-color", glow);
setTimeout(() => {
document.documentElement.style.setProperty("--star-color", this.originalColor);
}, duration);
}
showNotify(text, { duration = 3000, color = "white", fontSize = "1.2em" } = {}) {
// Create container if needed
if (!this._notifyContainer) {
this._notifyContainer = document.createElement("div");
this._notifyContainer.className = "star-notify-container";
document.body.appendChild(this._notifyContainer);
}
const messages = document.querySelectorAll('.star-notify');
const count = Array.from(messages).filter(el => el.textContent.trim() === text).length;
if (count) return;
const note = document.createElement("div");
note.className = "star-notify";
note.textContent = text;
note.style.color = color;
note.style.fontSize = fontSize;
this._notifyContainer.appendChild(note);
// Trigger animation
setTimeout(() => {
note.style.opacity = 1;
note.style.transform = "translateY(0)";
}, 10);
// Remove after duration
setTimeout(() => {
note.style.opacity = 0;
note.style.transform = "translateY(-10px)";
setTimeout(() => note.remove(), 500);
}, duration);
} }
const starField = new StarField(document.body, {
starCount: 200, renderWord(word, { font = "bold", resolution = 8, duration = 1500, rainbow = false } = {}) {
minSize: 1, const canvas = document.createElement("canvas");
maxSize: 3, const ctx = canvas.getContext("2d");
speed: 5, canvas.width = this.container.clientWidth;
color: "white" canvas.height = this.container.clientHeight / 3;
});
*/ const fontSize = Math.floor(canvas.height / 2.5);
ctx.font = `${fontSize}px sans-serif`;
ctx.fillStyle = "black";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(word, canvas.width / 2, canvas.height / 2);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
const targetPositions = [];
for (let y = 0; y < canvas.height; y += resolution) {
for (let x = 0; x < canvas.width; x += resolution) {
const i = (y * canvas.width + x) * 4;
if (imageData[i + 3] > 128) {
targetPositions.push([
(x / canvas.width) * 100,
(y / canvas.height) * 100,
]);
}
}
}
targetPositions.sort(() => Math.random() - 0.5);
const used = targetPositions.slice(0, this.stars.length);
this.stars.forEach((star, i) => {
star.style.transition = `top ${duration}ms ease, left ${duration}ms ease, opacity ${duration}ms ease, background-color 1s ease`;
if (i < used.length) {
const [left, top] = used[i];
star.style.left = `${left}%`;
star.style.top = `${top}%`;
star.style.opacity = 1;
if (rainbow) {
const hue = (i / used.length) * 360;
star.style.backgroundColor = `hsl(${hue}, 100%, 70%)`;
}
} else {
star.style.opacity = 0;
}
});
setTimeout(() => {
this.stars.forEach(star => {
star.position = this._getStarPosition(star);
if (rainbow) star.style.backgroundColor = "";
});
}, duration);
}
explodeAndReturn(duration = 1000) {
const originalPositions = this.stars.map(star => ({
left: star.style.left,
top: star.style.top
}));
this.stars.forEach(star => {
const angle = Math.random() * 2 * Math.PI;
const radius = Math.random() * 200;
const x = 50 + Math.cos(angle) * radius;
const y = 50 + Math.sin(angle) * radius;
star.style.transition = `top ${duration / 2}ms ease-out, left ${duration / 2}ms ease-out`;
star.style.left = `${x}%`;
star.style.top = `${y}%`;
});
setTimeout(() => {
this.stars.forEach((star, i) => {
star.style.transition = `top ${duration}ms ease-in, left ${duration}ms ease-in`;
star.style.left = originalPositions[i].left;
star.style.top = originalPositions[i].top;
});
}, duration / 2);
}
startColorCycle() {
let hue = 0;
if (this._colorInterval) clearInterval(this._colorInterval);
this._colorInterval = setInterval(() => {
hue = (hue + 2) % 360;
this.stars.forEach((star, i) => {
star.style.backgroundColor = `hsl(${(hue + i * 3) % 360}, 100%, 75%)`;
});
}, 100);
}
stopColorCycle() {
if (this._colorInterval) {
clearInterval(this._colorInterval);
this._colorInterval = null;
this.stars.forEach(star => star.style.backgroundColor = "");
}
}
addSpecialStar({ title, content, category = "Info", color = "gold", onClick }) {
const star = this.stars.find(s => !s._dataAttached);
if (!star) return;
star.classList.add("special");
star.style.backgroundColor = color;
star._dataAttached = true;
star._specialData = { title, content, category, color, onClick };
const tooltip = document.getElementById("star-tooltip");
const showTooltip = (e) => {
tooltip.innerText = `${title} (${category})`;
tooltip.style.display = "block";
tooltip.style.left = `${e.clientX + 10}px`;
tooltip.style.top = `${e.clientY + 10}px`;
};
const hideTooltip = () => tooltip.style.display = "none";
star.addEventListener("mouseenter", showTooltip);
star.addEventListener("mouseleave", hideTooltip);
star.addEventListener("mousemove", showTooltip);
const showPopup = (e) => {
e.stopPropagation();
this._showPopup(star, star._specialData);
if (onClick) onClick(star._specialData);
};
star.addEventListener("click", showPopup);
star.addEventListener("touchend", showPopup);
return star;
}
_showPopup(star, data) {
const popup = document.getElementById("star-popup");
popup.innerHTML = `<strong>${data.title}</strong><br><small>${data.category}</small><div style="margin-top: 5px;">${data.content}</div>`;
popup.style.display = "block";
const rect = star.getBoundingClientRect();
popup.style.left = `${rect.left + window.scrollX + 10}px`;
popup.style.top = `${rect.top + window.scrollY + 10}px`;
const closeHandler = () => {
popup.style.display = "none";
document.removeEventListener("click", closeHandler);
};
setTimeout(() => document.addEventListener("click", closeHandler), 100);
}
removeSpecialStar(star) {
if (!star._specialData) return;
star.classList.remove("special");
delete star._specialData;
star._dataAttached = false;
const clone = star.cloneNode(true);
star.replaceWith(clone);
const index = this.stars.indexOf(star);
if (index >= 0) this.stars[index] = clone;
}
getSpecialStars() {
return this.stars
.filter(star => star._specialData)
.map(star => ({
star,
data: star._specialData
}));
}
}
const starField = new StarField({starCount: 200});
app.starField = starField;
class DemoSequence {
constructor(steps = []) {
this.steps = steps;
this.overlay = document.createElement("div");
this.overlay.className = "demo-overlay";
document.body.appendChild(this.overlay);
}
start() {
this._runStep(0);
}
_runStep(index) {
if (index >= this.steps.length) {
this._clearOverlay();
return;
}
const { target, text, duration = 3000 } = this.steps[index];
this._clearHighlights();
this._showOverlay(text);
if (target) {
const el = typeof target === 'string' ? document.querySelector(target) : target;
if (el) {
el.classList.remove("demo-highlight");
void el.offsetWidth; // force reflow to reset animation
el.classList.add("demo-highlight");
el.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
setTimeout(() => {
this._hideOverlay();
this._runStep(index + 1);
}, duration);
}
_showOverlay(text) {
this.overlay.innerText = text;
this.overlay.style.animation = "demoFadeIn 0.8s ease forwards";
}
_hideOverlay() {
this.overlay.style.opacity = 0;
this._clearHighlights();
}
_clearOverlay() {
this.overlay.remove();
this._clearHighlights();
}
_clearHighlights() {
document.querySelectorAll(".demo-highlight").forEach(el => {
el.classList.remove("demo-highlight");
});
}
}
/*
const demo = new DemoSequence([
{
text: "💬 Welcome to the Snek Developer Community!",
duration: 3000
},
{
target: ".channels-list",
text: "🔗 Channels help you organize conversations.",
duration: 3000
},
{
target: ".user-icon",
text: "👥 Invite team members here.",
duration: 3000
},
{
target: ".chat-input",
text: "⌨️ Type your message and hit Enter!",
duration: 3000
}
]);*/
// Start when ready (e.g., after load or user action)
//demo.start();
</script> </script>

View File

@ -6,11 +6,12 @@
<section class="chat-area"> <section class="chat-area">
<message-list class="chat-messages"> <message-list class="chat-messages">
{% for message in messages %} {% for message in messages %}
{% autoescape false %} {% autoescape false %}
{{ message.html }} {{ message.html }}
{% endautoescape %} {% endautoescape %}
{% endfor %} {% endfor %}
<div class="message-list-bottom"></div>
</message-list> </message-list>
<chat-input live-type="false" channel="{{ channel.uid.value }}"></chat-input> <chat-input live-type="false" channel="{{ channel.uid.value }}"></chat-input>
</section> </section>
@ -36,7 +37,21 @@
showHelp(); showHelp();
} }
} }
app.ws.addEventListener("refresh", (data) => {
app.starField.showNotify(data.message);
setTimeout(() => {
window.location.reload();
},4000)
})
app.ws.addEventListener("deployed", (data) => {
app.starField.renderWord("Deployed",{"rainbow":true,"resolution":8});
setTimeout(() => {
app.starField.shuffleAll(5000);
},10000)
})
app.ws.addEventListener("starfield.render_word", (data) => {
app.starField.renderWord(data.word,data);
})
const textBox = document.querySelector("chat-input").textarea const textBox = document.querySelector("chat-input").textarea
textBox.addEventListener("paste", async (e) => { textBox.addEventListener("paste", async (e) => {
try { try {
@ -199,9 +214,8 @@
}); });
lastMessage = messagesContainer.querySelector(".message:last-child"); lastMessage = messagesContainer.querySelector(".message:last-child");
if (doScrollDown) { if (doScrollDown) {
lastMessage?.scrollIntoView({ block: "end", inline: "nearest" }); messagesContainer.scrollToBottom()
chatInputField.scrollIntoView({ block: "end", inline: "nearest" });
} }
} }

View File

@ -9,7 +9,7 @@
import json import json
import traceback import traceback
import asyncio
from aiohttp import web from aiohttp import web
from snek.system.model import now from snek.system.model import now
@ -21,13 +21,30 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RPCView(BaseView): class RPCView(BaseView):
class RPCApi: class RPCApi:
def __init__(self, view, ws): def __init__(self, view, ws):
self.view = view self.view = view
self.app = self.view.app self.app = self.view.app
self.services = self.app.services self.services = self.app.services
self.ws = ws self.ws = ws
self.user_session = {}
self._scheduled = []
async def _session_ensure(self):
uid = await self.view.session_get("uid")
if not uid in self.user_session:
self.user_session[uid] = {
"said_hello": False,
}
async def session_get(self, key, default):
await self._session_ensure()
return self.user_session[self.user_uid].get(key, default)
async def session_set(self, key, value):
await self._session_ensure()
self.user_session[self.user_uid][key] = value
return True
async def db_insert(self, table_name, record): async def db_insert(self, table_name, record):
self._require_login() self._require_login()
@ -323,6 +340,13 @@ class RPCView(BaseView):
async for record in self.services.channel.get_users(channel_uid) async for record in self.services.channel.get_users(channel_uid)
] ]
async def _schedule(self, seconds, call):
self._scheduled.append(call)
await asyncio.sleep(seconds)
await self.services.socket.send_to_user(self.user_uid, call)
self._scheduled.remove(call)
async def ping(self, callId, *args): async def ping(self, callId, *args):
if self.user_uid: if self.user_uid:
user = await self.services.user.get(uid=self.user_uid) user = await self.services.user.get(uid=self.user_uid)
@ -330,7 +354,17 @@ class RPCView(BaseView):
await self.services.user.save(user) await self.services.user.save(user)
return {"pong": args} return {"pong": args}
async def get(self): async def get(self):
scheduled = []
async def schedule(uid, seconds, call):
scheduled.append(call)
await asyncio.sleep(seconds)
await self.services.socket.send_to_user(uid, call)
scheduled.remove(call)
ws = web.WebSocketResponse() ws = web.WebSocketResponse()
await ws.prepare(self.request) await ws.prepare(self.request)
if self.request.session.get("logged_in"): if self.request.session.get("logged_in"):
@ -343,6 +377,16 @@ class RPCView(BaseView):
await self.services.socket.subscribe( await self.services.socket.subscribe(
ws, subscription["channel_uid"], self.request.session.get("uid") ws, subscription["channel_uid"], self.request.session.get("uid")
) )
if not scheduled and self.request.app.uptime_seconds < 5:
await schedule(self.request.session.get("uid"),0,{"event":"refresh", "data": {
"message": "Finishing deployment"}
}
)
await schedule(self.request.session.get("uid"),10,{"event": "deployed", "data": {
"uptime": self.request.app.uptime}
}
)
rpc = RPCView.RPCApi(self, ws) rpc = RPCView.RPCApi(self, ws)
async for msg in ws: async for msg in ws:
if msg.type == web.WSMsgType.TEXT: if msg.type == web.WSMsgType.TEXT: