Compare commits
126
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bde3819510 | ||
|
|
097889ba3f | ||
|
|
4854d40508 | ||
|
|
7dd3133475 | ||
|
|
24dfa39f91 | ||
|
|
7ec65f7c12 | ||
|
|
4f8edef42b | ||
|
|
7818410d55 | ||
|
|
1762191b03 | ||
|
|
2df92e809e | ||
|
|
59a8d32e40 | ||
|
|
c3b3963760 | ||
|
|
a0cd39e3bc | ||
|
|
e48b2258e0 | ||
|
|
35aaf8824f | ||
|
|
76c69ca3ec | ||
|
|
9994225911 | ||
|
|
03f699e448 | ||
|
|
2fd01a5ab7 | ||
|
|
96629113f1 | ||
|
|
973afa0cc2 | ||
|
|
9bc55e771a | ||
|
|
40a292d05e | ||
|
|
bf723db2cc | ||
|
|
46052172b2 | ||
|
|
4f777f0003 | ||
|
|
b5e1ba72d0 | ||
|
|
df120098f9 | ||
|
|
1c1d578db7 | ||
|
|
69352fe0b5 | ||
|
|
fb3980dad0 | ||
|
|
6c21a1e619 | ||
|
|
112c0dc70a | ||
|
|
538a9ce25d | ||
|
|
cdc3d10df5 | ||
|
|
1b150e3e64 | ||
|
|
27dccc324a | ||
|
|
1bb68ab33b | ||
|
|
3c6ea15d47 | ||
|
|
36e663e1ed | ||
|
|
8d2e0381a7 | ||
|
|
f67d7b35f1 | ||
|
|
9ec62f7471 | ||
|
|
6bbbc41360 | ||
|
|
d3844ac7a7 | ||
|
|
9378e95a5b | ||
|
|
5e4c4ce228 | ||
|
|
ffc373db62 | ||
|
|
60266bf0dc | ||
|
|
8393a80022 | ||
|
|
234edf4756 | ||
|
|
5fd401bfb6 | ||
|
|
5663a5f376 | ||
|
|
81327a9e20 | ||
|
|
2a5b9ad276 | ||
|
|
662e71c621 | ||
|
|
636adfd997 | ||
|
|
b94f7a9532 | ||
|
|
f954a34384 | ||
|
|
6a74263606 | ||
|
|
0bf714061c | ||
|
|
e4e2e919c2 | ||
|
|
7fe4289f42 | ||
|
|
3ce866b7da | ||
|
|
43982c16fa | ||
|
|
e33e4196ab | ||
|
|
2c506db4e4 | ||
|
|
9b9d356849 | ||
|
|
30b7871583 | ||
|
|
1c873b7d02 | ||
|
|
539fb262b2 | ||
|
|
3bf09f9083 | ||
|
|
a55d15b635 | ||
|
|
431748c489 | ||
|
|
a0fb214332 | ||
|
|
f0545cbf02 | ||
|
|
a11c336cf5 | ||
|
|
6b083f8b1b | ||
|
|
89afbba165 | ||
|
|
b2a4887e23 | ||
|
|
9f577875f2 | ||
|
|
c322d6147a | ||
|
|
87b6b3362d | ||
|
|
d261f54327 | ||
|
|
59a815f85a | ||
|
|
2e837f96c5 | ||
|
|
00fce6bd68 | ||
|
|
8a85cd7990 | ||
|
|
db5431d77d | ||
|
|
527b010b24 | ||
|
|
e1727caa5f | ||
|
|
c45b61681d | ||
|
|
e09652413f | ||
|
|
0f337e569f | ||
|
|
59a2668c8c | ||
|
|
e79abf4a26 | ||
|
|
53811ca9b2 | ||
|
|
1bed47fbf5 | ||
|
|
ffb22165da | ||
|
|
48c3daf398 | ||
|
|
c0b4ba715c | ||
|
|
00557ec9ea | ||
|
|
c387225a6e | ||
|
|
93462d4c4b | ||
|
|
c5b55399a1 | ||
|
|
79c39828f0 | ||
|
|
dd80f3732b | ||
|
|
25d109beed | ||
|
|
db6d6c0106 | ||
|
|
af1cf4f5ae | ||
|
|
0ea0cd96db | ||
|
|
3858dcbd62 | ||
|
|
b55d74fb12 | ||
|
|
a21e3590ef | ||
|
|
319c1b1b52 | ||
|
|
964a747f42 | ||
|
|
12d2870424 | ||
|
|
015b188d5e | ||
|
|
8cd2f16c5c | ||
|
|
d09055986e | ||
|
|
2e324ff118 | ||
|
|
adad5ed4fe | ||
|
|
ba3152f553 | ||
|
|
a4bea94495 | ||
|
|
ac2f68f93f | ||
|
|
f156a153de |
@@ -20,7 +20,6 @@ serve: run
|
||||
|
||||
run:
|
||||
.venv/bin/snek serve
|
||||
#$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload
|
||||
|
||||
install: ubuntu
|
||||
python3.12 -m venv .venv
|
||||
|
||||
+6
-3
@@ -18,7 +18,8 @@ dependencies = [
|
||||
"lxml",
|
||||
"IPython",
|
||||
"shed",
|
||||
"app @ git+https://retoor.molodetz.nl/retoor/app",
|
||||
"app @ git+https://retoor.molodetz.nl/retoor/app.git",
|
||||
"zhurnal @git+https://retoor.molodetz.nl/retoor/zhurnal.git",
|
||||
"beautifulsoup4",
|
||||
"gunicorn",
|
||||
"imgkit",
|
||||
@@ -33,8 +34,10 @@ dependencies = [
|
||||
"PyJWT",
|
||||
"multiavatar",
|
||||
"gitpython",
|
||||
"uvloop",
|
||||
"humanize"
|
||||
'uvloop; platform_system != "Windows"',
|
||||
"humanize",
|
||||
"Pillow",
|
||||
"pillow-heif",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
+26
-1
@@ -4,17 +4,42 @@ from aiohttp import web
|
||||
import asyncio
|
||||
from snek.app import Application
|
||||
from IPython import start_ipython
|
||||
import sqlite3
|
||||
import pathlib
|
||||
import shutil
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
pass
|
||||
|
||||
@cli.command()
|
||||
@click.option('--db_path',default="snek.db", help='Database to initialize if not exists.')
|
||||
@click.option('--source',default=None, help='Database to initialize if not exists.')
|
||||
def init(db_path,source):
|
||||
if source and pathlib.Path(source).exists():
|
||||
print(f"Copying {source} to {db_path}")
|
||||
shutil.copy2(source,db_path)
|
||||
print("Database initialized.")
|
||||
return
|
||||
|
||||
if pathlib.Path(db_path).exists():
|
||||
return
|
||||
print(f"Initializing database at {db_path}")
|
||||
db = sqlite3.connect(db_path)
|
||||
db.cursor().executescript(
|
||||
pathlib.Path(__file__).parent.joinpath("schema.sql").read_text()
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
print("Database initialized.")
|
||||
|
||||
@cli.command()
|
||||
@click.option('--port', default=8081, show_default=True, help='Port to run the application on')
|
||||
@click.option('--host', default='0.0.0.0', show_default=True, help='Host to run the application on')
|
||||
@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')
|
||||
def serve(port, host, db_path):
|
||||
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||
#init(db_path)
|
||||
#asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||
web.run_app(
|
||||
Application(db_path=f"sqlite:///{db_path}"), port=port, host=host
|
||||
)
|
||||
|
||||
+55
-9
@@ -3,9 +3,10 @@ import logging
|
||||
import pathlib
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from datetime import datetime
|
||||
from snek import snode
|
||||
from snek.view.threads import ThreadsView
|
||||
|
||||
import json
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -55,9 +56,10 @@ 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
|
||||
|
||||
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
|
||||
|
||||
|
||||
@@ -96,21 +98,60 @@ 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()
|
||||
self.ssh_server = None
|
||||
self.sync_service = None
|
||||
self.executor = None
|
||||
self.cache = Cache(self)
|
||||
self.services = get_services(app=self)
|
||||
self.mappers = get_mappers(app=self)
|
||||
self.on_startup.append(self.start_ssh_server)
|
||||
self.broadcast_service = None
|
||||
self.user_availability_service_task = None
|
||||
|
||||
self.on_startup.append(self.prepare_asyncio)
|
||||
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):
|
||||
self.sync_service = asyncio.create_task(snode.sync_service(app))
|
||||
|
||||
async def start_ssh_server(self, app):
|
||||
app.ssh_server = await start_ssh_server(app,app.ssh_host,app.ssh_port)
|
||||
asyncio.create_task(app.ssh_server.wait_closed())
|
||||
if app.ssh_server:
|
||||
asyncio.create_task(app.ssh_server.wait_closed())
|
||||
|
||||
async def prepare_asyncio(self, app):
|
||||
# app.loop = asyncio.get_running_loop()
|
||||
@@ -125,10 +166,7 @@ class Application(BaseApplication):
|
||||
task = await self.tasks.get()
|
||||
self.db.begin()
|
||||
try:
|
||||
task_start = time.time()
|
||||
await task
|
||||
task_end = time.time()
|
||||
print(f"Task {task} took {task_end - task_start} seconds")
|
||||
self.tasks.task_done()
|
||||
except Exception as ex:
|
||||
print(ex)
|
||||
@@ -184,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)
|
||||
@@ -201,6 +240,10 @@ class Application(BaseApplication):
|
||||
self.router.add_view("/settings/repositories/create.html", RepositoriesCreateView)
|
||||
self.router.add_view("/settings/repositories/repository/{name}/update.html", RepositoriesUpdateView)
|
||||
self.router.add_view("/settings/repositories/repository/{name}/delete.html", RepositoriesDeleteView)
|
||||
self.router.add_view("/settings/containers/index.html", ContainersIndexView)
|
||||
self.router.add_view("/settings/containers/create.html", ContainersCreateView)
|
||||
self.router.add_view("/settings/containers/container/{uid}/update.html", ContainersUpdateView)
|
||||
self.router.add_view("/settings/containers/container/{uid}/delete.html", ContainersDeleteView)
|
||||
self.webdav = WebdavApplication(self)
|
||||
self.git = GitApplication(self)
|
||||
self.add_subapp("/webdav", self.webdav)
|
||||
@@ -236,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:
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
class LoadBalancer:
|
||||
def __init__(self, backend_ports):
|
||||
self.backend_ports = backend_ports
|
||||
self.backend_processes = []
|
||||
self.client_counts = [0] * len(backend_ports)
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
async def start_backend_servers(self,port,workers):
|
||||
for x in range(workers):
|
||||
port += 1
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
sys.argv[0],
|
||||
'backend',
|
||||
str(port),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
port += 1
|
||||
self.backend_processes.append(process)
|
||||
print(f"Started backend server on port {(port-1)/port} with PID {process.pid}")
|
||||
|
||||
async def handle_client(self, reader, writer):
|
||||
async with self.lock:
|
||||
min_clients = min(self.client_counts)
|
||||
server_index = self.client_counts.index(min_clients)
|
||||
self.client_counts[server_index] += 1
|
||||
backend = ('127.0.0.1', self.backend_ports[server_index])
|
||||
try:
|
||||
backend_reader, backend_writer = await asyncio.open_connection(*backend)
|
||||
|
||||
async def forward(r, w):
|
||||
try:
|
||||
while True:
|
||||
data = await r.read(1024)
|
||||
if not data:
|
||||
break
|
||||
w.write(data)
|
||||
await w.drain()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
w.close()
|
||||
|
||||
task1 = asyncio.create_task(forward(reader, backend_writer))
|
||||
task2 = asyncio.create_task(forward(backend_reader, writer))
|
||||
await asyncio.gather(task1, task2)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
finally:
|
||||
writer.close()
|
||||
async with self.lock:
|
||||
self.client_counts[server_index] -= 1
|
||||
|
||||
async def monitor(self):
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
print("Connected clients per server:")
|
||||
for i, count in enumerate(self.client_counts):
|
||||
print(f"Server {self.backend_ports[i]}: {count} clients")
|
||||
|
||||
async def start(self, host='0.0.0.0', port=8081,workers=5):
|
||||
await self.start_backend_servers(port,workers)
|
||||
server = await asyncio.start_server(self.handle_client, host, port)
|
||||
monitor_task = asyncio.create_task(self.monitor())
|
||||
|
||||
# Handle shutdown gracefully
|
||||
try:
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
# Terminate backend processes
|
||||
for process in self.backend_processes:
|
||||
process.terminate()
|
||||
await asyncio.gather(*(p.wait() for p in self.backend_processes))
|
||||
print("Backend processes terminated.")
|
||||
|
||||
async def backend_echo_server(port):
|
||||
async def handle_echo(reader, writer):
|
||||
try:
|
||||
while True:
|
||||
data = await reader.read(1024)
|
||||
if not data:
|
||||
break
|
||||
writer.write(data)
|
||||
await writer.drain()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
server = await asyncio.start_server(handle_echo, '127.0.0.1', port)
|
||||
print(f"Backend echo server running on port {port}")
|
||||
await server.serve_forever()
|
||||
|
||||
async def main():
|
||||
backend_ports = [8001, 8003, 8005, 8006]
|
||||
# Launch backend echo servers
|
||||
# Wait a moment for servers to start
|
||||
lb = LoadBalancer(backend_ports)
|
||||
await lb.start()
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1:
|
||||
if sys.argv[1] == 'backend':
|
||||
port = int(sys.argv[2])
|
||||
from snek.app import Application
|
||||
snek = Application(port=port)
|
||||
web.run_app(snek, port=port, host='127.0.0.1')
|
||||
elif sys.argv[1] == 'sync':
|
||||
from snek.sync import app
|
||||
web.run_app(snek, port=port, host='127.0.0.1')
|
||||
else:
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
print("Shutting down...")
|
||||
|
||||
@@ -10,10 +10,12 @@ from snek.mapper.user import UserMapper
|
||||
from snek.mapper.user_property import UserPropertyMapper
|
||||
from snek.mapper.repository import RepositoryMapper
|
||||
from snek.mapper.channel_attachment import ChannelAttachmentMapper
|
||||
from snek.mapper.container import ContainerMapper
|
||||
from snek.system.object import Object
|
||||
|
||||
|
||||
@functools.cache
|
||||
|
||||
def get_mappers(app=None):
|
||||
return Object(
|
||||
**{
|
||||
@@ -27,6 +29,7 @@ def get_mappers(app=None):
|
||||
"user_property": UserPropertyMapper(app=app),
|
||||
"repository": RepositoryMapper(app=app),
|
||||
"channel_attachment": ChannelAttachmentMapper(app=app),
|
||||
"container": ContainerMapper(app=app),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from snek.model.container import Container
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
class ContainerMapper(BaseMapper):
|
||||
model_class = Container
|
||||
table_name = "container"
|
||||
@@ -12,6 +12,7 @@ from snek.model.user import UserModel
|
||||
from snek.model.user_property import UserPropertyModel
|
||||
from snek.model.repository import RepositoryModel
|
||||
from snek.model.channel_attachment import ChannelAttachmentModel
|
||||
from snek.model.container import Container
|
||||
from snek.system.object import Object
|
||||
|
||||
|
||||
@@ -29,6 +30,7 @@ def get_models():
|
||||
"user_property": UserPropertyModel,
|
||||
"repository": RepositoryModel,
|
||||
"channel_attachment": ChannelAttachmentModel,
|
||||
"container": Container,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
from snek.model.user import UserModel
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
from datetime import datetime,timezone
|
||||
|
||||
class ChannelMessageModel(BaseModel):
|
||||
channel_uid = ModelField(name="channel_uid", required=True, kind=str)
|
||||
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())
|
||||
|
||||
async def get_user(self) -> UserModel:
|
||||
return await self.app.services.user.get(uid=self["user_uid"])
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
class Container(BaseModel):
|
||||
id = ModelField(name="id", required=True, kind=str)
|
||||
name = ModelField(name="name", required=True, kind=str)
|
||||
status = ModelField(name="status", required=True, kind=str)
|
||||
resources = ModelField(name="resources", required=False, kind=str)
|
||||
user_uid = ModelField(name="user_uid", required=False, kind=str)
|
||||
path = ModelField(name="path", required=False, kind=str)
|
||||
readonly = ModelField(name="readonly", required=False, kind=bool, default=False)
|
||||
@@ -0,0 +1,242 @@
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
import dataset
|
||||
import dataset.util
|
||||
import traceback
|
||||
import socket
|
||||
import base64
|
||||
import uuid
|
||||
|
||||
class DatasetMethod:
|
||||
def __init__(self, dt, name):
|
||||
self.dt = dt
|
||||
self.name = name
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
return self.dt.ds.call(
|
||||
self.dt.name,
|
||||
self.name,
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
class DatasetTable:
|
||||
|
||||
def __init__(self, ds, name):
|
||||
self.ds = ds
|
||||
self.name = name
|
||||
|
||||
def __getattr__(self, name):
|
||||
return DatasetMethod(self, name)
|
||||
|
||||
|
||||
|
||||
|
||||
class WebSocketClient2:
|
||||
def __init__(self, uri):
|
||||
self.uri = uri
|
||||
self.loop = asyncio.get_event_loop()
|
||||
self.websocket = None
|
||||
self.receive_queue = asyncio.Queue()
|
||||
|
||||
# Schedule connection setup
|
||||
if self.loop.is_running():
|
||||
# Schedule connect in the existing loop
|
||||
self._connect_future = asyncio.run_coroutine_threadsafe(self._connect(), self.loop)
|
||||
else:
|
||||
# If loop isn't running, connect synchronously
|
||||
self.loop.run_until_complete(self._connect())
|
||||
|
||||
async def _connect(self):
|
||||
self.websocket = await websockets.connect(self.uri)
|
||||
# Start listening for messages
|
||||
asyncio.create_task(self._receive_loop())
|
||||
|
||||
async def _receive_loop(self):
|
||||
try:
|
||||
async for message in self.websocket:
|
||||
await self.receive_queue.put(message)
|
||||
except Exception:
|
||||
pass # Handle exceptions as needed
|
||||
|
||||
def send(self, message: str):
|
||||
if self.loop.is_running():
|
||||
# Schedule send in the existing loop
|
||||
asyncio.run_coroutine_threadsafe(self.websocket.send(message), self.loop)
|
||||
else:
|
||||
# If loop isn't running, run directly
|
||||
self.loop.run_until_complete(self.websocket.send(message))
|
||||
|
||||
def receive(self):
|
||||
# Wait for a message synchronously
|
||||
future = asyncio.run_coroutine_threadsafe(self.receive_queue.get(), self.loop)
|
||||
return future.result()
|
||||
|
||||
def close(self):
|
||||
if self.websocket:
|
||||
if self.loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(self.websocket.close(), self.loop)
|
||||
else:
|
||||
self.loop.run_until_complete(self.websocket.close())
|
||||
|
||||
|
||||
import websockets
|
||||
|
||||
class DatasetWrapper(object):
|
||||
|
||||
def __init__(self):
|
||||
self.ws = WebSocketClient()
|
||||
|
||||
def begin(self):
|
||||
self.call(None, 'begin')
|
||||
|
||||
def commit(self):
|
||||
self.call(None, 'commit')
|
||||
|
||||
def __getitem__(self, name):
|
||||
return DatasetTable(self, name)
|
||||
|
||||
def query(self, *args, **kwargs):
|
||||
return self.call(None, 'query', *args, **kwargs)
|
||||
|
||||
def call(self, table, method, *args, **kwargs):
|
||||
payload = {"table": table, "method": method, "args": args, "kwargs": kwargs,"call_uid":None}
|
||||
#if method in ['find','find_one']:
|
||||
payload["call_uid"] = str(uuid.uuid4())
|
||||
self.ws.write(json.dumps(payload))
|
||||
if payload["call_uid"]:
|
||||
response = self.ws.read()
|
||||
return json.loads(response)['result']
|
||||
return True
|
||||
|
||||
|
||||
|
||||
class DatasetWebSocketView:
|
||||
def __init__(self):
|
||||
self.ws = None
|
||||
self.db = dataset.connect('sqlite:///snek.db')
|
||||
self.setattr(self, "db", self.get)
|
||||
self.setattr(self, "db", self.set)
|
||||
)
|
||||
super()
|
||||
|
||||
def format_result(self, result):
|
||||
|
||||
try:
|
||||
return dict(result)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
return [dict(row) for row in result]
|
||||
except:
|
||||
pass
|
||||
return result
|
||||
|
||||
async def send_str(self, msg):
|
||||
return await self.ws.send_str(msg)
|
||||
|
||||
def get(self, key):
|
||||
returnl loads(dict(self.db['_kv'].get(key=key)['value']))
|
||||
|
||||
def set(self, key, value):
|
||||
return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])
|
||||
|
||||
|
||||
|
||||
async def handle(self, request):
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
self.ws = ws
|
||||
|
||||
async for msg in ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
try:
|
||||
data = json.loads(msg.data)
|
||||
call_uid = data.get("call_uid")
|
||||
method = data.get("method")
|
||||
table_name = data.get("table")
|
||||
args = data.get("args", {})
|
||||
kwargs = data.get("kwargs", {})
|
||||
|
||||
|
||||
function = getattr(self.db, method, None)
|
||||
if table_name:
|
||||
function = getattr(self.db[table_name], method, None)
|
||||
|
||||
print(method, table_name, args, kwargs,flush=True)
|
||||
|
||||
if function:
|
||||
response = {}
|
||||
try:
|
||||
result = function(*args, **kwargs)
|
||||
print(result)
|
||||
response['result'] = self.format_result(result)
|
||||
response["call_uid"] = call_uid
|
||||
response["success"] = True
|
||||
except Exception as e:
|
||||
response["call_uid"] = call_uid
|
||||
response["success"] = False
|
||||
response["error"] = str(e)
|
||||
response["traceback"] = traceback.format_exc()
|
||||
|
||||
if call_uid:
|
||||
await self.send_str(json.dumps(response,default=str))
|
||||
else:
|
||||
await self.send_str(json.dumps({"status": "error", "error":"Method not found.","call_uid": call_uid}))
|
||||
except Exception as e:
|
||||
await self.send_str(json.dumps({"success": False,"call_uid": call_uid, "error": str(e), "error": str(e), "traceback": traceback.format_exc()},default=str))
|
||||
elif msg.type == aiohttp.WSMsgType.ERROR:
|
||||
print('ws connection closed with exception %s' % ws.exception())
|
||||
|
||||
return ws
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
app = web.Application()
|
||||
view = DatasetWebSocketView()
|
||||
app.router.add_get('/db', view.handle)
|
||||
|
||||
async def run_server():
|
||||
|
||||
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, 'localhost', 3131)
|
||||
await site.start()
|
||||
|
||||
print("Server started at http://localhost:8080")
|
||||
await asyncio.Event().wait()
|
||||
|
||||
async def client():
|
||||
print("x")
|
||||
d = DatasetWrapper()
|
||||
print("y")
|
||||
|
||||
for x in range(100):
|
||||
for x in range(100):
|
||||
if d['test'].insert({"name": "test", "number":x}):
|
||||
print(".",end="",flush=True)
|
||||
print("")
|
||||
print(d['test'].find_one(name="test", order_by="-number"))
|
||||
|
||||
print("DONE")
|
||||
|
||||
|
||||
|
||||
import time
|
||||
async def main():
|
||||
await run_server()
|
||||
|
||||
import sys
|
||||
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[1] == 'server':
|
||||
asyncio.run(main())
|
||||
if sys.argv[1] == 'client':
|
||||
asyncio.run(client())
|
||||
@@ -0,0 +1,51 @@
|
||||
import snek.serpentarium
|
||||
|
||||
import time
|
||||
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
|
||||
durations = []
|
||||
|
||||
def task1():
|
||||
global durations
|
||||
client = snek.serpentarium.DatasetWrapper()
|
||||
|
||||
start=time.time()
|
||||
for x in range(1500):
|
||||
|
||||
client['a'].delete()
|
||||
client['a'].insert({"foo": x})
|
||||
client['a'].find(foo=x)
|
||||
client['a'].find_one(foo=x)
|
||||
client['a'].count()
|
||||
#print(client['a'].find(foo=x) )
|
||||
#print(client['a'].find_one(foo=x) )
|
||||
#print(client['a'].count())
|
||||
client.close()
|
||||
duration1 = f"{time.time()-start}"
|
||||
durations.append(duration1)
|
||||
print(durations)
|
||||
|
||||
with ProcessPoolExecutor(max_workers=4) as executor:
|
||||
tasks = [executor.submit(task1),
|
||||
executor.submit(task1),
|
||||
executor.submit(task1),
|
||||
executor.submit(task1)
|
||||
]
|
||||
for task in tasks:
|
||||
task.result()
|
||||
|
||||
|
||||
import dataset
|
||||
client = dataset.connect("sqlite:///snek.db")
|
||||
start=time.time()
|
||||
for x in range(1500):
|
||||
|
||||
client['a'].delete()
|
||||
client['a'].insert({"foo": x})
|
||||
print([dict(row) for row in client['a'].find(foo=x)])
|
||||
print(dict(client['a'].find_one(foo=x) ))
|
||||
print(client['a'].count())
|
||||
duration2 = f"{time.time()-start}"
|
||||
|
||||
print(duration1,duration2)
|
||||
@@ -0,0 +1,103 @@
|
||||
CREATE TABLE IF NOT EXISTS http_access (
|
||||
id INTEGER NOT NULL,
|
||||
created TEXT,
|
||||
path TEXT,
|
||||
duration FLOAT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user (
|
||||
id INTEGER NOT NULL,
|
||||
color TEXT,
|
||||
created_at TEXT,
|
||||
deleted_at TEXT,
|
||||
email TEXT,
|
||||
is_admin TEXT,
|
||||
last_ping TEXT,
|
||||
nick TEXT,
|
||||
password TEXT,
|
||||
uid TEXT,
|
||||
updated_at TEXT,
|
||||
username TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_user_e2577dd78b54fe28 ON user (uid);
|
||||
CREATE TABLE IF NOT EXISTS channel (
|
||||
id INTEGER NOT NULL,
|
||||
created_at TEXT,
|
||||
created_by_uid TEXT,
|
||||
deleted_at TEXT,
|
||||
description TEXT,
|
||||
"index" BIGINT,
|
||||
is_listed BOOLEAN,
|
||||
is_private BOOLEAN,
|
||||
label TEXT,
|
||||
last_message_on TEXT,
|
||||
tag TEXT,
|
||||
uid TEXT,
|
||||
updated_at TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_channel_e2577dd78b54fe28 ON channel (uid);
|
||||
CREATE TABLE IF NOT EXISTS channel_member (
|
||||
id INTEGER NOT NULL,
|
||||
channel_uid TEXT,
|
||||
created_at TEXT,
|
||||
deleted_at TEXT,
|
||||
is_banned BOOLEAN,
|
||||
is_moderator BOOLEAN,
|
||||
is_muted BOOLEAN,
|
||||
is_read_only BOOLEAN,
|
||||
label TEXT,
|
||||
new_count BIGINT,
|
||||
uid TEXT,
|
||||
updated_at TEXT,
|
||||
user_uid TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_channel_member_e2577dd78b54fe28 ON channel_member (uid);
|
||||
CREATE TABLE IF NOT EXISTS broadcast (
|
||||
id INTEGER NOT NULL,
|
||||
channel_uid TEXT,
|
||||
message TEXT,
|
||||
created_at TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS channel_message (
|
||||
id INTEGER NOT NULL,
|
||||
channel_uid TEXT,
|
||||
created_at TEXT,
|
||||
deleted_at TEXT,
|
||||
html TEXT,
|
||||
message TEXT,
|
||||
uid TEXT,
|
||||
updated_at TEXT,
|
||||
user_uid TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_channel_message_e2577dd78b54fe28 ON channel_message (uid);
|
||||
CREATE TABLE IF NOT EXISTS notification (
|
||||
id INTEGER NOT NULL,
|
||||
created_at TEXT,
|
||||
deleted_at TEXT,
|
||||
message TEXT,
|
||||
object_type TEXT,
|
||||
object_uid TEXT,
|
||||
read_at TEXT,
|
||||
uid TEXT,
|
||||
updated_at TEXT,
|
||||
user_uid TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_notification_e2577dd78b54fe28 ON notification (uid);
|
||||
CREATE TABLE IF NOT EXISTS repository (
|
||||
id INTEGER NOT NULL,
|
||||
created_at TEXT,
|
||||
deleted_at TEXT,
|
||||
is_private BIGINT,
|
||||
name TEXT,
|
||||
uid TEXT,
|
||||
updated_at TEXT,
|
||||
user_uid TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_repository_e2577dd78b54fe28 ON repository (uid);
|
||||
@@ -13,6 +13,7 @@ from snek.service.user_property import UserPropertyService
|
||||
from snek.service.util import UtilService
|
||||
from snek.service.repository import RepositoryService
|
||||
from snek.service.channel_attachment import ChannelAttachmentService
|
||||
from snek.service.container import ContainerService
|
||||
from snek.system.object import Object
|
||||
from snek.service.db import DBService
|
||||
|
||||
@@ -34,6 +35,7 @@ def get_services(app):
|
||||
"repository": RepositoryService(app=app),
|
||||
"db": DBService(app=app),
|
||||
"channel_attachment": ChannelAttachmentService(app=app),
|
||||
"container": ContainerService(app=app),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -86,7 +90,7 @@ class ChannelService(BaseService):
|
||||
if (
|
||||
datetime.fromisoformat(now())
|
||||
- datetime.fromisoformat(user["last_ping"])
|
||||
).total_seconds() < 20:
|
||||
).total_seconds() < 180:
|
||||
yield user
|
||||
|
||||
async def get_for_user(self, user_uid):
|
||||
|
||||
@@ -15,7 +15,7 @@ class ChannelAttachmentService(BaseService):
|
||||
attachment["mime_type"] = mimetypes.guess_type(name)[0]
|
||||
attachment['resource_type'] = "file"
|
||||
real_file_name = f"{attachment['uid']}-{name}"
|
||||
attachment["relative_url"] = urllib.parse.quote(f"{attachment['uid']}/{name}")
|
||||
attachment["relative_url"] = (f"{attachment['uid']}-{name}")
|
||||
attachment_folder = await self.services.channel.get_attachment_folder(channel_uid)
|
||||
attachment_path = attachment_folder.joinpath(real_file_name)
|
||||
attachment["path"] = str(attachment_path)
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -30,7 +31,7 @@ class ChannelMessageService(BaseService):
|
||||
except Exception as ex:
|
||||
print(ex, flush=True)
|
||||
|
||||
if await self.save(model):
|
||||
if await super().save(model):
|
||||
return model
|
||||
raise Exception(f"Failed to create channel message: {model.errors}.")
|
||||
|
||||
@@ -50,6 +51,22 @@ class ChannelMessageService(BaseService):
|
||||
"username": user["username"],
|
||||
}
|
||||
|
||||
async def save(self, model):
|
||||
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)
|
||||
|
||||
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):
|
||||
results = []
|
||||
offset = page * page_size
|
||||
|
||||
@@ -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,10 +56,11 @@ class ChatService(BaseService):
|
||||
"username": user["username"],
|
||||
"uid": channel_message["uid"],
|
||||
"user_nick": user["nick"],
|
||||
"is_final": is_final,
|
||||
},
|
||||
)
|
||||
await self.app.create_task(
|
||||
self.services.notification.create_channel_message(channel_message_uid)
|
||||
)
|
||||
|
||||
return True
|
||||
return channel_message
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from snek.system.service import BaseService
|
||||
|
||||
class ContainerService(BaseService):
|
||||
mapper_name = "container"
|
||||
|
||||
async def create(self, id, name, status, resources=None, user_uid=None, path=None, readonly=False):
|
||||
model = await self.new()
|
||||
model["id"] = id
|
||||
model["name"] = name
|
||||
model["status"] = status
|
||||
if resources:
|
||||
model["resources"] = resources
|
||||
if user_uid:
|
||||
model["user_uid"] = user_uid
|
||||
if path:
|
||||
model["path"] = path
|
||||
model["readonly"] = readonly
|
||||
if await super().save(model):
|
||||
return model
|
||||
raise Exception(f"Failed to create container: {model.errors}")
|
||||
|
||||
async def get(self, id):
|
||||
return await self.mapper.get(id)
|
||||
|
||||
async def update(self, model):
|
||||
return await self.mapper.update(model)
|
||||
|
||||
async def delete(self, id):
|
||||
return await self.mapper.delete(id)
|
||||
@@ -16,4 +16,5 @@ class DriveItemService(BaseService):
|
||||
if await self.save(model):
|
||||
return model
|
||||
errors = await model.errors
|
||||
print("XXXXXXXXXX")
|
||||
raise Exception(f"Failed to create drive item: {errors}.")
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
from snek.model.user import UserModel
|
||||
from snek.system.service import BaseService
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
from snek.system.model import now
|
||||
|
||||
class SocketService(BaseService):
|
||||
|
||||
@@ -10,6 +15,7 @@ class SocketService(BaseService):
|
||||
self.is_connected = True
|
||||
self.user = user
|
||||
|
||||
|
||||
async def send_json(self, data):
|
||||
if not self.is_connected:
|
||||
return False
|
||||
@@ -33,10 +39,31 @@ class SocketService(BaseService):
|
||||
self.sockets = set()
|
||||
self.users = {}
|
||||
self.subscriptions = {}
|
||||
self.last_update = str(datetime.now())
|
||||
|
||||
|
||||
async def user_availability_service(self):
|
||||
logger.info("User availability update service started.")
|
||||
while True:
|
||||
logger.info("Updating user availability...")
|
||||
users_updated = []
|
||||
for s in self.sockets:
|
||||
if not s.is_connected:
|
||||
continue
|
||||
if not s.user in users_updated:
|
||||
s.user["last_ping"] = now()
|
||||
await self.app.services.user.save(s.user)
|
||||
users_updated.append(s.user)
|
||||
logger.info(f"Updated user availability for {len(users_updated)} online users.")
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
||||
async def add(self, ws, user_uid):
|
||||
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
|
||||
self.sockets.add(s)
|
||||
s.user["last_ping"] = now()
|
||||
await self.app.services.user.save(s.user)
|
||||
logger.info(f"Added socket for user {s.user['username']}")
|
||||
if not self.users.get(user_uid):
|
||||
self.users[user_uid] = set()
|
||||
self.users[user_uid].add(s)
|
||||
@@ -54,17 +81,25 @@ class SocketService(BaseService):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
async def broadcast(self, channel_uid, message):
|
||||
await self._broadcast(channel_uid, message)
|
||||
|
||||
async def _broadcast(self, channel_uid, message):
|
||||
sent = 0
|
||||
try:
|
||||
async for user_uid in self.services.channel_member.get_user_uids(
|
||||
channel_uid
|
||||
):
|
||||
await self.send_to_user(user_uid, message)
|
||||
sent += await self.send_to_user(user_uid, message)
|
||||
except Exception as ex:
|
||||
print(ex, flush=True)
|
||||
logger.info(f"Broadcasted a message to {sent} users.")
|
||||
return True
|
||||
|
||||
async def delete(self, ws):
|
||||
for s in [sock for sock in self.sockets if sock.ws == ws]:
|
||||
await s.close()
|
||||
logger.info(f"Removed socket for user {s.user['username']}")
|
||||
self.sockets.remove(s)
|
||||
|
||||
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
import git
|
||||
|
||||
import shutil
|
||||
import json
|
||||
import tempfile
|
||||
@@ -14,6 +14,8 @@ logger = logging.getLogger('git_server')
|
||||
|
||||
class GitApplication(web.Application):
|
||||
def __init__(self, parent=None):
|
||||
#import git
|
||||
#globals()['git'] = git
|
||||
self.parent = parent
|
||||
super().__init__(client_max_size=1024*1024*1024*5)
|
||||
self.add_routes([
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import aiohttp
|
||||
|
||||
ENABLED = False
|
||||
|
||||
import aiohttp
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
|
||||
import sqlite3
|
||||
|
||||
import dataset
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
import json
|
||||
|
||||
queue = asyncio.Queue()
|
||||
|
||||
class State:
|
||||
do_not_sync = False
|
||||
|
||||
async def sync_service(app):
|
||||
if not ENABLED:
|
||||
return
|
||||
session = aiohttp.ClientSession()
|
||||
async with session.ws_connect('http://localhost:3131/ws') as ws:
|
||||
async def receive():
|
||||
|
||||
queries_synced = 0
|
||||
async for msg in ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
try:
|
||||
data = json.loads(msg.data)
|
||||
State.do_not_sync = True
|
||||
app.db.execute(*data)
|
||||
app.db.commit()
|
||||
State.do_not_sync = False
|
||||
queries_synced += 1
|
||||
print("queries synced: " + str(queries_synced))
|
||||
print(*data)
|
||||
await app.services.socket.broadcast_event()
|
||||
except Exception as e:
|
||||
print(e)
|
||||
pass
|
||||
#print(f"Received: {msg.data}")
|
||||
elif msg.type == aiohttp.WSMsgType.ERROR:
|
||||
break
|
||||
async def write():
|
||||
while True:
|
||||
msg = await queue.get()
|
||||
await ws.send_str(json.dumps(msg,default=str))
|
||||
queue.task_done()
|
||||
|
||||
await asyncio.gather(receive(), write())
|
||||
|
||||
await session.close()
|
||||
|
||||
queries_queued = 0
|
||||
# Attach a listener to log all executed statements
|
||||
@event.listens_for(Engine, "before_cursor_execute")
|
||||
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
if not ENABLED:
|
||||
return
|
||||
global queries_queued
|
||||
if State.do_not_sync:
|
||||
print(statement,parameters)
|
||||
return
|
||||
if statement.startswith("SELECT"):
|
||||
return
|
||||
queue.put_nowait((statement, parameters))
|
||||
queries_queued += 1
|
||||
print("Queries queued: " + str(queries_queued))
|
||||
|
||||
async def websocket_handler(request):
|
||||
queries_broadcasted = 0
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
request.app['websockets'].append(ws)
|
||||
async for msg in ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
for client in request.app['websockets']:
|
||||
if client != ws:
|
||||
await client.send_str(msg.data)
|
||||
cursor = request.app['db'].cursor()
|
||||
data = json.loads(msg.data)
|
||||
queries_broadcasted += 1
|
||||
|
||||
cursor.execute(*data)
|
||||
cursor.close()
|
||||
print("Queries broadcasted: " + str(queries_broadcasted))
|
||||
elif msg.type == aiohttp.WSMsgType.ERROR:
|
||||
print(f'WebSocket connection closed with exception {ws.exception()}')
|
||||
|
||||
request.app['websockets'].remove(ws)
|
||||
return ws
|
||||
|
||||
app = web.Application()
|
||||
app['websockets'] = []
|
||||
|
||||
app.router.add_get('/ws', websocket_handler)
|
||||
|
||||
async def on_startup(app):
|
||||
app['db'] = sqlite3.connect('snek.db')
|
||||
print("Server starting...")
|
||||
|
||||
async def on_cleanup(app):
|
||||
for ws in app['websockets']:
|
||||
await ws.close()
|
||||
app['db'].close()
|
||||
|
||||
app.on_startup.append(on_startup)
|
||||
app.on_cleanup.append(on_cleanup)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
web.run_app(app, host='127.0.0.1', port=3131)
|
||||
+12
-31
@@ -1,14 +1,9 @@
|
||||
import asyncio
|
||||
import asyncssh
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import pty
|
||||
|
||||
global _app
|
||||
|
||||
|
||||
def set_app(app):
|
||||
global _app
|
||||
_app = app
|
||||
@@ -16,19 +11,11 @@ def set_app(app):
|
||||
def get_app():
|
||||
return _app
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler("sftp_server.log"),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
roots = {}
|
||||
|
||||
class MySFTPServer(asyncssh.SFTPServer):
|
||||
class SFTPServer(asyncssh.SFTPServer):
|
||||
|
||||
def __init__(self, chan: asyncssh.SSHServerChannel):
|
||||
self.root = get_app().services.user.get_home_folder_by_username(
|
||||
@@ -39,31 +26,26 @@ class MySFTPServer(asyncssh.SFTPServer):
|
||||
super().__init__(chan, chroot=self.root)
|
||||
|
||||
def map_path(self, path):
|
||||
|
||||
# Map client path to local filesystem path
|
||||
mapped_path = Path(self.root).joinpath(path.lstrip(b"/").decode())
|
||||
#Path(mapped_path).mkdir(exist_ok=True)
|
||||
print(mapped_path)
|
||||
logger.debug(f"Mapping client path {path} to {mapped_path}")
|
||||
return str(mapped_path).encode()
|
||||
|
||||
class MySSHServer(asyncssh.SSHServer):
|
||||
class SSHServer(asyncssh.SSHServer):
|
||||
def password_auth_supported(self):
|
||||
return True
|
||||
|
||||
def validate_password(self, username, password):
|
||||
# Simple in-memory user database
|
||||
|
||||
logger.debug(f"Validating credentials for user {username}")
|
||||
return get_app().services.user.authenticate_sync(username,password)
|
||||
|
||||
result = get_app().services.user.authenticate_sync(username,password)
|
||||
logger.info(f"Validating credentials for user {username}: {result}")
|
||||
return result
|
||||
|
||||
async def start_ssh_server(app,host,port):
|
||||
set_app(app)
|
||||
logger.info("Starting SFTP server setup")
|
||||
# Server host key (generate or load one)
|
||||
|
||||
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}")
|
||||
@@ -76,20 +58,19 @@ async def start_ssh_server(app,host,port):
|
||||
logger.error(f"Failed to generate or load host key: {e}")
|
||||
raise
|
||||
|
||||
# Start the SSH server with SFTP support
|
||||
logger.info("Starting SFTP server on localhost:8022")
|
||||
logger.info(f"Starting SFTP server on 127.0.0.1:{port}")
|
||||
try:
|
||||
x = await asyncssh.listen(
|
||||
host=host,
|
||||
port=port,
|
||||
#process_factory=handle_client,
|
||||
server_host_keys=[key],
|
||||
server_factory=MySSHServer,
|
||||
sftp_factory=MySFTPServer
|
||||
server_factory=SSHServer,
|
||||
sftp_factory=SFTPServer
|
||||
)
|
||||
return x
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start SFTP server: {e}")
|
||||
raise
|
||||
logger.warning(f"Failed to start SFTP server. Already running.")
|
||||
pass
|
||||
|
||||
|
||||
|
||||
+221
-204
@@ -7,244 +7,261 @@
|
||||
|
||||
// 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..");
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
try {
|
||||
this._socket = new WebSocket(this._url);
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
setTimeout(() => {
|
||||
this.ensureConnection();
|
||||
}, 1000);
|
||||
}
|
||||
this._socket.onconnect = () => {
|
||||
this._connected();
|
||||
this._waitSocket();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
this._socket.onconnect = () => {
|
||||
this._connected();
|
||||
this._waitSocket();
|
||||
};
|
||||
});
|
||||
}
|
||||
generateUniqueId() {
|
||||
return "id-" + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
generateUniqueId() {
|
||||
return 'id-' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
};
|
||||
}
|
||||
|
||||
_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();
|
||||
|
||||
+233
-75
@@ -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 {
|
||||
@@ -152,6 +160,10 @@ footer {
|
||||
|
||||
}
|
||||
|
||||
.chat-messages picture img {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
@@ -228,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;
|
||||
@@ -273,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 {
|
||||
@@ -291,6 +307,7 @@ input[type="text"], .chat-input textarea {
|
||||
.avatar {
|
||||
user-select: none;
|
||||
opacity: 1;
|
||||
max-height: unset;
|
||||
}
|
||||
|
||||
.author {
|
||||
@@ -298,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,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 {
|
||||
@@ -367,63 +385,203 @@ 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%;
|
||||
}
|
||||
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;
|
||||
}
|
||||
/*
|
||||
body {
|
||||
justify-content: flex-start;
|
||||
|
||||
nav {
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
header{
|
||||
position: sticky;
|
||||
display: block;
|
||||
.logo {
|
||||
display:block;
|
||||
}
|
||||
}
|
||||
.chat-input {
|
||||
position:sticky;
|
||||
}*/
|
||||
}
|
||||
|
||||
/*
|
||||
body {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
header{
|
||||
position: sticky;
|
||||
display: block;
|
||||
.logo {
|
||||
display:block;
|
||||
}
|
||||
}
|
||||
.chat-input {
|
||||
position:sticky;
|
||||
}*/
|
||||
}
|
||||
|
||||
dialog {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
background-color: #000; /* Deep black */
|
||||
color: #f1f1f1;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.8);
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
|
||||
animation: dialogFadeIn 0.3s ease-out, dialogScaleIn 0.3s ease-out;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* Backdrop styling */
|
||||
dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Title and content */
|
||||
dialog .dialog-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
dialog .dialog-content {
|
||||
font-size: 1rem;
|
||||
color: #ccc;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Button layout */
|
||||
dialog .dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
dialog .dialog-button {
|
||||
padding: 8px 16px;
|
||||
font-size: 0.95rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
@keyframes dialogFadeIn {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
dialog .dialog-button.primary {
|
||||
background-color: #f05a28;
|
||||
color: white;
|
||||
}
|
||||
|
||||
dialog .dialog-button.primary:hover {
|
||||
background-color: #f05a28;
|
||||
}
|
||||
|
||||
dialog .dialog-button.secondary {
|
||||
background-color: #f0a328;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
dialog .dialog-button.secondary:hover {
|
||||
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;
|
||||
}
|
||||
+355
-51
@@ -1,69 +1,373 @@
|
||||
// Written by retoor@molodetz.nl
|
||||
import { app } from "../app.js";
|
||||
|
||||
// This JavaScript class defines a custom HTML element for a chat input widget, featuring a text area and an upload button. It handles user input and triggers events for input changes and message submission.
|
||||
|
||||
// Includes standard DOM manipulation methods; no external imports used.
|
||||
|
||||
// MIT License: This code is open-source and can be reused and distributed under the terms of the MIT License.
|
||||
|
||||
class ChatInputElement extends HTMLElement {
|
||||
_chatWindow = null
|
||||
class ChatInputComponent extends HTMLElement {
|
||||
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.attachShadow({ mode: 'open' });
|
||||
this.component = document.createElement('div');
|
||||
this.shadowRoot.appendChild(this.component);
|
||||
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;
|
||||
}
|
||||
set chatWindow(value){
|
||||
this._chatWindow = value
|
||||
|
||||
get value() {
|
||||
return this._value;
|
||||
}
|
||||
get chatWindow(){
|
||||
return this._chatWindow
|
||||
}
|
||||
get channelUid() {
|
||||
return this.chatWindow.channel.uid
|
||||
}
|
||||
connectedCallback() {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = '/base.css';
|
||||
this.component.appendChild(link);
|
||||
|
||||
this.container = document.createElement('div');
|
||||
this.container.classList.add('chat-input');
|
||||
this.container.innerHTML = `
|
||||
<textarea placeholder="Type a message..." rows="2"></textarea>
|
||||
<upload-button></upload-button>
|
||||
`;
|
||||
this.textBox = this.container.querySelector('textarea');
|
||||
this.uploadButton = this.container.querySelector('upload-button');
|
||||
this.uploadButton.chatInput = this
|
||||
this.textBox.addEventListener('input', (e) => {
|
||||
this.dispatchEvent(new CustomEvent('input', { detail: e.target.value, bubbles: true }));
|
||||
const message = e.target.value;
|
||||
const button = this.container.querySelector('button');
|
||||
button.disabled = !message;
|
||||
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;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
mentions.push(match[1]);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
||||
if(!this.isSubsequence(lowerMention,lowerAuthor)) {
|
||||
distance += 10
|
||||
}
|
||||
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestAuthor = author;
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
this.textBox.addEventListener('change', (e) => {
|
||||
e.preventDefault();
|
||||
this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));
|
||||
console.error(e.target.value);
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[b.length][a.length];
|
||||
}
|
||||
|
||||
|
||||
|
||||
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.textBox.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
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();
|
||||
const message = e.target.value.trim();
|
||||
if (!message) return;
|
||||
this.dispatchEvent(new CustomEvent('submit', { detail: message, bubbles: true }));
|
||||
e.target.value = '';
|
||||
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.component.appendChild(this.container);
|
||||
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', ChatInputElement);
|
||||
customElements.define("chat-input", ChatInputComponent);
|
||||
|
||||
@@ -12,71 +12,71 @@
|
||||
// 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");
|
||||
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");
|
||||
this.container = document.createElement("section");
|
||||
this.container.classList.add("chat-area", "chat-window");
|
||||
|
||||
const chatHeader = document.createElement("div");
|
||||
chatHeader.classList.add("chat-header");
|
||||
const chatHeader = document.createElement("div");
|
||||
chatHeader.classList.add("chat-header");
|
||||
|
||||
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 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 channels = await app.rpc.getChannels();
|
||||
const channel = channels[0];
|
||||
this.channel = channel;
|
||||
chatTitle.innerText = channel.name;
|
||||
const channels = await app.rpc.getChannels();
|
||||
const channel = channels[0];
|
||||
this.channel = channel;
|
||||
chatTitle.innerText = channel.name;
|
||||
|
||||
const channelElement = document.createElement('message-list');
|
||||
channelElement.setAttribute("channel", channel.uid);
|
||||
this.container.appendChild(channelElement);
|
||||
const channelElement = document.createElement("message-list");
|
||||
channelElement.setAttribute("channel", channel.uid);
|
||||
this.container.appendChild(channelElement);
|
||||
|
||||
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 chatInput = document.createElement("chat-input");
|
||||
chatInput.chatWindow = this;
|
||||
chatInput.addEventListener("submit", (e) => {
|
||||
app.rpc.sendMessage(channel.uid, e.detail);
|
||||
});
|
||||
this.container.appendChild(chatInput);
|
||||
|
||||
this.component.appendChild(this.container);
|
||||
this.component.appendChild(this.container);
|
||||
|
||||
const messages = await app.rpc.getMessages(channel.uid);
|
||||
messages.forEach(message => {
|
||||
if (!message['user_nick']) return;
|
||||
channelElement.addMessage(message);
|
||||
});
|
||||
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);
|
||||
const me = this;
|
||||
channelElement.addEventListener("message", (message) => {
|
||||
if (me.user.uid !== message.detail.user_uid) app.playSound(0);
|
||||
|
||||
message.detail.element.scrollIntoView({"block": "end"});
|
||||
});
|
||||
}
|
||||
message.detail.element.scrollIntoView({ block: "end" });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('chat-window', ChatWindowElement);
|
||||
customElements.define("chat-window", ChatWindowElement);
|
||||
|
||||
@@ -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">></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);
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (this.url === "/back" || this.url === "/back/") {
|
||||
window.history.back();
|
||||
} else if (this.url) {
|
||||
window.location = this.url;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("fancy-button", FancyButton);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
+93
-156
@@ -5,166 +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.uid, data);
|
||||
});
|
||||
app.ws.addEventListener("set_typing", (data) => {
|
||||
this.triggerGlow(data.user_uid,data.color);
|
||||
});
|
||||
|
||||
class MessageListElement extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ["messages"];
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
});
|
||||
if (lastElement) {
|
||||
lastElement.classList.add("glow");
|
||||
setTimeout(() => {
|
||||
lastElement.classList.remove("glow");
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
linkifyText(text) {
|
||||
const urlRegex = /https?:\/\/[^\s]+/g;
|
||||
return text.replace(urlRegex, (url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`);
|
||||
}
|
||||
set data(items) {
|
||||
this.items = items;
|
||||
this.render();
|
||||
}
|
||||
render() {
|
||||
this.innerHTML = "";
|
||||
|
||||
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
@@ -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,
|
||||
};
|
||||
|
||||
+32
-28
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
:root {
|
||||
--star-color: white;
|
||||
--background-color: black;
|
||||
}
|
||||
|
||||
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;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
font-family: sans-serif;
|
||||
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,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
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
.user-list__item {
|
||||
display: flex;
|
||||
margin-bottom: 1em;
|
||||
border: 1px solid #ccc;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.user-list__item-avatar {
|
||||
margin-right: 10px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: block;
|
||||
}
|
||||
.user-list__item-content {
|
||||
flex: 1;
|
||||
}
|
||||
.user-list__item-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
.user-list__item-text {
|
||||
margin: 5px 0;
|
||||
}
|
||||
.user-list__item-time {
|
||||
font-size: 0.8em;
|
||||
color: gray;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
class UserList extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.users = [];
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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 = "";
|
||||
|
||||
this.users.forEach((user) => {
|
||||
const html = `
|
||||
<div class="user-list__item"
|
||||
data-uid="${user.uid}"
|
||||
data-color="${user.color}"
|
||||
data-user_nick="${user.nick}"
|
||||
data-created_at="${user.created_at}"
|
||||
data-user_uid="${user.user_uid}">
|
||||
|
||||
<a class="user-list__item-avatar" style="background-color: ${user.color}; color: black;" href="/user/${user.uid}.html">
|
||||
<img width="40px" height="40px" src="/avatar/${user.uid}.svg" alt="${user.nick}">
|
||||
</a>
|
||||
|
||||
<div class="user-list__item-content">
|
||||
<div class="user-list__item-name" style="color: ${user.color};">${user.nick}</div>
|
||||
<div class="user-list__item-time" data-created_at="${user.last_ping}">
|
||||
<a href="/user/${user.uid}.html">profile</a>
|
||||
<a href="/channel/${user.uid}.html">dm</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
this.insertAdjacentHTML("beforeend", html);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("user-list", UserList);
|
||||
@@ -0,0 +1,135 @@
|
||||
|
||||
|
||||
|
||||
|
||||
class DatasetWebSocketView:
|
||||
def __init__(self):
|
||||
self.ws = None
|
||||
self.db = dataset.connect('sqlite:///snek.db')
|
||||
self.setattr(self, "db", self.get)
|
||||
self.setattr(self, "db", self.set)
|
||||
)
|
||||
super()
|
||||
|
||||
def format_result(self, result):
|
||||
|
||||
try:
|
||||
return dict(result)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
return [dict(row) for row in result]
|
||||
except:
|
||||
pass
|
||||
return result
|
||||
|
||||
async def send_str(self, msg):
|
||||
return await self.ws.send_str(msg)
|
||||
|
||||
def get(self, key):
|
||||
returnl loads(dict(self.db['_kv'].get(key=key)['value']))
|
||||
|
||||
def set(self, key, value):
|
||||
return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])
|
||||
|
||||
|
||||
|
||||
async def handle(self, request):
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
self.ws = ws
|
||||
|
||||
async for msg in ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
try:
|
||||
data = json.loads(msg.data)
|
||||
call_uid = data.get("call_uid")
|
||||
method = data.get("method")
|
||||
table_name = data.get("table")
|
||||
args = data.get("args", {})
|
||||
kwargs = data.get("kwargs", {})
|
||||
|
||||
|
||||
function = getattr(self.db, method, None)
|
||||
if table_name:
|
||||
function = getattr(self.db[table_name], method, None)
|
||||
|
||||
print(method, table_name, args, kwargs,flush=True)
|
||||
|
||||
if function:
|
||||
response = {}
|
||||
try:
|
||||
result = function(*args, **kwargs)
|
||||
print(result)
|
||||
response['result'] = self.format_result(result)
|
||||
response["call_uid"] = call_uid
|
||||
response["success"] = True
|
||||
except Exception as e:
|
||||
response["call_uid"] = call_uid
|
||||
response["success"] = False
|
||||
response["error"] = str(e)
|
||||
response["traceback"] = traceback.format_exc()
|
||||
|
||||
if call_uid:
|
||||
await self.send_str(json.dumps(response,default=str))
|
||||
else:
|
||||
await self.send_str(json.dumps({"status": "error", "error":"Method not found.","call_uid": call_uid}))
|
||||
except Exception as e:
|
||||
await self.send_str(json.dumps({"success": False,"call_uid": call_uid, "error": str(e), "error": str(e), "traceback": traceback.format_exc()},default=str))
|
||||
elif msg.type == aiohttp.WSMsgType.ERROR:
|
||||
print('ws connection closed with exception %s' % ws.exception())
|
||||
|
||||
return ws
|
||||
|
||||
class BroadCastSocketView:
|
||||
def __init__(self):
|
||||
self.ws = None
|
||||
super()
|
||||
|
||||
def format_result(self, result):
|
||||
|
||||
try:
|
||||
return dict(result)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
return [dict(row) for row in result]
|
||||
except:
|
||||
pass
|
||||
return result
|
||||
|
||||
async def send_str(self, msg):
|
||||
return await self.ws.send_str(msg)
|
||||
|
||||
def get(self, key):
|
||||
returnl loads(dict(self.db['_kv'].get(key=key)['value']))
|
||||
|
||||
def set(self, key, value):
|
||||
return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])
|
||||
|
||||
|
||||
|
||||
async def handle(self, request):
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
self.ws = ws
|
||||
app = request.app
|
||||
app['broadcast_clients'].append(ws)
|
||||
|
||||
async for msg in ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
print(msg.data)
|
||||
for client in app['broadcast_clients'] if not client == ws:
|
||||
await client.send_str(msg.data)
|
||||
elif msg.type == aiohttp.WSMsgType.ERROR:
|
||||
print('ws connection closed with exception %s' % ws.exception())
|
||||
app['broadcast_clients'].remove(ws)
|
||||
return ws
|
||||
|
||||
|
||||
app = web.Application()
|
||||
view = DatasetWebSocketView()
|
||||
app['broadcast_clients'] = []
|
||||
app.router.add_get('/db', view.handle)
|
||||
app.router.add_get('/broadcast', sync_view.handle)
|
||||
|
||||
@@ -14,10 +14,13 @@ class Cache:
|
||||
self.cache = {}
|
||||
self.max_items = max_items
|
||||
self.stats = {}
|
||||
self.enabled = False
|
||||
self.lru = []
|
||||
self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4
|
||||
|
||||
async def get(self, args):
|
||||
if not self.enabled:
|
||||
return None
|
||||
await self.update_stat(args, "get")
|
||||
try:
|
||||
self.lru.pop(self.lru.index(args))
|
||||
@@ -76,6 +79,8 @@ class Cache:
|
||||
)
|
||||
|
||||
async def set(self, args, result):
|
||||
if not self.enabled:
|
||||
return
|
||||
is_new = args not in self.cache
|
||||
self.cache[args] = result
|
||||
await self.update_stat(args, "set")
|
||||
@@ -94,6 +99,8 @@ class Cache:
|
||||
# print(f"Cache store! {len(self.lru)} items. New version:", self.version, flush=True)
|
||||
|
||||
async def delete(self, args):
|
||||
if not self.enabled:
|
||||
return
|
||||
await self.update_stat(args, "delete")
|
||||
if args in self.cache:
|
||||
try:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
DEFAULT_LIMIT = 30
|
||||
import typing
|
||||
|
||||
import asyncio
|
||||
from snek.system.model import BaseModel
|
||||
|
||||
|
||||
@@ -19,6 +19,13 @@ class BaseMapper:
|
||||
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)
|
||||
|
||||
@@ -4,6 +4,9 @@ from types import SimpleNamespace
|
||||
|
||||
from app.cache import time_cache_async
|
||||
from mistune import HTMLRenderer, Markdown
|
||||
from mistune.plugins.formatting import strikethrough
|
||||
from mistune.plugins.spoiler import spoiler
|
||||
from mistune.plugins.url import url
|
||||
from pygments import highlight
|
||||
from pygments.formatters import html
|
||||
from pygments.lexers import get_lexer_by_name
|
||||
@@ -14,6 +17,8 @@ class MarkdownRenderer(HTMLRenderer):
|
||||
_allow_harmful_protocols = True
|
||||
|
||||
def __init__(self, app, template):
|
||||
super().__init__(False, True)
|
||||
|
||||
self.template = template
|
||||
|
||||
self.app = app
|
||||
@@ -46,10 +51,18 @@ class MarkdownRenderer(HTMLRenderer):
|
||||
markdown = Markdown(renderer=renderer)
|
||||
return markdown(markdown_string)
|
||||
|
||||
# def image(self, text: str, url: str, title: Optional[str] = None) -> str:
|
||||
# src = self.safe_url(url)
|
||||
# alt = escape(striptags(text))
|
||||
# s = '<picture><srcset srcset="' + src +'" /><img src="' + src + '" alt="' + alt + '"'
|
||||
# if title:
|
||||
# s += ' title="' + safe_entity(title) + '"'
|
||||
# return s + ' /></picture>'
|
||||
|
||||
|
||||
def render_markdown_sync(app, markdown_string):
|
||||
renderer = MarkdownRenderer(app, None)
|
||||
markdown = Markdown(renderer=renderer)
|
||||
markdown = Markdown(renderer=renderer, plugins=[url, strikethrough, spoiler])
|
||||
return markdown(markdown_string)
|
||||
|
||||
|
||||
|
||||
+248
-26
@@ -1,7 +1,11 @@
|
||||
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
|
||||
@@ -89,37 +93,104 @@ 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)
|
||||
|
||||
|
||||
def embed_image(text):
|
||||
soup = BeautifulSoup(text, "html.parser")
|
||||
for element in soup.find_all("a"):
|
||||
for extension in [
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".svg",
|
||||
".bmp",
|
||||
".tiff",
|
||||
".ico",
|
||||
".heif",
|
||||
]:
|
||||
if extension in element.attrs["href"].lower():
|
||||
embed_template = f'<img src="{element.attrs["href"]}" title="{element.attrs["href"]}" alt="{element.attrs["href"]}" />'
|
||||
element.replace_with(BeautifulSoup(embed_template, "html.parser"))
|
||||
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",
|
||||
]
|
||||
)
|
||||
):
|
||||
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("/"):
|
||||
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"]}&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)
|
||||
|
||||
|
||||
@@ -162,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"}
|
||||
|
||||
@@ -205,6 +423,11 @@ class LinkifyExtension(Extension):
|
||||
result = embed_media(result)
|
||||
result = embed_image(result)
|
||||
result = embed_youtube(result)
|
||||
|
||||
result = enrich_image_rendering(result)
|
||||
|
||||
result = embed_url(result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -224,7 +447,6 @@ class PythonExtension(Extension):
|
||||
).set_lineno(line_number)
|
||||
|
||||
def _to_html(self, md_file, caller):
|
||||
|
||||
def fn(source):
|
||||
import subprocess
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
class WebSocketClient:
|
||||
def __init__(self, hostname, port):
|
||||
self.buffer = b''
|
||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.hostname = hostname
|
||||
self.port = port
|
||||
self.connect()
|
||||
|
||||
def __getattr__(self, method, *args, **kwargs):
|
||||
if method in self.__dict__.keys():
|
||||
return self.__dict__[method]
|
||||
def call(*args, **kwargs):
|
||||
self.write(json.dumps({'method': method, 'args': args, 'kwargs': kwargs}))
|
||||
return json.loads(self.read())
|
||||
return call
|
||||
|
||||
def connect(self):
|
||||
self.socket.connect((self.hostname, self.port))
|
||||
key = base64.b64encode(b'1234123412341234').decode('utf-8')
|
||||
handshake = (
|
||||
f"GET /db HTTP/1.1\r\n"
|
||||
f"Host: localhost:3131\r\n"
|
||||
f"Upgrade: websocket\r\n"
|
||||
f"Connection: Upgrade\r\n"
|
||||
f"Sec-WebSocket-Key: {key}\r\n"
|
||||
f"Sec-WebSocket-Version: 13\r\n\r\n"
|
||||
)
|
||||
self.socket.sendall(handshake.encode('utf-8'))
|
||||
response = self.read_until(b'\r\n\r\n')
|
||||
if b'101 Switching Protocols' not in response:
|
||||
raise Exception("Failed to connect to WebSocket")
|
||||
|
||||
def write(self, message):
|
||||
message_bytes = message.encode('utf-8')
|
||||
length = len(message_bytes)
|
||||
if length <= 125:
|
||||
self.socket.sendall(b'\x81' + bytes([length]) + message_bytes)
|
||||
elif length >= 126 and length <= 65535:
|
||||
self.socket.sendall(b'\x81' + bytes([126]) + length.to_bytes(2, 'big') + message_bytes)
|
||||
else:
|
||||
self.socket.sendall(b'\x81' + bytes([127]) + length.to_bytes(8, 'big') + message_bytes)
|
||||
|
||||
|
||||
def read_until(self, delimiter):
|
||||
while True:
|
||||
find_pos = self.buffer.find(delimiter)
|
||||
if find_pos != -1:
|
||||
data = self.buffer[:find_pos+4]
|
||||
self.buffer = self.buffer[find_pos+4:]
|
||||
return data
|
||||
|
||||
chunk = self.socket.recv(1024)
|
||||
if not chunk:
|
||||
return None
|
||||
self.buffer += chunk
|
||||
|
||||
def read_exactly(self, length):
|
||||
while len(self.buffer) < length:
|
||||
chunk = self.socket.recv(length - len(self.buffer))
|
||||
if not chunk:
|
||||
return None
|
||||
self.buffer += chunk
|
||||
response = self.buffer[: length]
|
||||
self.buffer = self.buffer[length:]
|
||||
return response
|
||||
|
||||
def read(self):
|
||||
frame = None
|
||||
frame = self.read_exactly(2)
|
||||
length = frame[1] & 127
|
||||
if length == 126:
|
||||
length = int.from_bytes(self.read_exactly(2), 'big')
|
||||
elif length == 127:
|
||||
length = int.from_bytes(self.read_exactly(8), 'big')
|
||||
message = self.read_exactly(length)
|
||||
return message
|
||||
|
||||
def close(self):
|
||||
self.socket.close()
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -16,6 +16,12 @@
|
||||
<script src="/html-frame.js" type="module"></script>
|
||||
<script src="/app.js" type="module"></script>
|
||||
<script src="/file-manager.js" type="module"></script>
|
||||
<script src="/user-list.js"></script>
|
||||
<script src="/message-list.js" type="module"></script>
|
||||
<script src="/chat-input.js" type="module"></script>
|
||||
<link rel="stylesheet" href="/sandbox.css">
|
||||
<link rel="stylesheet" href="/user-list.css">
|
||||
|
||||
<link rel="stylesheet" href="/base.css">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
@@ -44,10 +50,11 @@
|
||||
</nav>
|
||||
|
||||
</header>
|
||||
<main>
|
||||
|
||||
{% block sidebar %}
|
||||
{% include "sidebar_channels.html" %}
|
||||
{% endblock %}
|
||||
<main>
|
||||
|
||||
{% block main %}
|
||||
<chat-window class="chat-area"></chat-window>
|
||||
@@ -73,5 +80,6 @@ let installPrompt = null
|
||||
|
||||
;
|
||||
</script>
|
||||
{% include "sandbox.html" %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
|
||||
<dialog id="help-dialog">
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-box">
|
||||
<div class="dialog-title"><h2>Help</h2></div>
|
||||
<div class="dialog-content">
|
||||
<help-command-list></help-command-list>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button class="dialog-button primary">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
class HelpCommandListComponent extends HTMLElement {
|
||||
helpCommands = [
|
||||
{
|
||||
command: "/help",
|
||||
description: "Show this help message"
|
||||
},
|
||||
{
|
||||
command: "/online",
|
||||
description: "Show online users"
|
||||
},
|
||||
{
|
||||
command: "/clear",
|
||||
description: "Clear the board"
|
||||
},
|
||||
{
|
||||
command: "/img-gen",
|
||||
description: "Generate an image"
|
||||
},
|
||||
{
|
||||
command: "/live",
|
||||
description: "Toggle live typing mode"
|
||||
}
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.innerHTML = this.helpCommands
|
||||
.map(cmd => `<div><h2>${cmd.command}</h2><div>${cmd.description}</div></div>`)
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('help-command-list', HelpCommandListComponent);
|
||||
|
||||
const helpDialog = document.getElementById("help-dialog");
|
||||
const helpCloseButton = helpDialog.querySelector('.dialog-button.primary');
|
||||
function showHelp() {
|
||||
helpDialog.showModal();
|
||||
|
||||
helpCloseButton.focus();
|
||||
}
|
||||
helpCloseButton.addEventListener('click', () => {
|
||||
helpDialog.close();
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,28 @@
|
||||
|
||||
<dialog id="online-users">
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-box">
|
||||
<div class="dialog-title"><h2>Online Users</h2></div>
|
||||
<div class="dialog-content"><user-list></user-list></div>
|
||||
<div class="dialog-actions">
|
||||
<button class="dialog-button primary">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
const onlineUsersDialog = document.getElementById("online-users");
|
||||
const closeButton = onlineUsersDialog.querySelector('.dialog-button.primary');
|
||||
|
||||
closeButton.addEventListener('click', () => {
|
||||
onlineUsersDialog.close();
|
||||
});
|
||||
|
||||
async function showOnline() {
|
||||
const users = await app.rpc.getOnlineUsers('{{ channel.uid.value }}');
|
||||
onlineUsersDialog.querySelector('user-list').data = users;
|
||||
onlineUsersDialog.showModal();
|
||||
closeButton.focus();
|
||||
}
|
||||
</script>
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block header_text %}Drive{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="container">
|
||||
<div class="container" style="overflow-y: auto;">
|
||||
<file-manager path="{{path}}" style="flex: 1"></file-manager>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
+312
-24
@@ -1,31 +1,319 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
background-color: white; /* Ensure the iframe has a white background */
|
||||
}
|
||||
|
||||
|
||||
/* Base reset */
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI',sans-serif;
|
||||
background: #000;
|
||||
color: #eee;
|
||||
line-height:1.5;
|
||||
}
|
||||
a { color: #7ef; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* Container */
|
||||
.container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }
|
||||
|
||||
/* Hero */
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: 3rem;
|
||||
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.hero p {
|
||||
font-size: 1.2rem;
|
||||
margin: 1rem 0 2rem;
|
||||
}
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: .75rem 1.5rem;
|
||||
margin: .5rem;
|
||||
background: #0fa;
|
||||
color: #111;
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
transition: background .2s;
|
||||
}
|
||||
.btn:hover { background: #7ef; }
|
||||
|
||||
/* Features grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.card {
|
||||
background: #1a1a1a;
|
||||
border-radius: 6px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.6);
|
||||
}
|
||||
.card h3 {
|
||||
margin-bottom: .75rem;
|
||||
color: #7ef;
|
||||
}
|
||||
.card ul {
|
||||
list-style: disc inside;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
text-align: center;
|
||||
font-size: .9rem;
|
||||
padding: 2rem 0;
|
||||
color: #888;
|
||||
}
|
||||
footer code {
|
||||
background: #222;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
color: #7ef;
|
||||
}
|
||||
|
||||
/* Mobile tweaks */
|
||||
@media (max-width: 480px) {
|
||||
.hero h1 { font-size: 2.4rem; }
|
||||
.btn { width: 100%; box-sizing: border-box; text-align:center; }
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Snek chat by Molodetz</title>
|
||||
<link rel="stylesheet" href="generic-form.css">
|
||||
<link rel="stylesheet" href="base.css">
|
||||
<style>
|
||||
.registration-container {
|
||||
max-width: 300px;
|
||||
margin: 20px auto;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
<script src="/fancy-button.js"></script>
|
||||
<script defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea"></script>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Snek – The Ultimate Web Community</title>
|
||||
<style>
|
||||
/* Base reset */
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI',sans-serif;
|
||||
background: #111;
|
||||
color: #eee;
|
||||
line-height:1.5;
|
||||
}
|
||||
a { color: #7ef; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* Container */
|
||||
.container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }
|
||||
|
||||
/* Hero */
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: 3rem;
|
||||
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.hero p {
|
||||
font-size: 1.2rem;
|
||||
margin: 1rem 0 2rem;
|
||||
}
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: .75rem 1.5rem;
|
||||
margin: .5rem;
|
||||
background: #0fa;
|
||||
color: #111;
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
transition: background .2s;
|
||||
}
|
||||
.btn:hover { background: #7ef; }
|
||||
|
||||
/* Features grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.card {
|
||||
background: #1a1a1a;
|
||||
border-radius: 6px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.6);
|
||||
}
|
||||
.card h3 {
|
||||
margin-bottom: .75rem;
|
||||
color: #7ef;
|
||||
}
|
||||
.card ul {
|
||||
list-style: disc inside;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
text-align: center;
|
||||
font-size: .9rem;
|
||||
padding: 2rem 0;
|
||||
color: #888;
|
||||
}
|
||||
footer code {
|
||||
background: #222;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
color: #7ef;
|
||||
}
|
||||
|
||||
/* Mobile tweaks */
|
||||
@media (max-width: 480px) {
|
||||
.hero h1 { font-size: 2.4rem; }
|
||||
.btn { width: 100%; box-sizing: border-box; text-align:center; }
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/sandbox.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="registration-container">
|
||||
|
||||
<header class="container hero">
|
||||
<h1>Snek</h1>
|
||||
<p style="padding-bottom:20px">Rocket Chat got bloated, too commercialized,
|
||||
So Snek came through, lean and optimized.</p>
|
||||
<div style="text-align: center;">
|
||||
<fancy-button url="/login.html" text="Login"></fancy-button>
|
||||
<span style="padding:10px;">OR</span>
|
||||
<fancy-button url="/register.html" text="Register"></fancy-button>
|
||||
</div>
|
||||
</div>
|
||||
<p>The Ultimate Web Community for Devs, Testers & AI Enthusiasts</p>
|
||||
<a href="/login.html" class="btn">Login</a>
|
||||
<a href="/register.html" class="btn">Register</a>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
|
||||
<section id="features" class="grid">
|
||||
<div class="card">
|
||||
<h3>File Sharing</h3>
|
||||
<ul>
|
||||
<li>SFTP storage with your Snek credentials</li>
|
||||
<li>WebDAV support – same login</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Git Repositories</h3>
|
||||
<ul>
|
||||
<li>Configure repos for any official Git client</li>
|
||||
<li>Instant setup, push & pull</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>AI Powerhouse</h3>
|
||||
<ul>
|
||||
<li>Chat with free & commercial AIs</li>
|
||||
<li>Generate AI-powered images</li>
|
||||
<li>Build your own AI bots in <5 minutes (copy/paste example)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Dev & Terminal</h3>
|
||||
<ul>
|
||||
<li>Ubuntu web terminal in-browser</li>
|
||||
<li>Full profile & permissions management</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Chat & Media</h3>
|
||||
<ul>
|
||||
<li>Upload any file type in chat</li>
|
||||
<li>Rich media support (audio, video, images…)</li>
|
||||
<li>Direct messaging with other users</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Privacy & Community</h3>
|
||||
<ul>
|
||||
<li>No logging—never even your IP</li>
|
||||
<li>No email required to sign up</li>
|
||||
<li>Multi-national, open community</li>
|
||||
<li>Hacking encouraged!</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Customization & Deployment</h3>
|
||||
<ul>
|
||||
<li>Full layout & theme customization</li>
|
||||
<li>Install as a PWA on your phone</li>
|
||||
<li>Optionally self-host: <code>pip install snek</code>, zero config</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="signup" style="text-align:center; margin:4rem 0;">
|
||||
<h2>Ready to join?</h2>
|
||||
<p>No email. No logs. Just sign up, pick a username, and dive in!</p>
|
||||
<a href="/register" class="btn">Sign Up Now</a>
|
||||
</section>
|
||||
|
||||
<section id="selfhost" style="text-align:center; margin-bottom:4rem;">
|
||||
<h2>Self-Host in Seconds</h2>
|
||||
<p>Just run:</p>
|
||||
<pre style="display:inline-block; background:#222; padding:1rem; border-radius:4px; color:#7ef;">
|
||||
pip install git+https://retoor.molodetz.nl/retoor/snek.git
|
||||
snek serve
|
||||
</pre>
|
||||
<p>No configuration required—it's that simple.</p>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2025 Snek – Join our global community of developers, testers & AI enthusiasts.</p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
|
||||
// number of stars you want
|
||||
const STAR_COUNT = 200;
|
||||
const body = document.body;
|
||||
|
||||
for (let i = 0; i < STAR_COUNT; i++) {
|
||||
const star = document.createElement('div');
|
||||
star.classList.add('star');
|
||||
|
||||
// random position within the viewport
|
||||
star.style.left = Math.random() * 100 + '%';
|
||||
star.style.top = Math.random() * 100 + '%';
|
||||
|
||||
// random size (optional)
|
||||
const size = Math.random() * 2 + 1; // between 1px and 3px
|
||||
star.style.width = size + 'px';
|
||||
star.style.height = size + 'px';
|
||||
|
||||
// random animation timing for natural flicker
|
||||
const duration = Math.random() * 3 + 2; // 2s–5s
|
||||
const delay = Math.random() * 5; // 0s–5s
|
||||
star.style.animationDuration = duration + 's';
|
||||
star.style.animationDelay = delay + 's';
|
||||
|
||||
body.appendChild(star);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +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";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
_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";
|
||||
}
|
||||
|
||||
_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);
|
||||
}
|
||||
}
|
||||
|
||||
_randomizeStar(star) {
|
||||
star.style.left = `${Math.random() * 100}%`;
|
||||
star.style.top = `${Math.random() * 100}%`;
|
||||
star.style.width = `${Math.random() * 2 + 1}px`;
|
||||
star.style.height = `${Math.random() * 2 + 1}px`;
|
||||
star.style.animationDelay = `${Math.random() * 2}s`;
|
||||
star.style.position = "absolute";
|
||||
star.style.transition = "top 1s ease, left 1s ease, opacity 1s ease";
|
||||
|
||||
star.shuffle = () => this._randomizeStar(star);
|
||||
star.position = this._getStarPosition(star);
|
||||
}
|
||||
|
||||
_placeStar(star) {
|
||||
const pos = star.position;
|
||||
if (!this.positionMap[pos]) this.positionMap[pos] = [];
|
||||
this.positionMap[pos].push(star);
|
||||
}
|
||||
|
||||
shuffleAll(duration = 1000) {
|
||||
this.stars.forEach(star => {
|
||||
star.style.transition = `top ${duration}ms ease, left ${duration}ms ease, opacity ${duration}ms ease`;
|
||||
star.style.filter = "drop-shadow(0 0 2px white)";
|
||||
const left = Math.random() * 100;
|
||||
const top = Math.random() * 100;
|
||||
star.style.left = `${left}%`;
|
||||
star.style.top = `${top}%`;
|
||||
|
||||
setTimeout(() => {
|
||||
star.style.filter = "";
|
||||
star.position = this._getStarPosition(star);
|
||||
}, duration);
|
||||
});
|
||||
}
|
||||
|
||||
glowColor(tempColor, duration = 2500) {
|
||||
const lighten = (hex, percent) => {
|
||||
const num = parseInt(hex.replace("#", ""), 16);
|
||||
const r = Math.min(255, (num >> 16) + Math.round(255 * percent / 100));
|
||||
const g = Math.min(255, ((num >> 8) & 0xff) + Math.round(255 * percent / 100));
|
||||
const b = Math.min(255, (num & 0xff) + Math.round(255 * percent / 100));
|
||||
return `#${(1 << 24 | r << 16 | g << 8 | b).toString(16).slice(1)}`;
|
||||
};
|
||||
|
||||
const glow = lighten(tempColor, 10);
|
||||
document.documentElement.style.setProperty("--star-color", glow);
|
||||
setTimeout(() => {
|
||||
document.documentElement.style.setProperty("--star-color", this.originalColor);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
showNotify(text, { duration = 3000, color = "white", fontSize = "1.2em" } = {}) {
|
||||
// Create container if needed
|
||||
if (!this._notifyContainer) {
|
||||
this._notifyContainer = document.createElement("div");
|
||||
this._notifyContainer.className = "star-notify-container";
|
||||
document.body.appendChild(this._notifyContainer);
|
||||
}
|
||||
const messages = document.querySelectorAll('.star-notify');
|
||||
|
||||
const count = Array.from(messages).filter(el => el.textContent.trim() === text).length;
|
||||
|
||||
if (count) return;
|
||||
const note = document.createElement("div");
|
||||
note.className = "star-notify";
|
||||
note.textContent = text;
|
||||
note.style.color = color;
|
||||
note.style.fontSize = fontSize;
|
||||
|
||||
this._notifyContainer.appendChild(note);
|
||||
|
||||
// Trigger animation
|
||||
setTimeout(() => {
|
||||
note.style.opacity = 1;
|
||||
note.style.transform = "translateY(0)";
|
||||
}, 10);
|
||||
|
||||
// Remove after duration
|
||||
setTimeout(() => {
|
||||
note.style.opacity = 0;
|
||||
note.style.transform = "translateY(-10px)";
|
||||
setTimeout(() => note.remove(), 500);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends 'settings/index.html' %}
|
||||
|
||||
{% block header_text %}<h1><i class="fa-solid fa-plus"></i> Create Container</h1>{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
{% include 'settings/containers/form.html' %}
|
||||
<div class="container">
|
||||
<form action="/settings/containers/create.html" method="post">
|
||||
<div>
|
||||
<label for="name"><i class="fa-solid fa-box"></i> Name</label>
|
||||
<input type="text" id="name" name="name" required placeholder="Container name">
|
||||
</div>
|
||||
<div>
|
||||
<label for="status"><i class="fa-solid fa-info-circle"></i> Status</label>
|
||||
<input type="text" id="status" name="status" required placeholder="Container status">
|
||||
</div>
|
||||
<div>
|
||||
<label for="resources"><i class="fa-solid fa-memory"></i> Resources</label>
|
||||
<input type="text" id="resources" name="resources" placeholder="Resource details">
|
||||
</div>
|
||||
<div>
|
||||
<label for="user_uid"><i class="fa-solid fa-user"></i> User UID</label>
|
||||
<input type="text" id="user_uid" name="user_uid" placeholder="User UID">
|
||||
</div>
|
||||
<div>
|
||||
<label for="path"><i class="fa-solid fa-folder"></i> Path</label>
|
||||
<input type="text" id="path" name="path" placeholder="Container path">
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<input type="checkbox" name="readonly" value="1">
|
||||
<i class="fa-solid fa-lock"></i> Readonly
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit"><i class="fa-solid fa-plus"></i> Create</button>
|
||||
<button onclick="history.back()" class="cancel"><i class="fa-solid fa-arrow-left"></i>Cancel</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends 'settings/index.html' %}
|
||||
|
||||
{% block header_text %}<h1><i class="fa-solid fa-trash-can"></i> Delete Container</h1>{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="container">
|
||||
<p>Are you sure you want to <strong>delete</strong> the following container?</p>
|
||||
<div class="container-name"><i class="fa-solid fa-box"></i> {{ container.name }}</div>
|
||||
<form method="post" style="margin-top:1.5rem;">
|
||||
<input type="hidden" name="id" value="{{ container.id }}">
|
||||
<div class="actions">
|
||||
<button type="submit"><i class="fa-solid fa-trash"></i> Yes, delete</button>
|
||||
<button type="button" onclick="history.back()" class="cancel"><i class="fa-solid fa-arrow-left"></i> Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
|
||||
<style>
|
||||
form {
|
||||
padding: 2rem;
|
||||
border-radius: 10px;
|
||||
div {
|
||||
padding: 10px;
|
||||
padding-bottom: 15px
|
||||
}
|
||||
}
|
||||
label { font-weight: bold; display: flex; align-items: center; gap: 0.5rem;}
|
||||
button {
|
||||
background: #0d6efd; color: #fff;
|
||||
border: none; border-radius: 5px; padding: 0.6rem 1rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
}
|
||||
.cancel {
|
||||
background: #6c757d;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.container { max-width: 98vw; }
|
||||
form { padding: 1rem; }
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
{% extends 'settings/index.html' %}
|
||||
|
||||
{% block header_text %}<h1><i class="fa-solid fa-database"></i> Containers</h1>{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Containers - List</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
|
||||
<style>
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.container-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.container-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.container-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
}
|
||||
.container-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.container-row { flex-direction: column; align-items: stretch; }
|
||||
.actions { justify-content: flex-start; }
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
button, a.button {
|
||||
background: #198754; color: #fff; border: none; border-radius: 5px;
|
||||
padding: 0.4rem 0.8rem; text-decoration: none; cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
}
|
||||
.button.delete { background: #dc3545; }
|
||||
.button.edit { background: #0d6efd; }
|
||||
.button.clone { background: #6c757d; }
|
||||
.button.browse { background: #ffc107; color: #212529; }
|
||||
.button.create { background: #20c997; margin-left: 0.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="topbar">
|
||||
<a class="button create" href="/settings/containers/create.html">
|
||||
<i class="fa-solid fa-plus"></i> New Container
|
||||
</a>
|
||||
</div>
|
||||
<section class="container-list">
|
||||
{% for container in containers %}
|
||||
<div class="container-row">
|
||||
<div class="container-info">
|
||||
<span class="container-name"><i class="fa-solid fa-box"></i> {{ container.name }}</span>
|
||||
<span title="Status"><i class="fa-solid fa-info-circle"></i> {{ container.status }}</span>
|
||||
<span title="Readonly">
|
||||
<i class="fa-solid {% if container.readonly %}fa-lock{% else %}fa-lock-open{% endif %}"></i>
|
||||
{% if container.readonly %}Readonly{% else %}Writable{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="button edit" href="/settings/containers/container/{{ container.id }}/update.html">
|
||||
<i class="fa-solid fa-pen"></i> Edit
|
||||
</a>
|
||||
<a class="button delete" href="/settings/containers/container/{{ container.id }}/delete.html">
|
||||
<i class="fa-solid fa-trash"></i> Delete
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,40 @@
|
||||
{% extends "settings/index.html" %}
|
||||
|
||||
{% block header_text %}<h1><i class="fa-solid fa-pen"></i> Update Container</h1>{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
{% include "settings/containers/form.html" %}
|
||||
<div class="container">
|
||||
<form method="post">
|
||||
<input type="hidden" name="id" value="{{ container.id }}">
|
||||
<div>
|
||||
<label for="name"><i class="fa-solid fa-box"></i> Name</label>
|
||||
<input type="text" id="name" name="name" value="{{ container.name }}" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="status"><i class="fa-solid fa-info-circle"></i> Status</label>
|
||||
<input type="text" id="status" name="status" value="{{ container.status }}" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="resources"><i class="fa-solid fa-memory"></i> Resources</label>
|
||||
<input type="text" id="resources" name="resources" value="{{ container.resources }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="user_uid"><i class="fa-solid fa-user"></i> User UID</label>
|
||||
<input type="text" id="user_uid" name="user_uid" value="{{ container.user_uid }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="path"><i class="fa-solid fa-folder"></i> Path</label>
|
||||
<input type="text" id="path" name="path" value="{{ container.path }}">
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<input type="checkbox" name="readonly" value="1" {% if container.readonly %}checked{% endif %}>
|
||||
<i class="fa-solid fa-lock"></i> Readonly
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit"><i class="fa-solid fa-pen"></i> Update</button>
|
||||
<button onclick="history.back()" class="cancel"><i class="fa-solid fa-arrow-left"></i>Cancel</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+240
-266
@@ -4,317 +4,291 @@
|
||||
|
||||
{% block main %}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<section class="chat-area">
|
||||
<div class="chat-messages">
|
||||
<message-list class="chat-messages">
|
||||
{% for message in messages %}
|
||||
{% autoescape false %}
|
||||
{{ message.html }}
|
||||
{% endautoescape %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="chat-input">
|
||||
<textarea placeholder="Type a message..." rows="2"></textarea>
|
||||
<upload-button channel="{{ channel.uid.value }}"></upload-button>
|
||||
</div>
|
||||
<div class="message-list-bottom"></div>
|
||||
</message-list>
|
||||
<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 }}";
|
||||
import { Schedule } from "/schedule.js";
|
||||
|
||||
function getInputField(){
|
||||
return document.querySelector("textarea")
|
||||
}
|
||||
// --- 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 }}";
|
||||
|
||||
function initInputField(textBox) {
|
||||
textBox.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const message = e.target.value.trim();
|
||||
if (message) {
|
||||
app.rpc.sendMessage(channelUid, message);
|
||||
e.target.value = '';
|
||||
}
|
||||
}else{
|
||||
app.rpc.set_typing(channelUid)
|
||||
}
|
||||
// --- 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
});
|
||||
document.querySelector("upload-button").addEventListener("upload",function(e){
|
||||
getInputField().focus();
|
||||
})
|
||||
document.querySelector("upload-button").addEventListener("uploaded",function(e){
|
||||
e.detail.files.forEach((file)=>{
|
||||
app.rpc.sendMessage(channelUid,``)
|
||||
})
|
||||
})
|
||||
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 = document.querySelector("upload-button");
|
||||
const input = uploadButton.shadowRoot.querySelector('.file-input')
|
||||
input.files = dt.files;
|
||||
|
||||
await uploadButton.uploadFiles();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to read clipboard contents: ", error);
|
||||
}
|
||||
});
|
||||
|
||||
function triggerGlow(uid) {
|
||||
document.querySelectorAll(".avatar").forEach((el)=>{
|
||||
const div = el.closest('a');
|
||||
if(el.href.indexOf(uid)!=-1){
|
||||
el.classList.add('glow')
|
||||
let originalColor = el.style.backgroundColor
|
||||
//console.error(originalColor)
|
||||
//el.style.backgroundColor = 'black'
|
||||
setTimeout(()=>{
|
||||
// el.style.backgroundColor = originalColor
|
||||
// console.error(el.style.backgroundColor)
|
||||
el.classList.remove('glow')
|
||||
},1200)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
app.ws.addEventListener("set_typing",(data)=>{
|
||||
triggerGlow(data.data.user_uid)
|
||||
|
||||
})
|
||||
|
||||
|
||||
const chatInput = document.querySelector(".chat-area")
|
||||
chatInput.addEventListener("drop", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const dt = e.dataTransfer;
|
||||
if (dt.items.length > 0) {
|
||||
const uploadButton = document.querySelector("upload-button");
|
||||
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";
|
||||
})
|
||||
|
||||
textBox.focus();
|
||||
firstMessage.parentNode.insertBefore(frag, firstMessage);
|
||||
updateLayout(false);
|
||||
}
|
||||
isLoadingExtra = false;
|
||||
}
|
||||
messagesContainer.addEventListener("scroll", throttle(loadExtra, 200));
|
||||
|
||||
function replyMessage(message) {
|
||||
const field = getInputField()
|
||||
field.value = "```markdown\n> " + (message || '') + "\n```\n";
|
||||
field.focus();
|
||||
}
|
||||
|
||||
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" });
|
||||
const inputBox = document.querySelector(".chat-input");
|
||||
inputBox.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) => {
|
||||
if (data.channel_uid !== channelUid) {
|
||||
if(!isMentionForSomeoneElse(data.message)){
|
||||
channelSidebar.notify(data);
|
||||
app.playSound("messageOtherChannel");
|
||||
}
|
||||
|
||||
return;
|
||||
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.username !== "{{ user.username.value }}") {
|
||||
if(isMentionToMe(data.message)){
|
||||
app.playSound("mention");
|
||||
}else if (!isMentionForSomeoneElse(data.message)){
|
||||
} 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";
|
||||
});
|
||||
|
||||
// --- 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");
|
||||
}
|
||||
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 lastElement = messagesContainer.querySelector(".message-list-bottom");
|
||||
const doScrollDown = messagesContainer.isScrolledToBottom();
|
||||
|
||||
const messagesContainer = document.querySelector(".chat-messages");
|
||||
lastMessage = messagesContainer.querySelector(".message:last-child");
|
||||
const doScrollDownBecauseLastMessageIsVisible = !lastMessage || isElementVisible(lastMessage);
|
||||
const oldMessage = messagesContainer.querySelector(`.message[data-uid="${data.uid}"]`);
|
||||
if (oldMessage) {
|
||||
oldMessage.remove();
|
||||
}
|
||||
|
||||
const message = document.createElement("div");
|
||||
message.innerHTML = data.html;
|
||||
document.querySelector(".chat-messages").appendChild(message.firstChild);
|
||||
updateLayout(doScrollDownBecauseLastMessageIsVisible);
|
||||
setTimeout(() => {
|
||||
updateLayout(doScrollDownBecauseLastMessageIsVisible)
|
||||
}, 1000);
|
||||
app.rpc.markAsRead(channelUid);
|
||||
});
|
||||
const message = document.createElement("div");
|
||||
|
||||
let escPressed = false;
|
||||
let gPressCount = 0;
|
||||
let keyTimeout;
|
||||
document.addEventListener('keydown', function(event) {
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
escPressed = true;
|
||||
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 === '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(() => {
|
||||
|
||||
getInputField().focus();
|
||||
},500)
|
||||
|
||||
}
|
||||
}
|
||||
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(document.activeElement != getInputField()){
|
||||
}
|
||||
if (event.shiftKey && event.key === 'G') {
|
||||
if (!chatInputField.isActive()) {
|
||||
event.preventDefault();
|
||||
updateLayout(true);
|
||||
setTimeout(() => chatInputField.focus(), 500);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
updateLayout(true);
|
||||
setTimeout(() => {
|
||||
getInputField().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 (doScrollDown) messagesContainer.scrollToBottom?.();
|
||||
}
|
||||
|
||||
// --- Utility: check if element is visible ---
|
||||
|
||||
|
||||
// --- Utility: check if scrolled past half ---
|
||||
function isScrolledPastHalf() {
|
||||
let scrollTop = messagesContainer.scrollTop;
|
||||
let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;
|
||||
return scrollTop < scrollableHeight / 2;
|
||||
}
|
||||
|
||||
// --- Initial layout update ---
|
||||
updateLayout(true);
|
||||
|
||||
|
||||
|
||||
initInputField(getInputField());
|
||||
updateLayout(true);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
+121
-14
@@ -1,26 +1,107 @@
|
||||
import asyncio
|
||||
import mimetypes
|
||||
from os.path import isfile
|
||||
|
||||
from PIL import Image
|
||||
import pillow_heif.HeifImagePlugin
|
||||
|
||||
from snek.system.view import BaseView
|
||||
import aiofiles
|
||||
from aiohttp import web
|
||||
import pathlib
|
||||
import urllib.parse
|
||||
|
||||
class ChannelAttachmentView(BaseView):
|
||||
|
||||
async def get(self):
|
||||
relative_path = self.request.match_info.get("relative_url")
|
||||
channel_attachment = await self.services.channel_attachment.get(relative_url=relative_path)
|
||||
response = web.FileResponse(channel_attachment["path"])
|
||||
response.headers["Cache-Control"] = f"public, max-age={1337*420}"
|
||||
response.headers["Content-Disposition"] = (
|
||||
f'attachment; filename="{channel_attachment["name"]}"'
|
||||
channel_attachment = await self.services.channel_attachment.get(
|
||||
relative_url=relative_path
|
||||
)
|
||||
return response
|
||||
|
||||
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")
|
||||
height = self.request.query.get("height")
|
||||
|
||||
if any([format_, width, height]) and original_format.startswith("image/"):
|
||||
if not format_:
|
||||
format_ = original_format.split("/")[1]
|
||||
with Image.open(channel_attachment["path"]) as image:
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
reason="OK",
|
||||
headers={
|
||||
"Cache-Control": f"public, max-age={1337 * 420}",
|
||||
"Content-Type": f"image/{format_}",
|
||||
"Content-Disposition": f'attachment; filename="{channel_attachment["name"]}"',
|
||||
},
|
||||
)
|
||||
|
||||
if width or height:
|
||||
width = min(int(width), image.size[0]) if width else None
|
||||
height = min(int(height), image.size[1]) if height else None
|
||||
|
||||
if width and height:
|
||||
smallest_ratio = max(
|
||||
image.size[0] / int(width), image.size[1] / int(height)
|
||||
)
|
||||
image.thumbnail(
|
||||
(
|
||||
int(image.size[0] / smallest_ratio),
|
||||
int(image.size[1] / smallest_ratio),
|
||||
)
|
||||
)
|
||||
elif width:
|
||||
image.thumbnail(
|
||||
(
|
||||
int(width),
|
||||
int(image.size[1] * image.size[0] / int(width)),
|
||||
)
|
||||
)
|
||||
elif height:
|
||||
image.thumbnail(
|
||||
(
|
||||
int(image.size[0] * image.size[1] / int(height)),
|
||||
int(height),
|
||||
)
|
||||
)
|
||||
|
||||
await response.prepare(self.request)
|
||||
# response.write is async but image.save is not
|
||||
naughty_steal = response.write
|
||||
tasks = []
|
||||
def sync_writer(*args, **kwargs):
|
||||
tasks.append(naughty_steal(*args, **kwargs))
|
||||
return True
|
||||
|
||||
setattr(response, "write", sync_writer)
|
||||
|
||||
image.save(response, format=format_, quality=100, optimize=True, save_all=True)
|
||||
|
||||
setattr(response, "write", naughty_steal)
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
return response
|
||||
else:
|
||||
response = web.FileResponse(channel_attachment["path"])
|
||||
response.headers["Cache-Control"] = f"public, max-age={1337 * 420}"
|
||||
response.headers["Content-Disposition"] = (
|
||||
f'attachment; filename="{channel_attachment["name"]}"'
|
||||
)
|
||||
response.headers["Content-Type"] = original_format
|
||||
return response
|
||||
|
||||
async def post(self):
|
||||
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
user_uid = self.request.session.get("uid")
|
||||
|
||||
channel_member = await self.services.channel_member.get(user_uid=user_uid, channel_uid=channel_uid,deleted_at=None,is_banned=False)
|
||||
channel_member = await self.services.channel_member.get(
|
||||
user_uid=user_uid, channel_uid=channel_uid, deleted_at=None, is_banned=False
|
||||
)
|
||||
|
||||
if not channel_member:
|
||||
return web.HTTPNotFound()
|
||||
@@ -29,25 +110,51 @@ class ChannelAttachmentView(BaseView):
|
||||
attachments = []
|
||||
|
||||
while field := await reader.next():
|
||||
|
||||
filename = field.filename
|
||||
if not filename:
|
||||
continue
|
||||
|
||||
attachment = await self.services.channel_attachment.create_file(
|
||||
channel_uid=channel_uid, name=filename,user_uid=user_uid
|
||||
channel_uid=channel_uid, name=filename, user_uid=user_uid
|
||||
)
|
||||
|
||||
attachments.append(attachment)
|
||||
pathlib.Path(attachment['path']).parent.mkdir(parents=True, exist_ok=True)
|
||||
async with aiofiles.open(attachment['path'], "wb") as f:
|
||||
pathlib.Path(attachment["path"]).parent.mkdir(parents=True, exist_ok=True)
|
||||
async with aiofiles.open(attachment["path"], "wb") as f:
|
||||
while chunk := await field.read_chunk():
|
||||
await f.write(chunk)
|
||||
|
||||
attachment_records = []
|
||||
for attachment in attachments:
|
||||
attachment_record = attachment.record
|
||||
attachment_record['relative_url'] = urllib.parse.quote(attachment_record['relative_url'])
|
||||
attachment_records.append(attachment_record)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"message": "Files uploaded successfully",
|
||||
"files": [attachment.record for attachment in attachments],
|
||||
"files": attachment_records,
|
||||
"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"]))
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import humanize
|
||||
from aiohttp import web
|
||||
from snek.system.view import BaseView
|
||||
import asyncio
|
||||
from git import Repo
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+124
-11
@@ -9,22 +9,42 @@
|
||||
|
||||
import json
|
||||
import traceback
|
||||
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
|
||||
from snek.system.model import now
|
||||
from snek.system.profiler import Profiler
|
||||
from snek.system.view import BaseView
|
||||
|
||||
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()
|
||||
@@ -33,9 +53,11 @@ class RPCView(BaseView):
|
||||
async def db_update(self, table_name, record):
|
||||
self._require_login()
|
||||
return await self.services.db.update(self.user_uid, table_name, record)
|
||||
async def set_typing(self,channel_uid):
|
||||
async def set_typing(self,channel_uid,color=None):
|
||||
self._require_login()
|
||||
user = await self.services.user.get(self.user_uid)
|
||||
if not color:
|
||||
color = user["color"]
|
||||
return await self.services.socket.broadcast(channel_uid, {
|
||||
"channel_uid": "293ecf12-08c9-494b-b423-48ba1a2d12c2",
|
||||
"event": "set_typing",
|
||||
@@ -44,7 +66,8 @@ class RPCView(BaseView):
|
||||
"user_uid": user['uid'],
|
||||
"username": user["username"],
|
||||
"nick": user["nick"],
|
||||
"channel_uid": channel_uid
|
||||
"channel_uid": channel_uid,
|
||||
"color": color
|
||||
}
|
||||
})
|
||||
|
||||
@@ -170,11 +193,56 @@ class RPCView(BaseView):
|
||||
)
|
||||
return channels
|
||||
|
||||
async def send_message(self, channel_uid, message):
|
||||
async def finalize_message(self, message_uid):
|
||||
self._require_login()
|
||||
await self.services.chat.send(self.user_uid, channel_uid, message)
|
||||
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)
|
||||
if message["user_uid"] != self.user_uid:
|
||||
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
|
||||
if not text:
|
||||
message['deleted_at'] = now()
|
||||
else:
|
||||
message['deleted_at'] = None
|
||||
|
||||
await self.services.channel_message.save(message)
|
||||
data = message.record
|
||||
data['text'] = message["message"]
|
||||
data['message_uid'] = message_uid
|
||||
|
||||
await self.services.socket.broadcast(message["channel_uid"], {
|
||||
"channel_uid": message["channel_uid"],
|
||||
"event": "update_message_text",
|
||||
"data": message.record
|
||||
})
|
||||
|
||||
return {"success": True}
|
||||
|
||||
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,is_final)
|
||||
|
||||
return message["uid"]
|
||||
|
||||
async def echo(self, *args):
|
||||
self._require_login()
|
||||
return args
|
||||
@@ -243,12 +311,14 @@ class RPCView(BaseView):
|
||||
except Exception as ex:
|
||||
result = {"exception": str(ex), "traceback": traceback.format_exc()}
|
||||
success = False
|
||||
logger.exception(ex)
|
||||
if result != "noresponse":
|
||||
await self._send_json(
|
||||
{"callId": call_id, "success": success, "data": result}
|
||||
)
|
||||
except Exception as ex:
|
||||
print(str(ex), flush=True)
|
||||
logger.exception(ex)
|
||||
await self._send_json(
|
||||
{"callId": call_id, "success": False, "data": str(ex)}
|
||||
)
|
||||
@@ -259,6 +329,19 @@ class RPCView(BaseView):
|
||||
async def get_online_users(self, channel_uid):
|
||||
self._require_login()
|
||||
|
||||
results = [
|
||||
record async for record in self.services.channel.get_recent_users(channel_uid)
|
||||
]
|
||||
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"],
|
||||
@@ -266,13 +349,9 @@ class RPCView(BaseView):
|
||||
"nick": record["nick"],
|
||||
"last_ping": record["last_ping"],
|
||||
}
|
||||
async for record in self.services.channel.get_online_users(channel_uid)
|
||||
async for record in self.services.channel.get_recent_users(channel_uid)
|
||||
]
|
||||
|
||||
async def echo(self, obj):
|
||||
await self.ws.send_json(obj)
|
||||
return "noresponse"
|
||||
|
||||
async def get_users(self, channel_uid):
|
||||
self._require_login()
|
||||
|
||||
@@ -286,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)
|
||||
@@ -293,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"):
|
||||
@@ -306,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:
|
||||
@@ -314,6 +426,7 @@ class RPCView(BaseView):
|
||||
await rpc(msg.json())
|
||||
except Exception as ex:
|
||||
print("Deleting socket", ex, flush=True)
|
||||
logger.exception(ex)
|
||||
await self.services.socket.delete(ws)
|
||||
break
|
||||
elif msg.type == web.WSMsgType.ERROR:
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
|
||||
from snek.system.view import BaseFormView
|
||||
import pathlib
|
||||
|
||||
class ContainersIndexView(BaseFormView):
|
||||
|
||||
login_required = True
|
||||
|
||||
async def get(self):
|
||||
|
||||
user_uid = self.session.get("uid")
|
||||
|
||||
containers = []
|
||||
async for container in self.services.container.find(user_uid=user_uid):
|
||||
containers.append(container.record)
|
||||
|
||||
user = await self.services.user.get(uid=self.session.get("uid"))
|
||||
|
||||
return await self.render_template("settings/containers/index.html", {"containers": containers, "user": user})
|
||||
|
||||
class ContainersCreateView(BaseFormView):
|
||||
|
||||
login_required = True
|
||||
|
||||
async def get(self):
|
||||
|
||||
return await self.render_template("settings/containers/create.html")
|
||||
|
||||
async def post(self):
|
||||
data = await self.request.post()
|
||||
container = await self.services.container.create(
|
||||
user_uid=self.session.get("uid"),
|
||||
name=data['name'],
|
||||
status=data['status'],
|
||||
resources=data.get('resources', ''),
|
||||
path=data.get('path', ''),
|
||||
readonly=bool(data.get('readonly', False))
|
||||
)
|
||||
return web.HTTPFound("/settings/containers/index.html")
|
||||
|
||||
class ContainersUpdateView(BaseFormView):
|
||||
|
||||
login_required = True
|
||||
|
||||
async def get(self):
|
||||
|
||||
container = await self.services.container.get(
|
||||
user_uid=self.session.get("uid"), uid=self.request.match_info["uid"]
|
||||
)
|
||||
if not container:
|
||||
return web.HTTPNotFound()
|
||||
return await self.render_template("settings/containers/update.html", {"container": container.record})
|
||||
|
||||
async def post(self):
|
||||
data = await self.request.post()
|
||||
container = await self.services.container.get(
|
||||
user_uid=self.session.get("uid"), uid=self.request.match_info["uid"]
|
||||
)
|
||||
container['status'] = data['status']
|
||||
container['resources'] = data.get('resources', '')
|
||||
container['path'] = data.get('path', '')
|
||||
container['readonly'] = bool(data.get('readonly', False))
|
||||
await self.services.container.save(container)
|
||||
return web.HTTPFound("/settings/containers/index.html")
|
||||
|
||||
class ContainersDeleteView(BaseFormView):
|
||||
|
||||
login_required = True
|
||||
|
||||
async def get(self):
|
||||
|
||||
container = await self.services.container.get(
|
||||
user_uid=self.session.get("uid"), uid=self.request.match_info["uid"]
|
||||
)
|
||||
if not container:
|
||||
return web.HTTPNotFound()
|
||||
|
||||
return await self.render_template("settings/containers/delete.html", {"container": container.record})
|
||||
|
||||
async def post(self):
|
||||
user_uid = self.session.get("uid")
|
||||
uid = self.request.match_info["uid"]
|
||||
container = await self.services.container.get(
|
||||
user_uid=user_uid, uid=uid
|
||||
)
|
||||
if not container:
|
||||
return web.HTTPNotFound()
|
||||
await self.services.container.delete(user_uid=user_uid, uid=uid)
|
||||
return web.HTTPFound("/settings/containers/index.html")
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user