Compare commits

...
Author SHA1 Message Date
retoor bde3819510 Update. 2025-06-01 00:24:45 +02:00
retoor 097889ba3f Make database asnyc. 2025-05-31 23:30:41 +02:00
retoor 4854d40508 Update. 2025-05-31 13:52:04 +02:00
retoor 7dd3133475 Merge pull request 'Add URL embedding functionality with metadata extraction and responsive design' (#48) from BordedDev/snek:feat/url-embedding into main
Reviewed-on: #48
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-30 19:53:22 +02:00
retoor 24dfa39f91 Update chat input. 2025-05-30 19:32:18 +02:00
BordedDev 7ec65f7c12 Cleaned up imports 2025-05-30 02:14:45 +02:00
BordedDev 4f8edef42b Add URL embedding functionality with metadata extraction and responsive design 2025-05-30 02:13:36 +02:00
retoor 7818410d55 Upddated interval. 2025-05-29 02:42:31 +02:00
retoor 1762191b03 Update. 2025-05-28 21:49:30 +02:00
retoor 2df92e809e Added iinput mode. 2025-05-28 13:40:12 +02:00
retoor 59a8d32e40 Added iinput mode. 2025-05-28 13:36:43 +02:00
retoor c3b3963760 Fix cross typing. 2025-05-28 12:31:17 +02:00
retoor a0cd39e3bc Fix cross typing. 2025-05-28 12:29:01 +02:00
retoor e48b2258e0 Made live typing default. 2025-05-28 11:38:16 +02:00
retoor 35aaf8824f Fixed directory does not exist bug. 2025-05-28 10:40:05 +02:00
retoor 76c69ca3ec Is finalized 2025-05-27 23:14:06 +02:00
retoor 9994225911 Update sort. 2025-05-27 22:22:32 +02:00
retoor 03f699e448 Updated online users. 2025-05-27 22:16:41 +02:00
retoor 2fd01a5ab7 Stars update. 2025-05-27 14:28:07 +02:00
retoor 96629113f1 Most recent users. 2025-05-27 14:03:01 +02:00
retoor 973afa0cc2 Scrolled to bottom fix. 2025-05-27 12:33:04 +02:00
retoor 9bc55e771a Scrolled to bottom fix. 2025-05-27 12:30:42 +02:00
retoor 40a292d05e Scrolled to bottom fix. 2025-05-27 12:21:40 +02:00
retoor bf723db2cc Scrolled to bottom fix. 2025-05-27 12:16:47 +02:00
retoor 46052172b2 Scrolled to bottom fix. 2025-05-27 12:14:02 +02:00
retoor 4f777f0003 Scrolled to bottom fix. 2025-05-27 12:12:03 +02:00
retoor b5e1ba72d0 Scrolled to bottom fix. 2025-05-27 12:10:26 +02:00
retoor df120098f9 Scrolled to bottom fix. 2025-05-27 12:08:53 +02:00
retoor 1c1d578db7 Fixed mentions. 2025-05-27 12:06:35 +02:00
retoor 69352fe0b5 Fixed mentions. 2025-05-27 11:45:23 +02:00
retoor fb3980dad0 Removed last-message. 2025-05-27 11:03:25 +02:00
retoor 6c21a1e619 Async load users. 2025-05-27 10:57:18 +02:00
retoor 112c0dc70a Update channel message. 2025-05-27 10:50:09 +02:00
retoor 538a9ce25d Scrolled to bottom fix. 2025-05-27 10:43:44 +02:00
retoor cdc3d10df5 Moved method. 2025-05-27 10:33:00 +02:00
retoor 1b150e3e64 Moved method. 2025-05-27 10:33:00 +02:00
retoor 27dccc324a Moved method. 2025-05-27 10:33:00 +02:00
retoor 1bb68ab33b Moved method. 2025-05-27 10:33:00 +02:00
retoor 3c6ea15d47 Update render to include user info. 2025-05-27 10:33:00 +02:00
retoor 36e663e1ed added animal view. 2025-05-27 10:33:00 +02:00
retoor 8d2e0381a7 Merge pull request 'Enhance mobile responsiveness by updating viewport settings and restructuring layout' (#47) from BordedDev/snek:bugfix/page-resize-on-mobile into main
Reviewed-on: #47
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-27 00:27:33 +02:00
retoor f67d7b35f1 Merge pull request 'Potential missing letter fix' (#46) from BordedDev/snek:bugfix/potential-lag-fix into main
Reviewed-on: #46
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-27 00:25:56 +02:00
BordedDev 9ec62f7471 Enhance mobile responsiveness by updating viewport settings and restructuring layout 2025-05-27 00:23:41 +02:00
retoor 6bbbc41360 Update. 2025-05-25 20:50:32 +02:00
retoor d3844ac7a7 Resoted avatars. 2025-05-25 19:01:28 +02:00
retoor 9378e95a5b Resoted avatars. 2025-05-25 18:55:39 +02:00
retoor 5e4c4ce228 Resoted avatars. 2025-05-25 18:51:55 +02:00
retoor ffc373db62 Channel support. 2025-05-25 12:30:43 +02:00
retoor 60266bf0dc Channel support. 2025-05-25 12:30:26 +02:00
BordedDev 8393a80022 Potential missing letter fix 2025-05-25 02:46:49 +02:00
retoor 234edf4756 Merge pull request 'Fixed double messaging when live typing' (#45) from BordedDev/snek:bugfix/live-typing into main
Reviewed-on: #45
2025-05-25 02:29:04 +02:00
BordedDev 5fd401bfb6 Fixed double messaging when live typing 2025-05-25 02:23:52 +02:00
retoor 5663a5f376 Update. 2025-05-25 01:28:42 +02:00
retoor 81327a9e20 Update. 2025-05-25 01:25:48 +02:00
retoor 2a5b9ad276 Merge pull request 'Fix CSS selector for the last message display' (#44) from BordedDev/snek:bugfix/fix-last-message-time-reply into main
Reviewed-on: #44
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-24 18:46:43 +02:00
BordedDev 662e71c621 Fix CSS selector for the last message display 2025-05-24 18:41:12 +02:00
retoor 636adfd997 Update performance. 2025-05-24 15:00:26 +02:00
retoor b94f7a9532 Update. 2025-05-24 14:45:53 +02:00
retoor f954a34384 Update performance. 2025-05-24 14:22:12 +02:00
retoor 6a74263606 Update performance. 2025-05-24 14:18:38 +02:00
retoor 0bf714061c Update. 2025-05-24 12:58:49 +02:00
retoor e4e2e919c2 Update. 2025-05-24 12:58:49 +02:00
retoor 7fe4289f42 Merge pull request 'Made datetime/reply visible when date range is long, also fixes opacity to 1 for the last message's time display' (#39) from BordedDev/snek:bugfix/opacity-for-last-message into main
Reviewed-on: #39
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-24 01:48:07 +02:00
BordedDev 3ce866b7da Merge branch 'main' into bugfix/opacity-for-last-message 2025-05-23 18:14:32 +02:00
retoor 43982c16fa Merge pull request 'Fix timestamp parsing by correcting string slicing for start time' (#40) from BordedDev/snek:bugfix/youtube-timestamp into main
Reviewed-on: #40
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-23 18:13:50 +02:00
BordedDev e33e4196ab Merge branch 'main' into bugfix/youtube-timestamp 2025-05-23 15:45:56 +02:00
BordedDev 2c506db4e4 Merge branch 'main' into bugfix/opacity-for-last-message 2025-05-23 15:34:30 +02:00
retoor 9b9d356849 update. 2025-05-23 15:26:47 +02:00
retoor 30b7871583 Update. 2025-05-23 07:07:19 +02:00
retoor 1c873b7d02 Update. 2025-05-23 07:02:45 +02:00
retoor 539fb262b2 Update. 2025-05-23 07:00:28 +02:00
retoor 3bf09f9083 Update. 2025-05-23 06:48:18 +02:00
retoor a55d15b635 The force. 2025-05-23 03:01:20 +02:00
retoor 431748c489 Updated dem glow. 2025-05-23 02:39:48 +02:00
retoor a0fb214332 Updated dem glow. 2025-05-23 01:17:41 +02:00
retoor f0545cbf02 Format. 2025-05-23 01:17:41 +02:00
BordedDev a11c336cf5 Fix timestamp parsing by correcting string slicing for start time 2025-05-22 08:10:39 +02:00
BordedDev 6b083f8b1b Added feature to show time and reply when messages are from a long time before the next message 2025-05-22 00:19:39 +02:00
BordedDev 89afbba165 Set opacity to 1 for the last message's time display 2025-05-22 00:00:51 +02:00
retoor b2a4887e23 Merge pull request 'Enhance hover effect for time display and avatar visibility' (#38) from BordedDev/snek:feat/show-time-reply-hover into main
Reviewed-on: #38
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-21 23:36:25 +02:00
BordedDev 9f577875f2 Enhance hover effect for time display and avatar visibility 2025-05-21 22:30:15 +02:00
retoor c322d6147a Merge pull request 'bugfix/youtube-embed' (#37) from BordedDev/snek:bugfix/youtube-embed into main
Reviewed-on: #37
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-20 22:02:16 +02:00
retoor 87b6b3362d New avatar. 2025-05-20 04:20:10 +02:00
BordedDev d261f54327 Merge branch 'main' into bugfix/youtube-embed 2025-05-20 03:34:05 +02:00
retoor 59a815f85a Update. 2025-05-19 01:35:34 +02:00
retoor 2e837f96c5 Update. 2025-05-19 01:28:22 +02:00
retoor 00fce6bd68 Update. 2025-05-19 01:23:12 +02:00
retoor 8a85cd7990 Update. 2025-05-19 01:13:18 +02:00
BordedDev 0f337e569f Fixed gif resizing 2025-05-18 14:51:38 +02:00
BordedDev 59a2668c8c Merge branch 'main' into bugfix/youtube-embed 2025-05-18 03:26:53 +02:00
BordedDev 53811ca9b2 Re-added webp fallback 2025-05-17 13:55:46 +02:00
BordedDev 1bed47fbf5 Re-added webp fallback 2025-05-17 13:29:25 +02:00
BordedDev ffb22165da Fix YouTube embed parsing and add support for start time; handle missing channel attachments 2025-05-17 13:23:32 +02:00
44 changed files with 3851 additions and 3015 deletions
-992
View File
File diff suppressed because one or more lines are too long
-289
View File
@@ -1,289 +0,0 @@
import http.server
import socketserver
import json
import os
import subprocess
from urllib.parse import parse_qs, urlparse
import mimetypes
import html
# --- Theme selection (choose one: "light1", "light2", "dark1", "dark2") ---
THEME = "light1" # Change this to "light2", "dark1", or "dark2" as desired
THEMES = {
"light1": """
body { font-family: Arial, sans-serif; background: #f6f8fa; margin: 0; padding: 0; color: #222; }
.container { max-width: 960px; margin: auto; padding: 2em; }
.date-header { font-size: 1.2em; margin-top: 2em; border-bottom: 1px solid #ccc; }
.commit { margin: 1em 0; padding: 1em; background: #fff; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.07); }
.hash { color: #555; font-family: monospace; }
.diff { white-space: pre-wrap; background: #f1f1f1; padding: 1em; border-radius: 4px; overflow-x: auto; }
ul { list-style: none; padding-left: 0; }
li { margin: 0.3em 0; }
a { text-decoration: none; color: #0366d6; }
a:hover { text-decoration: underline; }
""",
"light2": """
body { font-family: 'Segoe UI', sans-serif; background: #fdf6e3; margin: 0; padding: 0; color: #333; }
.container { max-width: 900px; margin: auto; padding: 2em; }
.date-header { font-size: 1.2em; margin-top: 2em; border-bottom: 2px solid #e1c699; color: #b58900; }
.commit { margin: 1em 0; padding: 1em; background: #fffbe6; border-radius: 6px; box-shadow: 0 1px 3px rgba(200,180,100,0.08); }
.hash { color: #b58900; font-family: monospace; }
.diff { white-space: pre-wrap; background: #f5e9c9; padding: 1em; border-radius: 4px; overflow-x: auto; }
ul { list-style: none; padding-left: 0; }
li { margin: 0.3em 0; }
a { text-decoration: none; color: #b58900; }
a:hover { text-decoration: underline; color: #cb4b16; }
""",
"dark1": """
body { font-family: Arial, sans-serif; background: #181a1b; margin: 0; padding: 0; color: #eaeaea; }
.container { max-width: 960px; margin: auto; padding: 2em; }
.date-header { font-size: 1.2em; margin-top: 2em; border-bottom: 1px solid #333; color: #8ab4f8; }
.commit { margin: 1em 0; padding: 1em; background: #23272b; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.18); }
.hash { color: #8ab4f8; font-family: monospace; }
.diff { white-space: pre-wrap; background: #23272b; padding: 1em; border-radius: 4px; overflow-x: auto; }
ul { list-style: none; padding-left: 0; }
li { margin: 0.3em 0; }
a { text-decoration: none; color: #8ab4f8; }
a:hover { text-decoration: underline; color: #bb86fc; }
""",
"dark2": """
body { font-family: 'Fira Sans', sans-serif; background: #121212; margin: 0; padding: 0; color: #d0d0d0; }
.container { max-width: 900px; margin: auto; padding: 2em; }
.date-header { font-size: 1.2em; margin-top: 2em; border-bottom: 2px solid #444; color: #ffb86c; }
.commit { margin: 1em 0; padding: 1em; background: #22223b; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.22); }
.hash { color: #ffb86c; font-family: monospace; }
.diff { white-space: pre-wrap; background: #282a36; padding: 1em; border-radius: 4px; overflow-x: auto; }
ul { list-style: none; padding-left: 0; }
li { margin: 0.3em 0; }
a { text-decoration: none; color: #ffb86c; }
a:hover { text-decoration: underline; color: #8be9fd; }
""",
}
def HTML_TEMPLATE(content, theme=THEME):
return f"""
<!DOCTYPE html>
<html lang=\"en\">
<head>
<meta charset=\"UTF-8\">
<title>Git Log Viewer</title>
<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css\">
<style>
{THEMES[theme]}
</style>
<script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>
<script>hljs.highlightAll();</script>
</head>
<body>
<div class=\"container\">
{content}
</div>
</body>
</html>
"""
REPO_ROOT = os.path.abspath(".")
LOG_FILE = os.path.join(REPO_ROOT, "gitlog.jsonl")
PORT = 8481
def format_diff_to_html(diff_text: str) -> str:
lines = diff_text.strip().splitlines()
html_lines = ['<div style="font-family: monospace; white-space: pre;">']
while not lines[3].startswith('diff'):
lines.pop(3)
lines.insert(3, "")
for line in lines:
escaped = html.escape(line)
if "//" in line:
continue
if "#" in line:
continue
if "/*" in line:
continue
if "*/" in line:
continue
if line.startswith('+++') or line.startswith('---'):
html_lines.append(f'<div style="color: #0000aa;">{escaped}</div>')
elif line.startswith('@@'):
html_lines.append(f'<div style="color: #005cc5;">{escaped}</div>')
elif line.startswith('+'):
html_lines.append(f'<div style="color: #22863a;">{escaped}</div>')
elif line.startswith('-'):
html_lines.append(f'<div style="color: #b31d28;">{escaped}</div>')
elif line.startswith('\\'):
html_lines.append(f'<div style="color: #6a737d;">{escaped}</div>')
else:
html_lines.append(f'<div>{escaped}</div>')
html_lines.append('</div>')
return '\n'.join(html_lines)
def parse_logs():
logs = []
if not os.path.exists(LOG_FILE):
return []
lines = []
with open(LOG_FILE, "r", encoding="utf-8") as f:
for line in f:
if line.strip():
if line.strip() not in lines:
lines.append(line.strip())
logs.append(json.loads(line.strip()))
return logs
def group_by_date(logs):
grouped = {}
for entry in logs:
date = entry["date"]
grouped.setdefault(date, []).append(entry)
return dict(sorted(grouped.items(), reverse=True))
def get_git_diff(commit_hash):
try:
result = subprocess.run(
["git", "-C", REPO_ROOT, "show", commit_hash, "--no-color"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=True,
)
return result.stdout
except subprocess.CalledProcessError as e:
return f"Error retrieving diff: {e.stderr}"
def list_directory(path, base_url="/browse?path="):
try:
entries = os.listdir(path)
except OSError:
return "<div>Cannot access directory.</div>"
entries.sort()
content = "<ul>"
# Parent directory link
parent = os.path.dirname(path)
if os.path.abspath(path) != REPO_ROOT:
parent_rel = os.path.relpath(parent, REPO_ROOT)
content += f"<li><a href='{base_url}{html.escape(parent_rel)}'>.. (parent directory)</a></li>"
for entry in entries:
full_path = os.path.join(path, entry)
rel_path = os.path.relpath(full_path, REPO_ROOT)
if os.path.isdir(full_path):
content += f"<li>📁 <a href='{base_url}{html.escape(rel_path)}'>{html.escape(entry)}/</a></li>"
else:
content += f"<li>📄 <a href='{base_url}{html.escape(rel_path)}'>{html.escape(entry)}</a></li>"
content += "</ul>"
return content
def read_file_content(path):
try:
with open(path, "r", encoding="utf-8") as f:
return f.read()
except Exception as e:
return f"Error reading file: {e}"
def get_language_class(filename):
ext = os.path.splitext(filename)[1].lower()
return {
'.py': 'python',
'.js': 'javascript',
'.html': 'html',
'.css': 'css',
'.json': 'json',
'.sh': 'bash',
'.md': 'markdown',
'.c': 'c',
'.cpp': 'cpp',
'.h': 'cpp',
'.java': 'java',
'.rb': 'ruby',
'.go': 'go',
'.php': 'php',
'.rs': 'rust',
'.ts': 'typescript',
'.xml': 'xml',
'.yml': 'yaml',
'.yaml': 'yaml',
}.get(ext, '')
class GitLogHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/":
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
logs = parse_logs()
grouped = group_by_date(logs)
content = "<p><a href='/browse'>Browse Files</a></p>"
for date, commits in grouped.items():
content += f"<div class='date-header'>{date}</div>"
for c in commits:
commit_link = f"/diff?hash={c['commit']}"
content += f"""
<div class='commit'>
<div><strong>{c['line'].splitlines()[0]}</strong></div>
<div class='hash'><a href='{commit_link}'>{c['commit']}</a></div>
</div>
"""
self.wfile.write(HTML_TEMPLATE(content).encode("utf-8"))
elif parsed.path == "/diff":
qs = parse_qs(parsed.query)
commit = qs.get("hash", [""])[0]
diff = format_diff_to_html(get_git_diff(html.escape(commit)))
diff_html = f"<h2>Commit: {commit}</h2><div class='diff'>{diff}</div><p><a href='/'>← Back to commits</a></p>"
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(HTML_TEMPLATE(diff_html).encode("utf-8"))
elif parsed.path == "/browse":
qs = parse_qs(parsed.query)
rel_path = qs.get("path", [""])[0]
abs_path = os.path.abspath(os.path.join(REPO_ROOT, rel_path))
# Security: prevent escaping the repo root
if not abs_path.startswith(REPO_ROOT):
self.send_error(403, "Forbidden")
return
if os.path.isdir(abs_path):
content = f"<h2>Browsing: /{html.escape(rel_path)}</h2>"
content += list_directory(abs_path)
content += "<p><a href='/'>← Back to commits</a></p>"
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(HTML_TEMPLATE(content).encode("utf-8"))
elif os.path.isfile(abs_path):
file_content = read_file_content(abs_path)
lang_class = get_language_class(abs_path)
content = f"<h2>File: /{html.escape(rel_path)}</h2>"
content += (
f"<pre style='background:#f1f1f1; padding:1em; border-radius:4px; overflow-x:auto;'>"
f"<code class='{lang_class}'>{html.escape(file_content)}</code></pre>"
)
content += "<p><a href='{}'>← Back to directory</a></p>".format(
f"/browse?path={html.escape(os.path.dirname(rel_path))}"
)
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(HTML_TEMPLATE(content).encode("utf-8"))
else:
self.send_error(404, "Not found")
else:
self.send_error(404)
if __name__ == "__main__":
while True:
try:
with socketserver.TCPServer(("", PORT), GitLogHandler) as httpd:
print(f"Serving at http://localhost:{PORT}")
httpd.serve_forever()
break
except Exception as ex:
print(ex)
PORT += 1
+36 -2
View File
@@ -3,6 +3,7 @@ import logging
import pathlib
import time
import uuid
from datetime import datetime
from snek import snode
from snek.view.threads import ThreadsView
import json
@@ -55,6 +56,7 @@ from snek.view.upload import UploadView
from snek.view.user import UserView
from snek.view.web import WebView
from snek.view.channel import ChannelAttachmentView
from snek.view.channel import ChannelView
from snek.view.settings.containers import ContainersIndexView, ContainersCreateView, ContainersUpdateView, ContainersDeleteView
from snek.webdav import WebdavApplication
from snek.sgit import GitApplication
@@ -96,6 +98,7 @@ class Application(BaseApplication):
self.jinja2_env.add_extension(LinkifyExtension)
self.jinja2_env.add_extension(PythonExtension)
self.jinja2_env.add_extension(EmojiExtension)
self.time_start = datetime.now()
self.ssh_host = "0.0.0.0"
self.ssh_port = 2242
self.setup_router()
@@ -112,7 +115,34 @@ class Application(BaseApplication):
self.on_startup.append(self.start_user_availability_service)
self.on_startup.append(self.start_ssh_server)
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):
app.user_availability_service_task = asyncio.create_task(app.services.socket.user_availability_service())
async def snode_sync(self, app):
@@ -192,6 +222,7 @@ class Application(BaseApplication):
self.router.add_get("/http-get", self.handle_http_get)
self.router.add_get("/http-photo", self.handle_http_photo)
self.router.add_get("/rpc.ws", RPCView)
self.router.add_get("/c/{channel:.*}", ChannelView)
self.router.add_view("/channel/{channel_uid}/attachment.bin",ChannelAttachmentView)
self.router.add_view("/channel/attachment/{relative_url:.*}",ChannelAttachmentView)
self.router.add_view("/channel/{channel}.html", WebView)
@@ -248,11 +279,14 @@ class Application(BaseApplication):
async for subscribed_channel in self.services.channel_member.find(
user_uid=request.session.get("uid"), deleted_at=None, is_banned=False
):
parent_object = await subscribed_channel.get_channel()
item = {}
other_user = await self.services.channel_member.get_other_dm_user(
subscribed_channel["channel_uid"], request.session.get("uid")
)
parent_object = await subscribed_channel.get_channel()
last_message = await parent_object.get_last_message()
color = None
if last_message:
+1
View File
@@ -7,6 +7,7 @@ class ChannelMessageModel(BaseModel):
user_uid = ModelField(name="user_uid", required=True, kind=str)
message = ModelField(name="message", required=True, kind=str)
html = ModelField(name="html", required=False, kind=str)
is_final = ModelField(name="is_final", required=True, kind=bool, value=True)
def get_seconds_since_last_update(self):
return int((datetime.now(timezone.utc) - datetime.fromisoformat(self["updated_at"])).total_seconds())
+4
View File
@@ -67,6 +67,10 @@ class ChannelService(BaseService):
await self.services.channel_member.create_dm(channel["uid"], user1, user2)
return channel
async def get_recent_users(self, channel_uid):
async for user in self.query("SELECT user.uid, user.username,user.color,user.last_ping,user.nick FROM channel_member INNER JOIN user ON user.uid = channel_member.user_uid WHERE channel_uid=:channel_uid AND user.last_ping >= datetime('now', '-3 minutes') ORDER BY last_ping DESC LIMIT 30", {"channel_uid": channel_uid}):
yield user
async def get_users(self, channel_uid):
async for channel_member in self.services.channel_member.find(
channel_uid=channel_uid,
+13 -2
View File
@@ -4,12 +4,13 @@ from snek.system.service import BaseService
class ChannelMessageService(BaseService):
mapper_name = "channel_message"
async def create(self, channel_uid, user_uid, message):
async def create(self, channel_uid, user_uid, message, is_final=True):
model = await self.new()
model["channel_uid"] = channel_uid
model["user_uid"] = user_uid
model["message"] = message
model['is_final'] = is_final
context = {}
@@ -51,7 +52,17 @@ class ChannelMessageService(BaseService):
}
async def save(self, model):
context = model.record
context = {}
context.update(model.record)
user = await self.app.services.user.get(model['user_uid'])
context.update(
{
"user_uid": user["uid"],
"username": user["username"],
"user_nick": user["nick"],
"color": user["color"],
}
)
template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context)
return await super().save(model)
+29 -2
View File
@@ -4,12 +4,38 @@ from snek.system.service import BaseService
class ChatService(BaseService):
async def send(self, user_uid, channel_uid, message):
async def finalize(self, message_uid):
channel_message = await self.services.channel_message.get(uid=message_uid)
channel_message["is_final"] = True
await self.services.channel_message.save(channel_message)
user = await self.services.user.get(uid=channel_message["user_uid"])
channel = await self.services.channel.get(uid=channel_message["channel_uid"])
channel["last_message_on"] = now()
await self.services.channel.save(channel)
await self.services.socket.broadcast(
channel['uid'],
{
"message": channel_message["message"],
"html": channel_message["html"],
"user_uid": user['uid'],
"color": user["color"],
"channel_uid": channel['uid'],
"created_at": channel_message["created_at"],
"updated_at": channel_message["updated_at"],
"username": user["username"],
"uid": channel_message["uid"],
"user_nick": user["nick"],
"is_final": channel_message["is_final"],
},
)
async def send(self, user_uid, channel_uid, message, is_final=True):
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
raise Exception("Channel not found.")
channel_message = await self.services.channel_message.create(
channel_uid, user_uid, message
channel_uid, user_uid, message,is_final
)
channel_message_uid = channel_message["uid"]
@@ -30,6 +56,7 @@ class ChatService(BaseService):
"username": user["username"],
"uid": channel_message["uid"],
"user_nick": user["nick"],
"is_final": is_final,
},
)
await self.app.create_task(
+1 -1
View File
@@ -45,7 +45,7 @@ async def start_ssh_server(app,host,port):
logger.info("Starting SFTP server setup")
host_key_path = Path("drive") / ".ssh" / "sftp_server_key"
host_key_path.parent.mkdir(exist_ok=True)
host_key_path.parent.mkdir(exist_ok=True, parents=True)
try:
if not host_key_path.exists():
logger.info(f"Generating new host key at {host_key_path}")
+223 -206
View File
@@ -1,250 +1,267 @@
// 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.
// No additional imports were used beyond standard JavaScript objects and constructors.
// MIT License
import { Schedule } from './schedule.js';
import { Schedule } from "./schedule.js";
import { EventHandler } from "./event-handler.js";
import { Socket } from "./socket.js";
export class RESTClient {
debug = false;
debug = false;
async get(url, params = {}) {
const encodedParams = new URLSearchParams(params);
if (encodedParams) url += '?' + encodedParams;
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const result = await response.json();
if (this.debug) {
console.debug({ url, params, result });
}
return result;
async get(url, params = {}) {
const encodedParams = new URLSearchParams(params);
if (encodedParams) url += "?" + encodedParams;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (this.debug) {
console.debug({ url, params, result });
}
return result;
}
async post(url, data) {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
async post(url, data) {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
const result = await response.json();
if (this.debug) {
console.debug({ url, data, result });
}
return result;
const result = await response.json();
if (this.debug) {
console.debug({ url, data, result });
}
return result;
}
}
export class Chat extends EventHandler {
constructor() {
super();
this._url = window.location.hostname === 'localhost' ? 'ws://localhost/chat.ws' : 'wss://' + window.location.hostname + '/chat.ws';
this._socket = null;
this._waitConnect = null;
this._promises = {};
constructor() {
super();
this._url =
window.location.hostname === "localhost"
? "ws://localhost/chat.ws"
: "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() {
if (this._waitConnect) {
return this._waitConnect;
}
return new Promise((resolve) => {
this._waitConnect = resolve;
console.debug("Connecting..");
this._socket.onconnect = () => {
this._connected();
this._waitSocket();
};
});
}
try {
this._socket = new WebSocket(this._url);
} catch (e) {
console.warn(e);
setTimeout(() => {
this.ensureConnection();
}, 1000);
}
generateUniqueId() {
return "id-" + Math.random().toString(36).substr(2, 9);
}
this._socket.onconnect = () => {
this._connected();
this._waitSocket();
};
});
}
call(method, ...args) {
return new Promise((resolve, reject) => {
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() {
return 'id-' + Math.random().toString(36).substr(2, 9);
}
_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");
};
}
call(method, ...args) {
return new Promise((resolve, reject) => {
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);
}
});
}
_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,
});
}
async privmsg(room, text) {
await rest.post("/api/privmsg", {
room,
text,
});
}
}
export class NotificationAudio {
constructor(timeout = 500) {
this.schedule = new Schedule(timeout);
}
constructor(timeout = 500) {
this.schedule = new Schedule(timeout);
}
sounds = {
"message": "/audio/soundfx.d_beep3.mp3",
"mention": "/audio/750607__deadrobotmusic__notification-sound-1.wav",
"messageOtherChannel": "/audio/750608__deadrobotmusic__notification-sound-2.wav",
"ping": "/audio/750609__deadrobotmusic__notification-sound-3.wav",
}
sounds = {
message: "/audio/soundfx.d_beep3.mp3",
mention: "/audio/750607__deadrobotmusic__notification-sound-1.wav",
messageOtherChannel:
"/audio/750608__deadrobotmusic__notification-sound-2.wav",
ping: "/audio/750609__deadrobotmusic__notification-sound-3.wav",
};
play(soundIndex = 0) {
this.schedule.delay(() => {
new Audio(this.sounds[soundIndex]).play()
.then(() => {
console.debug("Gave sound notification");
})
.catch(error => {
console.error("Notification failed:", error);
});
play(soundIndex = 0) {
this.schedule.delay(() => {
new Audio(this.sounds[soundIndex])
.play()
.then(() => {
console.debug("Gave sound notification");
})
.catch((error) => {
console.error("Notification failed:", error);
});
}
});
}
}
export class App extends EventHandler {
rest = new RESTClient();
ws = null;
rpc = null;
audio = null;
user = {};
typeLock = null;
typeListener = null
typeEventChannelUid = null
async set_typing(channel_uid){
this.typeEventChannel_uid = channel_uid
}
rest = new RESTClient();
ws = null;
rpc = null;
audio = null;
user = {};
typeLock = null;
typeListener = null;
typeEventChannelUid = null;
_debug = false
async set_typing(channel_uid) {
this.typeEventChannel_uid = channel_uid;
}
debug() {
this._debug = !this._debug;
this.ws._debug = this._debug;
}
async ping(...args) {
if (this.is_pinging) return false
this.is_pinging = true
await this.rpc.ping(...args);
this.is_pinging = false
}
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);
}
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;
}
});
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("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`;
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("data", (data) => {
if(this._debug){
console.debug(data)
}
if (hours) {
return `${hours} ${hours > 1 ? 'hours' : 'hour'} ago`;
}
if (minutes) {
return `${minutes} ${minutes > 1 ? 'minutes' : 'minute'} ago`;
}
return 'just now';
}
});
this.rpc.getUser(null).then((user) => {
me.user = user;
});
}
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}`);
});
}));
}
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();
+155 -85
View File
@@ -5,6 +5,10 @@
box-sizing: border-box;
}
html {
height: 100%;
}
.gallery {
padding: 50px;
height: auto;
@@ -25,33 +29,35 @@ body {
background-color: #000000;
color: #e6e6e6;
line-height: 1.5;
display: flex;
flex-direction: column;
height: 100vh;
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
"header header"
"sidebar chat-area";
min-width: 100%;
height: 100%;
}
main {
display: flex;
flex: 1;
overflow: hidden;
grid-area: chat-area;
}
header {
background-color: #000000;
padding-top: 10px;
padding-left: 20px;
padding-right: 20px;
padding-bottom: 10px;
grid-area: header;
padding: 10px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
header .logo {
color: #fff;
color: #fff;
font-weight: bold;
font-size: 1.2em;
font-size: 1.2em;
color: #fff;
}
@@ -81,13 +87,13 @@ a {
}
h1 {
font-size: 2em;
color: #f05a28;
font-size: 2em;
color: #f05a28;
}
h2 {
font-size: 1.4em;
color: #f05a28;
font-size: 1.4em;
color: #f05a28;
}
@@ -95,13 +101,11 @@ h2 {
flex: 1;
display: flex;
flex-direction: column;
background-color: #000000;
overflow: hidden;
}
.chat-header {
padding: 10px 20px;
background-color: #000000;
user-select: none;
}
@@ -112,10 +116,10 @@ h2 {
}
footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
}
.message-list {
@@ -132,7 +136,11 @@ footer {
-ms-overflow-style: none;
padding: 10px;
height: 10px;
background: #000000;
}
.chat-messages {
display: flex;
flex-direction: column;
}
.container {
@@ -151,9 +159,11 @@ footer {
}
}
.chat-messages picture img {
cursor: pointer;
.chat-messages picture img {
cursor: pointer;
}
.chat-messages::-webkit-scrollbar {
display: none;
}
@@ -230,15 +240,14 @@ footer {
.chat-input {
padding: 15px;
background-color: #000000;
display: flex;
align-items: center;
}
input[type="text"], .chat-input textarea {
flex: 1;
background-color: #000000;
color: white;
background: none;
border: none;
padding: 10px;
border-radius: 5px;
@@ -275,13 +284,18 @@ input[type="text"], .chat-input textarea {
}
.avatar {
opacity: 0;
max-height: 0;
overflow: hidden;
}
.author, .time {
.author {
display: none;
}
&:not(:hover, :focus-within, :active) .time {
opacity: 0;
}
}
.message.switch-user {
@@ -293,6 +307,7 @@ input[type="text"], .chat-input textarea {
.avatar {
user-select: none;
opacity: 1;
max-height: unset;
}
.author {
@@ -300,9 +315,10 @@ input[type="text"], .chat-input textarea {
}
}
.message:has(+ .message.switch-user), .message:last-child {
.message:has(+ .message.switch-user), .message:has(+ .message.long-time), .message:not(:has(+ .message)) {
.time {
display: block;
opacity: 1;
}
}
@@ -336,11 +352,11 @@ a {
.sidebar {
width: 250px;
background-color: #000000;
padding-left: 20px;
padding-right: 20px;
padding-top: 10px;
overflow-y: auto;
grid-area: sidebar;
}
.sidebar h2 {
@@ -369,65 +385,68 @@ a {
}
@keyframes glow {
0% {
box-shadow: 0 0 5px #3498db;
}
50% {
box-shadow: 0 0 20px #3498db, 0 0 30px #3498db;
}
100% {
box-shadow: 0 0 5px #3498db;
}
0% {
box-shadow: 0 0 5px #3498db;
}
50% {
box-shadow: 0 0 20px #3498db, 0 0 30px #3498db;
}
100% {
box-shadow: 0 0 5px #3498db;
}
}
.glow {
animation: glow 1s;
animation: glow 1s;
}
@media only screen and (max-width: 768px) {
header{
top: 0;
left: 0;
text-overflow: ellipsis;
width:100%;
display: flex;
flex-direction: column;
.logo {
display:block;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
h2 {
font-size: 14px;
}
text-align: center;
}
nav {
text-align: right;
flex: 1;
display: block;
width: 100%;
}
}
/*
body {
justify-content: flex-start;
header {
top: 0;
left: 0;
text-overflow: ellipsis;
width: 100%;
display: flex;
flex-direction: column;
.logo {
display: block;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
h2 {
font-size: 14px;
}
text-align: center;
}
header{
position: sticky;
display: block;
.logo {
display:block;
}
nav {
text-align: right;
flex: 1;
display: block;
width: 100%;
}
.chat-input {
position:sticky;
}*/
}
/*
body {
justify-content: flex-start;
}
header{
position: sticky;
display: block;
.logo {
display:block;
}
}
.chat-input {
position:sticky;
}*/
}
dialog {
@@ -487,13 +506,23 @@ dialog .dialog-button {
@keyframes dialogFadeIn {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogScaleIn {
from { transform: scale(0.95) translate(-50%, -50%); opacity: 0; }
to { transform: scale(1) translate(-50%, -50%); opacity: 1; }
from {
transform: scale(0.95) translate(-50%, -50%);
opacity: 0;
}
to {
transform: scale(1) translate(-50%, -50%);
opacity: 1;
}
}
dialog .dialog-button.primary {
@@ -506,12 +535,53 @@ dialog .dialog-button.primary:hover {
}
dialog .dialog-button.secondary {
background-color: #f0a328;
background-color: #f0a328;
color: #eee;
}
dialog .dialog-button.secondary:hover {
background-color: #f0b84c;
background-color: #f0b84c;
}
.embed-url-link {
display: flex;
flex-direction: column;
}
.embed-url-link img,
.embed-url-link video,
.embed-url-link iframe,
.embed-url-link div {
width: auto;
height: auto;
max-width: 100%;
max-height: 400px;
object-fit: contain;
border-radius: 12px 12px 0 0;
}
.embed-url-link .page-site {
font-size: 0.9em;
color: #aaa;
margin-bottom: 5px;
}
.embed-url-link .page-name {
font-size: 1.2em;
color: #f05a28;
margin-bottom: 5px;
}
.embed-url-link .page-description {
font-size: 1em;
color: #e6e6e6;
margin-bottom: 10px;
}
.embed-url-link .page-link {
font-size: 0.9em;
color: #f05a28;
text-decoration: none;
margin-top: 10px;
}
+358 -220
View File
@@ -1,235 +1,373 @@
import { app } from '../app.js';
import { app } from "../app.js";
class ChatInputComponent extends HTMLElement {
autoCompletions = {
'example 1': () => {
autoCompletions = {
"example 1": () => {},
"example 2": () => {},
}
hiddenCompletions = {
"/starsRender": () => {
app.rpc.starsRender(this.channelUid,this.value.replace("/starsRender ",""))
}
}
users = []
textarea = null
_value = ""
lastUpdateEvent = null
previousValue = ""
lastChange = null
changed = false
constructor() {
super();
this.lastUpdateEvent = new Date();
this.textarea = document.createElement("textarea");
this.value = this.getAttribute("value") || "";
this.previousValue = this.value;
this.lastChange = new Date();
this.changed = false;
}
},
'example 2': () => {
get value() {
return this._value;
}
set value(value) {
this._value = value || "";
this.textarea.value = this._value;
}
get allAutoCompletions() {
return Object.assign({},this.autoCompletions,this.hiddenCompletions)
}
resolveAutoComplete() {
let count = 0;
let value = null;
Object.keys(this.allAutoCompletions).forEach((key) => {
if (key.startsWith(this.value.split(" ")[0])) {
count++;
value = key;
}
});
if (count == 1) return value;
return null;
}
isActive() {
return document.activeElement === this.textarea;
}
focus() {
this.textarea.focus();
}
getAuthors(){
let authors = []
for (let i = 0; i < this.users.length; i++) {
authors.push(this.users[i].username)
authors.push(this.users[i].nick)
}
return authors
}
extractMentions(text) {
const regex = /@([a-zA-Z0-9_-]+)/g;
const mentions = [];
let match;
constructor() {
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;
}
while ((match = regex.exec(text)) !== null) {
mentions.push(match[1]);
}
get value() {
return this._value;
}
return mentions;
}
matchMentionsToAuthors(mentions, authors) {
return mentions.map(mention => {
let closestAuthor = null;
let minDistance = Infinity;
const lowerMention = mention.toLowerCase();
authors.forEach(author => {
const lowerAuthor = author.toLowerCase();
let distance = this.levenshteinDistance(lowerMention, lowerAuthor);
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.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();
const message = e.target.value;
this.messageUid = null;
this.value = '';
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) {
const millisecondsDifference = event2Time.getTime() - event1Time.getTime();
return millisecondsDifference / 1000;
}
newMessage() {
if (!this.messageUid) {
this.messageUid = '?';
if(!this.isSubsequence(lowerMention,lowerAuthor)) {
distance += 10
}
this.sendMessage(this.channelUid, this.value).then((uid) => {
this.messageUid = uid;
});
if (distance < minDistance) {
minDistance = distance;
closestAuthor = author;
}
});
return { mention, closestAuthor, distance: minDistance };
});
}
levenshteinDistance(a, b) {
const matrix = [];
// Initialize the first row and column
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
// Fill in the matrix
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // Deletion
matrix[i][j - 1] + 1, // Insertion
matrix[i - 1][j - 1] + 1 // Substitution
);
}
}
}
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();
}
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 matrix[b.length][a.length];
}
customElements.define('chat-input', ChatInputComponent);
replaceMentionsWithAuthors(text) {
const authors = this.getAuthors();
const mentions = this.extractMentions(text);
const matches = this.matchMentionsToAuthors(mentions, authors);
let updatedText = text;
matches.forEach(({ mention, closestAuthor }) => {
const mentionRegex = new RegExp(`@${mention}`, 'g');
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
});
return updatedText;
}
async connectedCallback() {
this.user = null
app.rpc.getUser(null).then((user) => {
this.user=user
})
const me = this;
this.liveType = this.getAttribute("live-type") === "true";
this.liveTypeInterval =
parseInt(this.getAttribute("live-type-interval")) || 6;
this.channelUid = this.getAttribute("channel");
app.rpc.getRecentUsers(this.channelUid).then(users=>{
this.users = users
})
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;
let autoCompletion = null;
if (e.key === "Tab") {
e.preventDefault();
autoCompletion = this.resolveAutoComplete();
if (autoCompletion) {
e.target.value = autoCompletion;
this.value = autoCompletion;
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const message = me.replaceMentionsWithAuthors(this.value);
e.target.value = "";
if (!message) {
return;
}
let autoCompletionHandler = this.allAutoCompletions[this.value.split(" ")[0]];
if (autoCompletionHandler) {
autoCompletionHandler();
this.value = "";
this.previousValue = "";
e.target.value = "";
return;
}
this.updateMessage()
app.rpc.finalizeMessage(this.messageUid)
this.value = "";
this.previousValue = "";
this.messageUid = null;
}
});
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,true);
});
setTimeout(()=>{
this.focus();
},1000)
}
trackSecondsBetweenEvents(event1Time, event2Time) {
const millisecondsDifference = event2Time.getTime() - event1Time.getTime();
return millisecondsDifference / 1000;
}
isSubsequence(s, t) {
let i = 0, j = 0;
while (i < s.length && j < t.length) {
if (s[i] === t[j]) {
i++;
}
j++;
}
return i === s.length;
}
newMessage() {
if (!this.messageUid) {
this.messageUid = "?";
}
this.value = this.replaceMentionsWithAuthors(this.value);
this.sendMessage(this.channelUid, this.value,!this.liveType).then((uid) => {
if (this.liveType) {
this.messageUid = uid;
}
});
}
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.replaceMentionsWithAuthors(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();
}
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,is_final) {
if (!value.trim()) {
return null;
}
return await app.rpc.sendMessage(channelUid, value,is_final);
}
}
customElements.define("chat-input", ChatInputComponent);
+58 -58
View File
@@ -6,77 +6,77 @@
// 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:
//
//
// 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.
class ChatWindowElement extends HTMLElement {
receivedHistory = false;
channel = null
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.component = document.createElement('section');
this.app = app;
this.shadowRoot.appendChild(this.component);
}
receivedHistory = false;
channel = null;
constructor() {
super();
this.attachShadow({ mode: "open" });
this.component = document.createElement("section");
this.app = app;
this.shadowRoot.appendChild(this.component);
}
get user() {
return this.app.user;
}
get user() {
return this.app.user;
}
async connectedCallback() {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/base.css';
this.component.appendChild(link);
this.component.classList.add("chat-area");
this.container = document.createElement("section");
this.container.classList.add("chat-area", "chat-window");
async connectedCallback() {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "/base.css";
this.component.appendChild(link);
this.component.classList.add("chat-area");
const chatHeader = document.createElement("div");
chatHeader.classList.add("chat-header");
this.container = document.createElement("section");
this.container.classList.add("chat-area", "chat-window");
const chatTitle = document.createElement('h2');
chatTitle.classList.add("chat-title");
chatTitle.classList.add("no-select");
chatTitle.innerText = "Loading...";
chatHeader.appendChild(chatTitle);
this.container.appendChild(chatHeader);
const chatHeader = document.createElement("div");
chatHeader.classList.add("chat-header");
const channels = await app.rpc.getChannels();
const channel = channels[0];
this.channel = channel;
chatTitle.innerText = channel.name;
const chatTitle = document.createElement("h2");
chatTitle.classList.add("chat-title");
chatTitle.classList.add("no-select");
chatTitle.innerText = "Loading...";
chatHeader.appendChild(chatTitle);
this.container.appendChild(chatHeader);
const channelElement = document.createElement('message-list');
channelElement.setAttribute("channel", channel.uid);
this.container.appendChild(channelElement);
const channels = await app.rpc.getChannels();
const channel = channels[0];
this.channel = channel;
chatTitle.innerText = channel.name;
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 channelElement = document.createElement("message-list");
channelElement.setAttribute("channel", channel.uid);
this.container.appendChild(channelElement);
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);
messages.forEach(message => {
if (!message['user_nick']) return;
channelElement.addMessage(message);
});
this.component.appendChild(this.container);
const me = this;
channelElement.addEventListener("message", (message) => {
if (me.user.uid !== message.detail.user_uid) app.playSound(0);
message.detail.element.scrollIntoView({"block": "end"});
});
}
const messages = await app.rpc.getMessages(channel.uid);
messages.forEach((message) => {
if (!message["user_nick"]) return;
channelElement.addMessage(message);
});
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);
+150
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);
+12 -13
View File
@@ -1,16 +1,15 @@
export class EventHandler {
constructor() {
this.subscribers = {};
}
constructor() {
this.subscribers = {};
}
addEventListener(type, handler) {
if (!this.subscribers[type]) this.subscribers[type] = [];
this.subscribers[type].push(handler);
}
addEventListener(type, handler) {
if (!this.subscribers[type]) this.subscribers[type] = [];
this.subscribers[type].push(handler);
}
emit(type, ...data) {
if (this.subscribers[type]) this.subscribers[type].forEach(handler => handler(...data));
}
}
emit(type, ...data) {
if (this.subscribers[type])
this.subscribers[type].forEach((handler) => handler(...data));
}
}
+36 -38
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.
// MIT License
class FancyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.url = null;
this.type = "button";
this.value = null;
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.url = null;
this.type = "button";
this.value = null;
}
connectedCallback() {
this.container = document.createElement('span');
let size = this.getAttribute('size');
console.info({ GG: size });
size = size === 'auto' ? '1%' : '33%';
connectedCallback() {
this.container = document.createElement("span");
let size = this.getAttribute("size");
console.info({ GG: size });
size = size === "auto" ? "1%" : "33%";
this.styleElement = document.createElement("style");
this.styleElement.innerHTML = `
this.styleElement = document.createElement("style");
this.styleElement.innerHTML = `
:root {
width: 100%;
--width: 100%;
@@ -49,29 +46,30 @@ class FancyButton extends HTMLElement {
}
`;
this.container.appendChild(this.styleElement);
this.buttonElement = document.createElement('button');
this.container.appendChild(this.buttonElement);
this.shadowRoot.appendChild(this.container);
this.container.appendChild(this.styleElement);
this.buttonElement = document.createElement("button");
this.container.appendChild(this.buttonElement);
this.shadowRoot.appendChild(this.container);
this.url = this.getAttribute('url');
this.url = this.getAttribute("url");
this.value = this.getAttribute('value');
this.buttonElement.appendChild(document.createTextNode(this.getAttribute("text")));
this.buttonElement.addEventListener("click", () => {
if(this.url == 'submit'){
this.closest('form').submit()
return
}
if (this.url === "/back" || this.url === "/back/") {
window.history.back();
} else if (this.url) {
window.location = this.url;
}
});
}
this.value = this.getAttribute("value");
this.buttonElement.appendChild(
document.createTextNode(this.getAttribute("text")),
);
this.buttonElement.addEventListener("click", () => {
if (this.url == "submit") {
this.closest("form").submit();
return;
}
if (this.url === "/back" || this.url === "/back/") {
window.history.back();
} else if (this.url) {
window.location = this.url;
}
});
}
}
customElements.define("fancy-button", FancyButton);
+27 -12
View File
@@ -3,13 +3,13 @@ class FileBrowser extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.path = ""; // current virtual path ("" = ROOT)
this.offset = 0; // pagination offset
this.limit = 40; // items per request
this.path = ""; // current virtual path ("" = ROOT)
this.offset = 0; // pagination offset
this.limit = 40; // items per request
}
connectedCallback() {
this.path = this.getAttribute("path") || "";
this.path = this.getAttribute("path") || "";
this.renderShell();
this.load();
}
@@ -40,19 +40,30 @@ class FileBrowser extends HTMLElement {
<button id="next">Next</button>
</nav>
`;
this.shadowRoot.getElementById("up").addEventListener("click", () => this.goUp());
this.shadowRoot
.getElementById("up")
.addEventListener("click", () => this.goUp());
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.offset += this.limit; this.load();
this.offset += this.limit;
this.load();
});
}
// ---------- Networking ----------------------------------------------
async load() {
const r = await fetch(`/drive.json?path=${encodeURIComponent(this.path)}&offset=${this.offset}&limit=${this.limit}`);
if (!r.ok) { console.error(await r.text()); return; }
const r = await fetch(
`/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();
this.renderTiles(data.items);
this.updateNav(data.pagination);
@@ -62,13 +73,17 @@ class FileBrowser extends HTMLElement {
renderTiles(items) {
const grid = this.shadowRoot.getElementById("grid");
grid.innerHTML = "";
items.forEach(item => {
items.forEach((item) => {
const tile = document.createElement("div");
tile.className = "tile";
if (item.type === "directory") {
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 {
if (item.mimetype?.startsWith("image/")) {
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("prev").disabled = offset === 0;
this.shadowRoot.getElementById("next").disabled = offset + limit >= total;
this.shadowRoot.getElementById("up").disabled = this.path === "";
this.shadowRoot.getElementById("up").disabled = this.path === "";
}
goUp() {
+57 -40
View File
@@ -40,7 +40,7 @@ class GenericField extends HTMLElement {
}
set value(val) {
val = val ?? '';
val = val ?? "";
this.inputElement.value = val;
this.inputElement.setAttribute("value", val);
}
@@ -62,9 +62,9 @@ class GenericField extends HTMLElement {
constructor() {
super();
this.attachShadow({mode: 'open'});
this.container = document.createElement('div');
this.styleElement = document.createElement('style');
this.attachShadow({ mode: "open" });
this.container = document.createElement("div");
this.styleElement = document.createElement("style");
this.styleElement.innerHTML = `
h1 {
@@ -174,7 +174,7 @@ class GenericField extends HTMLElement {
if (this.inputElement == null && this.field) {
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.inputElement.name = this.field.name;
@@ -182,26 +182,39 @@ class GenericField extends HTMLElement {
const me = this;
this.inputElement.addEventListener("keyup", (e) => {
if (e.key === 'Enter') {
const event = new CustomEvent("change", {detail: me, bubbles: true});
if (e.key === "Enter") {
const event = new CustomEvent("change", {
detail: me,
bubbles: true,
});
me.dispatchEvent(event);
me.dispatchEvent(new Event("submit"));
} 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);
}
});
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);
});
this.inputElement.addEventListener("blur", (e) => {
const event = new CustomEvent("change", {detail: me, bubbles: true});
me.dispatchEvent(event);
}, true);
this.inputElement.addEventListener(
"blur",
(e) => {
const event = new CustomEvent("change", {
detail: me,
bubbles: true,
});
me.dispatchEvent(event);
},
true,
);
this.container.appendChild(this.inputElement);
}
@@ -210,8 +223,8 @@ class GenericField extends HTMLElement {
return;
}
this.inputElement.setAttribute("type", this.field.type ?? 'input');
this.inputElement.setAttribute("name", this.field.name ?? '');
this.inputElement.setAttribute("type", this.field.type ?? "input");
this.inputElement.setAttribute("name", this.field.name ?? "");
if (this.field.text != null) {
this.inputElement.innerText = this.field.text;
@@ -239,14 +252,14 @@ class GenericField extends HTMLElement {
this.inputElement.removeAttribute("required");
}
if (!this.footerElement) {
this.footerElement = document.createElement('div');
this.footerElement.style.clear = 'both';
this.footerElement = document.createElement("div");
this.footerElement.style.clear = "both";
this.container.appendChild(this.footerElement);
}
}
}
customElements.define('generic-field', GenericField);
customElements.define("generic-field", GenericField);
class GenericForm extends HTMLElement {
fields = {};
@@ -254,7 +267,7 @@ class GenericForm extends HTMLElement {
constructor() {
super();
this.attachShadow({mode: 'open'});
this.attachShadow({ mode: "open" });
this.styleElement = document.createElement("style");
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.classList.add("generic-form-container");
this.shadowRoot.appendChild(this.container);
}
connectedCallback() {
const preloadedForm = this.getAttribute('preloaded-structure');
const preloadedForm = this.getAttribute("preloaded-structure");
if (preloadedForm) {
try {
const form = JSON.parse(preloadedForm);
this.constructForm(form)
this.constructForm(form);
} catch (error) {
console.error(error, preloadedForm);
}
}
const url = this.getAttribute('url');
const url = this.getAttribute("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("/")) {
fullUrl.searchParams.set('url', url);
fullUrl.searchParams.set("url", url);
}
this.loadForm(fullUrl.toString());
} else {
@@ -318,10 +333,10 @@ class GenericForm extends HTMLElement {
let hasAutoFocus = Object.keys(this.fields).length !== 0;
fields.sort((a, b) => a.index - b.index);
fields.forEach(field => {
const updatingField = field.name in this.fields
fields.forEach((field) => {
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];
@@ -362,7 +377,7 @@ class GenericForm extends HTMLElement {
window.location.pathname = saveResult.redirect_url;
}
}
})
});
}
});
} catch (error) {
@@ -374,7 +389,9 @@ class GenericForm extends HTMLElement {
try {
const response = await fetch(url);
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());
@@ -387,15 +404,15 @@ class GenericForm extends HTMLElement {
const url = this.getAttribute("url");
let response = await fetch(url, {
method: 'POST',
method: "POST",
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();
Object.values(form.fields).forEach(field => {
Object.values(form.fields).forEach((field) => {
if (!this.form.fields[field.name]) {
return;
}
@@ -409,23 +426,23 @@ class GenericForm extends HTMLElement {
this.fields[field.name].setAttribute("field", field);
this.fields[field.name].updateAttributes();
});
Object.values(form.fields).forEach(field => {
Object.values(form.fields).forEach((field) => {
this.fields[field.name].setErrors(field.errors);
});
return form['is_valid'];
return form["is_valid"];
}
async submit() {
const url = this.getAttribute("url");
const response = await fetch(url, {
method: 'POST',
method: "POST",
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();
}
}
customElements.define('generic-form', GenericForm);
customElements.define("generic-form", GenericForm);
+41 -39
View File
@@ -7,48 +7,50 @@
// MIT License
class HTMLFrame extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.container = document.createElement('div');
this.shadowRoot.appendChild(this.container);
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.container = document.createElement("div");
this.shadowRoot.appendChild(this.container);
}
connectedCallback() {
this.container.classList.add("html_frame");
let url = this.getAttribute('url');
if (!url.startsWith("https")) {
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!";
}
connectedCallback() {
this.container.classList.add("html_frame");
let url = this.getAttribute("url");
if (!url.startsWith("https")) {
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!";
}
}
async loadAndRender(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Error: ${response.status} ${response.statusText}`);
}
const html = await response.text();
if (url.endsWith(".md")) {
const markdownElement = document.createElement('div');
markdownElement.innerHTML = html;
this.outerHTML = html;
} else {
this.container.innerHTML = html;
}
} catch (error) {
this.container.textContent = `Error: ${error.message}`;
}
async loadAndRender(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Error: ${response.status} ${response.statusText}`);
}
const html = await response.text();
if (url.endsWith(".md")) {
const markdownElement = document.createElement("div");
markdownElement.innerHTML = html;
this.outerHTML = html;
} else {
this.container.innerHTML = html;
}
} catch (error) {
this.container.textContent = `Error: ${error.message}`;
}
}
}
customElements.define('html-frame', HTMLFrame);
customElements.define("html-frame", HTMLFrame);
+9 -9
View File
@@ -12,22 +12,22 @@
class HTMLFrame extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.container = document.createElement('div');
this.attachShadow({ mode: "open" });
this.container = document.createElement("div");
this.shadowRoot.appendChild(this.container);
}
connectedCallback() {
this.container.classList.add('html_frame');
const url = this.getAttribute('url');
this.container.classList.add("html_frame");
const url = this.getAttribute("url");
if (url) {
const fullUrl = url.startsWith('/')
const fullUrl = url.startsWith("/")
? window.location.origin + url
: new URL(window.location.origin + '/http-get');
if (!url.startsWith('/')) fullUrl.searchParams.set('url', 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!';
this.container.textContent = "No source URL!";
}
}
@@ -45,4 +45,4 @@ class HTMLFrame extends HTMLElement {
}
}
customElements.define('markdown-frame', HTMLFrame);
customElements.define("markdown-frame", HTMLFrame);
+98 -100
View File
@@ -4,7 +4,6 @@
// No external libraries or dependencies are used other than standard web components.
// 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:
@@ -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.
class TileGridElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.gridId = this.getAttribute('grid');
this.component = document.createElement('div');
this.shadowRoot.appendChild(this.component);
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.gridId = this.getAttribute("grid");
this.component = document.createElement("div");
this.shadowRoot.appendChild(this.component);
}
connectedCallback() {
console.log('connected');
this.styleElement = document.createElement('style');
this.styleElement.textContent = `
connectedCallback() {
console.log("connected");
this.styleElement = document.createElement("style");
this.styleElement.textContent = `
.grid {
padding: 10px;
display: flex;
@@ -47,53 +45,53 @@ class TileGridElement extends HTMLElement {
transform: scale(1.1);
}
`;
this.component.appendChild(this.styleElement);
this.container = document.createElement('div');
this.container.classList.add('gallery');
this.component.appendChild(this.container);
}
this.component.appendChild(this.styleElement);
this.container = document.createElement("div");
this.container.classList.add("gallery");
this.component.appendChild(this.container);
}
addImage(src) {
const item = document.createElement('img');
item.src = src;
item.classList.add('tile');
item.style.width = '100px';
item.style.height = '100px';
this.container.appendChild(item);
}
addImage(src) {
const item = document.createElement("img");
item.src = src;
item.classList.add("tile");
item.style.width = "100px";
item.style.height = "100px";
this.container.appendChild(item);
}
addImages(srcs) {
srcs.forEach(src => this.addImage(src));
}
addImages(srcs) {
srcs.forEach((src) => this.addImage(src));
}
addElement(element) {
element.classList.add('tile');
this.container.appendChild(element);
}
addElement(element) {
element.classList.add("tile");
this.container.appendChild(element);
}
}
class UploadButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.component = document.createElement('div');
this.shadowRoot.appendChild(this.component);
window.u = this;
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.component = document.createElement("div");
this.shadowRoot.appendChild(this.component);
window.u = this;
}
get gridSelector() {
return this.getAttribute('grid');
}
grid = null;
get gridSelector() {
return this.getAttribute("grid");
}
grid = null;
addImages(urls) {
this.grid.addImages(urls);
}
addImages(urls) {
this.grid.addImages(urls);
}
connectedCallback() {
console.log('connected');
this.styleElement = document.createElement('style');
this.styleElement.textContent = `
connectedCallback() {
console.log("connected");
this.styleElement = document.createElement("style");
this.styleElement.textContent = `
.upload-button {
display: flex;
flex-direction: column;
@@ -115,61 +113,61 @@ class UploadButton extends HTMLElement {
background-color: #999;
}
`;
this.shadowRoot.appendChild(this.styleElement);
this.container = document.createElement('div');
this.container.classList.add('upload-button');
this.shadowRoot.appendChild(this.container);
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.multiple = true;
input.addEventListener('change', (e) => {
const files = e.target.files;
const urls = [];
for (let i = 0; i < files.length; i++) {
const reader = new FileReader();
reader.onload = (e) => {
urls.push(e.target.result);
if (urls.length === files.length) {
this.addImages(urls);
}
};
reader.readAsDataURL(files[i]);
}
});
const label = document.createElement('label');
label.textContent = 'Upload Images';
label.appendChild(input);
this.container.appendChild(label);
}
this.shadowRoot.appendChild(this.styleElement);
this.container = document.createElement("div");
this.container.classList.add("upload-button");
this.shadowRoot.appendChild(this.container);
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.multiple = true;
input.addEventListener("change", (e) => {
const files = e.target.files;
const urls = [];
for (let i = 0; i < files.length; i++) {
const reader = new FileReader();
reader.onload = (e) => {
urls.push(e.target.result);
if (urls.length === files.length) {
this.addImages(urls);
}
};
reader.readAsDataURL(files[i]);
}
});
const label = document.createElement("label");
label.textContent = "Upload Images";
label.appendChild(input);
this.container.appendChild(label);
}
}
customElements.define('upload-button', UploadButton);
customElements.define('tile-grid', TileGridElement);
customElements.define("upload-button", UploadButton);
customElements.define("tile-grid", TileGridElement);
class MeniaUploadElement extends HTMLElement {
constructor(){
super();
this.attachShadow({ mode: 'open' });
this.component = document.createElement("div");
alert('aaaa');
this.shadowRoot.appendChild(this.component);
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.component = document.createElement("div");
alert("aaaa");
this.shadowRoot.appendChild(this.component);
}
connectedCallback() {
this.container = document.createElement("div");
this.component.style.height = '100%';
this.component.style.backgroundColor = 'blue';
this.shadowRoot.appendChild(this.container);
connectedCallback() {
this.container = document.createElement("div");
this.component.style.height = "100%";
this.component.style.backgroundColor = "blue";
this.shadowRoot.appendChild(this.container);
this.tileElement = document.createElement("tile-grid");
this.tileElement.style.backgroundColor = 'red';
this.tileElement.style.height = '100%';
this.component.appendChild(this.tileElement);
this.tileElement = document.createElement("tile-grid");
this.tileElement.style.backgroundColor = "red";
this.tileElement.style.height = "100%";
this.component.appendChild(this.tileElement);
this.uploadButton = document.createElement('upload-button');
this.component.appendChild(this.uploadButton);
}
this.uploadButton = document.createElement("upload-button");
this.component.appendChild(this.uploadButton);
}
}
customElements.define('menia-upload', MeniaUploadElement);
customElements.define("menia-upload", MeniaUploadElement);
+16 -17
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.
//
//
//
// MIT License
// 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
// SOFTWARE.
class MessageListManagerElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.container = document.createElement("div");
this.shadowRoot.appendChild(this.container);
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.container = document.createElement("div");
this.shadowRoot.appendChild(this.container);
}
async connectedCallback() {
const channels = await app.rpc.getChannels();
channels.forEach(channel => {
const messageList = document.createElement("message-list");
messageList.setAttribute("channel", channel.uid);
this.container.appendChild(messageList);
});
}
async connectedCallback() {
const channels = await app.rpc.getChannels();
channels.forEach((channel) => {
const messageList = document.createElement("message-list");
messageList.setAttribute("channel", channel.uid);
this.container.appendChild(messageList);
});
}
}
customElements.define("message-list-manager", MessageListManagerElement);
customElements.define("message-list-manager", MessageListManagerElement);
+92 -215
View File
@@ -5,226 +5,103 @@
// 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.
import {app} from '../app.js'
class MessageList extends HTMLElement {
constructor() {
super();
app.ws.addEventListener("update_message_text",(data)=>{
this.updateMessageText(data.data.uid,data.data)
})
app.ws.addEventListener("set_typing",(data)=>{
this.triggerGlow(data.data.user_uid)
import { app } from "../app.js";
class MessageList extends HTMLElement {
constructor() {
super();
app.ws.addEventListener("update_message_text", (data) => {
this.updateMessageText(data.uid, data);
});
app.ws.addEventListener("set_typing", (data) => {
this.triggerGlow(data.user_uid,data.color);
});
})
this.items = [];
}
this.items = [];
connectedCallback() {
const messagesContainer = this
messagesContainer.addEventListener('click', (e) => {
if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return;
const img = e.target;
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;justify-content:center;align-items:center;z-index:9999;'
const fullImg = document.createElement('img');
const urlObj = new URL(img.src); urlObj.search = '';
fullImg.src = urlObj.toString();
fullImg.alt = img.alt;
fullImg.style.maxWidth = '90%';
fullImg.style.maxHeight = '90%';
overlay.appendChild(fullImg);
document.body.appendChild(overlay);
overlay.addEventListener('click', () => document.body.removeChild(overlay));
})
}
isElementVisible(element) {
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
isScrolledToBottom() {
return this.isElementVisible(this.querySelector(".message-list-bottom"));
}
scrollToBottom(force) {
this.scrollTop = this.scrollHeight;
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 scrollToBottom = this.isScrolledToBottom();
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";
if(scrollToBottom)
this.scrollToBottom(true)
}
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(!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) {
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);
}
});
if (lastElement) {
lastElement.classList.add("glow");
setTimeout(() => {
lastElement.classList.remove("glow");
}, 1000);
}
}
customElements.define('message-list', MessageList);
set data(items) {
this.items = items;
this.render();
}
render() {
this.innerHTML = "";
class MessageListElementOLD extends HTMLElement {
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);
}
//this.insertAdjacentHTML("beforeend", html);
}
}
//customElements.define('message-list', MessageListElement);
customElements.define("message-list", MessageList);
+24 -14
View File
@@ -7,20 +7,30 @@
// MIT License
class MessageModel {
constructor(uid, channel_uid, user_uid, user_nick, color, message, html, created_at, updated_at) {
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
}
constructor(
uid,
channel_uid,
user_uid,
user_nick,
color,
message,
html,
created_at,
updated_at,
) {
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 = {
Message: MessageModel
}
Message: MessageModel,
};
-1
View File
@@ -1 +0,0 @@
+32 -28
View File
@@ -1,30 +1,34 @@
this.onpush = (event) => {
console.log(event.data);
// From here we can write the data to IndexedDB, send it to any open
// windows, display a notification, etc.
};
console.log(event.data);
// From here we can write the data to IndexedDB, send it to any open
// windows, display a notification, etc.
};
navigator.serviceWorker
.register("/service-worker.js")
.then((serviceWorkerRegistration) => {
serviceWorkerRegistration.pushManager.subscribe().then(
(pushSubscription) => {
const subscriptionObject = {
endpoint: pushSubscription.endpoint,
keys: {
p256dh: pushSubscription.getKey('p256dh'),
auth: pushSubscription.getKey('auth'),
},
encoding: PushManager.supportedContentEncodings,
/* other app-specific data, such as user identity */
};
console.log(pushSubscription.endpoint, pushSubscription, subscriptionObject);
// The push subscription details needed by the application
// server are now available, and can be sent to it using,
// for example, the fetch() API.
},
(error) => {
console.error(error);
},
);
});
navigator.serviceWorker
.register("/service-worker.js")
.then((serviceWorkerRegistration) => {
serviceWorkerRegistration.pushManager.subscribe().then(
(pushSubscription) => {
const subscriptionObject = {
endpoint: pushSubscription.endpoint,
keys: {
p256dh: pushSubscription.getKey("p256dh"),
auth: pushSubscription.getKey("auth"),
},
encoding: PushManager.supportedContentEncodings,
/* other app-specific data, such as user identity */
};
console.log(
pushSubscription.endpoint,
pushSubscription,
subscriptionObject,
);
// The push subscription details needed by the application
// server are now available, and can be sent to it using,
// for example, the fetch() API.
},
(error) => {
console.error(error);
},
);
});
+175 -38
View File
@@ -1,42 +1,179 @@
: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;
z-index: -1;
}
@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;
width: 2px;
height: 2px;
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-size: 12px;
color: white;
font-family: sans-serif;
text-align: center;
top: 40%;
transform: translateY(-40%);
pointer-events: none;
z-index: 9999;
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;
}
+1 -1
View File
@@ -51,4 +51,4 @@ export class Schedule {
me.timeOutCount = 0;
}, this.msDelay);
}
}
}
+11 -10
View File
@@ -1,33 +1,34 @@
async function requestNotificationPermission() {
const permission = await Notification.requestPermission();
return permission === 'granted';
return permission === "granted";
}
// Subscribe to Push Notifications
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({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY)
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY),
});
// Send subscription to your backend
await fetch('/subscribe', {
method: 'POST',
await fetch("/subscribe", {
method: "POST",
body: JSON.stringify(subscription),
headers: {
'Content-Type': 'application/json'
}
"Content-Type": "application/json",
},
});
}
// Service Worker (service-worker.js)
self.addEventListener('push', event => {
self.addEventListener("push", (event) => {
const data = event.data.json();
self.registration.showNotification(data.title, {
body: data.message,
icon: data.icon
icon: data.icon,
});
});
+130 -121
View File
@@ -1,142 +1,151 @@
import {EventHandler} from "./event-handler.js";
import { EventHandler } from "./event-handler.js";
export class Socket extends EventHandler {
/**
* @type {URL}
*/
url
/**
* @type {WebSocket|null}
*/
ws = null
/**
* @type {URL}
*/
url;
/**
* @type {WebSocket|null}
*/
ws = null;
/**
* @type {null|PromiseWithResolvers<Socket>&{resolved?:boolean}}
*/
connection = null
/**
* @type {null|PromiseWithResolvers<Socket>&{resolved?:boolean}}
*/
connection = null;
shouldReconnect = true;
shouldReconnect = true;
get isConnected() {
return this.ws && this.ws.readyState === WebSocket.OPEN;
_debug = false;
get isConnected() {
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() {
return this.ws && this.ws.readyState === WebSocket.CONNECTING;
if (!this.connection || this.connection.resolved) {
this.connection = Promise.withResolvers();
}
constructor() {
super();
this.ws = new WebSocket(this.url);
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.url.protocol = this.url.protocol.replace('http', 'ws');
this.connect()
}
connect() {
if (this.ws) {
return this.connection.promise;
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 (!this.connection || this.connection.resolved) {
this.connection = Promise.withResolvers()
}
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);
}
}
})
onData(data) {
if (data.success !== undefined && !data.success) {
console.error(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.callId) {
this.emit(data.callId, data.data);
}
disconnect() {
this.ws?.close();
this.ws = null;
if (this.shouldReconnect) setTimeout(() => {
console.log("Reconnecting");
return this.connect();
}, 0);
if (data.channel_uid) {
this.emit(data.channel_uid, data.data);
if (!data["event"]) this.emit("channel-message", data);
}
_camelToSnake(str) {
return str.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
this.emit("data", data.data);
if (data["event"]) {
this.emit(data.event, data.data);
}
}
get client() {
const me = this;
return new Proxy({}, {
get(_, prop) {
return (...args) => {
const functionName = me._camelToSnake(prop);
return me.call(functionName, ...args);
};
},
});
}
disconnect() {
this.ws?.close();
this.ws = null;
generateCallId() {
return self.crypto.randomUUID();
}
if (this.shouldReconnect)
setTimeout(() => {
console.log("Reconnecting");
this.emit("reconnecting");
return this.connect();
}, 0);
}
async sendJson(data) {
await this.connect().then(api => {
api.ws.send(JSON.stringify(data));
});
}
_camelToSnake(str) {
return str.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
}
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);
});
}
get client() {
const me = this;
return new Proxy(
{},
{
get(_, prop) {
return (...args) => {
const functionName = me._camelToSnake(prop);
if(me._debug){
const call = {}
call[functionName] = args
console.debug(call)
}
return me.call(functionName, ...args);
};
},
},
);
}
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);
});
}
}
+65 -64
View File
@@ -2,61 +2,62 @@
// 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:
class UploadButtonElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
constructor() {
super();
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) {
return;
}
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);
const files = fileInput.files;
const formData = new FormData();
for (let i = 0; i < files.length; i++) {
formData.append("files[]", files[i]);
}
channelUid = null
connectedCallback() {
this.styleElement = document.createElement('style');
this.styleElement.innerHTML = `
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;
connectedCallback() {
this.styleElement = document.createElement("style");
this.styleElement.innerHTML = `
body {
font-family: Arial, sans-serif;
display: flex;
@@ -97,9 +98,9 @@ class UploadButtonElement extends HTMLElement {
display: none;
}
`;
this.shadowRoot.appendChild(this.styleElement);
this.container = document.createElement('div');
this.container.innerHTML = `
this.shadowRoot.appendChild(this.styleElement);
this.container = document.createElement("div");
this.container.innerHTML = `
<div class="upload-container">
<button class="upload-button">
📤
@@ -107,17 +108,17 @@ class UploadButtonElement extends HTMLElement {
<input class="hidden-input file-input" type="file" multiple />
</div>
`;
this.shadowRoot.appendChild(this.container);
this.channelUid = this.getAttribute('channel');
this.uploadButton = this.container.querySelector('.upload-button');
this.fileInput = this.container.querySelector('.hidden-input');
this.uploadButton.addEventListener('click', () => {
this.fileInput.click();
});
this.fileInput.addEventListener('change', () => {
this.uploadFiles();
});
}
this.shadowRoot.appendChild(this.container);
this.channelUid = this.getAttribute("channel");
this.uploadButton = this.container.querySelector(".upload-button");
this.fileInput = this.container.querySelector(".hidden-input");
this.uploadButton.addEventListener("click", () => {
this.fileInput.click();
});
this.fileInput.addEventListener("change", () => {
this.uploadFiles();
});
}
}
customElements.define('upload-button', UploadButtonElement);
customElements.define("upload-button", UploadButtonElement);
+33 -33
View File
@@ -1,36 +1,36 @@
class UserList extends HTMLElement {
constructor() {
super();
this.users = [];
}
class UserList extends HTMLElement {
constructor() {
super();
this.users = [];
}
set data(userArray) {
this.users = userArray;
this.render();
}
set data(userArray) {
this.users = userArray;
this.render();
}
formatRelativeTime(timestamp) {
const now = new Date();
const msgTime = new Date(timestamp);
const diffMs = now - msgTime;
const minutes = Math.floor(diffMs / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
formatRelativeTime(timestamp) {
const now = new Date();
const msgTime = new Date(timestamp);
const diffMs = now - msgTime;
const minutes = Math.floor(diffMs / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${days} day${days > 1 ? 's' : ''} ago`;
} else if (hours > 0) {
return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${hours} hour${hours > 1 ? 's' : ''} ago`;
} else {
return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${minutes} min ago`;
}
}
if (days > 0) {
return `${msgTime.getHours().toString().padStart(2, "0")}:${msgTime.getMinutes().toString().padStart(2, "0")}, ${days} day${days > 1 ? "s" : ""} ago`;
} else if (hours > 0) {
return `${msgTime.getHours().toString().padStart(2, "0")}:${msgTime.getMinutes().toString().padStart(2, "0")}, ${hours} hour${hours > 1 ? "s" : ""} ago`;
} else {
return `${msgTime.getHours().toString().padStart(2, "0")}:${msgTime.getMinutes().toString().padStart(2, "0")}, ${minutes} min ago`;
}
}
render() {
this.innerHTML = '';
render() {
this.innerHTML = "";
this.users.forEach(user => {
const html = `
this.users.forEach((user) => {
const html = `
<div class="user-list__item"
data-uid="${user.uid}"
data-color="${user.color}"
@@ -51,9 +51,9 @@
</div>
</div>
`;
this.insertAdjacentHTML("beforeend", html);
});
}
}
this.insertAdjacentHTML("beforeend", html);
});
}
}
customElements.define('user-list', UserList);
customElements.define("user-list", UserList);
+17 -9
View File
@@ -1,6 +1,6 @@
DEFAULT_LIMIT = 30
import typing
import asyncio
from snek.system.model import BaseModel
@@ -12,13 +12,20 @@ class BaseMapper:
def __init__(self, app):
self.app = app
self.default_limit = self.__class__.default_limit
@property
def db(self):
return self.app.db
@property
def loop(self):
return asyncio.get_event_loop()
async def run_in_executor(self, func, *args, **kwargs):
return await self.loop.run_in_executor(None, lambda: func(*args, **kwargs))
async def new(self):
return self.model_class(mapper=self, app=self.app)
@@ -29,7 +36,8 @@ class BaseMapper:
async def get(self, uid: str = None, **kwargs) -> BaseModel:
if uid:
kwargs["uid"] = uid
record = self.table.find_one(**kwargs)
record = await self.run_in_executor(self.table.find_one,**kwargs)
if not record:
return None
record = dict(record)
@@ -40,31 +48,31 @@ class BaseMapper:
return await self.model_class.from_record(mapper=self, record=record)
async def exists(self, **kwargs):
return self.table.exists(**kwargs)
return await self.run_in_executor(self.table.exists,**kwargs)
async def count(self, **kwargs) -> int:
return self.table.count(**kwargs)
return await self.run_in_executor(self.table.count, **kwargs)
async def save(self, model: BaseModel) -> bool:
if not model.record.get("uid"):
raise Exception(f"Attempt to save without uid: {model.record}.")
model.updated_at.update()
return self.table.upsert(model.record, ["uid"])
return await self.run_in_executor(self.table.upsert, model.record, ["uid"])
async def find(self, **kwargs) -> typing.AsyncGenerator:
if not kwargs.get("_limit"):
kwargs["_limit"] = self.default_limit
for record in self.table.find(**kwargs):
for record in await self.run_in_executor(self.table.find, **kwargs):
model = await self.new()
for key, value in record.items():
model[key] = value
yield model
async def query(self, sql, *args):
for record in self.db.query(sql, *args):
for record in await self.run_in_executor(self.db.query,sql, *args):
yield dict(record)
async def delete(self, **kwargs) -> int:
if not kwargs or not isinstance(kwargs, dict):
raise Exception("Can't execute delete with no filter.")
return self.table.delete(**kwargs)
return await self.run_in_executor(self.table.delete, **kwargs)
+231 -30
View File
@@ -1,8 +1,11 @@
import re
from types import SimpleNamespace
import mimetypes
import re
from functools import lru_cache
from types import SimpleNamespace
from urllib.parse import urlparse, parse_qs
from app.cache import time_cache
import emoji
import requests
from bs4 import BeautifulSoup
from jinja2 import TemplateSyntaxError, nodes
from jinja2.ext import Extension
@@ -90,16 +93,59 @@ def set_link_target_blank(text):
def embed_youtube(text):
soup = BeautifulSoup(text, "html.parser")
for element in soup.find_all("a"):
if element.attrs["href"].startswith("https://www.you"):
video_name = element.attrs["href"].split("/")[-1]
if "v=" in element.attrs["href"]:
video_name = element.attrs["href"].split("?v=")[1].split("&")[0]
# if "si=" in element.attrs["href"]:
# video_name = "?v=" + element.attrs["href"].split("/")[-1]
# if "t=" in element.attrs["href"]:
# video_name += "&t=" + element.attrs["href"].split("&t=")[1].split("&")[0]
embed_template = f'<iframe width="560" height="315" style="display:block" src="https://www.youtube.com/embed/{video_name}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>'
# Check if the link is a YouTube link
url = urlparse(element["href"])
if (
url.hostname in ["www.youtu.be", "youtu.be"]
or url.hostname
in [
"www.youtube.com",
"youtube.com",
"www.youtube-nocookie.com",
"youtube-nocookie.com",
]
and any(url.path.startswith(p) for p in ["/watch", "/embed"])
):
queries = parse_qs(url.query)
if "v" in queries:
video_name = queries["v"][0]
else:
video_name = url.path.split("/")[-1]
queries.pop("v", None)
start_time = queries.get("t", None)
if start_time:
queries.pop("t", None)
queries["start"] = []
for t in start_time:
if t.endswith("s"):
t = t[:-1]
if t.isdigit():
queries["start"].append(t)
else:
queries["start"].append(
str(
sum(
int(x) * 60**i
for i, x in enumerate(reversed(t.split(":")))
)
)
)
new_queries = "&".join(
[f"{key}={v}" for key, value in queries.items() for v in value]
)
base_url = (
"youtube-nocookie.com"
if "youtube-nocookie" in url.hostname
else "youtube.com"
)
embed_template = f'<iframe width="560" height="315" style="display:block" src="https://www.{base_url}/embed/{video_name}?{new_queries}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>'
element.replace_with(BeautifulSoup(embed_template, "html.parser"))
return str(soup)
@@ -108,35 +154,41 @@ def embed_image(text):
for element in soup.find_all("a"):
file_mime = mimetypes.guess_type(element.attrs["href"])[0]
if file_mime and file_mime.startswith("image/") or any(
ext in element.attrs["href"].lower() for ext in [
".png",
".jpg",
".jpeg",
".gif",
".webp",
".svg",
".bmp",
".tiff",
".ico",
".heif",
".heic",
]
if (
file_mime
and file_mime.startswith("image/")
or any(
ext in element.attrs["href"].lower()
for ext in [
".png",
".jpg",
".jpeg",
".gif",
".webp",
".svg",
".bmp",
".tiff",
".ico",
".heif",
".heic",
]
)
):
embed_template = f'<img src="{element.attrs["href"]}" title="{element.attrs["href"]}?width=420" alt="{element.attrs["href"]}" />'
element.replace_with(BeautifulSoup(embed_template, "html.parser"))
return str(soup)
def enrich_image_rendering(text):
soup = BeautifulSoup(text, "html.parser")
for element in soup.find_all("img"):
if element.attrs["src"].startswith("/" ):
if element.attrs["src"].startswith("/"):
element.attrs["src"] += "?width=240&height=240"
picture_template = f'''
<picture>
<source srcset="{element.attrs["src"]}" type="{mimetypes.guess_type(element.attrs["src"])[0]}" />
<source srcset="{element.attrs["src"]}" type="image/webp" />
<img src="{element.attrs["src"]}" title="{element.attrs["src"]}" alt="{element.attrs["src"]}" />
<source srcset="{element.attrs["src"]}&format=webp" type="image/webp" />
<img src="{element.attrs["src"]}&format=png" title="{element.attrs["src"]}" alt="{element.attrs["src"]}" />
</picture>'''
element.replace_with(BeautifulSoup(picture_template, "html.parser"))
return str(soup)
@@ -181,6 +233,153 @@ def linkify_https(text):
return set_link_target_blank(str(soup))
@time_cache(timeout=60*60)
def get_url_content(url):
try:
response = requests.get(url, timeout=5)
response.raise_for_status()
return response.text
except Exception as e:
print(f"Error fetching {url}: {e}")
return None
def embed_url(text):
soup = BeautifulSoup(text, "html.parser")
attachments = {}
for element in soup.find_all("a"):
if "href" in element.attrs and element.attrs["href"].startswith("http"):
page_url = urlparse(element.attrs["href"])
page = get_url_content(element.attrs["href"])
if page:
parsed_page = BeautifulSoup(page, "html.parser")
head_info = parsed_page.find("head")
if head_info:
def get_element_options(
elem=None, meta=None, ograph=None, twitter=None
):
if twitter:
tw_tag = head_info.find(
"meta", attrs={"name": "twitter:" + twitter}
) or head_info.find(
"meta", attrs={"property": "twitter:" + twitter}
)
if tw_tag:
return tw_tag.get("content", tw_tag.get("value", None))
if ograph:
og_tag = head_info.find(
"meta", attrs={"property": "og:" + ograph}
) or head_info.find("meta", attrs={"name": "og:" + ograph})
if og_tag:
return og_tag.get("content", og_tag.get("value", None))
if meta:
meta_tag = head_info.find(
"meta", attrs={"name": meta}
) or head_info.find("meta", attrs={"property": meta})
if meta_tag:
return meta_tag.get(
"content", meta_tag.get("value", None)
)
if elem:
elem_tag = head_info.find(elem)
if elem_tag:
return elem_tag.text
return None
original_link_name = element.attrs["href"]
if original_link_name in attachments:
continue
page_name = (
get_element_options("title", "title", "title", "title")
or page_url.netloc
)
page_site = (
get_element_options(None, "site", "site", "site")
or page_url.netloc
)
page_description = get_element_options(
None, "description", "description", "description"
)
page_image = get_element_options(None, "image", "image", "image")
page_image_alt = get_element_options(
None, "image:alt", "image:alt", "image:alt"
)
page_video = get_element_options(None, "video", "video", "video")
page_audio = get_element_options(None, "audio", "audio", "audio")
preview_size = (
get_element_options(None, None, None, "card")
or "summary_large_image"
)
attachment_base = BeautifulSoup(str(element), "html.parser")
attachments[original_link_name] = attachment_base
attachment = next(attachment_base.children)
attachment.clear()
attachment.attrs["class"] = "embed-url-link"
render_element = attachment
if page_image:
image_template = f'<span><img src="{page_image}" alt="{page_image_alt or page_name}" title="{page_name}" width="420" height="240" /></span>'
render_element.append(
BeautifulSoup(image_template, "html.parser")
)
if page_video:
video_template = f'<video controls><source src="{page_video}">Your browser does not support the video tag.</video>'
render_element.append(
BeautifulSoup(video_template, "html.parser")
)
if page_audio:
audio_template = f'<audio controls><source src="{page_audio}">Your browser does not support the audio tag.</audio>'
render_element.append(
BeautifulSoup(audio_template, "html.parser")
)
description_element_base = BeautifulSoup(
"<span class='description'></span>", "html.parser"
)
description_element = next(description_element_base.children)
description_element.append(
BeautifulSoup(
f'<p class="page-site">{page_site}</p>',
"html.parser",
)
)
description_element.append(
BeautifulSoup(f'<strong class="page-name">{page_name}</strong>', "html.parser")
)
description_element.append(
BeautifulSoup(f"<p class='page-description'>{page_description or "No description available."}</p>", "html.parser")
)
description_element.append(
BeautifulSoup(f"<p class='page-original-link'>{original_link_name}</p>", "html.parser")
)
render_element.append(description_element_base)
for attachment in attachments.values():
soup.append(attachment)
return str(soup)
class EmojiExtension(Extension):
tags = {"emoji"}
@@ -226,6 +425,9 @@ class LinkifyExtension(Extension):
result = embed_youtube(result)
result = enrich_image_rendering(result)
result = embed_url(result)
return result
@@ -245,7 +447,6 @@ class PythonExtension(Extension):
).set_lineno(line_number)
def _to_html(self, md_file, caller):
def fn(source):
import subprocess
+3 -2
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
<link rel="manifest" href="/manifest.json" />
<title>Snek</title>
<style>{{highlight_styles}}</style>
@@ -50,10 +50,11 @@
</nav>
</header>
<main>
{% block sidebar %}
{% include "sidebar_channels.html" %}
{% endblock %}
<main>
{% block main %}
<chat-window class="chat-area"></chat-window>
+1 -1
View File
@@ -21,7 +21,7 @@
<script src="/html-frame.js" type="module"></script>
<script src="/generic-form.js?rid={{ rid }}" type="module"></script>
<link rel="stylesheet" href="/html-frame.css">
<script defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea"></script>
<script async defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea"></script>
{% block head %}
{% endblock %}
</head>
+1 -1
View File
@@ -1 +1 @@
<div style="max-width:100%;" data-uid="{{uid}}" data-color="{{color}}" data-channel_uid="{{channel_uid}}" data-user_nick="{{user_nick}}" data-created_at="{{created_at}}" data-user_uid="{{user_uid}}" class="message"><a class="avatar" style="background-color: {{color}}; color: black;" href="/user/{{user_uid}}.html"><img width="40px" height="40px" src="/avatar/{{user_uid}}.svg" /></a><div class="message-content"><div class="author" style="color: {{color}};">{{user_nick}}</div><div class="text">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class="time no-select" data-created_at="{{created_at}}"></div></div></div>
<div style="max-width:100%;" data-uid="{{uid}}" data-color="{{color}}" data-channel_uid="{{channel_uid}}" data-user_nick="{{user_nick}}" data-created_at="{{created_at}}" data-user_uid="{{user_uid}}" class="message"><a class="avatar" style="background-color: {{color}}; color: black;" href="/user/{{user_uid}}.html"><img class="avatar-img" width="40px" height="40px" src="/avatar/{{user_uid}}.svg" /></a><div class="message-content"><div class="author" style="color: {{color}};">{{user_nick}}</div><div class="text">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class="time no-select" data-created_at="{{created_at}}"></div></div></div>
+376 -40
View File
@@ -1,56 +1,392 @@
<div id="star-tooltip" class="star-tooltip"></div>
<div id="star-popup" class="star-popup"></div>
<script type="module">
import { app } from "/app.js";
const STAR_COUNT = 200;
const body = document.body;
class StarField {
constructor({ count = 200, container = document.body } = {}) {
this.container = container;
this.starCount = count;
this.stars = [];
this.positionMap = {};
this.originalColor = getComputedStyle(document.documentElement).getPropertyValue("--star-color").trim();
this._createStars();
window.stars = this.positionMap;
}
function createStar() {
const star = document.createElement('div');
star.classList.add('star');
star.style.left = `${Math.random() * 100}%`;
star.style.top = `${Math.random() * 100}%`;
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);
}
_getStarPosition(star) {
const left = parseFloat(star.style.left);
const top = parseFloat(star.style.top);
if (top < 40 && left >= 40 && left <= 60) return "North";
if (top > 60 && left >= 40 && left <= 60) return "South";
if (left < 40 && top >= 40 && top <= 60) return "West";
if (left > 60 && top >= 40 && top <= 60) return "East";
if (top >= 40 && top <= 60 && left >= 40 && left <= 60) return "Center";
return "Corner or Edge";
}
Array.from({ length: STAR_COUNT }, createStar);
_createStars() {
for (let i = 0; i < this.starCount; i++) {
const star = document.createElement("div");
star.classList.add("star");
this._randomizeStar(star);
this._placeStar(star);
this.container.appendChild(star);
this.stars.push(star);
}
}
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)}`;
}
_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";
const originalColor = document.documentElement.style.getPropertyValue("--star-color").trim();
star.shuffle = () => this._randomizeStar(star);
star.position = this._getStarPosition(star);
}
function glowCSSVariable(varName, glowColor, duration = 500) {
const root = document.documentElement;
_placeStar(star) {
const pos = star.position;
if (!this.positionMap[pos]) this.positionMap[pos] = [];
this.positionMap[pos].push(star);
}
//igetComputedStyle(root).getPropertyValue(varName).trim();
glowColor = lightenColor(glowColor, 20);
root.style.setProperty(varName, glowColor);
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(() => {
root.style.setProperty(varName, originalColor);
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);
}
function updateStarColorDelayed(color) {
glowCSSVariable('--star-color', color, 2500);
renderWord(word, { font = "bold", resolution = 8, duration = 1500, rainbow = false } = {}) {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = this.container.clientWidth;
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
}));
}
}
app.ws.addEventListener("set_typing", (data) => {
updateStarColorDelayed(data.data.color);
});
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>
+237 -271
View File
@@ -6,323 +6,289 @@
<section class="chat-area">
<message-list class="chat-messages">
{% for message in messages %}
{% autoescape false %}
{{ message.html }}
{% endautoescape %}
{% endfor %}
{% for message in messages %}
{% autoescape false %}
{{ message.html }}
{% endautoescape %}
{% endfor %}
<div class="message-list-bottom"></div>
</message-list>
<chat-input live-type="false" channel="{{ channel.uid.value }}"></chat-input>
<chat-input live-type="true" channel="{{ channel.uid.value }}"></chat-input>
</section>
{% include "dialog_help.html" %}
{% include "dialog_online.html" %}
<script type="module">
import { app } from "/app.js";
import { Schedule } from "/schedule.js";
const channelUid = "{{ channel.uid.value }}";
const chatInputField = document.querySelector("chat-input");
chatInputField.autoCompletions = {
"/online": () =>{
showOnline();
},
"/clear": () => {
document.querySelector(".chat-messages").innerHTML = '';
},
"/live": () =>{
chatInputField.liveType = !chatInputField.liveType
},
"/help": () => {
showHelp();
import { Schedule } from "/schedule.js";
// --- Cache selectors ---
const chatInputField = document.querySelector("chat-input");
const messagesContainer = document.querySelector(".chat-messages");
const chatArea = document.querySelector(".chat-area");
const channelUid = "{{ channel.uid.value }}";
const username = "{{ user.username.value }}";
// --- Command completions ---
chatInputField.autoCompletions = {
"/online": showOnline,
"/clear": () => { messagesContainer.innerHTML = ''; },
"/live": () => { chatInputField.liveType = !chatInputField.liveType; },
"/help": showHelp
};
// --- Throttle utility ---
function throttle(fn, wait) {
let last = 0;
return function(...args) {
const now = Date.now();
if (now - last >= wait) {
last = now;
fn.apply(this, args);
}
}
const textBox = document.querySelector("chat-input").textarea
textBox.addEventListener("paste", async (e) => {
try {
const clipboardItems = await navigator.clipboard.read();
};
}
// DataTransfer needs to be used to modify the files list of the input
const dt = new DataTransfer();
for (const clipboardItem of clipboardItems) {
const fileTypes = clipboardItem.types.filter(type => !type.startsWith('text/'))
for (const fileType of fileTypes) {
const blob = await clipboardItem.getType(fileType);
// Do something with the image blob.
dt.items.add(new File([blob], "image.png", { type: fileType }));
}
}
if (dt.items.length > 0) {
const uploadButton = chatInputField.uploadButton
const input = uploadButton.shadowRoot.querySelector('.file-input')
input.files = dt.files;
await uploadButton.uploadFiles();
}
} catch (error) {
console.error("Failed to read clipboard contents: ", error);
}
// --- Scroll: load extra messages, throttled ---
let isLoadingExtra = false;
async function loadExtra() {
const firstMessage = messagesContainer.querySelector(".message:first-child");
if (isLoadingExtra || !isScrolledPastHalf() || !firstMessage) return;
isLoadingExtra = true;
const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);
if (messages.length) {
const frag = document.createDocumentFragment();
messages.forEach(msg => {
const temp = document.createElement("div");
temp.innerHTML = msg.html;
frag.appendChild(temp.firstChild);
});
const chatInput = document.querySelector(".chat-area")
chatInput.addEventListener("drop", async (e) => {
e.preventDefault();
const dt = e.dataTransfer;
if (dt.items.length > 0) {
const uploadButton = chatInputField.uploadButton
const input = uploadButton.shadowRoot.querySelector('.file-input')
input.files = dt.files;
await uploadButton.uploadFiles();
}
})
chatInput.addEventListener("dragover", async (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "link";
})
chatInputField.textarea.focus();
function replyMessage(message) {
const field = chatInputField
field.value = "```markdown\n> " + (message || '') + "\n```\n";
field.focus();
firstMessage.parentNode.insertBefore(frag, firstMessage);
updateLayout(false);
}
isLoadingExtra = false;
}
messagesContainer.addEventListener("scroll", throttle(loadExtra, 200));
function updateTimes() {
document.querySelectorAll(".time").forEach((container) => {
// --- Only update visible times ---
function updateTimes() {
const containers = messagesContainer.querySelectorAll(".time");
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
containers.forEach(container => {
const rect = container.getBoundingClientRect();
if (rect.top >= 0 && rect.bottom <= viewportHeight) {
const messageDiv = container.closest('.message');
const userNick = messageDiv.dataset.user_nick;
const text = messageDiv.querySelector(".text").innerText;
const time = document.createElement("span");
time.innerText = app.timeDescription(container.dataset.created_at);
container.replaceChildren(time);
const reply = document.createElement("a");
reply.innerText = " reply";
reply.href = "#reply";
container.appendChild(reply);
reply.addEventListener('click', (e) => {
reply.addEventListener('click', e => {
e.preventDefault();
replyMessage(text);
})
});
}
function isElementVisible(element) {
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
const messagesContainer = document.querySelector(".chat-messages");
function isScrolledPastHalf() {
let scrollTop = messagesContainer.scrollTop;
let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;
if (scrollTop < scrollableHeight / 2) {
return true;
});
}
return false;
}
let isLoadingExtra = false;
async function loadExtra() {
const firstMessage = messagesContainer.querySelector(".message:first-child");
if (isLoadingExtra) {
return;
}
if (!isScrolledPastHalf()) {
return;
}
isLoadingExtra = true;
const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);
messages.forEach((message) => {
firstMessage.insertAdjacentHTML("beforebegin", message.html);
})
updateLayout(false);
isLoadingExtra = false;
}
messagesContainer.addEventListener("scroll", () => {
loadExtra();
});
}
setInterval(() => requestIdleCallback(updateTimes), 30000);
let lastMessage
function updateLayout(doScrollDown) {
const messagesContainer = document.querySelector(".chat-messages");
updateTimes();
let previousUser = null;
document.querySelectorAll(".message").forEach((message) => {
if (previousUser !== message.dataset.user_uid) {
message.classList.add("switch-user");
previousUser = message.dataset.user_uid;
} else {
message.classList.remove("switch-user");
// --- Paste & drag/drop uploads ---
const textBox = chatInputField.textarea;
textBox.addEventListener("paste", async (e) => {
try {
const clipboardItems = await navigator.clipboard.read();
const dt = new DataTransfer();
for (const item of clipboardItems) {
for (const type of item.types.filter(t => !t.startsWith('text/'))) {
const blob = await item.getType(type);
dt.items.add(new File([blob], "image.png", { type }));
}
});
lastMessage = messagesContainer.querySelector(".message:last-child");
if (doScrollDown) {
lastMessage?.scrollIntoView({ block: "end", inline: "nearest" });
chatInputField.scrollIntoView({ block: "end", inline: "nearest" });
}
}
setInterval(updateTimes, 30000);
function isMentionToMe(message){
const mentionText = '@{{ user.username.value }}';
return message.toLowerCase().includes(mentionText);
}
function extractMentions(message) {
return [...new Set(message.match(/@\w+/g) || [])];
}
function isMentionForSomeoneElse(message){
const mentions = extractMentions(message);
const mentionText = '@{{ user.username.value }}';
return mentions.length > 0 && mentions.indexOf(mentionText) == -1;
}
app.addEventListener("channel-message", (data) => {
let display = 'block';
if(!data.text || !data.text.trim()){
display = "none";
if (dt.items.length > 0) {
const uploadButton = chatInputField.uploadButton;
const input = uploadButton.shadowRoot.querySelector('.file-input');
input.files = dt.files;
await uploadButton.uploadFiles();
}
if (data.channel_uid !== channelUid) {
if(!isMentionForSomeoneElse(data.message)){
channelSidebar.notify(data);
app.playSound("messageOtherChannel");
}
} catch (error) {
console.error("Failed to read clipboard contents: ", error);
}
});
chatArea.addEventListener("drop", async (e) => {
e.preventDefault();
const dt = e.dataTransfer;
if (dt.items.length > 0) {
const uploadButton = chatInputField.uploadButton;
const input = uploadButton.shadowRoot.querySelector('.file-input');
input.files = dt.files;
await uploadButton.uploadFiles();
}
});
chatArea.addEventListener("dragover", e => {
e.preventDefault();
e.dataTransfer.dropEffect = "link";
});
return;
// --- Focus input on load ---
chatInputField.textarea.focus();
// --- Reply helper ---
function replyMessage(message) {
chatInputField.value = "```markdown\n> " + (message || '') + "\n```\n";
chatInputField.focus();
}
// --- Mention helpers ---
function extractMentions(message) {
return [...new Set(message.match(/@\w+/g) || [])];
}
function isMentionToMe(message) {
return message.toLowerCase().includes('@' + username.toLowerCase());
}
function isMentionForSomeoneElse(message) {
const mentions = extractMentions(message);
return mentions.length > 0 && !mentions.includes('@' + username);
}
// --- WebSocket events ---
app.ws.addEventListener("refresh", (data) => {
app.starField.showNotify(data.message);
setTimeout(() => window.location.reload(), 4000);
});
app.ws.addEventListener("stars_render", (data)=>{
app.starField.renderWord(data.message, { rainbow: true, resolution: 8 });
setTimeout(() => app.starField.shuffleAll(5000), 10000);
})
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);
});
// --- Channel message event ---
app.addEventListener("channel-message", (data) => {
let display = data.text && data.text.trim() ? 'block' : 'none';
if (data.channel_uid !== channelUid) {
if (!isMentionForSomeoneElse(data.message)) {
channelSidebar.notify(data);
app.playSound("messageOtherChannel");
}
if (data.username !== "{{ user.username.value }}") {
if(isMentionToMe(data.message)){
app.playSound("mention");
}else if (!isMentionForSomeoneElse(data.message)){
return;
}
if (data.username !== username) {
if (isMentionToMe(data.message)) {
app.playSound("mention");
} else if (!isMentionForSomeoneElse(data.message)) {
if(data.is_final){
app.playSound("message");
}
}
const messagesContainer = document.querySelector(".chat-messages");
lastMessage = messagesContainer.querySelector(".message:last-child");
const doScrollDownBecauseLastMessageIsVisible = !lastMessage || isElementVisible(lastMessage);
const message = document.createElement("div");
message.innerHTML = data.html;
message.style.display = display
document.querySelector(".chat-messages").appendChild(message.firstChild);
updateLayout(doScrollDownBecauseLastMessageIsVisible);
setTimeout(() => {
updateLayout(doScrollDownBecauseLastMessageIsVisible)
}, 1000);
app.rpc.markAsRead(channelUid);
});
let escPressed = false;
let gPressCount = 0;
let keyTimeout;
document.addEventListener('keydown', function(event) {
}
const lastElement = messagesContainer.querySelector(".message-list-bottom");
const doScrollDown = messagesContainer.isScrolledToBottom();
if (event.key === 'Escape') {
escPressed = true;
const oldMessage = messagesContainer.querySelector(`.message[data-uid="${data.uid}"]`);
if (oldMessage) {
oldMessage.remove();
}
const message = document.createElement("div");
message.innerHTML = data.html;
message.style.display = display;
messagesContainer.insertBefore(message.firstChild, lastElement);
updateLayout(doScrollDown);
setTimeout(() => updateLayout(doScrollDown), 1000);
app.rpc.markAsRead(channelUid);
});
// --- Keyboard shortcuts ---
let escPressed = false, gPressCount = 0, keyTimeout;
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
gPressCount = 0;
document.activeElement.blur();
}
if (event.key === 'g' && !chatInputField.isActive()) {
gPressCount++;
clearTimeout(keyTimeout);
keyTimeout = setTimeout(() => { gPressCount = 0; }, 300);
if (gPressCount === 2) {
gPressCount = 0;
clearTimeout(keyTimeout);
keyTimeout = setTimeout(() => {
escPressed = false;
}, 300);
messagesContainer.querySelector(".message:first-child")?.scrollIntoView({ block: "end", inline: "nearest" });
loadExtra();
}
}
if(event.key == 'i' && !chatInputField.isActive()) {
event.preventDefault();
chatInputField.focus();
}
if(event.key == 'r' && !chatInputField.isActive()) {
event.preventDefault();
const replyLinks = document.querySelectorAll('.time a')
if(replyLinks.length){
const replyLink = replyLinks[replyLinks.length - 1]
replyLink.click()
}
}
if (event.shiftKey && event.key === 'G') {
if (!chatInputField.isActive()) {
event.preventDefault();
updateLayout(true);
setTimeout(() => chatInputField.focus(), 500);
}
}
});
if (event.key === 'G' && escPressed) {
gPressCount++;
clearTimeout(keyTimeout);
keyTimeout = setTimeout(() => {
gPressCount = 0;
}, 300);
if (gPressCount === 2) {
gPressCount = 0;
escPressed = false;
messagesContainer.querySelector(".message:last-child").scrollIntoView({ block: "end", inline: "nearest" });
setTimeout(() => {
chatInputField.focus();
},500)
// --- Image click-to-zoom (delegated) ---
// --- Layout update ---
function updateLayout(doScrollDown) {
updateTimes();
let previousUser = null, previousDate = null;
messagesContainer.querySelectorAll(".message").forEach((message) => {
if (previousUser !== message.dataset.user_uid) {
message.classList.add("switch-user");
previousUser = message.dataset.user_uid;
previousDate = new Date(message.dataset.created_at);
} else {
message.classList.remove("switch-user");
if (!previousDate) {
previousDate = new Date(message.dataset.created_at);
} else {
const currentDate = new Date(message.dataset.created_at);
if (currentDate.getTime() - previousDate.getTime() > 1000 * 60 * 20) {
message.classList.add("long-time");
} else {
message.classList.remove("long-time");
}
previousDate = currentDate;
}
}
if (event.shiftKey && event.key === 'G') {
if(chatInputField.isActive()){
updateLayout(true);
setTimeout(() => {
chatInputField.focus();
},500)
}
}
});
if (doScrollDown) messagesContainer.scrollToBottom?.();
}
messagesContainer.addEventListener('click', (e) => {
if(e.target.tagName != 'IMG')
return
const img = e.target
if(e.target.classList.contains('avatar')){
return
}
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.top = 0;
overlay.style.left = 0;
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'rgba(0,0,0,0.9)';
overlay.style.display = 'flex';
overlay.style.justifyContent = 'center';
overlay.style.alignItems = 'center';
overlay.style.zIndex = 9999;
// --- Utility: check if element is visible ---
const fullImg = document.createElement('img');
const urlObj = new URL(img.src);
urlObj.search = ''
fullImg.src = urlObj.toString();
// --- Utility: check if scrolled past half ---
function isScrolledPastHalf() {
let scrollTop = messagesContainer.scrollTop;
let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;
return scrollTop < scrollableHeight / 2;
}
fullImg.alt = img.alt;
fullImg.style.maxWidth = '90%';
fullImg.style.maxHeight = '90%';
// --- Initial layout update ---
updateLayout(true);
overlay.appendChild(fullImg);
document.body.appendChild(overlay);
overlay.addEventListener('click', () => {
document.body.removeChild(overlay);
});
});
updateLayout(true);
</script>
{% endblock %}
+38
View File
@@ -29,12 +29,50 @@ from aiohttp import web
from multiavatar import multiavatar
from snek.system.view import BaseView
from snek.view.avatar_animal import generate_avatar_with_options
import functools
class AvatarView(BaseView):
login_required = False
def __init__(self, *args,**kwargs):
super().__init__(*args,**kwargs)
self.avatars = {}
async def get(self):
type_ = self.request.query.get("type")
type_match = {
"animal": self.get_animal,
"default": self.get_default
}
handler = type_match.get(type_, self.get_default)
return await handler()
async def get_animal(self):
uid = self.request.match_info.get("uid")
if uid == "unique":
uid = str(uuid.uuid4())
key = "avatar_animal_" + uid
avatar = await self.app.get(key)
if avatar:
return web.Response(text=avatar, content_type="image/svg+xml")
while True:
try:
avatar = generate_avatar_with_options(self.request.query)
await self.app.set(key, avatar)
return web.Response(text=avatar, content_type="image/svg+xml")
except Exception as e:
pass
def _get(self, uid):
avatar = generate_avatar_with_options(self.request.query)
return avatar
async def get_default(self):
uid = self.request.match_info.get("uid")
if uid == "unique":
uid = str(uuid.uuid4())
+871
View File
@@ -0,0 +1,871 @@
import random
import math
import argparse
import json
from typing import Dict, List, Tuple, Optional, Union
class AnimalAvatarGenerator:
"""A generator for animal-themed avatar SVGs."""
# Constants
ANIMALS = [
"cat", "dog", "fox", "wolf", "bear", "panda", "koala", "tiger", "lion",
"rabbit", "monkey", "elephant", "giraffe", "zebra", "penguin", "owl",
"deer", "raccoon", "squirrel", "hedgehog", "otter", "frog"
]
COLOR_PALETTES = {
"natural": {
"cat": ["#8B4513", "#A0522D", "#D2B48C", "#F5DEB3", "#000000", "#808080", "#FFFFFF", "#FFA500"],
"dog": ["#8B4513", "#A0522D", "#D2B48C", "#F5DEB3", "#000000", "#808080", "#FFFFFF", "#FFA500"],
"fox": ["#FF6600", "#FF7F00", "#FF8C00", "#FFA500", "#FFFFFF", "#000000"],
"wolf": ["#808080", "#A9A9A9", "#D3D3D3", "#FFFFFF", "#000000", "#696969"],
"bear": ["#8B4513", "#A0522D", "#D2B48C", "#F5DEB3", "#000000", "#FFFFFF"],
"panda": ["#000000", "#FFFFFF"],
"koala": ["#808080", "#A9A9A9", "#D3D3D3", "#FFFFFF", "#000000"],
"tiger": ["#FF8C00", "#FF7F00", "#FFFFFF", "#000000"],
"lion": ["#DAA520", "#B8860B", "#CD853F", "#D2B48C", "#FFFFFF", "#000000"],
"rabbit": ["#FFFFFF", "#F5F5F5", "#D3D3D3", "#A9A9A9", "#FFC0CB", "#000000"],
"monkey": ["#8B4513", "#A0522D", "#D2B48C", "#F5DEB3", "#000000"],
"elephant": ["#808080", "#A9A9A9", "#D3D3D3", "#FFFFFF", "#000000"],
"giraffe": ["#DAA520", "#B8860B", "#F5DEB3", "#FFFFFF", "#000000"],
"zebra": ["#000000", "#FFFFFF"],
"penguin": ["#000000", "#FFFFFF", "#FFA500"],
"owl": ["#8B4513", "#A0522D", "#D2B48C", "#F5DEB3", "#000000", "#FFFFFF", "#FFC0CB"],
"deer": ["#8B4513", "#A0522D", "#D2B48C", "#F5DEB3", "#FFFFFF", "#000000"],
"raccoon": ["#808080", "#A9A9A9", "#D3D3D3", "#FFFFFF", "#000000"],
"squirrel": ["#8B4513", "#A0522D", "#D2B48C", "#F5DEB3", "#000000"],
"hedgehog": ["#8B4513", "#A0522D", "#D2B48C", "#F5DEB3", "#000000"],
"otter": ["#8B4513", "#A0522D", "#D2B48C", "#F5DEB3", "#000000"],
"frog": ["#008000", "#00FF00", "#ADFF2F", "#7FFF00", "#000000", "#FFFFFF"]
},
"pastel": {
"all": ["#FFB6C1", "#FFD700", "#FFDAB9", "#98FB98", "#ADD8E6", "#DDA0DD", "#F0E68C", "#FFFFE0"]
},
"vibrant": {
"all": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFA500", "#FF1493"]
},
"mono": {
"all": ["#000000", "#333333", "#666666", "#999999", "#CCCCCC", "#FFFFFF"]
}
}
EYE_STYLES = ["round", "oval", "almond", "wide", "narrow", "cute"]
FACE_SHAPES = ["round", "oval", "square", "heart", "triangular", "diamond"]
EAR_STYLES = {
"cat": ["pointed", "folded", "round", "large", "small"],
"dog": ["floppy", "pointed", "round", "large", "small"],
"fox": ["pointed", "large", "small"],
"wolf": ["pointed", "large", "small"],
"bear": ["round", "small"],
"panda": ["round", "small"],
"koala": ["round", "large"],
"tiger": ["round", "small"],
"lion": ["round", "small"],
"rabbit": ["long", "floppy", "standing"],
"monkey": ["round", "small"],
"elephant": ["large", "wide"],
"giraffe": ["small", "pointed"],
"zebra": ["pointed", "small"],
"penguin": ["none"],
"owl": ["none", "tufted"],
"deer": ["small", "pointed"],
"raccoon": ["round", "small"],
"squirrel": ["pointed", "small"],
"hedgehog": ["round", "small"],
"otter": ["round", "small"],
"frog": ["none"]
}
NOSE_STYLES = ["round", "triangular", "small", "large", "heart", "button"]
SPECIAL_FEATURES = {
"cat": ["whiskers", "stripes", "spots"],
"dog": ["spots", "patch", "whiskers"],
"fox": ["mask", "whiskers", "brush_tail"],
"wolf": ["mask", "whiskers", "brush_tail"],
"bear": ["none", "patch"],
"panda": ["eye_patches", "none"],
"koala": ["none", "nose_patch"],
"tiger": ["stripes", "none"],
"lion": ["mane", "none"],
"rabbit": ["whiskers", "nose_patch"],
"monkey": ["none", "cheek_patches"],
"elephant": ["tusks", "none"],
"giraffe": ["spots", "none"],
"zebra": ["stripes", "none"],
"penguin": ["bib", "none"],
"owl": ["feather_tufts", "none"],
"deer": ["antlers", "spots", "none"],
"raccoon": ["mask", "whiskers", "none"],
"squirrel": ["bushy_tail", "none"],
"hedgehog": ["spikes", "none"],
"otter": ["whiskers", "none"],
"frog": ["spots", "none"]
}
EXPRESSIONS = ["happy", "serious", "surprised", "sleepy", "wink"]
def __init__(self, seed: Optional[int] = None):
"""Initialize the avatar generator with an optional seed for reproducibility."""
if seed is not None:
random.seed(seed)
def _get_colors(self, animal: str, color_palette: str) -> List[str]:
"""Get colors for the given animal and palette."""
if color_palette in self.COLOR_PALETTES:
if animal in self.COLOR_PALETTES[color_palette]:
return self.COLOR_PALETTES[color_palette][animal]
elif "all" in self.COLOR_PALETTES[color_palette]:
return self.COLOR_PALETTES[color_palette]["all"]
# Default to natural palette for the animal or general natural colors
if animal in self.COLOR_PALETTES["natural"]:
return self.COLOR_PALETTES["natural"][animal]
# If no specific colors found, use a mix of browns and grays
return ["#8B4513", "#A0522D", "#D2B48C", "#F5DEB3", "#808080", "#A9A9A9", "#FFFFFF"]
def _get_ear_style(self, animal: str) -> str:
"""Get a random ear style appropriate for the animal."""
if animal in self.EAR_STYLES:
return random.choice(self.EAR_STYLES[animal])
return "none" # Default for animals not in the list
def _get_special_feature(self, animal: str) -> str:
"""Get a random special feature appropriate for the animal."""
if animal in self.SPECIAL_FEATURES:
return random.choice(self.SPECIAL_FEATURES[animal])
return "none" # Default for animals not in the list
def _draw_circle(self, cx: float, cy: float, r: float, fill: str,
stroke: str = "none", stroke_width: float = 1.0) -> str:
"""Generate SVG for a circle."""
return f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="{fill}" stroke="{stroke}" stroke-width="{stroke_width}" />'
def _draw_ellipse(self, cx: float, cy: float, rx: float, ry: float,
fill: str, stroke: str = "none", stroke_width: float = 1.0) -> str:
"""Generate SVG for an ellipse."""
return f'<ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" fill="{fill}" stroke="{stroke}" stroke-width="{stroke_width}" />'
def _draw_path(self, d: str, fill: str, stroke: str = "none",
stroke_width: float = 1.0, stroke_linecap: str = "round") -> str:
"""Generate SVG for a path."""
return f'<path d="{d}" fill="{fill}" stroke="{stroke}" stroke-width="{stroke_width}" stroke-linecap="{stroke_linecap}" />'
def _draw_polygon(self, points: str, fill: str, stroke: str = "none",
stroke_width: float = 1.0) -> str:
"""Generate SVG for a polygon."""
return f'<polygon points="{points}" fill="{fill}" stroke="{stroke}" stroke-width="{stroke_width}" />'
def _draw_face(self, animal: str, face_shape: str, face_color: str,
x: float, y: float, size: float) -> str:
"""Draw the animal's face based on face shape."""
elements = []
if face_shape == "round":
elements.append(self._draw_circle(x, y, size * 0.4, face_color))
elif face_shape == "oval":
elements.append(self._draw_ellipse(x, y, size * 0.35, size * 0.45, face_color))
elif face_shape == "square":
points = (f"{x-size*0.35},{y-size*0.35} {x+size*0.35},{y-size*0.35} "
f"{x+size*0.35},{y+size*0.35} {x-size*0.35},{y+size*0.35}")
elements.append(self._draw_polygon(points, face_color))
elif face_shape == "heart":
# Create a heart shape using paths
cx, cy = x, y + size * 0.05
r = size * 0.2
path = (f"M {cx} {cy-r*0.4} "
f"C {cx-r*1.5} {cy-r*1.5}, {cx-r*2} {cy+r*0.5}, {cx} {cy+r} "
f"C {cx+r*2} {cy+r*0.5}, {cx+r*1.5} {cy-r*1.5}, {cx} {cy-r*0.4} Z")
elements.append(self._draw_path(path, face_color))
elif face_shape == "triangular":
points = f"{x},{y-size*0.4} {x+size*0.4},{y+size*0.3} {x-size*0.4},{y+size*0.3}"
elements.append(self._draw_polygon(points, face_color))
elif face_shape == "diamond":
points = f"{x},{y-size*0.4} {x+size*0.35},{y} {x},{y+size*0.4} {x-size*0.35},{y}"
elements.append(self._draw_polygon(points, face_color))
return "\n".join(elements)
def _draw_ears(self, animal: str, ear_style: str, face_color: str,
inner_color: str, x: float, y: float, size: float) -> str:
"""Draw the animal's ears based on ear style."""
elements = []
if ear_style == "none":
return ""
if ear_style == "pointed":
# Left ear
points_left = f"{x-size*0.2},{y-size*0.1} {x-size*0.35},{y-size*0.45} {x-size*0.05},{y-size*0.15}"
elements.append(self._draw_polygon(points_left, face_color))
# Right ear
points_right = f"{x+size*0.2},{y-size*0.1} {x+size*0.35},{y-size*0.45} {x+size*0.05},{y-size*0.15}"
elements.append(self._draw_polygon(points_right, face_color))
# Inner ears
points_inner_left = f"{x-size*0.2},{y-size*0.13} {x-size*0.3},{y-size*0.38} {x-size*0.1},{y-size*0.17}"
elements.append(self._draw_polygon(points_inner_left, inner_color))
points_inner_right = f"{x+size*0.2},{y-size*0.13} {x+size*0.3},{y-size*0.38} {x+size*0.1},{y-size*0.17}"
elements.append(self._draw_polygon(points_inner_right, inner_color))
elif ear_style == "round":
# Left ear
elements.append(self._draw_circle(x-size*0.25, y-size*0.25, size*0.15, face_color))
# Right ear
elements.append(self._draw_circle(x+size*0.25, y-size*0.25, size*0.15, face_color))
# Inner ears
elements.append(self._draw_circle(x-size*0.25, y-size*0.25, size*0.08, inner_color))
elements.append(self._draw_circle(x+size*0.25, y-size*0.25, size*0.08, inner_color))
elif ear_style == "folded" or ear_style == "floppy":
# Left ear
path_left = (f"M {x-size*0.15} {y-size*0.15} "
f"C {x-size*0.3} {y-size*0.4}, {x-size*0.4} {y-size*0.2}, {x-size*0.35} {y}")
elements.append(self._draw_path(path_left, face_color, stroke="none", stroke_width=size*0.08))
# Right ear
path_right = (f"M {x+size*0.15} {y-size*0.15} "
f"C {x+size*0.3} {y-size*0.4}, {x+size*0.4} {y-size*0.2}, {x+size*0.35} {y}")
elements.append(self._draw_path(path_right, face_color, stroke="none", stroke_width=size*0.08))
elif ear_style == "long" or ear_style == "standing":
# Left ear
points_left = f"{x-size*0.2},{y-size*0.15} {x-size*0.3},{y-size*0.6} {x-size*0.05},{y-size*0.15}"
elements.append(self._draw_polygon(points_left, face_color))
# Right ear
points_right = f"{x+size*0.2},{y-size*0.15} {x+size*0.3},{y-size*0.6} {x+size*0.05},{y-size*0.15}"
elements.append(self._draw_polygon(points_right, face_color))
# Inner ears
points_inner_left = f"{x-size*0.18},{y-size*0.18} {x-size*0.25},{y-size*0.5} {x-size*0.1},{y-size*0.18}"
elements.append(self._draw_polygon(points_inner_left, inner_color))
points_inner_right = f"{x+size*0.18},{y-size*0.18} {x+size*0.25},{y-size*0.5} {x+size*0.1},{y-size*0.18}"
elements.append(self._draw_polygon(points_inner_right, inner_color))
elif ear_style == "large":
# Left ear
elements.append(self._draw_ellipse(x-size*0.25, y-size*0.3, size*0.2, size*0.25, face_color))
# Right ear
elements.append(self._draw_ellipse(x+size*0.25, y-size*0.3, size*0.2, size*0.25, face_color))
# Inner ears
elements.append(self._draw_ellipse(x-size*0.25, y-size*0.3, size*0.1, size*0.15, inner_color))
elements.append(self._draw_ellipse(x+size*0.25, y-size*0.3, size*0.1, size*0.15, inner_color))
elif ear_style == "small":
# Left ear
elements.append(self._draw_ellipse(x-size*0.22, y-size*0.22, size*0.1, size*0.12, face_color))
# Right ear
elements.append(self._draw_ellipse(x+size*0.22, y-size*0.22, size*0.1, size*0.12, face_color))
# Inner ears
elements.append(self._draw_ellipse(x-size*0.22, y-size*0.22, size*0.05, size*0.06, inner_color))
elements.append(self._draw_ellipse(x+size*0.22, y-size*0.22, size*0.05, size*0.06, inner_color))
elif ear_style == "tufted" and animal == "owl":
# Left ear tuft
points_left = f"{x-size*0.2},{y-size*0.15} {x-size*0.35},{y-size*0.45} {x-size*0.05},{y-size*0.15}"
elements.append(self._draw_polygon(points_left, face_color))
# Right ear tuft
points_right = f"{x+size*0.2},{y-size*0.15} {x+size*0.35},{y-size*0.45} {x+size*0.05},{y-size*0.15}"
elements.append(self._draw_polygon(points_right, face_color))
elif ear_style == "wide" and animal == "elephant":
# Left ear
path_left = (f"M {x-size*0.15} {y-size*0.1} "
f"C {x-size*0.5} {y-size*0.2}, {x-size*0.6} {y+size*0.2}, {x-size*0.15} {y+size*0.2}")
elements.append(self._draw_path(path_left, face_color, stroke=face_color, stroke_width=size*0.04))
# Right ear
path_right = (f"M {x+size*0.15} {y-size*0.1} "
f"C {x+size*0.5} {y-size*0.2}, {x+size*0.6} {y+size*0.2}, {x+size*0.15} {y+size*0.2}")
elements.append(self._draw_path(path_right, face_color, stroke=face_color, stroke_width=size*0.04))
return "\n".join(elements)
def _draw_eyes(self, eye_style: str, expression: str, eye_color: str,
x: float, y: float, size: float) -> str:
"""Draw the animal's eyes based on eye style and expression."""
elements = []
eye_spacing = size * 0.2
if eye_style == "round":
eye_size = size * 0.08
# Left eye
elements.append(self._draw_circle(x - eye_spacing, y - size * 0.05, eye_size, "#FFFFFF"))
elements.append(self._draw_circle(x - eye_spacing, y - size * 0.05, eye_size * 0.6, eye_color))
# Right eye
elements.append(self._draw_circle(x + eye_spacing, y - size * 0.05, eye_size, "#FFFFFF"))
elements.append(self._draw_circle(x + eye_spacing, y - size * 0.05, eye_size * 0.6, eye_color))
elif eye_style == "oval":
eye_width = size * 0.1
eye_height = size * 0.07
# Left eye
elements.append(self._draw_ellipse(x - eye_spacing, y - size * 0.05, eye_width, eye_height, "#FFFFFF"))
elements.append(self._draw_ellipse(x - eye_spacing, y - size * 0.05, eye_width * 0.6, eye_height * 0.6, eye_color))
# Right eye
elements.append(self._draw_ellipse(x + eye_spacing, y - size * 0.05, eye_width, eye_height, "#FFFFFF"))
elements.append(self._draw_ellipse(x + eye_spacing, y - size * 0.05, eye_width * 0.6, eye_height * 0.6, eye_color))
elif eye_style == "almond":
# Left eye - almond shape
path_left = (f"M {x-eye_spacing-size*0.1} {y-size*0.05} "
f"C {x-eye_spacing} {y-size*0.12}, {x-eye_spacing} {y+size*0.02}, {x-eye_spacing+size*0.1} {y-size*0.05} "
f"C {x-eye_spacing} {y+size*0.02}, {x-eye_spacing} {y-size*0.12}, {x-eye_spacing-size*0.1} {y-size*0.05} Z")
elements.append(self._draw_path(path_left, "#FFFFFF"))
elements.append(self._draw_circle(x - eye_spacing, y - size * 0.05, size * 0.05, eye_color))
# Right eye - almond shape
path_right = (f"M {x+eye_spacing-size*0.1} {y-size*0.05} "
f"C {x+eye_spacing} {y-size*0.12}, {x+eye_spacing} {y+size*0.02}, {x+eye_spacing+size*0.1} {y-size*0.05} "
f"C {x+eye_spacing} {y+size*0.02}, {x+eye_spacing} {y-size*0.12}, {x+eye_spacing-size*0.1} {y-size*0.05} Z")
elements.append(self._draw_path(path_right, "#FFFFFF"))
elements.append(self._draw_circle(x + eye_spacing, y - size * 0.05, size * 0.05, eye_color))
elif eye_style == "wide":
eye_width = size * 0.12
eye_height = size * 0.08
# Left eye
elements.append(self._draw_ellipse(x - eye_spacing, y - size * 0.05, eye_width, eye_height, "#FFFFFF"))
elements.append(self._draw_ellipse(x - eye_spacing, y - size * 0.05, eye_width * 0.5, eye_height * 0.6, eye_color))
# Right eye
elements.append(self._draw_ellipse(x + eye_spacing, y - size * 0.05, eye_width, eye_height, "#FFFFFF"))
elements.append(self._draw_ellipse(x + eye_spacing, y - size * 0.05, eye_width * 0.5, eye_height * 0.6, eye_color))
elif eye_style == "narrow":
eye_width = size * 0.12
eye_height = size * 0.04
# Left eye
elements.append(self._draw_ellipse(x - eye_spacing, y - size * 0.05, eye_width, eye_height, "#FFFFFF"))
elements.append(self._draw_ellipse(x - eye_spacing, y - size * 0.05, eye_width * 0.4, eye_height * 0.7, eye_color))
# Right eye
elements.append(self._draw_ellipse(x + eye_spacing, y - size * 0.05, eye_width, eye_height, "#FFFFFF"))
elements.append(self._draw_ellipse(x + eye_spacing, y - size * 0.05, eye_width * 0.4, eye_height * 0.7, eye_color))
elif eye_style == "cute":
eye_size = size * 0.1
# Left eye
elements.append(self._draw_circle(x - eye_spacing, y - size * 0.05, eye_size, "#FFFFFF"))
elements.append(self._draw_circle(x - eye_spacing - size * 0.02, y - size * 0.07, eye_size * 0.5, eye_color))
elements.append(self._draw_circle(x - eye_spacing - size * 0.03, y - size * 0.08, eye_size * 0.15, "#FFFFFF"))
# Right eye
elements.append(self._draw_circle(x + eye_spacing, y - size * 0.05, eye_size, "#FFFFFF"))
elements.append(self._draw_circle(x + eye_spacing - size * 0.02, y - size * 0.07, eye_size * 0.5, eye_color))
elements.append(self._draw_circle(x + eye_spacing - size * 0.03, y - size * 0.08, eye_size * 0.15, "#FFFFFF"))
# Apply expression
if expression == "happy":
# Close bottom half of eyes slightly
path_left = (f"M {x-eye_spacing-size*0.1} {y-size*0.03} "
f"Q {x-eye_spacing} {y+size*0.02}, {x-eye_spacing+size*0.1} {y-size*0.03}")
elements.append(self._draw_path(path_left, face_color, stroke="#000000", stroke_width=size*0.01))
path_right = (f"M {x+eye_spacing-size*0.1} {y-size*0.03} "
f"Q {x+eye_spacing} {y+size*0.02}, {x+eye_spacing+size*0.1} {y-size*0.03}")
elements.append(self._draw_path(path_right, face_color, stroke="#000000", stroke_width=size*0.01))
elif expression == "serious":
# Serious eyebrows
path_left = (f"M {x-eye_spacing-size*0.08} {y-size*0.12} "
f"L {x-eye_spacing+size*0.08} {y-size*0.15}")
elements.append(self._draw_path(path_left, "none", stroke="#000000", stroke_width=size*0.02))
path_right = (f"M {x+eye_spacing-size*0.08} {y-size*0.15} "
f"L {x+eye_spacing+size*0.08} {y-size*0.12}")
elements.append(self._draw_path(path_right, "none", stroke="#000000", stroke_width=size*0.02))
elif expression == "surprised":
# Raise eyebrows
path_left = (f"M {x-eye_spacing-size*0.08} {y-size*0.15} "
f"Q {x-eye_spacing} {y-size*0.18}, {x-eye_spacing+size*0.08} {y-size*0.15}")
elements.append(self._draw_path(path_left, "none", stroke="#000000", stroke_width=size*0.015))
path_right = (f"M {x+eye_spacing-size*0.08} {y-size*0.15} "
f"Q {x+eye_spacing} {y-size*0.18}, {x+eye_spacing+size*0.08} {y-size*0.15}")
elements.append(self._draw_path(path_right, "none", stroke="#000000", stroke_width=size*0.015))
elif expression == "sleepy":
# Half-closed eyes
path_left = (f"M {x-eye_spacing-size*0.1} {y-size*0.08} "
f"Q {x-eye_spacing} {y-size*0.01}, {x-eye_spacing+size*0.1} {y-size*0.08}")
elements.append(self._draw_path(path_left, "none", stroke="#000000", stroke_width=size*0.015))
path_right = (f"M {x+eye_spacing-size*0.1} {y-size*0.08} "
f"Q {x+eye_spacing} {y-size*0.01}, {x+eye_spacing+size*0.1} {y-size*0.08}")
elements.append(self._draw_path(path_right, "none", stroke="#000000", stroke_width=size*0.015))
elif expression == "wink":
# Right eye normal
# Left eye winking
path_left = (f"M {x-eye_spacing-size*0.1} {y-size*0.05} "
f"Q {x-eye_spacing} {y-size*0.1}, {x-eye_spacing+size*0.1} {y-size*0.05}")
elements.append(self._draw_path(path_left, "none", stroke="#000000", stroke_width=size*0.02))
return "\n".join(elements)
def _draw_nose(self, animal: str, nose_style: str, nose_color: str,
x: float, y: float, size: float) -> str:
"""Draw the animal's nose based on nose style."""
elements = []
if nose_style == "round":
elements.append(self._draw_circle(x, y + size * 0.05, size * 0.08, nose_color))
elif nose_style == "triangular":
points = f"{x},{y+size*0.02} {x-size*0.08},{y+size*0.12} {x+size*0.08},{y+size*0.12}"
elements.append(self._draw_polygon(points, nose_color))
elif nose_style == "small":
elements.append(self._draw_circle(x, y + size * 0.05, size * 0.05, nose_color))
elif nose_style == "large":
if animal in ["dog", "bear", "panda", "koala"]:
elements.append(self._draw_ellipse(x, y + size * 0.05, size * 0.1, size * 0.08, nose_color))
else:
elements.append(self._draw_circle(x, y + size * 0.05, size * 0.1, nose_color))
elif nose_style == "heart":
# Heart-shaped nose
cx, cy = x, y + size * 0.05
r = size * 0.06
path = (f"M {cx} {cy-r*0.2} "
f"C {cx-r*1.5} {cy-r*1.2}, {cx-r*1.8} {cy+r*0.6}, {cx} {cy+r*0.8} "
f"C {cx+r*1.8} {cy+r*0.6}, {cx+r*1.5} {cy-r*1.2}, {cx} {cy-r*0.2} Z")
elements.append(self._draw_path(path, nose_color))
elif nose_style == "button":
elements.append(self._draw_circle(x, y + size * 0.05, size * 0.06, nose_color))
elements.append(self._draw_circle(x, y + size * 0.05, size * 0.04, nose_color, stroke="#000000", stroke_width=size*0.01))
return "\n".join(elements)
def _draw_mouth(self, expression: str, x: float, y: float, size: float) -> str:
"""Draw the animal's mouth based on expression."""
elements = []
if expression == "happy":
path = (f"M {x-size*0.15} {y+size*0.12} "
f"Q {x} {y+size*0.25}, {x+size*0.15} {y+size*0.12}")
elements.append(self._draw_path(path, "none", stroke="#000000", stroke_width=size*0.02))
elif expression == "serious":
path = (f"M {x-size*0.12} {y+size*0.15} "
f"L {x+size*0.12} {y+size*0.15}")
elements.append(self._draw_path(path, "none", stroke="#000000", stroke_width=size*0.02))
elif expression == "surprised":
elements.append(self._draw_ellipse(x, y + size * 0.15, size * 0.06, size * 0.08, "#FFFFFF",
stroke="#000000", stroke_width=size*0.01))
elif expression == "sleepy":
path = (f"M {x-size*0.08} {y+size*0.15} "
f"Q {x} {y+size*0.12}, {x+size*0.08} {y+size*0.15}")
elements.append(self._draw_path(path, "none", stroke="#000000", stroke_width=size*0.015))
elif expression == "wink":
path = (f"M {x-size*0.15} {y+size*0.12} "
f"Q {x} {y+size*0.22}, {x+size*0.15} {y+size*0.12}")
elements.append(self._draw_path(path, "none", stroke="#000000", stroke_width=size*0.02))
return "\n".join(elements)
def _draw_special_features(self, animal: str, special_feature: str, face_color: str,
accent_color: str, x: float, y: float, size: float) -> str:
"""Draw special features based on animal and feature type."""
elements = []
if special_feature == "none":
return ""
elif special_feature == "whiskers":
# Left whiskers
for i in range(3):
angle = -30 + i * 30
length = size * 0.25
end_x = x - size * 0.15 + length * math.cos(math.radians(angle))
end_y = y + size * 0.05 + length * math.sin(math.radians(angle))
elements.append(self._draw_path(
f"M {x-size*0.15} {y+size*0.05} L {end_x} {end_y}",
"none", stroke="#000000", stroke_width=size*0.01))
# Right whiskers
for i in range(3):
angle = -150 + i * 30
length = size * 0.25
end_x = x + size * 0.15 + length * math.cos(math.radians(angle))
end_y = y + size * 0.05 + length * math.sin(math.radians(angle))
elements.append(self._draw_path(
f"M {x+size*0.15} {y+size*0.05} L {end_x} {end_y}",
"none", stroke="#000000", stroke_width=size*0.01))
elif special_feature == "stripes":
# Vertical stripes
if animal == "tiger":
for i in range(3):
offset = -size * 0.2 + i * size * 0.2
path = (f"M {x+offset} {y-size*0.3} "
f"Q {x+offset+size*0.1} {y}, {x+offset} {y+size*0.3}")
elements.append(self._draw_path(path, "none", stroke=accent_color, stroke_width=size*0.04))
# Horizontal stripes for zebra
elif animal == "zebra":
for i in range(3):
offset = -size * 0.2 + i * size * 0.2
path = (f"M {x-size*0.3} {y+offset} "
f"Q {x} {y+offset+size*0.1}, {x+size*0.3} {y+offset}")
elements.append(self._draw_path(path, "none", stroke=accent_color, stroke_width=size*0.04))
elif special_feature == "spots":
# Random spots
num_spots = random.randint(3, 6)
for _ in range(num_spots):
spot_x = x + random.uniform(-size * 0.3, size * 0.3)
spot_y = y + random.uniform(-size * 0.3, size * 0.3)
spot_size = random.uniform(size * 0.03, size * 0.08)
elements.append(self._draw_circle(spot_x, spot_y, spot_size, accent_color))
elif special_feature == "patch":
# Eye patch or face patch
if animal == "dog":
elements.append(self._draw_ellipse(x - size * 0.2, y, size * 0.2, size * 0.25, accent_color))
else:
# Generic face patch
elements.append(self._draw_ellipse(x, y + size * 0.2, size * 0.2, size * 0.15, accent_color))
elif special_feature == "mask":
if animal == "raccoon":
# Raccoon mask
path = (f"M {x-size*0.3} {y-size*0.1} "
f"Q {x} {y-size*0.3}, {x+size*0.3} {y-size*0.1} "
f"Q {x+size*0.2} {y+size*0.1}, {x} {y+size*0.15} "
f"Q {x-size*0.2} {y+size*0.1}, {x-size*0.3} {y-size*0.1} Z")
elements.append(self._draw_path(path, accent_color))
elif animal in ["fox", "wolf"]:
# Fox/wolf mask
path = (f"M {x-size*0.3} {y-size*0.1} "
f"L {x} {y+size*0.1} "
f"L {x+size*0.3} {y-size*0.1} "
f"Q {x+size*0.15} {y-size*0.05}, {x} {y-size*0.1} "
f"Q {x-size*0.15} {y-size*0.05}, {x-size*0.3} {y-size*0.1} Z")
elements.append(self._draw_path(path, accent_color))
elif special_feature == "eye_patches" and animal == "panda":
# Panda eye patches
elements.append(self._draw_ellipse(x - size * 0.2, y - size * 0.05, size * 0.15, size * 0.2, accent_color))
elements.append(self._draw_ellipse(x + size * 0.2, y - size * 0.05, size * 0.15, size * 0.2, accent_color))
elif special_feature == "nose_patch":
elements.append(self._draw_ellipse(x, y + size * 0.05, size * 0.12, size * 0.1, accent_color))
elif special_feature == "mane" and animal == "lion":
# Lion mane
for i in range(12):
angle = i * 30
outer_x = x + size * 0.5 * math.cos(math.radians(angle))
outer_y = y + size * 0.5 * math.sin(math.radians(angle))
inner_x = x + size * 0.3 * math.cos(math.radians(angle))
inner_y = y + size * 0.3 * math.sin(math.radians(angle))
# Draw mane sections
path = (f"M {inner_x} {inner_y} "
f"L {outer_x} {outer_y} "
f"A {size*0.5} {size*0.5} 0 0 1 "
f"{x + size * 0.5 * math.cos(math.radians(angle + 30))} "
f"{y + size * 0.5 * math.sin(math.radians(angle + 30))} "
f"L {x + size * 0.3 * math.cos(math.radians(angle + 30))} "
f"{y + size * 0.3 * math.sin(math.radians(angle + 30))} Z")
elements.append(self._draw_path(path, accent_color))
elif special_feature == "tusks" and animal == "elephant":
# Elephant tusks
path_left = (f"M {x-size*0.15} {y+size*0.1} "
f"Q {x-size*0.3} {y+size*0.3}, {x-size*0.35} {y+size*0.5}")
elements.append(self._draw_path(path_left, "#FFFFF0", stroke="#F5F5DC", stroke_width=size*0.04))
path_right = (f"M {x+size*0.15} {y+size*0.1} "
f"Q {x+size*0.3} {y+size*0.3}, {x+size*0.35} {y+size*0.5}")
elements.append(self._draw_path(path_right, "#FFFFF0", stroke="#F5F5DC", stroke_width=size*0.04))
elif special_feature == "antlers" and animal == "deer":
# Deer antlers
# Left antler
path_left = (f"M {x-size*0.15} {y-size*0.2} "
f"L {x-size*0.3} {y-size*0.45} "
f"L {x-size*0.4} {y-size*0.4} "
f"M {x-size*0.3} {y-size*0.45} "
f"L {x-size*0.2} {y-size*0.5}")
elements.append(self._draw_path(path_left, "none", stroke=accent_color, stroke_width=size*0.03))
# Right antler
path_right = (f"M {x+size*0.15} {y-size*0.2} "
f"L {x+size*0.3} {y-size*0.45} "
f"L {x+size*0.4} {y-size*0.4} "
f"M {x+size*0.3} {y-size*0.45} "
f"L {x+size*0.2} {y-size*0.5}")
elements.append(self._draw_path(path_right, "none", stroke=accent_color, stroke_width=size*0.03))
elif special_feature == "bushy_tail" and animal == "squirrel":
# Squirrel bushy tail
path = (f"M {x+size*0.1} {y+size*0.2} "
f"Q {x+size*0.5} {y}, {x+size*0.3} {y-size*0.3} "
f"Q {x+size*0.4} {y-size*0.4}, {x+size*0.5} {y-size*0.35} "
f"Q {x+size*0.45} {y-size*0.25}, {x+size*0.6} {y-size*0.3} "
f"Q {x+size*0.55} {y-size*0.1}, {x+size*0.4} {y+size*0.1} "
f"Q {x+size*0.3} {y+size*0.3}, {x+size*0.1} {y+size*0.2} Z")
elements.append(self._draw_path(path, accent_color))
elif special_feature == "brush_tail" and animal in ["fox", "wolf"]:
# Fox/wolf brush tail
path = (f"M {x+size*0.1} {y+size*0.2} "
f"Q {x+size*0.4} {y+size*0.1}, {x+size*0.5} {y-size*0.1} "
f"Q {x+size*0.6} {y-size*0.2}, {x+size*0.7} {y-size*0.1} "
f"Q {x+size*0.65} {y}, {x+size*0.6} {y+size*0.1} "
f"Q {x+size*0.5} {y+size*0.2}, {x+size*0.3} {y+size*0.3} Z")
elements.append(self._draw_path(path, face_color))
# Tail tip
elements.append(self._draw_ellipse(x + size * 0.6, y - size * 0.05, size * 0.12, size * 0.08, accent_color))
elif special_feature == "bib" and animal == "penguin":
# Penguin bib/chest
path = (f"M {x-size*0.2} {y} "
f"Q {x} {y+size*0.4}, {x+size*0.2} {y} "
f"Q {x} {y+size*0.1}, {x-size*0.2} {y} Z")
elements.append(self._draw_path(path, "#FFFFFF"))
elif special_feature == "feather_tufts" and animal == "owl":
# Owl feather tufts
path_left = (f"M {x-size*0.1} {y-size*0.3} "
f"Q {x-size*0.15} {y-size*0.45}, {x-size*0.05} {y-size*0.5}")
elements.append(self._draw_path(path_left, face_color, stroke="#000000", stroke_width=size*0.01))
path_right = (f"M {x+size*0.1} {y-size*0.3} "
f"Q {x+size*0.15} {y-size*0.45}, {x+size*0.05} {y-size*0.5}")
elements.append(self._draw_path(path_right, face_color, stroke="#000000", stroke_width=size*0.01))
elif special_feature == "spikes" and animal == "hedgehog":
# Hedgehog spikes
for i in range(12):
angle = i * 30
inner_x = x + size * 0.3 * math.cos(math.radians(angle))
inner_y = y + size * 0.3 * math.sin(math.radians(angle))
outer_x = x + size * 0.5 * math.cos(math.radians(angle))
outer_y = y + size * 0.5 * math.sin(math.radians(angle))
path = f"M {inner_x} {inner_y} L {outer_x} {outer_y}"
elements.append(self._draw_path(path, "none", stroke=accent_color, stroke_width=size*0.03))
elif special_feature == "cheek_patches" and animal == "monkey":
# Monkey cheek patches
elements.append(self._draw_circle(x - size * 0.25, y + size * 0.1, size * 0.12, accent_color))
elements.append(self._draw_circle(x + size * 0.25, y + size * 0.1, size * 0.12, accent_color))
return "\n".join(elements)
def generate_avatar(self, animal: Optional[str] = None, color_palette: str = "natural",
face_shape: Optional[str] = None, eye_style: Optional[str] = None,
ear_style: Optional[str] = None, nose_style: Optional[str] = None,
expression: Optional[str] = None, special_feature: Optional[str] = None,
size: int = 500) -> str:
"""
Generate an animal avatar with the specified parameters.
Args:
animal: Animal type (e.g., "cat", "dog"). If None, a random animal is selected.
color_palette: Color palette to use (e.g., "natural", "pastel", "vibrant", "mono").
face_shape: Shape of the face. If None, a random shape is selected.
eye_style: Style of the eyes. If None, a random style is selected.
ear_style: Style of the ears. If None, a random style is selected for the animal.
nose_style: Style of the nose. If None, a random style is selected.
expression: Facial expression. If None, a random expression is selected.
special_feature: Special feature to add. If None, a random feature is selected for the animal.
size: Size of the avatar in pixels.
Returns:
SVG string representation of the generated avatar.
"""
# Select random animal if not specified
if animal is None or animal not in self.ANIMALS:
animal = random.choice(self.ANIMALS)
# Select random options if not specified
if face_shape is None or face_shape not in self.FACE_SHAPES:
face_shape = random.choice(self.FACE_SHAPES)
if eye_style is None or eye_style not in self.EYE_STYLES:
eye_style = random.choice(self.EYE_STYLES)
if ear_style is None:
ear_style = self._get_ear_style(animal)
if nose_style is None or nose_style not in self.NOSE_STYLES:
nose_style = random.choice(self.NOSE_STYLES)
if expression is None or expression not in self.EXPRESSIONS:
expression = random.choice(self.EXPRESSIONS)
if special_feature is None:
special_feature = self._get_special_feature(animal)
# Get colors
colors = self._get_colors(animal, color_palette)
face_color = random.choice(colors)
# Make sure accent color is different from face color
remaining_colors = [c for c in colors if c != face_color]
if not remaining_colors:
remaining_colors = ["#000000", "#FFFFFF"]
accent_color = random.choice(remaining_colors)
# Ensure inner ear color is different from face color
inner_ear_color = random.choice(remaining_colors)
# Eye color options
eye_colors = ["#000000", "#331800", "#0000FF", "#008000", "#FFA500", "#800080"]
eye_color = random.choice(eye_colors)
# Nose color options based on animal
if animal in ["dog", "cat", "fox", "wolf", "bear", "panda", "koala", "tiger", "lion"]:
nose_color = "#000000"
else:
nose_color = accent_color
# Center coordinates
x, y = size / 2, size / 2
# Generate SVG elements
elements = []
# Draw the face first
elements.append(self._draw_face(animal, face_shape, face_color, x, y, size))
# Draw special features behind the face if needed
if special_feature in ["mane", "spikes"]:
elements.append(self._draw_special_features(animal, special_feature, face_color,
accent_color, x, y, size))
# Draw ears
elements.append(self._draw_ears(animal, ear_style, face_color, inner_ear_color, x, y, size))
# Draw eyes
elements.append(self._draw_eyes(eye_style, expression, eye_color, x, y, size))
# Draw nose
elements.append(self._draw_nose(animal, nose_style, nose_color, x, y, size))
# Draw mouth
elements.append(self._draw_mouth(expression, x, y, size))
# Draw special features that should be in front
if special_feature not in ["mane", "spikes"]:
elements.append(self._draw_special_features(animal, special_feature, face_color,
accent_color, x, y, size))
# Assemble SVG
svg_content = '\n'.join(elements)
svg = (f'<svg viewBox="0 0 {size} {size}" xmlns="http://www.w3.org/2000/svg">\n'
f'{svg_content}\n'
f'</svg>')
return svg
def get_avatar_options(self) -> Dict[str, List[str]]:
"""Return all available avatar options."""
return {
"animals": self.ANIMALS,
"color_palettes": list(self.COLOR_PALETTES.keys()),
"face_shapes": self.FACE_SHAPES,
"eye_styles": self.EYE_STYLES,
"ear_styles": {animal: styles for animal, styles in self.EAR_STYLES.items()},
"nose_styles": self.NOSE_STYLES,
"expressions": self.EXPRESSIONS,
"special_features": {animal: features for animal, features in self.SPECIAL_FEATURES.items()}
}
def generate_random_avatar(self, size: int = 500) -> str:
"""Generate a completely random avatar."""
return self.generate_avatar(size=size)
def generate_avatar_with_options(options: Dict) -> str:
"""Generate an avatar with the given options."""
generator = AnimalAvatarGenerator(seed=options.get("seed"))
return generator.generate_avatar(
animal=options.get("animal"),
color_palette=options.get("color_palette", "natural"),
face_shape=options.get("face_shape"),
eye_style=options.get("eye_style"),
ear_style=options.get("ear_style"),
nose_style=options.get("nose_style"),
expression=options.get("expression"),
special_feature=options.get("special_feature"),
size=options.get("size", 500)
)
def list_avatar_options() -> Dict[str, List[str]]:
"""Return all available avatar options."""
generator = AnimalAvatarGenerator()
return generator.get_avatar_options()
def create_avatar_app():
"""Command-line interface for the avatar generator."""
parser = argparse.ArgumentParser(description="Generate animal avatars")
parser.add_argument("--animal", help="Animal type", choices=AnimalAvatarGenerator.ANIMALS)
parser.add_argument("--color-palette", help="Color palette", default="natural",
choices=["natural", "pastel", "vibrant", "mono"])
parser.add_argument("--face-shape", help="Face shape", choices=AnimalAvatarGenerator.FACE_SHAPES)
parser.add_argument("--eye-style", help="Eye style", choices=AnimalAvatarGenerator.EYE_STYLES)
parser.add_argument("--ear-style", help="Ear style")
parser.add_argument("--nose-style", help="Nose style", choices=AnimalAvatarGenerator.NOSE_STYLES)
parser.add_argument("--expression", help="Expression", choices=AnimalAvatarGenerator.EXPRESSIONS)
parser.add_argument("--special-feature", help="Special feature")
parser.add_argument("--size", help="Size in pixels", type=int, default=500)
parser.add_argument("--seed", help="Random seed for reproducibility", type=int)
parser.add_argument("--output", help="Output file path", default="avatar.svg")
parser.add_argument("--list-options", help="List all available options", action="store_true")
args = parser.parse_args()
if args.list_options:
options = list_avatar_options()
print(json.dumps(options, indent=2))
return
generator = AnimalAvatarGenerator(seed=args.seed)
svg = generator.generate_avatar(
animal=args.animal,
color_palette=args.color_palette,
face_shape=args.face_shape,
eye_style=args.eye_style,
ear_style=args.ear_style,
nose_style=args.nose_style,
expression=args.expression,
special_feature=args.special_feature,
size=args.size
)
with open(args.output, "w") as f:
f.write(svg)
print(f"Avatar saved to {args.output}")
if __name__ == "__main__":
create_avatar_app()
+67
View File
@@ -0,0 +1,67 @@
# Written by retoor@molodetz.nl
# This code defines a WebView class that inherits from BaseView and includes a method for rendering a web template, requiring login access for its usage.
# The code imports the BaseView class from the `snek.system.view` module.
# 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:
#
# 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.
import uuid
from aiohttp import web
from multiavatar import multiavatar
from snek.system.view import BaseView
from snek.view.avatar_animal import generate_avatar_with_options
import functools
class AvatarView(BaseView):
login_required = False
def __init__(self, *args,**kwargs):
super().__init__(*args,**kwargs)
self.avatars = {}
async def get(self):
uid = self.request.match_info.get("uid")
while True:
try:
return web.Response(text=self._get(uid), content_type="image/svg+xml")
except Exception as e:
pass
def _get(self, uid):
if uid in self.avatars:
return self.avatars[uid]
avatar = generate_avatar_with_options(self.request.query)
self.avatars[uid] = avatar
return avatar
async def get2(self):
uid = self.request.match_info.get("uid")
if uid == "unique":
uid = str(uuid.uuid4())
avatar = multiavatar.multiavatar(uid, True, None)
response = web.Response(text=avatar, content_type="image/svg+xml")
response.headers["Cache-Control"] = f"public, max-age={1337*42}"
return response
+27 -1
View File
@@ -1,5 +1,6 @@
import asyncio
import mimetypes
from os.path import isfile
from PIL import Image
import pillow_heif.HeifImagePlugin
@@ -17,6 +18,9 @@ class ChannelAttachmentView(BaseView):
relative_url=relative_path
)
if not channel_attachment or not isfile(channel_attachment["path"]):
return web.HTTPNotFound()
original_format = mimetypes.guess_type(channel_attachment["path"])[0]
format_ = self.request.query.get("format")
width = self.request.query.get("width")
@@ -75,7 +79,7 @@ class ChannelAttachmentView(BaseView):
setattr(response, "write", sync_writer)
image.save(response, format=format_)
image.save(response, format=format_, quality=100, optimize=True, save_all=True)
setattr(response, "write", naughty_steal)
@@ -88,6 +92,7 @@ class ChannelAttachmentView(BaseView):
response.headers["Content-Disposition"] = (
f'attachment; filename="{channel_attachment["name"]}"'
)
response.headers["Content-Type"] = original_format
return response
async def post(self):
@@ -132,3 +137,24 @@ class ChannelAttachmentView(BaseView):
"channel_uid": channel_uid,
}
)
class ChannelView(BaseView):
async def get(self):
channel_name = self.request.match_info.get("channel")
if(channel_name is None):
return web.HTTPNotFound()
channel = await self.services.channel.get(label="#" + channel_name)
if(channel is None):
channel = await self.services.channel.get(label=channel_name)
channel = await self.services.channel.get(channel_name)
if(channel is None):
channel = await self.services.channel.get(label=channel_name)
if(channel is None):
user = await self.services.user.get(uid=self.session.get("uid"))
is_listed = self.request.query.get("listed", False) == "true"
is_private = self.request.query.get("private", False) == "true"
channel = await self.services.channel.create(label=channel_name,created_by_uid=user['uid'],description="No description provided.",tag="user",is_private=is_private,is_listed=is_listed)
channel_member = await self.services.channel_member.create(channel_uid=channel['uid'],user_uid=user['uid'],is_moderator=True,is_read_only=False,is_muted=False,is_banned=False)
return web.HTTPFound("/channel/{}.html".format(channel["uid"]))
+85 -10
View File
@@ -9,7 +9,7 @@
import json
import traceback
import asyncio
from aiohttp import web
from snek.system.model import now
@@ -21,13 +21,30 @@ import logging
logger = logging.getLogger(__name__)
class RPCView(BaseView):
class RPCApi:
def __init__(self, view, ws):
self.view = view
self.app = self.view.app
self.services = self.app.services
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):
self._require_login()
@@ -176,6 +193,21 @@ class RPCView(BaseView):
)
return channels
async def finalize_message(self, message_uid):
self._require_login()
message = await self.services.channel_message.get(message_uid)
if not message:
return False
if message["user_uid"] != self.user_uid:
raise Exception("Not allowed")
if not message['is_final']:
await self.services.chat.finalize(message['uid'])
return True
async def update_message_text(self,message_uid, text):
self._require_login()
message = await self.services.channel_message.get(message_uid)
@@ -183,6 +215,7 @@ class RPCView(BaseView):
raise Exception("Not allowed")
if message.get_seconds_since_last_update() > 3:
await self.finalize_message(message["uid"])
return {"error": "Message too old","seconds_since_last_update": message.get_seconds_since_last_update(),"success": False}
message['message'] = text
@@ -204,9 +237,9 @@ class RPCView(BaseView):
return {"success": True}
async def send_message(self, channel_uid, message):
async def send_message(self, channel_uid, message,is_final=True):
self._require_login()
message = await self.services.chat.send(self.user_uid, channel_uid, message)
message = await self.services.chat.send(self.user_uid, channel_uid, message,is_final)
return message["uid"]
@@ -297,19 +330,28 @@ class RPCView(BaseView):
self._require_login()
results = [
record.record async for record in self.services.channel.get_online_users(channel_uid)
record async for record in self.services.channel.get_recent_users(channel_uid)
]
for result in results:
del result['email']
del result['password']
del result['deleted_at']
del result['updated_at']
results = sorted(results, key=lambda x: x["nick"])
return results
async def echo(self, obj):
await self.ws.send_json(obj)
return "noresponse"
async def get_recent_users(self, channel_uid):
self._require_login()
return [
{
"uid": record["uid"],
"username": record["username"],
"nick": record["nick"],
"last_ping": record["last_ping"],
}
async for record in self.services.channel.get_recent_users(channel_uid)
]
async def get_users(self, channel_uid):
self._require_login()
@@ -323,6 +365,13 @@ class RPCView(BaseView):
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):
if self.user_uid:
user = await self.services.user.get(uid=self.user_uid)
@@ -330,7 +379,23 @@ class RPCView(BaseView):
await self.services.user.save(user)
return {"pong": args}
async def stars_render(self, channel_uid, message):
for user in await self.get_online_users(channel_uid):
try:
await self.services.socket.send_to_user(user['uid'], dict(event="stars_render", data={"channel_uid": channel_uid, "message":message}))
except Exception as ex:
print(ex)
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()
await ws.prepare(self.request)
if self.request.session.get("logged_in"):
@@ -343,6 +408,16 @@ class RPCView(BaseView):
await self.services.socket.subscribe(
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"),15,{"event": "deployed", "data": {
"uptime": self.request.app.uptime}
}
)
rpc = RPCView.RPCApi(self, ws)
async for msg in ws:
if msg.type == web.WSMsgType.TEXT:
+10
View File
@@ -55,6 +55,16 @@ class WebView(BaseView):
user_uid=self.session.get("uid"), channel_uid=channel["uid"]
)
if not channel_member:
if not channel["is_private"]:
channel_member = await self.app.services.channel_member.create(
channel_uid=channel["uid"],
user_uid=self.session.get("uid"),
is_moderator=False,
is_read_only=False,
is_muted=False,
is_banned=False,
)
return web.HTTPNotFound()
channel_member["new_count"] = 0