Compare commits
214
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 | ||
|
|
c48b84bf3a | ||
|
|
01846bf23f | ||
|
|
2c90044185 | ||
|
|
4d7566de9b | ||
|
|
9133b7c3ce | ||
|
|
3412aa0bf0 | ||
|
|
f0591d4939 | ||
|
|
dd108c2004 | ||
|
|
44ac1d2bfa | ||
|
|
1616e4edb9 | ||
|
|
4c34d7eda5 | ||
|
|
17c6124a57 | ||
|
|
95ad49df43 | ||
|
|
7e8ae1632d | ||
|
|
e5d155e124 | ||
|
|
3ae30f1f76 | ||
|
|
adb59eff68 | ||
|
|
e06776d81d | ||
|
|
a5aac9a337 | ||
|
|
ee40c905d4 | ||
|
|
c56bf4fb49 | ||
|
|
5b28044d9e | ||
|
|
e359a8ebe2 | ||
|
|
b867b6ba78 | ||
|
|
ac570d036c | ||
|
|
165dda3210 | ||
|
|
02a0253c1d | ||
|
|
3c0fea6812 | ||
|
|
31062fddbf | ||
|
|
d0dd342e27 | ||
|
|
3c1d5d601f | ||
|
|
49ec99ef01 | ||
|
|
8799662159 | ||
|
|
f6706c165e | ||
|
|
0a3e151377 | ||
|
|
e153811ff3 | ||
|
|
fa59dbc095 | ||
|
|
d6d2f2892b | ||
|
|
707788583a | ||
|
|
b0666a0090 | ||
|
|
0f6eb5c043 | ||
|
|
529ebd23fc | ||
|
|
f7fda2d2c9 | ||
|
|
c709ee11c9 | ||
|
|
6312dfae47 | ||
|
|
061da150f9 | ||
|
|
46a8b612b4 | ||
|
|
1cd0b54656 | ||
|
|
4cc70640e4 | ||
|
|
c36ce17da5 | ||
|
|
3cfb79c8f5 | ||
|
|
d4f5a46409 | ||
|
|
0fa0488385 | ||
|
|
9fb6e64655 | ||
|
|
a3abd854bb | ||
|
|
3b05acffd2 | ||
|
|
bee7d828cd | ||
|
|
8ae9aac045 | ||
|
|
e4b0625799 | ||
|
|
4a770848a6 | ||
|
|
823892a302 | ||
|
|
9b49e659e5 | ||
|
|
ec9af49f29 | ||
|
|
22668f8a72 | ||
|
|
a1840cd034 | ||
|
|
bc65752ea2 | ||
|
|
3594ac1f59 | ||
|
|
0e6fbd523c | ||
|
|
743593affe | ||
|
|
44dd77cec5 | ||
|
|
8fa216c06c | ||
|
|
c529fc87fd | ||
|
|
656ea5f90e | ||
|
|
2582df360a | ||
|
|
6673f7b615 | ||
|
|
94e94cf7ca | ||
|
|
e6bd7aa152 | ||
|
|
087f9c10b4 | ||
|
|
6138cad782 | ||
|
|
c6575d8e52 | ||
|
|
b0a97ad267 | ||
|
|
b31c286a8b | ||
|
|
13f1d2f390 | ||
|
|
d23ed3711a | ||
|
|
d2e2bb8117 | ||
|
|
d71d5da6bc | ||
|
|
75593fd6bb | ||
|
|
c2b8061ac2 |
+1
-1
@@ -6,7 +6,7 @@ RUN apk add --no-cache gcc musl-dev linux-headers git openssh
|
|||||||
|
|
||||||
COPY pyproject.toml pyproject.toml
|
COPY pyproject.toml pyproject.toml
|
||||||
COPY src src
|
COPY src src
|
||||||
COpy ssh_host_key ssh_host_key
|
COPY ssh_host_key ssh_host_key
|
||||||
RUN pip install --upgrade pip
|
RUN pip install --upgrade pip
|
||||||
RUN pip install -e .
|
RUN pip install -e .
|
||||||
EXPOSE 2225
|
EXPOSE 2225
|
||||||
|
|||||||
+4
-3
@@ -1,11 +1,12 @@
|
|||||||
FROM ubuntu:latest
|
FROM ubuntu:latest
|
||||||
|
|
||||||
RUN apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop git curl wget -y
|
RUN apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop git curl wget xterm valgrind ack irssi lynx tmux -y
|
||||||
|
|
||||||
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- --default-toolchain nightly -y
|
||||||
|
|
||||||
RUN wget https://retoor.molodetz.nl/api/packages/retoor/generic/r/1.0.0/r
|
RUN wget https://retoor.molodetz.nl/api/packages/retoor/generic/r/1.0.0/r
|
||||||
|
|
||||||
RUN chmod +x r
|
RUN chmod +x r
|
||||||
|
|
||||||
RUN cp r /usr/local/bin
|
RUN mv r /usr/local/bin/r
|
||||||
|
|
||||||
CMD ["r"]
|
|
||||||
|
|||||||
@@ -5,21 +5,27 @@ GUNICORN=./.venv/bin/gunicorn
|
|||||||
GUNICORN_WORKERS = 1
|
GUNICORN_WORKERS = 1
|
||||||
PORT = 8081
|
PORT = 8081
|
||||||
|
|
||||||
python:
|
|
||||||
$(PYTHON)
|
|
||||||
|
shell:
|
||||||
|
.venv/bin/snek shell
|
||||||
|
|
||||||
dump:
|
dump:
|
||||||
@$(PYTHON) -m snek.dump
|
@$(PYTHON) -m snek.dump
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
|
||||||
|
serve: run
|
||||||
|
|
||||||
|
|
||||||
run:
|
run:
|
||||||
$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload
|
.venv/bin/snek serve
|
||||||
|
|
||||||
install:
|
install: ubuntu
|
||||||
python3.12 -m venv .venv
|
python3.12 -m venv .venv
|
||||||
$(PIP) install -e .
|
$(PIP) install -e .
|
||||||
|
|
||||||
|
ubuntu:
|
||||||
docker build -f DockerfileUbuntu -t snek_ubuntu .
|
docker build -f DockerfileUbuntu -t snek_ubuntu .
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+17
-3
@@ -16,9 +16,10 @@ requires-python = ">=3.12"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"mkdocs>=1.4.0",
|
"mkdocs>=1.4.0",
|
||||||
"lxml",
|
"lxml",
|
||||||
|
"IPython",
|
||||||
"shed",
|
"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",
|
"beautifulsoup4",
|
||||||
"gunicorn",
|
"gunicorn",
|
||||||
"imgkit",
|
"imgkit",
|
||||||
@@ -31,6 +32,19 @@ dependencies = [
|
|||||||
"emoji",
|
"emoji",
|
||||||
"aiofiles",
|
"aiofiles",
|
||||||
"PyJWT",
|
"PyJWT",
|
||||||
"multiavatar"
|
"multiavatar",
|
||||||
|
"gitpython",
|
||||||
|
'uvloop; platform_system != "Windows"',
|
||||||
|
"humanize",
|
||||||
|
"Pillow",
|
||||||
|
"pillow-heif",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"] # <-- this changed
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
"*" = ["*.*"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
snek = "snek.__main__:main"
|
||||||
|
|||||||
+53
-2
@@ -1,6 +1,57 @@
|
|||||||
|
import click
|
||||||
|
import uvloop
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
import asyncio
|
||||||
from snek.app import Application
|
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):
|
||||||
|
#init(db_path)
|
||||||
|
#asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||||
|
web.run_app(
|
||||||
|
Application(db_path=f"sqlite:///{db_path}"), port=port, host=host
|
||||||
|
)
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')
|
||||||
|
def shell(db_path):
|
||||||
|
app = Application(db_path=f"sqlite:///{db_path}")
|
||||||
|
start_ipython(argv=[], user_ns={'app': app})
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cli()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
web.run_app(Application(), port=8081, host="0.0.0.0")
|
main()
|
||||||
|
|||||||
+154
-24
@@ -3,9 +3,10 @@ import logging
|
|||||||
import pathlib
|
import pathlib
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from snek import snode
|
||||||
from snek.view.threads import ThreadsView
|
from snek.view.threads import ThreadsView
|
||||||
|
import json
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
@@ -18,7 +19,9 @@ from aiohttp_session import (
|
|||||||
)
|
)
|
||||||
from aiohttp_session.cookie_storage import EncryptedCookieStorage
|
from aiohttp_session.cookie_storage import EncryptedCookieStorage
|
||||||
from app.app import Application as BaseApplication
|
from app.app import Application as BaseApplication
|
||||||
|
from jinja2 import FileSystemLoader
|
||||||
|
|
||||||
|
from snek.sssh import start_ssh_server
|
||||||
from snek.docs.app import Application as DocsApplication
|
from snek.docs.app import Application as DocsApplication
|
||||||
from snek.mapper import get_mappers
|
from snek.mapper import get_mappers
|
||||||
from snek.service import get_services
|
from snek.service import get_services
|
||||||
@@ -32,20 +35,31 @@ from snek.view.about import AboutHTMLView, AboutMDView
|
|||||||
from snek.view.avatar import AvatarView
|
from snek.view.avatar import AvatarView
|
||||||
from snek.view.docs import DocsHTMLView, DocsMDView
|
from snek.view.docs import DocsHTMLView, DocsMDView
|
||||||
from snek.view.drive import DriveView
|
from snek.view.drive import DriveView
|
||||||
|
from snek.view.drive import DriveApiView
|
||||||
from snek.view.index import IndexView
|
from snek.view.index import IndexView
|
||||||
from snek.view.login import LoginView
|
from snek.view.login import LoginView
|
||||||
from snek.view.logout import LogoutView
|
from snek.view.logout import LogoutView
|
||||||
from snek.view.register import RegisterView
|
from snek.view.register import RegisterView
|
||||||
from snek.view.rpc import RPCView
|
from snek.view.rpc import RPCView
|
||||||
|
from snek.view.repository import RepositoryView
|
||||||
from snek.view.search_user import SearchUserView
|
from snek.view.search_user import SearchUserView
|
||||||
|
from snek.view.settings.repositories import RepositoriesIndexView
|
||||||
|
from snek.view.settings.repositories import RepositoriesCreateView
|
||||||
|
from snek.view.settings.repositories import RepositoriesUpdateView
|
||||||
|
from snek.view.settings.repositories import RepositoriesDeleteView
|
||||||
from snek.view.settings.index import SettingsIndexView
|
from snek.view.settings.index import SettingsIndexView
|
||||||
from snek.view.settings.profile import SettingsProfileView
|
from snek.view.settings.profile import SettingsProfileView
|
||||||
|
from snek.view.stats import StatsView
|
||||||
from snek.view.status import StatusView
|
from snek.view.status import StatusView
|
||||||
from snek.view.terminal import TerminalSocketView, TerminalView
|
from snek.view.terminal import TerminalSocketView, TerminalView
|
||||||
from snek.view.upload import UploadView
|
from snek.view.upload import UploadView
|
||||||
|
from snek.view.user import UserView
|
||||||
from snek.view.web import WebView
|
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.webdav import WebdavApplication
|
||||||
|
from snek.sgit import GitApplication
|
||||||
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
|
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
|
||||||
|
|
||||||
|
|
||||||
@@ -72,8 +86,9 @@ class Application(BaseApplication):
|
|||||||
web.normalize_path_middleware(merge_slashes=True),
|
web.normalize_path_middleware(merge_slashes=True),
|
||||||
]
|
]
|
||||||
self.template_path = pathlib.Path(__file__).parent.joinpath("templates")
|
self.template_path = pathlib.Path(__file__).parent.joinpath("templates")
|
||||||
|
self.static_path = pathlib.Path(__file__).parent.joinpath("static")
|
||||||
super().__init__(
|
super().__init__(
|
||||||
middlewares=middlewares, template_path=self.template_path, *args, **kwargs
|
middlewares=middlewares, template_path=self.template_path, client_max_size=1024*1024*1024*5 *args, **kwargs
|
||||||
)
|
)
|
||||||
session_setup(self, EncryptedCookieStorage(SESSION_KEY))
|
session_setup(self, EncryptedCookieStorage(SESSION_KEY))
|
||||||
self.tasks = asyncio.Queue()
|
self.tasks = asyncio.Queue()
|
||||||
@@ -83,13 +98,65 @@ class Application(BaseApplication):
|
|||||||
self.jinja2_env.add_extension(LinkifyExtension)
|
self.jinja2_env.add_extension(LinkifyExtension)
|
||||||
self.jinja2_env.add_extension(PythonExtension)
|
self.jinja2_env.add_extension(PythonExtension)
|
||||||
self.jinja2_env.add_extension(EmojiExtension)
|
self.jinja2_env.add_extension(EmojiExtension)
|
||||||
|
self.time_start = datetime.now()
|
||||||
|
self.ssh_host = "0.0.0.0"
|
||||||
|
self.ssh_port = 2242
|
||||||
self.setup_router()
|
self.setup_router()
|
||||||
|
self.ssh_server = None
|
||||||
|
self.sync_service = None
|
||||||
|
self.executor = None
|
||||||
self.cache = Cache(self)
|
self.cache = Cache(self)
|
||||||
self.services = get_services(app=self)
|
self.services = get_services(app=self)
|
||||||
self.mappers = get_mappers(app=self)
|
self.mappers = get_mappers(app=self)
|
||||||
|
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)
|
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)
|
||||||
|
if app.ssh_server:
|
||||||
|
asyncio.create_task(app.ssh_server.wait_closed())
|
||||||
|
|
||||||
|
async def prepare_asyncio(self, app):
|
||||||
|
# app.loop = asyncio.get_running_loop()
|
||||||
|
app.executor = ThreadPoolExecutor(max_workers=200)
|
||||||
|
app.loop.set_default_executor(self.executor)
|
||||||
|
|
||||||
async def create_task(self, task):
|
async def create_task(self, task):
|
||||||
await self.tasks.put(task)
|
await self.tasks.put(task)
|
||||||
@@ -99,10 +166,7 @@ class Application(BaseApplication):
|
|||||||
task = await self.tasks.get()
|
task = await self.tasks.get()
|
||||||
self.db.begin()
|
self.db.begin()
|
||||||
try:
|
try:
|
||||||
task_start = time.time()
|
|
||||||
await task
|
await task
|
||||||
task_end = time.time()
|
|
||||||
print(f"Task {task} took {task_end - task_start} seconds")
|
|
||||||
self.tasks.task_done()
|
self.tasks.task_done()
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
print(ex)
|
print(ex)
|
||||||
@@ -149,6 +213,7 @@ class Application(BaseApplication):
|
|||||||
self.router.add_view("/login.json", LoginView)
|
self.router.add_view("/login.json", LoginView)
|
||||||
self.router.add_view("/register.html", RegisterView)
|
self.router.add_view("/register.html", RegisterView)
|
||||||
self.router.add_view("/register.json", RegisterView)
|
self.router.add_view("/register.json", RegisterView)
|
||||||
|
self.router.add_view("/drive/{rel_path:.*}", DriveView)
|
||||||
self.router.add_view("/drive.bin", UploadView)
|
self.router.add_view("/drive.bin", UploadView)
|
||||||
self.router.add_view("/drive.bin/{uid}.{ext}", UploadView)
|
self.router.add_view("/drive.bin/{uid}.{ext}", UploadView)
|
||||||
self.router.add_view("/search-user.html", SearchUserView)
|
self.router.add_view("/search-user.html", SearchUserView)
|
||||||
@@ -157,20 +222,35 @@ class Application(BaseApplication):
|
|||||||
self.router.add_get("/http-get", self.handle_http_get)
|
self.router.add_get("/http-get", self.handle_http_get)
|
||||||
self.router.add_get("/http-photo", self.handle_http_photo)
|
self.router.add_get("/http-photo", self.handle_http_photo)
|
||||||
self.router.add_get("/rpc.ws", RPCView)
|
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)
|
self.router.add_view("/channel/{channel}.html", WebView)
|
||||||
self.router.add_view("/threads.html", ThreadsView)
|
self.router.add_view("/threads.html", ThreadsView)
|
||||||
self.router.add_view("/terminal.ws", TerminalSocketView)
|
self.router.add_view("/terminal.ws", TerminalSocketView)
|
||||||
self.router.add_view("/terminal.html", TerminalView)
|
self.router.add_view("/terminal.html", TerminalView)
|
||||||
self.router.add_view("/drive.json", DriveView)
|
self.router.add_view("/drive.json", DriveApiView)
|
||||||
|
self.router.add_view("/drive.html", DriveView)
|
||||||
self.router.add_view("/drive/{drive}.json", DriveView)
|
self.router.add_view("/drive/{drive}.json", DriveView)
|
||||||
|
self.router.add_view("/stats.json", StatsView)
|
||||||
|
self.router.add_view("/user/{user}.html", UserView)
|
||||||
|
self.router.add_view("/repository/{username}/{repository}", RepositoryView)
|
||||||
|
self.router.add_view("/repository/{username}/{repository}/{path:.*}", RepositoryView)
|
||||||
|
self.router.add_view("/settings/repositories/index.html", RepositoriesIndexView)
|
||||||
|
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.webdav = WebdavApplication(self)
|
||||||
|
self.git = GitApplication(self)
|
||||||
self.add_subapp("/webdav", self.webdav)
|
self.add_subapp("/webdav", self.webdav)
|
||||||
|
self.add_subapp("/git",self.git)
|
||||||
self.add_subapp(
|
|
||||||
"/docs",
|
#self.router.add_get("/{file_path:.*}", self.static_handler)
|
||||||
DocsApplication(path=pathlib.Path(__file__).parent.joinpath("docs")),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def handle_test(self, request):
|
async def handle_test(self, request):
|
||||||
|
|
||||||
return await self.render_template(
|
return await self.render_template(
|
||||||
@@ -194,17 +274,19 @@ class Application(BaseApplication):
|
|||||||
channels = []
|
channels = []
|
||||||
if not context:
|
if not context:
|
||||||
context = {}
|
context = {}
|
||||||
|
|
||||||
context["rid"] = str(uuid.uuid4())
|
context["rid"] = str(uuid.uuid4())
|
||||||
if request.session.get("uid"):
|
if request.session.get("uid"):
|
||||||
async for subscribed_channel in self.services.channel_member.find(
|
async for subscribed_channel in self.services.channel_member.find(
|
||||||
user_uid=request.session.get("uid"), deleted_at=None, is_banned=False
|
user_uid=request.session.get("uid"), deleted_at=None, is_banned=False
|
||||||
):
|
):
|
||||||
|
|
||||||
|
parent_object = await subscribed_channel.get_channel()
|
||||||
|
|
||||||
item = {}
|
item = {}
|
||||||
other_user = await self.services.channel_member.get_other_dm_user(
|
other_user = await self.services.channel_member.get_other_dm_user(
|
||||||
subscribed_channel["channel_uid"], request.session.get("uid")
|
subscribed_channel["channel_uid"], request.session.get("uid")
|
||||||
)
|
)
|
||||||
parent_object = await subscribed_channel.get_channel()
|
|
||||||
last_message = await parent_object.get_last_message()
|
last_message = await parent_object.get_last_message()
|
||||||
color = None
|
color = None
|
||||||
if last_message:
|
if last_message:
|
||||||
@@ -221,7 +303,6 @@ class Application(BaseApplication):
|
|||||||
item["uid"] = subscribed_channel["channel_uid"]
|
item["uid"] = subscribed_channel["channel_uid"]
|
||||||
item["new_count"] = subscribed_channel["new_count"]
|
item["new_count"] = subscribed_channel["new_count"]
|
||||||
|
|
||||||
print(item)
|
|
||||||
channels.append(item)
|
channels.append(item)
|
||||||
|
|
||||||
channels.sort(key=lambda x: x["last_message_on"] or "", reverse=True)
|
channels.sort(key=lambda x: x["last_message_on"] or "", reverse=True)
|
||||||
@@ -229,16 +310,65 @@ class Application(BaseApplication):
|
|||||||
context["channels"] = channels
|
context["channels"] = channels
|
||||||
if "user" not in context:
|
if "user" not in context:
|
||||||
context["user"] = await self.services.user.get(
|
context["user"] = await self.services.user.get(
|
||||||
uid=request.session.get("uid")
|
request.session.get("uid")
|
||||||
)
|
)
|
||||||
|
|
||||||
return await super().render_template(template, request, context)
|
self.template_path.joinpath(template)
|
||||||
|
|
||||||
|
await self.services.user.get_template_path(request.session.get("uid"))
|
||||||
|
|
||||||
|
self.original_loader = self.jinja2_env.loader
|
||||||
|
|
||||||
|
self.jinja2_env.loader = await self.get_user_template_loader(
|
||||||
|
request.session.get("uid")
|
||||||
|
)
|
||||||
|
|
||||||
|
rendered = await super().render_template(template, request, context)
|
||||||
|
|
||||||
|
self.jinja2_env.loader = self.original_loader
|
||||||
|
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
executor = ThreadPoolExecutor(max_workers=200)
|
async def static_handler(self, request):
|
||||||
|
file_name = request.match_info.get('filename', '')
|
||||||
|
|
||||||
|
paths = []
|
||||||
|
|
||||||
|
uid = request.session.get("uid")
|
||||||
|
if uid:
|
||||||
|
user_static_path = await self.services.user.get_static_path(uid)
|
||||||
|
if user_static_path:
|
||||||
|
paths.append(user_static_path)
|
||||||
|
|
||||||
|
for admin_uid in self.services.user.get_admin_uids():
|
||||||
|
user_static_path = await self.services.user.get_static_path(admin_uid)
|
||||||
|
if user_static_path:
|
||||||
|
paths.append(user_static_path)
|
||||||
|
|
||||||
|
paths.append(self.static_path)
|
||||||
|
|
||||||
|
for path in paths:
|
||||||
|
if pathlib.Path(path).joinpath(file_name).exists():
|
||||||
|
return web.FileResponse(pathlib.Path(path).joinpath(file_name))
|
||||||
|
return web.HTTPNotFound()
|
||||||
|
|
||||||
|
async def get_user_template_loader(self, uid=None):
|
||||||
|
template_paths = []
|
||||||
|
for admin_uid in self.services.user.get_admin_uids():
|
||||||
|
user_template_path = await self.services.user.get_template_path(admin_uid)
|
||||||
|
if user_template_path:
|
||||||
|
template_paths.append(user_template_path)
|
||||||
|
|
||||||
|
if uid:
|
||||||
|
user_template_path = await self.services.user.get_template_path(uid)
|
||||||
|
if user_template_path:
|
||||||
|
template_paths.append(user_template_path)
|
||||||
|
|
||||||
|
|
||||||
|
template_paths.append(self.template_path)
|
||||||
|
return FileSystemLoader(template_paths)
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
loop.set_default_executor(executor)
|
|
||||||
|
|
||||||
app = Application(db_path="sqlite:///snek.db")
|
app = Application(db_path="sqlite:///snek.db")
|
||||||
|
|
||||||
|
|||||||
@@ -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...")
|
||||||
|
|
||||||
@@ -1,14 +1,25 @@
|
|||||||
from snek.system.form import Form, FormInputElement, FormButtonElement, HTMLElement
|
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
|
||||||
|
|
||||||
|
|
||||||
class SettingsProfileForm(Form):
|
class SettingsProfileForm(Form):
|
||||||
|
|
||||||
nick = FormInputElement(name="nick", required=True, place_holder="Your Nickname", min_length=1, max_length=20)
|
nick = FormInputElement(
|
||||||
|
name="nick",
|
||||||
|
required=True,
|
||||||
|
place_holder="Your Nickname",
|
||||||
|
min_length=1,
|
||||||
|
max_length=20,
|
||||||
|
)
|
||||||
action = FormButtonElement(
|
action = FormButtonElement(
|
||||||
name="action", value="submit", text="Save", type="button"
|
name="action", value="submit", text="Save", type="button"
|
||||||
)
|
)
|
||||||
title = HTMLElement(tag="h1", text="Profile")
|
title = HTMLElement(tag="h1", text="Profile")
|
||||||
profile = FormInputElement(name="profile", place_holder="Tell about yourself.", required=False,max_length=300)
|
profile = FormInputElement(
|
||||||
|
name="profile",
|
||||||
|
place_holder="Tell about yourself.",
|
||||||
|
required=False,
|
||||||
|
max_length=300,
|
||||||
|
)
|
||||||
action = FormButtonElement(
|
action = FormButtonElement(
|
||||||
name="action", value="submit", text="Save", type="button"
|
name="action", value="submit", text="Save", type="button"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ from snek.mapper.drive_item import DriveItemMapper
|
|||||||
from snek.mapper.notification import NotificationMapper
|
from snek.mapper.notification import NotificationMapper
|
||||||
from snek.mapper.user import UserMapper
|
from snek.mapper.user import UserMapper
|
||||||
from snek.mapper.user_property import UserPropertyMapper
|
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
|
from snek.system.object import Object
|
||||||
|
|
||||||
|
|
||||||
@functools.cache
|
@functools.cache
|
||||||
|
|
||||||
def get_mappers(app=None):
|
def get_mappers(app=None):
|
||||||
return Object(
|
return Object(
|
||||||
**{
|
**{
|
||||||
@@ -23,6 +27,9 @@ def get_mappers(app=None):
|
|||||||
"drive_item": DriveItemMapper(app=app),
|
"drive_item": DriveItemMapper(app=app),
|
||||||
"drive": DriveMapper(app=app),
|
"drive": DriveMapper(app=app),
|
||||||
"user_property": UserPropertyMapper(app=app),
|
"user_property": UserPropertyMapper(app=app),
|
||||||
|
"repository": RepositoryMapper(app=app),
|
||||||
|
"channel_attachment": ChannelAttachmentMapper(app=app),
|
||||||
|
"container": ContainerMapper(app=app),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from snek.model.channel_attachment import ChannelAttachmentModel
|
||||||
|
from snek.system.mapper import BaseMapper
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelAttachmentMapper(BaseMapper):
|
||||||
|
table_name = "channel_attachment"
|
||||||
|
model_class = ChannelAttachmentModel
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from snek.model.repository import RepositoryModel
|
||||||
|
from snek.system.mapper import BaseMapper
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryMapper(BaseMapper):
|
||||||
|
model_class = RepositoryModel
|
||||||
|
table_name = "repository"
|
||||||
@@ -5,3 +5,16 @@ from snek.system.mapper import BaseMapper
|
|||||||
class UserMapper(BaseMapper):
|
class UserMapper(BaseMapper):
|
||||||
table_name = "user"
|
table_name = "user"
|
||||||
model_class = UserModel
|
model_class = UserModel
|
||||||
|
|
||||||
|
def get_admin_uids(self):
|
||||||
|
try:
|
||||||
|
return [
|
||||||
|
user["uid"]
|
||||||
|
for user in self.db.query(
|
||||||
|
"SELECT uid FROM user WHERE is_admin = :is_admin",
|
||||||
|
{"is_admin": True},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
except Exception as ex:
|
||||||
|
print(ex)
|
||||||
|
return []
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ from snek.model.drive_item import DriveItemModel
|
|||||||
from snek.model.notification import NotificationModel
|
from snek.model.notification import NotificationModel
|
||||||
from snek.model.user import UserModel
|
from snek.model.user import UserModel
|
||||||
from snek.model.user_property import UserPropertyModel
|
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
|
from snek.system.object import Object
|
||||||
|
|
||||||
|
|
||||||
@@ -25,6 +28,9 @@ def get_models():
|
|||||||
"drive": DriveModel,
|
"drive": DriveModel,
|
||||||
"notification": NotificationModel,
|
"notification": NotificationModel,
|
||||||
"user_property": UserPropertyModel,
|
"user_property": UserPropertyModel,
|
||||||
|
"repository": RepositoryModel,
|
||||||
|
"channel_attachment": ChannelAttachmentModel,
|
||||||
|
"container": Container,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from snek.system.model import BaseModel
|
||||||
|
from snek.system.model import BaseModel, ModelField
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelAttachmentModel(BaseModel):
|
||||||
|
|
||||||
|
name = ModelField(name="name", required=True, kind=str)
|
||||||
|
channel_uid = ModelField(name="channel_uid", required=True, kind=str)
|
||||||
|
path = ModelField(name="path", required=True, kind=str)
|
||||||
|
size = ModelField(name="size", required=False, kind=int)
|
||||||
|
user_uid = ModelField(name="user_uid", required=True, kind=str)
|
||||||
|
mime_type = ModelField(name="type", required=True, kind=str)
|
||||||
|
relative_url = ModelField(name="relative_url", required=True, kind=str)
|
||||||
|
resource_type = ModelField(name="resource_type", required=True, kind=str,value="file")
|
||||||
|
|
||||||
|
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
from snek.model.user import UserModel
|
from snek.model.user import UserModel
|
||||||
from snek.system.model import BaseModel, ModelField
|
from snek.system.model import BaseModel, ModelField
|
||||||
|
from datetime import datetime,timezone
|
||||||
|
|
||||||
class ChannelMessageModel(BaseModel):
|
class ChannelMessageModel(BaseModel):
|
||||||
channel_uid = ModelField(name="channel_uid", required=True, kind=str)
|
channel_uid = ModelField(name="channel_uid", required=True, kind=str)
|
||||||
user_uid = ModelField(name="user_uid", required=True, kind=str)
|
user_uid = ModelField(name="user_uid", required=True, kind=str)
|
||||||
message = ModelField(name="message", required=True, kind=str)
|
message = ModelField(name="message", required=True, kind=str)
|
||||||
html = ModelField(name="html", required=False, 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:
|
async def get_user(self) -> UserModel:
|
||||||
return await self.app.services.user.get(uid=self["user_uid"])
|
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)
|
||||||
@@ -9,6 +9,7 @@ class DriveItemModel(BaseModel):
|
|||||||
path = ModelField(name="path", required=True, kind=str)
|
path = ModelField(name="path", required=True, kind=str)
|
||||||
file_type = ModelField(name="file_type", required=True, kind=str)
|
file_type = ModelField(name="file_type", required=True, kind=str)
|
||||||
file_size = ModelField(name="file_size", required=True, kind=int)
|
file_size = ModelField(name="file_size", required=True, kind=int)
|
||||||
|
is_available = ModelField(name="is_available", required=True, kind=bool, initial_value=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extension(self):
|
def extension(self):
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from snek.model.user import UserModel
|
||||||
|
from snek.system.model import BaseModel, ModelField
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryModel(BaseModel):
|
||||||
|
|
||||||
|
user_uid = ModelField(name="user_uid", required=True, kind=str)
|
||||||
|
|
||||||
|
name = ModelField(name="name", required=True, kind=str)
|
||||||
|
|
||||||
|
is_private = ModelField(name="is_private", required=False, kind=bool)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -29,6 +29,30 @@ class UserModel(BaseModel):
|
|||||||
|
|
||||||
last_ping = ModelField(name="last_ping", required=False, kind=str)
|
last_ping = ModelField(name="last_ping", required=False, kind=str)
|
||||||
|
|
||||||
|
is_admin = ModelField(name="is_admin", required=False, kind=bool)
|
||||||
|
|
||||||
|
async def get_property(self, name):
|
||||||
|
prop = await self.app.services.user_property.find_one(
|
||||||
|
user_uid=self["uid"], name=name
|
||||||
|
)
|
||||||
|
if prop:
|
||||||
|
return prop["value"]
|
||||||
|
|
||||||
|
async def has_property(self, name):
|
||||||
|
return await self.app.services.user_property.exists(
|
||||||
|
user_uid=self["uid"], name=name
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_property(self, name, value):
|
||||||
|
if not await self.has_property(name):
|
||||||
|
await self.app.services.user_property.insert(
|
||||||
|
user_uid=self["uid"], name=name, value=value
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.app.services.user_property.update(
|
||||||
|
user_uid=self["uid"], name=name, value=value
|
||||||
|
)
|
||||||
|
|
||||||
async def get_channel_members(self):
|
async def get_channel_members(self):
|
||||||
async for channel_member in self.app.services.channel_member.find(
|
async for channel_member in self.app.services.channel_member.find(
|
||||||
user_uid=self["uid"], is_banned=False, deleted_at=None
|
user_uid=self["uid"], is_banned=False, deleted_at=None
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import mimetypes
|
|
||||||
|
|
||||||
from snek.system.model import BaseModel, ModelField
|
from snek.system.model import BaseModel, ModelField
|
||||||
|
|
||||||
|
|
||||||
@@ -7,4 +5,3 @@ class UserPropertyModel(BaseModel):
|
|||||||
user_uid = ModelField(name="user_uid", required=True, kind=str)
|
user_uid = ModelField(name="user_uid", required=True, kind=str)
|
||||||
name = ModelField(name="name", required=True, kind=str)
|
name = ModelField(name="name", required=True, kind=str)
|
||||||
value = ModelField(name="path", required=True, kind=str)
|
value = ModelField(name="path", required=True, kind=str)
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -9,9 +9,13 @@ from snek.service.drive_item import DriveItemService
|
|||||||
from snek.service.notification import NotificationService
|
from snek.service.notification import NotificationService
|
||||||
from snek.service.socket import SocketService
|
from snek.service.socket import SocketService
|
||||||
from snek.service.user import UserService
|
from snek.service.user import UserService
|
||||||
|
from snek.service.user_property import UserPropertyService
|
||||||
from snek.service.util import UtilService
|
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.system.object import Object
|
||||||
|
from snek.service.db import DBService
|
||||||
|
|
||||||
@functools.cache
|
@functools.cache
|
||||||
def get_services(app):
|
def get_services(app):
|
||||||
@@ -27,6 +31,11 @@ def get_services(app):
|
|||||||
"util": UtilService(app=app),
|
"util": UtilService(app=app),
|
||||||
"drive": DriveService(app=app),
|
"drive": DriveService(app=app),
|
||||||
"drive_item": DriveItemService(app=app),
|
"drive_item": DriveItemService(app=app),
|
||||||
|
"user_property": UserPropertyService(app=app),
|
||||||
|
"repository": RepositoryService(app=app),
|
||||||
|
"db": DBService(app=app),
|
||||||
|
"channel_attachment": ChannelAttachmentService(app=app),
|
||||||
|
"container": ContainerService(app=app),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,19 @@ from datetime import datetime
|
|||||||
from snek.system.model import now
|
from snek.system.model import now
|
||||||
from snek.system.service import BaseService
|
from snek.system.service import BaseService
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
|
||||||
class ChannelService(BaseService):
|
class ChannelService(BaseService):
|
||||||
mapper_name = "channel"
|
mapper_name = "channel"
|
||||||
|
|
||||||
|
async def get_attachment_folder(self, channel_uid,ensure=False):
|
||||||
|
path = pathlib.Path(f"./drive/{channel_uid}/attachments")
|
||||||
|
if ensure:
|
||||||
|
path.mkdir(
|
||||||
|
parents=True, exist_ok=True
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
async def get(self, uid=None, **kwargs):
|
async def get(self, uid=None, **kwargs):
|
||||||
if uid:
|
if uid:
|
||||||
kwargs["uid"] = uid
|
kwargs["uid"] = uid
|
||||||
@@ -58,6 +67,10 @@ class ChannelService(BaseService):
|
|||||||
await self.services.channel_member.create_dm(channel["uid"], user1, user2)
|
await self.services.channel_member.create_dm(channel["uid"], user1, user2)
|
||||||
return channel
|
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 def get_users(self, channel_uid):
|
||||||
async for channel_member in self.services.channel_member.find(
|
async for channel_member in self.services.channel_member.find(
|
||||||
channel_uid=channel_uid,
|
channel_uid=channel_uid,
|
||||||
@@ -77,7 +90,7 @@ class ChannelService(BaseService):
|
|||||||
if (
|
if (
|
||||||
datetime.fromisoformat(now())
|
datetime.fromisoformat(now())
|
||||||
- datetime.fromisoformat(user["last_ping"])
|
- datetime.fromisoformat(user["last_ping"])
|
||||||
).total_seconds() < 20:
|
).total_seconds() < 180:
|
||||||
yield user
|
yield user
|
||||||
|
|
||||||
async def get_for_user(self, user_uid):
|
async def get_for_user(self, user_uid):
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from snek.system.service import BaseService
|
||||||
|
import urllib.parse
|
||||||
|
import pathlib
|
||||||
|
import mimetypes
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
class ChannelAttachmentService(BaseService):
|
||||||
|
mapper_name="channel_attachment"
|
||||||
|
|
||||||
|
async def create_file(self, channel_uid, user_uid, name):
|
||||||
|
attachment = await self.new()
|
||||||
|
attachment["channel_uid"] = channel_uid
|
||||||
|
attachment['user_uid'] = user_uid
|
||||||
|
attachment["name"] = name
|
||||||
|
attachment["mime_type"] = mimetypes.guess_type(name)[0]
|
||||||
|
attachment['resource_type'] = "file"
|
||||||
|
real_file_name = 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)
|
||||||
|
if await self.save(attachment):
|
||||||
|
return attachment
|
||||||
|
raise Exception(f"Failed to create channel attachment: {attachment.errors}.")
|
||||||
|
|
||||||
@@ -10,6 +10,13 @@ class ChannelMemberService(BaseService):
|
|||||||
channel_member["new_count"] = 0
|
channel_member["new_count"] = 0
|
||||||
return await self.save(channel_member)
|
return await self.save(channel_member)
|
||||||
|
|
||||||
|
async def get_user_uids(self, channel_uid):
|
||||||
|
async for model in self.mapper.query(
|
||||||
|
"SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid",
|
||||||
|
{"channel_uid": channel_uid},
|
||||||
|
):
|
||||||
|
yield model["user_uid"]
|
||||||
|
|
||||||
async def create(
|
async def create(
|
||||||
self,
|
self,
|
||||||
channel_uid,
|
channel_uid,
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ from snek.system.service import BaseService
|
|||||||
class ChannelMessageService(BaseService):
|
class ChannelMessageService(BaseService):
|
||||||
mapper_name = "channel_message"
|
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 = await self.new()
|
||||||
|
|
||||||
model["channel_uid"] = channel_uid
|
model["channel_uid"] = channel_uid
|
||||||
model["user_uid"] = user_uid
|
model["user_uid"] = user_uid
|
||||||
model["message"] = message
|
model["message"] = message
|
||||||
|
model['is_final'] = is_final
|
||||||
|
|
||||||
context = {}
|
context = {}
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ class ChannelMessageService(BaseService):
|
|||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
print(ex, flush=True)
|
print(ex, flush=True)
|
||||||
|
|
||||||
if await self.save(model):
|
if await super().save(model):
|
||||||
return model
|
return model
|
||||||
raise Exception(f"Failed to create channel message: {model.errors}.")
|
raise Exception(f"Failed to create channel message: {model.errors}.")
|
||||||
|
|
||||||
@@ -50,6 +51,22 @@ class ChannelMessageService(BaseService):
|
|||||||
"username": user["username"],
|
"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):
|
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):
|
||||||
results = []
|
results = []
|
||||||
offset = page * page_size
|
offset = page * page_size
|
||||||
|
|||||||
@@ -4,12 +4,38 @@ from snek.system.service import BaseService
|
|||||||
|
|
||||||
class ChatService(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)
|
channel = await self.services.channel.get(uid=channel_uid)
|
||||||
if not channel:
|
if not channel:
|
||||||
raise Exception("Channel not found.")
|
raise Exception("Channel not found.")
|
||||||
channel_message = await self.services.channel_message.create(
|
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"]
|
channel_message_uid = channel_message["uid"]
|
||||||
|
|
||||||
@@ -30,10 +56,11 @@ class ChatService(BaseService):
|
|||||||
"username": user["username"],
|
"username": user["username"],
|
||||||
"uid": channel_message["uid"],
|
"uid": channel_message["uid"],
|
||||||
"user_nick": user["nick"],
|
"user_nick": user["nick"],
|
||||||
|
"is_final": is_final,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await self.app.create_task(
|
await self.app.create_task(
|
||||||
self.services.notification.create_channel_message(channel_message_uid)
|
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)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
from snek.system.service import BaseService
|
||||||
|
import dataset
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class DBService(BaseService):
|
||||||
|
|
||||||
|
async def get_db(self, user_uid):
|
||||||
|
|
||||||
|
home_folder = await self.app.services.user.get_home_folder(user_uid)
|
||||||
|
home_folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
db_path = home_folder.joinpath("snek/user.db")
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
return dataset.connect("sqlite:///" + str(db_path))
|
||||||
|
|
||||||
|
async def insert(self, user_uid, table_name, values):
|
||||||
|
db = await self.get_db(user_uid)
|
||||||
|
return db[table_name].insert(values)
|
||||||
|
|
||||||
|
|
||||||
|
async def update(self, user_uid, table_name, values, filters):
|
||||||
|
db = await self.get_db(user_uid)
|
||||||
|
if not filters:
|
||||||
|
filters = {}
|
||||||
|
if not values:
|
||||||
|
return False
|
||||||
|
return db[table_name].update(values, filters)
|
||||||
|
|
||||||
|
async def upsert(self, user_uid, table_name, values, keys):
|
||||||
|
db = await self.get_db(user_uid)
|
||||||
|
return db[table_name].upsert(values, keys)
|
||||||
|
|
||||||
|
async def find(self, user_uid, table_name, kwargs):
|
||||||
|
db = await self.get_db(user_uid)
|
||||||
|
kwargs['_limit'] = kwargs.get('_limit', 30)
|
||||||
|
return [dict(row) for row in db[table_name].find(**kwargs)]
|
||||||
|
|
||||||
|
async def get(self, user_uid, table_name, filters):
|
||||||
|
db = await self.get_db(user_uid)
|
||||||
|
if not filters:
|
||||||
|
filters = {}
|
||||||
|
try:
|
||||||
|
return dict(db[table_name].find_one(**filters))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def delete(self, user_uid, table_name, filters):
|
||||||
|
db = await self.get_db(user_uid)
|
||||||
|
if not filters:
|
||||||
|
filters = {}
|
||||||
|
return db[table_name].delete(**filters)
|
||||||
|
|
||||||
|
async def query(self, sql,values):
|
||||||
|
db = await self.app.db
|
||||||
|
return [dict(row) for row in db.query(sql, values or {})]
|
||||||
|
|
||||||
|
async def exists(self, user_uid, table_name, filters):
|
||||||
|
db = await self.get_db(user_uid)
|
||||||
|
if not filters:
|
||||||
|
filters = {}
|
||||||
|
return bool(db[table_name].find_one(**filters))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def count(self, user_uid, table_name, filters):
|
||||||
|
db = await self.get_db(user_uid)
|
||||||
|
if not filters:
|
||||||
|
filters = {}
|
||||||
|
return db[table_name].count(**filters)
|
||||||
@@ -16,4 +16,5 @@ class DriveItemService(BaseService):
|
|||||||
if await self.save(model):
|
if await self.save(model):
|
||||||
return model
|
return model
|
||||||
errors = await model.errors
|
errors = await model.errors
|
||||||
|
print("XXXXXXXXXX")
|
||||||
raise Exception(f"Failed to create drive item: {errors}.")
|
raise Exception(f"Failed to create drive item: {errors}.")
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from snek.system.service import BaseService
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
class RepositoryService(BaseService):
|
||||||
|
mapper_name = "repository"
|
||||||
|
|
||||||
|
async def delete(self, user_uid, name):
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
repository_path = (await self.services.user.get_repository_path(user_uid)).joinpath(name)
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(None, shutil.rmtree, repository_path)
|
||||||
|
except Exception as ex:
|
||||||
|
print(ex)
|
||||||
|
|
||||||
|
await super().delete(user_uid=user_uid, name=name)
|
||||||
|
|
||||||
|
|
||||||
|
async def exists(self, user_uid, name, **kwargs):
|
||||||
|
kwargs["user_uid"] = user_uid
|
||||||
|
kwargs["name"] = name
|
||||||
|
return await super().exists(**kwargs)
|
||||||
|
|
||||||
|
async def init(self, user_uid, name):
|
||||||
|
repository_path = await self.services.user.get_repository_path(user_uid)
|
||||||
|
if not repository_path.exists():
|
||||||
|
repository_path.mkdir(parents=True)
|
||||||
|
repository_path = repository_path.joinpath(name)
|
||||||
|
repository_path = str(repository_path)
|
||||||
|
if not repository_path.endswith(".git"):
|
||||||
|
repository_path += ".git"
|
||||||
|
command = ['git', 'init', '--bare', repository_path]
|
||||||
|
process = await asyncio.subprocess.create_subprocess_exec(
|
||||||
|
*command,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE
|
||||||
|
)
|
||||||
|
stdout, stderr = await process.communicate()
|
||||||
|
return process.returncode == 0
|
||||||
|
|
||||||
|
async def create(self, user_uid, name,is_private=False):
|
||||||
|
if await self.exists(user_uid=user_uid, name=name):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not await self.init(user_uid=user_uid, name=name):
|
||||||
|
return False
|
||||||
|
|
||||||
|
model = await self.new()
|
||||||
|
model["user_uid"] = user_uid
|
||||||
|
model["name"] = name
|
||||||
|
model["is_private"] = is_private
|
||||||
|
return await self.save(model)
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
from snek.model.user import UserModel
|
from snek.model.user import UserModel
|
||||||
from snek.system.service import BaseService
|
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):
|
class SocketService(BaseService):
|
||||||
|
|
||||||
@@ -10,15 +15,15 @@ class SocketService(BaseService):
|
|||||||
self.is_connected = True
|
self.is_connected = True
|
||||||
self.user = user
|
self.user = user
|
||||||
|
|
||||||
|
|
||||||
async def send_json(self, data):
|
async def send_json(self, data):
|
||||||
if not self.is_connected:
|
if not self.is_connected:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
await self.ws.send_json(data)
|
await self.ws.send_json(data)
|
||||||
except Exception as ex:
|
except Exception:
|
||||||
print(ex, flush=True)
|
|
||||||
self.is_connected = False
|
self.is_connected = False
|
||||||
return True
|
return self.is_connected
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
if not self.is_connected:
|
if not self.is_connected:
|
||||||
@@ -34,16 +39,36 @@ class SocketService(BaseService):
|
|||||||
self.sockets = set()
|
self.sockets = set()
|
||||||
self.users = {}
|
self.users = {}
|
||||||
self.subscriptions = {}
|
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):
|
async def add(self, ws, user_uid):
|
||||||
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
|
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
|
||||||
self.sockets.add(s)
|
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):
|
if not self.users.get(user_uid):
|
||||||
self.users[user_uid] = set()
|
self.users[user_uid] = set()
|
||||||
self.users[user_uid].add(s)
|
self.users[user_uid].add(s)
|
||||||
|
|
||||||
async def subscribe(self, ws, channel_uid, user_uid):
|
async def subscribe(self, ws, channel_uid, user_uid):
|
||||||
return
|
|
||||||
if channel_uid not in self.subscriptions:
|
if channel_uid not in self.subscriptions:
|
||||||
self.subscriptions[channel_uid] = set()
|
self.subscriptions[channel_uid] = set()
|
||||||
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
|
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
|
||||||
@@ -56,14 +81,25 @@ class SocketService(BaseService):
|
|||||||
count += 1
|
count += 1
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
async def broadcast(self, channel_uid, message):
|
async def broadcast(self, channel_uid, message):
|
||||||
async for channel_member in self.app.services.channel_member.find(
|
await self._broadcast(channel_uid, message)
|
||||||
channel_uid=channel_uid
|
|
||||||
):
|
async def _broadcast(self, channel_uid, message):
|
||||||
await self.send_to_user(channel_member["user_uid"], message)
|
sent = 0
|
||||||
|
try:
|
||||||
|
async for user_uid in self.services.channel_member.get_user_uids(
|
||||||
|
channel_uid
|
||||||
|
):
|
||||||
|
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
|
return True
|
||||||
|
|
||||||
async def delete(self, ws):
|
async def delete(self, ws):
|
||||||
for s in [sock for sock in self.sockets if sock.ws == ws]:
|
for s in [sock for sock in self.sockets if sock.ws == ws]:
|
||||||
await s.close()
|
await s.close()
|
||||||
|
logger.info(f"Removed socket for user {s.user['username']}")
|
||||||
self.sockets.remove(s)
|
self.sockets.remove(s)
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ from snek.system.service import BaseService
|
|||||||
class UserService(BaseService):
|
class UserService(BaseService):
|
||||||
mapper_name = "user"
|
mapper_name = "user"
|
||||||
|
|
||||||
|
async def get_by_username(self, username):
|
||||||
|
return await self.get(username=username)
|
||||||
|
|
||||||
async def search(self, query, **kwargs):
|
async def search(self, query, **kwargs):
|
||||||
query = query.strip().lower()
|
query = query.strip().lower()
|
||||||
if not query:
|
if not query:
|
||||||
raise []
|
return []
|
||||||
results = []
|
results = []
|
||||||
async for result in self.find(username={"ilike": "%" + query + "%"}, **kwargs):
|
async for result in self.find(username={"ilike": "%" + query + "%"}, **kwargs):
|
||||||
results.append(result)
|
results.append(result)
|
||||||
@@ -29,16 +32,57 @@ class UserService(BaseService):
|
|||||||
user["color"] = await self.services.util.random_light_hex_color()
|
user["color"] = await self.services.util.random_light_hex_color()
|
||||||
return await super().save(user)
|
return await super().save(user)
|
||||||
|
|
||||||
|
def authenticate_sync(self,username,password):
|
||||||
|
user = self.get_by_username_sync(username)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
if not security.verify_sync(password, user["password"]):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
async def authenticate(self, username, password):
|
async def authenticate(self, username, password):
|
||||||
print(username, password, flush=True)
|
|
||||||
success = await self.validate_login(username, password)
|
success = await self.validate_login(username, password)
|
||||||
print(success, flush=True)
|
|
||||||
if not success:
|
if not success:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
model = await self.get(username=username, deleted_at=None)
|
model = await self.get(username=username, deleted_at=None)
|
||||||
return model
|
return model
|
||||||
|
|
||||||
|
def get_admin_uids(self):
|
||||||
|
return self.mapper.get_admin_uids()
|
||||||
|
|
||||||
|
async def get_repository_path(self, user_uid):
|
||||||
|
return pathlib.Path(f"./drive/repositories/{user_uid}")
|
||||||
|
|
||||||
|
async def get_static_path(self, user_uid):
|
||||||
|
path = pathlib.Path(f"./drive/{user_uid}/snek/static")
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def get_template_path(self, user_uid):
|
||||||
|
path = pathlib.Path(f"./drive/{user_uid}/snek/templates")
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
return path
|
||||||
|
|
||||||
|
def get_by_username_sync(self, username):
|
||||||
|
user = self.mapper.db["user"].find_one(username=username, deleted_at=None)
|
||||||
|
return dict(user)
|
||||||
|
|
||||||
|
def get_home_folder_by_username(self, username):
|
||||||
|
user = self.get_by_username_sync(username)
|
||||||
|
folder = pathlib.Path(f"./drive/{user['uid']}")
|
||||||
|
if not folder.exists():
|
||||||
|
try:
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return folder
|
||||||
|
|
||||||
async def get_home_folder(self, user_uid):
|
async def get_home_folder(self, user_uid):
|
||||||
folder = pathlib.Path(f"./drive/{user_uid}")
|
folder = pathlib.Path(f"./drive/{user_uid}")
|
||||||
if not folder.exists():
|
if not folder.exists():
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from snek.system.service import BaseService
|
||||||
|
|
||||||
|
|
||||||
|
class UserPropertyService(BaseService):
|
||||||
|
mapper_name = "user_property"
|
||||||
|
|
||||||
|
async def set(self, user_uid, name, value):
|
||||||
|
self.mapper.db["user_property"].upsert(
|
||||||
|
{
|
||||||
|
"user_uid": user_uid,
|
||||||
|
"name": name,
|
||||||
|
"value": json.dumps(value, default=str),
|
||||||
|
},
|
||||||
|
["user_uid", "name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get(self, user_uid, name):
|
||||||
|
try:
|
||||||
|
return json.loads(
|
||||||
|
(await super().get(user_uid=user_uid, name=name))["value"]
|
||||||
|
)
|
||||||
|
except Exception as ex:
|
||||||
|
print(ex)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def search(self, query, **kwargs):
|
||||||
|
query = query.strip().lower()
|
||||||
|
if not query:
|
||||||
|
raise []
|
||||||
|
results = []
|
||||||
|
async for result in self.find(name={"ilike": "%" + query + "%"}, **kwargs):
|
||||||
|
results.append(result)
|
||||||
|
return results
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
import os
|
||||||
|
import aiohttp
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import base64
|
||||||
|
import pathlib
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
|
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([
|
||||||
|
web.post('/create/{repo_name}', self.create_repository),
|
||||||
|
web.delete('/delete/{repo_name}', self.delete_repository),
|
||||||
|
web.get('/clone/{repo_name}', self.clone_repository),
|
||||||
|
web.post('/push/{repo_name}', self.push_repository),
|
||||||
|
web.post('/pull/{repo_name}', self.pull_repository),
|
||||||
|
web.get('/status/{repo_name}', self.status_repository),
|
||||||
|
#web.get('/list', self.list_repositories),
|
||||||
|
web.get('/branches/{repo_name}', self.list_branches),
|
||||||
|
web.post('/branches/{repo_name}', self.create_branch),
|
||||||
|
web.get('/log/{repo_name}', self.commit_log),
|
||||||
|
web.get('/file/{repo_name}/{file_path:.*}', self.file_content),
|
||||||
|
web.get('/{path:.+}/info/refs', self.git_smart_http),
|
||||||
|
web.post('/{path:.+}/git-upload-pack', self.git_smart_http),
|
||||||
|
web.post('/{path:.+}/git-receive-pack', self.git_smart_http),
|
||||||
|
web.get('/{repo_name}.git/info/refs', self.git_smart_http),
|
||||||
|
web.post('/{repo_name}.git/git-upload-pack', self.git_smart_http),
|
||||||
|
web.post('/{repo_name}.git/git-receive-pack', self.git_smart_http),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
async def check_basic_auth(self, request):
|
||||||
|
auth_header = request.headers.get("Authorization", "")
|
||||||
|
if not auth_header.startswith("Basic "):
|
||||||
|
return None,None
|
||||||
|
encoded_creds = auth_header.split("Basic ")[1]
|
||||||
|
decoded_creds = base64.b64decode(encoded_creds).decode()
|
||||||
|
username, password = decoded_creds.split(":", 1)
|
||||||
|
request["user"] = await self.parent.services.user.authenticate(
|
||||||
|
username=username, password=password
|
||||||
|
)
|
||||||
|
if not request["user"]:
|
||||||
|
return None,None
|
||||||
|
request["repository_path"] = await self.parent.services.user.get_repository_path(
|
||||||
|
request["user"]["uid"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return request["user"]['username'],request["repository_path"]
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def require_auth(handler):
|
||||||
|
async def wrapped(self, request, *args, **kwargs):
|
||||||
|
username, repository_path = await self.check_basic_auth(request)
|
||||||
|
if not username or not repository_path:
|
||||||
|
return web.Response(status=401, headers={'WWW-Authenticate': 'Basic'}, text='Authentication required')
|
||||||
|
request['username'] = username
|
||||||
|
request['repository_path'] = repository_path
|
||||||
|
return await handler(self, request, *args, **kwargs)
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
def repo_path(self, repository_path, repo_name):
|
||||||
|
return repository_path.joinpath(repo_name + '.git')
|
||||||
|
|
||||||
|
def check_repo_exists(self, repository_path, repo_name):
|
||||||
|
repo_dir = self.repo_path(repository_path, repo_name)
|
||||||
|
if not os.path.exists(repo_dir):
|
||||||
|
return web.Response(text="Repository not found", status=404)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def create_repository(self, request):
|
||||||
|
username = request['username']
|
||||||
|
repo_name = request.match_info['repo_name']
|
||||||
|
repository_path = request['repository_path']
|
||||||
|
if not repo_name or '/' in repo_name or '..' in repo_name:
|
||||||
|
return web.Response(text="Invalid repository name", status=400)
|
||||||
|
repo_dir = self.repo_path(repository_path, repo_name)
|
||||||
|
if os.path.exists(repo_dir):
|
||||||
|
return web.Response(text="Repository already exists", status=400)
|
||||||
|
try:
|
||||||
|
git.Repo.init(repo_dir, bare=True)
|
||||||
|
logger.info(f"Created repository: {repo_name} for user {username}")
|
||||||
|
return web.Response(text=f"Created repository {repo_name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating repository {repo_name}: {str(e)}")
|
||||||
|
return web.Response(text=f"Error creating repository: {str(e)}", status=500)
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def delete_repository(self, request):
|
||||||
|
username = request['username']
|
||||||
|
repo_name = request.match_info['repo_name']
|
||||||
|
repository_path = request['repository_path']
|
||||||
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
|
if error_response:
|
||||||
|
return error_response
|
||||||
|
#'''
|
||||||
|
try:
|
||||||
|
shutil.rmtree(self.repo_path(repository_path, repo_name))
|
||||||
|
logger.info(f"Deleted repository: {repo_name} for user {username}")
|
||||||
|
return web.Response(text=f"Deleted repository {repo_name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting repository {repo_name}: {str(e)}")
|
||||||
|
return web.Response(text=f"Error deleting repository: {str(e)}", status=500)
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def clone_repository(self, request):
|
||||||
|
username = request['username']
|
||||||
|
repo_name = request.match_info['repo_name']
|
||||||
|
repository_path = request['repository_path']
|
||||||
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
|
if error_response:
|
||||||
|
return error_response
|
||||||
|
host = request.host
|
||||||
|
clone_url = f"http://{host}/{repo_name}.git"
|
||||||
|
response_data = {
|
||||||
|
"repository": repo_name,
|
||||||
|
"clone_command": f"git clone {clone_url}",
|
||||||
|
"clone_url": clone_url
|
||||||
|
}
|
||||||
|
return web.json_response(response_data)
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def push_repository(self, request):
|
||||||
|
username = request['username']
|
||||||
|
repo_name = request.match_info['repo_name']
|
||||||
|
repository_path = request['repository_path']
|
||||||
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
|
if error_response:
|
||||||
|
return error_response
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return web.Response(text="Invalid JSON data", status=400)
|
||||||
|
commit_message = data.get('commit_message', 'Update from server')
|
||||||
|
branch = data.get('branch', 'main')
|
||||||
|
changes = data.get('changes', [])
|
||||||
|
if not changes:
|
||||||
|
return web.Response(text="No changes provided", status=400)
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
|
||||||
|
for change in changes:
|
||||||
|
file_path = os.path.join(temp_dir, change.get('file', ''))
|
||||||
|
content = change.get('content', '')
|
||||||
|
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||||
|
with open(file_path, 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
temp_repo.git.add(A=True)
|
||||||
|
if not temp_repo.config_reader().has_section('user'):
|
||||||
|
temp_repo.config_writer().set_value("user", "name", "Git Server").release()
|
||||||
|
temp_repo.config_writer().set_value("user", "email", "git@server.local").release()
|
||||||
|
temp_repo.index.commit(commit_message)
|
||||||
|
origin = temp_repo.remote('origin')
|
||||||
|
origin.push(refspec=f"{branch}:{branch}")
|
||||||
|
logger.info(f"Pushed to repository: {repo_name} for user {username}")
|
||||||
|
return web.Response(text=f"Successfully pushed changes to {repo_name}")
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def pull_repository(self, request):
|
||||||
|
username = request['username']
|
||||||
|
repo_name = request.match_info['repo_name']
|
||||||
|
repository_path = request['repository_path']
|
||||||
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
|
if error_response:
|
||||||
|
return error_response
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
data = {}
|
||||||
|
remote_url = data.get('remote_url')
|
||||||
|
branch = data.get('branch', 'main')
|
||||||
|
if not remote_url:
|
||||||
|
return web.Response(text="Remote URL is required", status=400)
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
try:
|
||||||
|
local_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
|
||||||
|
remote_name = "pull_source"
|
||||||
|
try:
|
||||||
|
remote = local_repo.create_remote(remote_name, remote_url)
|
||||||
|
except git.GitCommandError:
|
||||||
|
remote = local_repo.remote(remote_name)
|
||||||
|
remote.set_url(remote_url)
|
||||||
|
remote.fetch()
|
||||||
|
local_repo.git.merge(f"{remote_name}/{branch}")
|
||||||
|
origin = local_repo.remote('origin')
|
||||||
|
origin.push()
|
||||||
|
logger.info(f"Pulled to repository {repo_name} from {remote_url} for user {username}")
|
||||||
|
return web.Response(text=f"Successfully pulled changes from {remote_url} to {repo_name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error pulling to {repo_name}: {str(e)}")
|
||||||
|
return web.Response(text=f"Error pulling changes: {str(e)}", status=500)
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def status_repository(self, request):
|
||||||
|
username = request['username']
|
||||||
|
repo_name = request.match_info['repo_name']
|
||||||
|
repository_path = request['repository_path']
|
||||||
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
|
if error_response:
|
||||||
|
return error_response
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
try:
|
||||||
|
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
|
||||||
|
branches = [b.name for b in temp_repo.branches]
|
||||||
|
active_branch = temp_repo.active_branch.name
|
||||||
|
commits = []
|
||||||
|
for commit in list(temp_repo.iter_commits(max_count=5)):
|
||||||
|
commits.append({
|
||||||
|
"id": commit.hexsha,
|
||||||
|
"author": f"{commit.author.name} <{commit.author.email}>",
|
||||||
|
"date": commit.committed_datetime.isoformat(),
|
||||||
|
"message": commit.message
|
||||||
|
})
|
||||||
|
files = []
|
||||||
|
for root, dirs, filenames in os.walk(temp_dir):
|
||||||
|
if '.git' in root:
|
||||||
|
continue
|
||||||
|
for filename in filenames:
|
||||||
|
full_path = os.path.join(root, filename)
|
||||||
|
rel_path = os.path.relpath(full_path, temp_dir)
|
||||||
|
files.append(rel_path)
|
||||||
|
status_info = {
|
||||||
|
"repository": repo_name,
|
||||||
|
"branches": branches,
|
||||||
|
"active_branch": active_branch,
|
||||||
|
"recent_commits": commits,
|
||||||
|
"files": files
|
||||||
|
}
|
||||||
|
return web.json_response(status_info)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting status for {repo_name}: {str(e)}")
|
||||||
|
return web.Response(text=f"Error getting repository status: {str(e)}", status=500)
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def list_repositories(self, request):
|
||||||
|
username = request['username']
|
||||||
|
try:
|
||||||
|
repos = []
|
||||||
|
user_dir = self.REPO_DIR
|
||||||
|
if os.path.exists(user_dir):
|
||||||
|
for item in os.listdir(user_dir):
|
||||||
|
item_path = os.path.join(user_dir, item)
|
||||||
|
if os.path.isdir(item_path) and item.endswith('.git'):
|
||||||
|
repos.append(item[:-4])
|
||||||
|
if request.query.get('format') == 'json':
|
||||||
|
return web.json_response({"repositories": repos})
|
||||||
|
else:
|
||||||
|
return web.Response(text="\n".join(repos) if repos else "No repositories found")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing repositories: {str(e)}")
|
||||||
|
return web.Response(text=f"Error listing repositories: {str(e)}", status=500)
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def list_branches(self, request):
|
||||||
|
username = request['username']
|
||||||
|
repo_name = request.match_info['repo_name']
|
||||||
|
repository_path = request['repository_path']
|
||||||
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
|
if error_response:
|
||||||
|
return error_response
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
|
||||||
|
branches = [b.name for b in temp_repo.branches]
|
||||||
|
return web.json_response({"branches": branches})
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def create_branch(self, request):
|
||||||
|
username = request['username']
|
||||||
|
repo_name = request.match_info['repo_name']
|
||||||
|
repository_path = request['repository_path']
|
||||||
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
|
if error_response:
|
||||||
|
return error_response
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return web.Response(text="Invalid JSON data", status=400)
|
||||||
|
branch_name = data.get('branch_name')
|
||||||
|
start_point = data.get('start_point', 'HEAD')
|
||||||
|
if not branch_name:
|
||||||
|
return web.Response(text="Branch name is required", status=400)
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
try:
|
||||||
|
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
|
||||||
|
temp_repo.git.branch(branch_name, start_point)
|
||||||
|
temp_repo.git.push('origin', branch_name)
|
||||||
|
logger.info(f"Created branch {branch_name} in repository {repo_name} for user {username}")
|
||||||
|
return web.Response(text=f"Created branch {branch_name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating branch {branch_name} in {repo_name}: {str(e)}")
|
||||||
|
return web.Response(text=f"Error creating branch: {str(e)}", status=500)
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def commit_log(self, request):
|
||||||
|
username = request['username']
|
||||||
|
repo_name = request.match_info['repo_name']
|
||||||
|
repository_path = request['repository_path']
|
||||||
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
|
if error_response:
|
||||||
|
return error_response
|
||||||
|
try:
|
||||||
|
limit = int(request.query.get('limit', 10))
|
||||||
|
branch = request.query.get('branch', 'main')
|
||||||
|
except ValueError:
|
||||||
|
return web.Response(text="Invalid limit parameter", status=400)
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
try:
|
||||||
|
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
|
||||||
|
commits = []
|
||||||
|
try:
|
||||||
|
for commit in list(temp_repo.iter_commits(branch, max_count=limit)):
|
||||||
|
commits.append({
|
||||||
|
"id": commit.hexsha,
|
||||||
|
"short_id": commit.hexsha[:7],
|
||||||
|
"author": f"{commit.author.name} <{commit.author.email}>",
|
||||||
|
"date": commit.committed_datetime.isoformat(),
|
||||||
|
"message": commit.message.strip()
|
||||||
|
})
|
||||||
|
except git.GitCommandError as e:
|
||||||
|
if "unknown revision or path" in str(e):
|
||||||
|
commits = []
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
return web.json_response({
|
||||||
|
"repository": repo_name,
|
||||||
|
"branch": branch,
|
||||||
|
"commits": commits
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting commit log for {repo_name}: {str(e)}")
|
||||||
|
return web.Response(text=f"Error getting commit log: {str(e)}", status=500)
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def file_content(self, request):
|
||||||
|
username = request['username']
|
||||||
|
repo_name = request.match_info['repo_name']
|
||||||
|
file_path = request.match_info.get('file_path', '')
|
||||||
|
branch = request.query.get('branch', 'main')
|
||||||
|
repository_path = request['repository_path']
|
||||||
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
|
if error_response:
|
||||||
|
return error_response
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
try:
|
||||||
|
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
|
||||||
|
try:
|
||||||
|
temp_repo.git.checkout(branch)
|
||||||
|
except git.GitCommandError:
|
||||||
|
return web.Response(text=f"Branch '{branch}' not found", status=404)
|
||||||
|
file_full_path = os.path.join(temp_dir, file_path)
|
||||||
|
if not os.path.exists(file_full_path):
|
||||||
|
return web.Response(text=f"File '{file_path}' not found", status=404)
|
||||||
|
if os.path.isdir(file_full_path):
|
||||||
|
files = os.listdir(file_full_path)
|
||||||
|
return web.json_response({
|
||||||
|
"repository": repo_name,
|
||||||
|
"path": file_path,
|
||||||
|
"type": "directory",
|
||||||
|
"contents": files
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
with open(file_full_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
return web.Response(text=content)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return web.Response(text=f"Cannot display binary file content for '{file_path}'", status=400)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting file content from {repo_name}: {str(e)}")
|
||||||
|
return web.Response(text=f"Error getting file content: {str(e)}", status=500)
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def git_smart_http(self, request):
|
||||||
|
username = request['username']
|
||||||
|
repository_path = request['repository_path']
|
||||||
|
path = request.path
|
||||||
|
async def get_repository_path():
|
||||||
|
req_path = path.lstrip('/')
|
||||||
|
if req_path.endswith('/info/refs'):
|
||||||
|
repo_name = req_path[:-len('/info/refs')]
|
||||||
|
elif req_path.endswith('/git-upload-pack'):
|
||||||
|
repo_name = req_path[:-len('/git-upload-pack')]
|
||||||
|
elif req_path.endswith('/git-receive-pack'):
|
||||||
|
repo_name = req_path[:-len('/git-receive-pack')]
|
||||||
|
else:
|
||||||
|
repo_name = req_path
|
||||||
|
if repo_name.endswith('.git'):
|
||||||
|
repo_name = repo_name[:-4]
|
||||||
|
repo_name = repo_name[4:]
|
||||||
|
repo_dir = repository_path.joinpath(repo_name + ".git")
|
||||||
|
logger.info(f"Resolved repo path: {repo_dir}")
|
||||||
|
return repo_dir
|
||||||
|
async def handle_info_refs(service):
|
||||||
|
repo_path = await get_repository_path()
|
||||||
|
|
||||||
|
logger.info(f"handle_info_refs: {repo_path}")
|
||||||
|
if not os.path.exists(repo_path):
|
||||||
|
return web.Response(text="Repository not found", status=404)
|
||||||
|
cmd = [service, '--stateless-rpc', '--advertise-refs', str(repo_path)]
|
||||||
|
try:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE
|
||||||
|
)
|
||||||
|
stdout, stderr = await process.communicate()
|
||||||
|
if process.returncode != 0:
|
||||||
|
logger.error(f"Git command failed: {stderr.decode()}")
|
||||||
|
return web.Response(text=f"Git error: {stderr.decode()}", status=500)
|
||||||
|
response = web.StreamResponse(
|
||||||
|
status=200,
|
||||||
|
reason='OK',
|
||||||
|
headers={
|
||||||
|
'Content-Type': f'application/x-{service}-advertisement',
|
||||||
|
'Cache-Control': 'no-cache'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await response.prepare(request)
|
||||||
|
packet = f"# service={service}\n"
|
||||||
|
length = len(packet) + 4
|
||||||
|
header = f"{length:04x}"
|
||||||
|
await response.write(f"{header}{packet}0000".encode())
|
||||||
|
await response.write(stdout)
|
||||||
|
return response
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error handling info/refs: {str(e)}")
|
||||||
|
return web.Response(text=f"Server error: {str(e)}", status=500)
|
||||||
|
async def handle_service_rpc(service):
|
||||||
|
repo_path = await get_repository_path()
|
||||||
|
logger.info(f"handle_service_rpc: {repo_path}")
|
||||||
|
if not os.path.exists(repo_path):
|
||||||
|
return web.Response(text="Repository not found", status=404)
|
||||||
|
if not request.headers.get('Content-Type') == f'application/x-{service}-request':
|
||||||
|
return web.Response(text="Invalid Content-Type", status=403)
|
||||||
|
body = await request.read()
|
||||||
|
cmd = [service, '--stateless-rpc', str(repo_path)]
|
||||||
|
try:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE
|
||||||
|
)
|
||||||
|
stdout, stderr = await process.communicate(input=body)
|
||||||
|
if process.returncode != 0:
|
||||||
|
logger.error(f"Git command failed: {stderr.decode()}")
|
||||||
|
return web.Response(text=f"Git error: {stderr.decode()}", status=500)
|
||||||
|
return web.Response(
|
||||||
|
body=stdout,
|
||||||
|
content_type=f'application/x-{service}-result'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error handling service RPC: {str(e)}")
|
||||||
|
return web.Response(text=f"Server error: {str(e)}", status=500)
|
||||||
|
if request.method == 'GET' and path.endswith('/info/refs'):
|
||||||
|
service = request.query.get('service')
|
||||||
|
if service in ('git-upload-pack', 'git-receive-pack'):
|
||||||
|
return await handle_info_refs(service)
|
||||||
|
else:
|
||||||
|
return web.Response(text="Smart HTTP requires service parameter", status=400)
|
||||||
|
elif request.method == 'POST' and '/git-upload-pack' in path:
|
||||||
|
return await handle_service_rpc('git-upload-pack')
|
||||||
|
elif request.method == 'POST' and '/git-receive-pack' in path:
|
||||||
|
return await handle_service_rpc('git-receive-pack')
|
||||||
|
return web.Response(text="Not found", status=404)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
import uvloop
|
||||||
|
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||||
|
logger.info("Using uvloop for improved performance")
|
||||||
|
except ImportError:
|
||||||
|
logger.info("uvloop not available, using standard event loop")
|
||||||
|
app = GitApplication()
|
||||||
|
logger.info("Starting Git server on port 8080")
|
||||||
|
web.run_app(app, port=8080)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import asyncssh
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
global _app
|
||||||
|
|
||||||
|
def set_app(app):
|
||||||
|
global _app
|
||||||
|
_app = app
|
||||||
|
|
||||||
|
def get_app():
|
||||||
|
return _app
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
roots = {}
|
||||||
|
|
||||||
|
class SFTPServer(asyncssh.SFTPServer):
|
||||||
|
|
||||||
|
def __init__(self, chan: asyncssh.SSHServerChannel):
|
||||||
|
self.root = get_app().services.user.get_home_folder_by_username(
|
||||||
|
chan.get_extra_info('username')
|
||||||
|
)
|
||||||
|
self.root.mkdir(exist_ok=True)
|
||||||
|
self.root = str(self.root)
|
||||||
|
super().__init__(chan, chroot=self.root)
|
||||||
|
|
||||||
|
def map_path(self, path):
|
||||||
|
mapped_path = Path(self.root).joinpath(path.lstrip(b"/").decode())
|
||||||
|
logger.debug(f"Mapping client path {path} to {mapped_path}")
|
||||||
|
return str(mapped_path).encode()
|
||||||
|
|
||||||
|
class SSHServer(asyncssh.SSHServer):
|
||||||
|
def password_auth_supported(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def validate_password(self, username, password):
|
||||||
|
logger.debug(f"Validating credentials for user {username}")
|
||||||
|
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")
|
||||||
|
|
||||||
|
host_key_path = Path("drive") / ".ssh" / "sftp_server_key"
|
||||||
|
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}")
|
||||||
|
key = asyncssh.generate_private_key("ecdsa-sha2-nistp256")
|
||||||
|
key.write_private_key(host_key_path)
|
||||||
|
else:
|
||||||
|
logger.info(f"Loading existing host key from {host_key_path}")
|
||||||
|
key = asyncssh.read_private_key(host_key_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to generate or load host key: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
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=SSHServer,
|
||||||
|
sftp_factory=SFTPServer
|
||||||
|
)
|
||||||
|
return x
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to start SFTP server. Already running.")
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
+224
-192
@@ -1,236 +1,268 @@
|
|||||||
// Written by retoor@molodetz.nl
|
// Written by retoor@molodetz.nl
|
||||||
|
|
||||||
// This project implements a client-server communication system using WebSockets and REST APIs.
|
// This project implements a client-server communication system using WebSockets and REST APIs.
|
||||||
// It features a chat system, a notification sound system, and interaction with server endpoints.
|
// It features a chat system, a notification sound system, and interaction with server endpoints.
|
||||||
|
|
||||||
// No additional imports were used beyond standard JavaScript objects and constructors.
|
// No additional imports were used beyond standard JavaScript objects and constructors.
|
||||||
|
|
||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
import { Schedule } from './schedule.js';
|
import { Schedule } from "./schedule.js";
|
||||||
import { EventHandler } from "./event-handler.js";
|
import { EventHandler } from "./event-handler.js";
|
||||||
import { Socket } from "./socket.js";
|
import { Socket } from "./socket.js";
|
||||||
|
|
||||||
export class RESTClient {
|
export class RESTClient {
|
||||||
debug = false;
|
debug = false;
|
||||||
|
|
||||||
async get(url, params = {}) {
|
async get(url, params = {}) {
|
||||||
const encodedParams = new URLSearchParams(params);
|
const encodedParams = new URLSearchParams(params);
|
||||||
if (encodedParams) url += '?' + encodedParams;
|
if (encodedParams) url += "?" + encodedParams;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.debug({ url, params, result });
|
console.debug({ url, params, result });
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async post(url, data) {
|
async post(url, data) {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.debug({ url, data, result });
|
console.debug({ url, data, result });
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Chat extends EventHandler {
|
export class Chat extends EventHandler {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this._url = window.location.hostname === 'localhost' ? 'ws://localhost/chat.ws' : 'wss://' + window.location.hostname + '/chat.ws';
|
this._url =
|
||||||
this._socket = null;
|
window.location.hostname === "localhost"
|
||||||
this._waitConnect = null;
|
? "ws://localhost/chat.ws"
|
||||||
this._promises = {};
|
: "wss://" + window.location.hostname + "/chat.ws";
|
||||||
|
this._socket = null;
|
||||||
|
this._waitConnect = null;
|
||||||
|
this._promises = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (this._waitConnect) {
|
||||||
|
return this._waitConnect;
|
||||||
}
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this._waitConnect = resolve;
|
||||||
|
console.debug("Connecting..");
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._socket = new WebSocket(this._url);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.ensureConnection();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
connect() {
|
this._socket.onconnect = () => {
|
||||||
if (this._waitConnect) {
|
this._connected();
|
||||||
return this._waitConnect;
|
this._waitSocket();
|
||||||
}
|
};
|
||||||
return new Promise((resolve) => {
|
});
|
||||||
this._waitConnect = resolve;
|
}
|
||||||
console.debug("Connecting..");
|
|
||||||
|
|
||||||
try {
|
generateUniqueId() {
|
||||||
this._socket = new WebSocket(this._url);
|
return "id-" + Math.random().toString(36).substr(2, 9);
|
||||||
} catch (e) {
|
}
|
||||||
console.warn(e);
|
|
||||||
setTimeout(() => {
|
|
||||||
this.ensureConnection();
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._socket.onconnect = () => {
|
call(method, ...args) {
|
||||||
this._connected();
|
return new Promise((resolve, reject) => {
|
||||||
this._waitSocket();
|
try {
|
||||||
};
|
const command = { method, args, message_id: this.generateUniqueId() };
|
||||||
});
|
this._promises[command.message_id] = resolve;
|
||||||
}
|
this._socket.send(JSON.stringify(command));
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
generateUniqueId() {
|
_connected() {
|
||||||
return 'id-' + Math.random().toString(36).substr(2, 9);
|
this._socket.onmessage = (event) => {
|
||||||
}
|
const message = JSON.parse(event.data);
|
||||||
|
if (message.message_id && this._promises[message.message_id]) {
|
||||||
|
this._promises[message.message_id](message);
|
||||||
|
delete this._promises[message.message_id];
|
||||||
|
} else {
|
||||||
|
this.emit("message", message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this._socket.onclose = () => {
|
||||||
|
this._waitSocket = null;
|
||||||
|
this._socket = null;
|
||||||
|
this.emit("close");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
call(method, ...args) {
|
async privmsg(room, text) {
|
||||||
return new Promise((resolve, reject) => {
|
await rest.post("/api/privmsg", {
|
||||||
try {
|
room,
|
||||||
const command = { method, args, message_id: this.generateUniqueId() };
|
text,
|
||||||
this._promises[command.message_id] = resolve;
|
});
|
||||||
this._socket.send(JSON.stringify(command));
|
}
|
||||||
} catch (e) {
|
|
||||||
reject(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_connected() {
|
|
||||||
this._socket.onmessage = (event) => {
|
|
||||||
const message = JSON.parse(event.data);
|
|
||||||
if (message.message_id && this._promises[message.message_id]) {
|
|
||||||
this._promises[message.message_id](message);
|
|
||||||
delete this._promises[message.message_id];
|
|
||||||
} else {
|
|
||||||
this.emit("message", message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this._socket.onclose = () => {
|
|
||||||
this._waitSocket = null;
|
|
||||||
this._socket = null;
|
|
||||||
this.emit('close');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async privmsg(room, text) {
|
|
||||||
await rest.post("/api/privmsg", {
|
|
||||||
room,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NotificationAudio {
|
export class NotificationAudio {
|
||||||
constructor(timeout = 500) {
|
constructor(timeout = 500) {
|
||||||
this.schedule = new Schedule(timeout);
|
this.schedule = new Schedule(timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
sounds = {
|
sounds = {
|
||||||
"message": "/audio/soundfx.d_beep3.mp3",
|
message: "/audio/soundfx.d_beep3.mp3",
|
||||||
"mention": "/audio/750607__deadrobotmusic__notification-sound-1.wav",
|
mention: "/audio/750607__deadrobotmusic__notification-sound-1.wav",
|
||||||
"messageOtherChannel": "/audio/750608__deadrobotmusic__notification-sound-2.wav",
|
messageOtherChannel:
|
||||||
"ping": "/audio/750609__deadrobotmusic__notification-sound-3.wav",
|
"/audio/750608__deadrobotmusic__notification-sound-2.wav",
|
||||||
}
|
ping: "/audio/750609__deadrobotmusic__notification-sound-3.wav",
|
||||||
|
};
|
||||||
|
|
||||||
play(soundIndex = 0) {
|
play(soundIndex = 0) {
|
||||||
this.schedule.delay(() => {
|
this.schedule.delay(() => {
|
||||||
new Audio(this.sounds[soundIndex]).play()
|
new Audio(this.sounds[soundIndex])
|
||||||
.then(() => {
|
.play()
|
||||||
console.debug("Gave sound notification");
|
.then(() => {
|
||||||
})
|
console.debug("Gave sound notification");
|
||||||
.catch(error => {
|
})
|
||||||
console.error("Notification failed:", error);
|
.catch((error) => {
|
||||||
});
|
console.error("Notification failed:", error);
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class App extends EventHandler {
|
export class App extends EventHandler {
|
||||||
rest = new RESTClient();
|
rest = new RESTClient();
|
||||||
ws = null;
|
ws = null;
|
||||||
rpc = null;
|
rpc = null;
|
||||||
audio = null;
|
audio = null;
|
||||||
user = {};
|
user = {};
|
||||||
|
typeLock = null;
|
||||||
|
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) {
|
async ping(...args) {
|
||||||
if (this.is_pinging) return false
|
if (this.is_pinging) return false;
|
||||||
this.is_pinging = true
|
this.is_pinging = true;
|
||||||
await this.rpc.ping(...args);
|
await this.rpc.ping(...args);
|
||||||
this.is_pinging = false
|
this.is_pinging = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async forcePing(...arg) {
|
async forcePing(...arg) {
|
||||||
await this.rpc.ping(...args);
|
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() {
|
const me = this;
|
||||||
super();
|
this.ws.addEventListener("connected", (data) => {
|
||||||
this.ws = new Socket();
|
this.ping("online");
|
||||||
this.rpc = this.ws.client;
|
});
|
||||||
this.audio = new NotificationAudio(500);
|
this.ws.addEventListener("reconnecting", (data) => {
|
||||||
this.is_pinging = false
|
this.starField?.showNotify("Connecting..","#CC0000")
|
||||||
this.ping_interval = setInterval(() => {
|
})
|
||||||
this.ping("active")
|
this.ws.addEventListener("channel-message", (data) => {
|
||||||
}, 15000)
|
me.emit("channel-message", data);
|
||||||
|
});
|
||||||
|
this.ws.addEventListener("data", (data) => {
|
||||||
const me = this
|
if(this._debug){
|
||||||
this.ws.addEventListener("connected", (data) => {
|
console.debug(data)
|
||||||
this.ping("online")
|
|
||||||
})
|
|
||||||
this.ws.addEventListener("channel-message", (data) => {
|
|
||||||
me.emit("channel-message", data);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.rpc.getUser(null).then(user => {
|
|
||||||
me.user = user;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
playSound(index) {
|
|
||||||
this.audio.play(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
timeDescription(isoDate) {
|
|
||||||
const date = new Date(isoDate);
|
|
||||||
const hours = String(date.getHours()).padStart(2, "0");
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
||||||
let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;
|
|
||||||
return timeStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
timeAgo(date1, date2) {
|
|
||||||
const diffMs = Math.abs(date2 - date1);
|
|
||||||
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
|
||||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
|
||||||
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
|
|
||||||
|
|
||||||
if (days) {
|
|
||||||
return `${days} ${days > 1 ? 'days' : 'day'} ago`;
|
|
||||||
}
|
}
|
||||||
if (hours) {
|
});
|
||||||
return `${hours} ${hours > 1 ? 'hours' : 'hour'} ago`;
|
this.rpc.getUser(null).then((user) => {
|
||||||
}
|
me.user = user;
|
||||||
if (minutes) {
|
});
|
||||||
return `${minutes} ${minutes > 1 ? 'minutes' : 'minute'} ago`;
|
}
|
||||||
}
|
|
||||||
return 'just now';
|
|
||||||
}
|
|
||||||
|
|
||||||
async benchMark(times = 100, message = "Benchmark Message") {
|
playSound(index) {
|
||||||
const promises = [];
|
this.audio.play(index);
|
||||||
const me = this;
|
}
|
||||||
for (let i = 0; i < times; i++) {
|
|
||||||
promises.push(this.rpc.getChannels().then(channels => {
|
timeDescription(isoDate) {
|
||||||
channels.forEach(channel => {
|
const date = new Date(isoDate);
|
||||||
me.rpc.sendMessage(channel.uid, `${message} ${i}`);
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
});
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||||
}));
|
let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;
|
||||||
}
|
return timeStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
timeAgo(date1, date2) {
|
||||||
|
const diffMs = Math.abs(date2 - date1);
|
||||||
|
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
const hours = Math.floor(
|
||||||
|
(diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60),
|
||||||
|
);
|
||||||
|
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
|
||||||
|
|
||||||
|
if (days) {
|
||||||
|
return `${days} ${days > 1 ? "days" : "day"} ago`;
|
||||||
}
|
}
|
||||||
|
if (hours) {
|
||||||
|
return `${hours} ${hours > 1 ? "hours" : "hour"} ago`;
|
||||||
|
}
|
||||||
|
if (minutes) {
|
||||||
|
return `${minutes} ${minutes > 1 ? "minutes" : "minute"} ago`;
|
||||||
|
}
|
||||||
|
return "just now";
|
||||||
|
}
|
||||||
|
|
||||||
|
async benchMark(times = 100, message = "Benchmark Message") {
|
||||||
|
const promises = [];
|
||||||
|
const me = this;
|
||||||
|
for (let i = 0; i < times; i++) {
|
||||||
|
promises.push(
|
||||||
|
this.rpc.getChannels().then((channels) => {
|
||||||
|
channels.forEach((channel) => {
|
||||||
|
me.rpc.sendMessage(channel.uid, `${message} ${i}`);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const app = new App();
|
export const app = new App();
|
||||||
window.app = app;
|
window.app = app;
|
||||||
|
|||||||
+245
-62
@@ -5,6 +5,10 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.gallery {
|
.gallery {
|
||||||
padding: 50px;
|
padding: 50px;
|
||||||
height: auto;
|
height: auto;
|
||||||
@@ -25,33 +29,35 @@ body {
|
|||||||
background-color: #000000;
|
background-color: #000000;
|
||||||
color: #e6e6e6;
|
color: #e6e6e6;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: auto 1fr;
|
||||||
height: 100vh;
|
grid-template-rows: auto 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
"header header"
|
||||||
|
"sidebar chat-area";
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
grid-area: chat-area;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
background-color: #000000;
|
grid-area: header;
|
||||||
padding-top: 10px;
|
padding: 10px 20px;
|
||||||
padding-left: 20px;
|
|
||||||
padding-right: 20px;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
header .logo {
|
header .logo {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,13 +87,13 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
color: #f05a28;
|
color: #f05a28;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.4em;
|
font-size: 1.4em;
|
||||||
color: #f05a28;
|
color: #f05a28;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -95,13 +101,11 @@ h2 {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background-color: #000000;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-header {
|
.chat-header {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
background-color: #000000;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,10 +116,10 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-list {
|
.message-list {
|
||||||
@@ -132,7 +136,11 @@ footer {
|
|||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
background: #000000;
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@@ -152,6 +160,10 @@ footer {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-messages picture img {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-messages::-webkit-scrollbar {
|
.chat-messages::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -228,15 +240,14 @@ footer {
|
|||||||
|
|
||||||
.chat-input {
|
.chat-input {
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
background-color: #000000;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"], .chat-input textarea {
|
input[type="text"], .chat-input textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background-color: #000000;
|
|
||||||
color: white;
|
color: white;
|
||||||
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@@ -273,13 +284,18 @@ input[type="text"], .chat-input textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.author, .time {
|
.author {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:not(:hover, :focus-within, :active) .time {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.switch-user {
|
.message.switch-user {
|
||||||
@@ -291,6 +307,7 @@ input[type="text"], .chat-input textarea {
|
|||||||
.avatar {
|
.avatar {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
max-height: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.author {
|
.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 {
|
.time {
|
||||||
display: block;
|
display: block;
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,11 +352,11 @@ a {
|
|||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 250px;
|
width: 250px;
|
||||||
background-color: #000000;
|
|
||||||
padding-left: 20px;
|
padding-left: 20px;
|
||||||
padding-right: 20px;
|
padding-right: 20px;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
grid-area: sidebar;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar h2 {
|
.sidebar h2 {
|
||||||
@@ -366,39 +384,204 @@ a {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 768px) {
|
@keyframes glow {
|
||||||
|
0% {
|
||||||
header{
|
box-shadow: 0 0 5px #3498db;
|
||||||
position:fixed;
|
}
|
||||||
top: 0;
|
50% {
|
||||||
left: 0;
|
box-shadow: 0 0 20px #3498db, 0 0 30px #3498db;
|
||||||
text-overflow: ellipsis;
|
}
|
||||||
width:100%;
|
100% {
|
||||||
*{
|
box-shadow: 0 0 5px #3498db;
|
||||||
font-size: 12px !important;
|
}
|
||||||
}
|
|
||||||
.logo {
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
h2 {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
body {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
header{
|
|
||||||
position: sticky;
|
|
||||||
display: block;
|
|
||||||
.logo {
|
|
||||||
display:block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.chat-input {
|
|
||||||
position:sticky;
|
|
||||||
}*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.glow {
|
||||||
|
animation: glow 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media only screen and (max-width: 768px) {
|
||||||
|
|
||||||
|
header {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: block;
|
||||||
|
flex: 1;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
text-align: right;
|
||||||
|
flex: 1;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
body {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
header{
|
||||||
|
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.
|
class ChatInputComponent extends HTMLElement {
|
||||||
|
autoCompletions = {
|
||||||
// Includes standard DOM manipulation methods; no external imports used.
|
"example 1": () => {},
|
||||||
|
"example 2": () => {},
|
||||||
// MIT License: This code is open-source and can be reused and distributed under the terms of the MIT License.
|
}
|
||||||
|
hiddenCompletions = {
|
||||||
class ChatInputElement extends HTMLElement {
|
"/starsRender": () => {
|
||||||
_chatWindow = null
|
app.rpc.starsRender(this.channelUid,this.value.replace("/starsRender ",""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
users = []
|
||||||
|
textarea = null
|
||||||
|
_value = ""
|
||||||
|
lastUpdateEvent = null
|
||||||
|
previousValue = ""
|
||||||
|
lastChange = null
|
||||||
|
changed = false
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: 'open' });
|
this.lastUpdateEvent = new Date();
|
||||||
this.component = document.createElement('div');
|
this.textarea = document.createElement("textarea");
|
||||||
this.shadowRoot.appendChild(this.component);
|
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');
|
set value(value) {
|
||||||
this.container.classList.add('chat-input');
|
this._value = value || "";
|
||||||
this.container.innerHTML = `
|
this.textarea.value = this._value;
|
||||||
<textarea placeholder="Type a message..." rows="2"></textarea>
|
}
|
||||||
<upload-button></upload-button>
|
get allAutoCompletions() {
|
||||||
`;
|
return Object.assign({},this.autoCompletions,this.hiddenCompletions)
|
||||||
this.textBox = this.container.querySelector('textarea');
|
}
|
||||||
this.uploadButton = this.container.querySelector('upload-button');
|
resolveAutoComplete() {
|
||||||
this.uploadButton.chatInput = this
|
let count = 0;
|
||||||
this.textBox.addEventListener('input', (e) => {
|
let value = null;
|
||||||
this.dispatchEvent(new CustomEvent('input', { detail: e.target.value, bubbles: true }));
|
|
||||||
const message = e.target.value;
|
Object.keys(this.allAutoCompletions).forEach((key) => {
|
||||||
const button = this.container.querySelector('button');
|
if (key.startsWith(this.value.split(" ")[0])) {
|
||||||
button.disabled = !message;
|
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) => {
|
return { mention, closestAuthor, distance: minDistance };
|
||||||
e.preventDefault();
|
});
|
||||||
this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));
|
}
|
||||||
console.error(e.target.value);
|
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) => {
|
this.appendChild(this.uploadButton);
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
|
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();
|
e.preventDefault();
|
||||||
const message = e.target.value.trim();
|
autoCompletion = this.resolveAutoComplete();
|
||||||
if (!message) return;
|
if (autoCompletion) {
|
||||||
this.dispatchEvent(new CustomEvent('submit', { detail: message, bubbles: true }));
|
e.target.value = autoCompletion;
|
||||||
e.target.value = '';
|
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);
|
||||||
|
|||||||
@@ -6,77 +6,77 @@
|
|||||||
|
|
||||||
// The MIT License (MIT)
|
// The MIT License (MIT)
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
//
|
//
|
||||||
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
//
|
//
|
||||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
class ChatWindowElement extends HTMLElement {
|
class ChatWindowElement extends HTMLElement {
|
||||||
receivedHistory = false;
|
receivedHistory = false;
|
||||||
channel = null
|
channel = null;
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: 'open' });
|
this.attachShadow({ mode: "open" });
|
||||||
this.component = document.createElement('section');
|
this.component = document.createElement("section");
|
||||||
this.app = app;
|
this.app = app;
|
||||||
this.shadowRoot.appendChild(this.component);
|
this.shadowRoot.appendChild(this.component);
|
||||||
}
|
}
|
||||||
|
|
||||||
get user() {
|
get user() {
|
||||||
return this.app.user;
|
return this.app.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
const link = document.createElement('link');
|
const link = document.createElement("link");
|
||||||
link.rel = 'stylesheet';
|
link.rel = "stylesheet";
|
||||||
link.href = '/base.css';
|
link.href = "/base.css";
|
||||||
this.component.appendChild(link);
|
this.component.appendChild(link);
|
||||||
this.component.classList.add("chat-area");
|
this.component.classList.add("chat-area");
|
||||||
|
|
||||||
this.container = document.createElement("section");
|
|
||||||
this.container.classList.add("chat-area", "chat-window");
|
|
||||||
|
|
||||||
const chatHeader = document.createElement("div");
|
this.container = document.createElement("section");
|
||||||
chatHeader.classList.add("chat-header");
|
this.container.classList.add("chat-area", "chat-window");
|
||||||
|
|
||||||
const chatTitle = document.createElement('h2');
|
const chatHeader = document.createElement("div");
|
||||||
chatTitle.classList.add("chat-title");
|
chatHeader.classList.add("chat-header");
|
||||||
chatTitle.classList.add("no-select");
|
|
||||||
chatTitle.innerText = "Loading...";
|
|
||||||
chatHeader.appendChild(chatTitle);
|
|
||||||
this.container.appendChild(chatHeader);
|
|
||||||
|
|
||||||
const channels = await app.rpc.getChannels();
|
const chatTitle = document.createElement("h2");
|
||||||
const channel = channels[0];
|
chatTitle.classList.add("chat-title");
|
||||||
this.channel = channel;
|
chatTitle.classList.add("no-select");
|
||||||
chatTitle.innerText = channel.name;
|
chatTitle.innerText = "Loading...";
|
||||||
|
chatHeader.appendChild(chatTitle);
|
||||||
|
this.container.appendChild(chatHeader);
|
||||||
|
|
||||||
const channelElement = document.createElement('message-list');
|
const channels = await app.rpc.getChannels();
|
||||||
channelElement.setAttribute("channel", channel.uid);
|
const channel = channels[0];
|
||||||
this.container.appendChild(channelElement);
|
this.channel = channel;
|
||||||
|
chatTitle.innerText = channel.name;
|
||||||
|
|
||||||
const chatInput = document.createElement('chat-input');
|
const channelElement = document.createElement("message-list");
|
||||||
chatInput.chatWindow = this;
|
channelElement.setAttribute("channel", channel.uid);
|
||||||
chatInput.addEventListener("submit", (e) => {
|
this.container.appendChild(channelElement);
|
||||||
app.rpc.sendMessage(channel.uid, e.detail);
|
|
||||||
});
|
|
||||||
this.container.appendChild(chatInput);
|
|
||||||
|
|
||||||
this.component.appendChild(this.container);
|
const chatInput = document.createElement("chat-input");
|
||||||
|
chatInput.chatWindow = this;
|
||||||
|
chatInput.addEventListener("submit", (e) => {
|
||||||
|
app.rpc.sendMessage(channel.uid, e.detail);
|
||||||
|
});
|
||||||
|
this.container.appendChild(chatInput);
|
||||||
|
|
||||||
const messages = await app.rpc.getMessages(channel.uid);
|
this.component.appendChild(this.container);
|
||||||
messages.forEach(message => {
|
|
||||||
if (!message['user_nick']) return;
|
|
||||||
channelElement.addMessage(message);
|
|
||||||
});
|
|
||||||
|
|
||||||
const me = this;
|
const messages = await app.rpc.getMessages(channel.uid);
|
||||||
channelElement.addEventListener("message", (message) => {
|
messages.forEach((message) => {
|
||||||
if (me.user.uid !== message.detail.user_uid) app.playSound(0);
|
if (!message["user_nick"]) return;
|
||||||
|
channelElement.addMessage(message);
|
||||||
message.detail.element.scrollIntoView({"block": "end"});
|
});
|
||||||
});
|
|
||||||
}
|
const me = this;
|
||||||
|
channelElement.addEventListener("message", (message) => {
|
||||||
|
if (me.user.uid !== message.detail.user_uid) app.playSound(0);
|
||||||
|
|
||||||
|
message.detail.element.scrollIntoView({ block: "end" });
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('chat-window', ChatWindowElement);
|
customElements.define("chat-window", ChatWindowElement);
|
||||||
|
|||||||
@@ -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 {
|
export class EventHandler {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.subscribers = {};
|
this.subscribers = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
addEventListener(type, handler) {
|
addEventListener(type, handler) {
|
||||||
if (!this.subscribers[type]) this.subscribers[type] = [];
|
if (!this.subscribers[type]) this.subscribers[type] = [];
|
||||||
this.subscribers[type].push(handler);
|
this.subscribers[type].push(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(type, ...data) {
|
emit(type, ...data) {
|
||||||
if (this.subscribers[type]) this.subscribers[type].forEach(handler => handler(...data));
|
if (this.subscribers[type])
|
||||||
}
|
this.subscribers[type].forEach((handler) => handler(...data));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,28 +2,25 @@
|
|||||||
|
|
||||||
// This JavaScript class defines a custom HTML element <fancy-button>, which creates a styled, clickable button element with customizable size, text, and URL redirect functionality.
|
// This JavaScript class defines a custom HTML element <fancy-button>, which creates a styled, clickable button element with customizable size, text, and URL redirect functionality.
|
||||||
|
|
||||||
|
|
||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class FancyButton extends HTMLElement {
|
class FancyButton extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: 'open' });
|
this.attachShadow({ mode: "open" });
|
||||||
this.url = null;
|
this.url = null;
|
||||||
this.type = "button";
|
this.type = "button";
|
||||||
this.value = null;
|
this.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.container = document.createElement('span');
|
this.container = document.createElement("span");
|
||||||
let size = this.getAttribute('size');
|
let size = this.getAttribute("size");
|
||||||
console.info({ GG: size });
|
console.info({ GG: size });
|
||||||
size = size === 'auto' ? '1%' : '33%';
|
size = size === "auto" ? "1%" : "33%";
|
||||||
|
|
||||||
this.styleElement = document.createElement("style");
|
this.styleElement = document.createElement("style");
|
||||||
this.styleElement.innerHTML = `
|
this.styleElement.innerHTML = `
|
||||||
:root {
|
:root {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
--width: 100%;
|
--width: 100%;
|
||||||
@@ -49,29 +46,30 @@ class FancyButton extends HTMLElement {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.container.appendChild(this.styleElement);
|
this.container.appendChild(this.styleElement);
|
||||||
this.buttonElement = document.createElement('button');
|
this.buttonElement = document.createElement("button");
|
||||||
this.container.appendChild(this.buttonElement);
|
this.container.appendChild(this.buttonElement);
|
||||||
this.shadowRoot.appendChild(this.container);
|
this.shadowRoot.appendChild(this.container);
|
||||||
|
|
||||||
this.url = this.getAttribute('url');
|
this.url = this.getAttribute("url");
|
||||||
|
|
||||||
|
|
||||||
this.value = this.getAttribute('value');
|
this.value = this.getAttribute("value");
|
||||||
this.buttonElement.appendChild(document.createTextNode(this.getAttribute("text")));
|
this.buttonElement.appendChild(
|
||||||
this.buttonElement.addEventListener("click", () => {
|
document.createTextNode(this.getAttribute("text")),
|
||||||
if(this.url == 'submit'){
|
);
|
||||||
this.closest('form').submit()
|
this.buttonElement.addEventListener("click", () => {
|
||||||
return
|
if (this.url == "submit") {
|
||||||
}
|
this.closest("form").submit();
|
||||||
|
return;
|
||||||
if (this.url === "/back" || this.url === "/back/") {
|
}
|
||||||
window.history.back();
|
|
||||||
} else if (this.url) {
|
if (this.url === "/back" || this.url === "/back/") {
|
||||||
window.location = this.url;
|
window.history.back();
|
||||||
}
|
} else if (this.url) {
|
||||||
});
|
window.location = this.url;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("fancy-button", FancyButton);
|
customElements.define("fancy-button", FancyButton);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
.file-manager {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #111;
|
||||||
|
color: #ddd;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.file-tile {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.file-tile:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
.file-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.file-name {
|
||||||
|
font-size: 14px;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
.file-tile img {
|
||||||
|
max-width: 80%;
|
||||||
|
height: auto;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/* A <file-browser> custom element that talks to /api/files */
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.path = this.getAttribute("path") || "";
|
||||||
|
this.renderShell();
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- UI scaffolding -------------------------------------------
|
||||||
|
renderShell() {
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host { display:block; font-family: system-ui, sans-serif; box-sizing: border-box; }
|
||||||
|
nav { display:flex; flex-wrap:wrap; gap:.5rem; margin:.5rem 0; align-items:center; }
|
||||||
|
button { padding:.35rem .65rem; border:none; border-radius:4px; background:#f05a28; color:#fff; cursor:pointer; font:inherit; }
|
||||||
|
button:disabled { background:#999; cursor:not-allowed; }
|
||||||
|
.crumb { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); gap:1rem; }
|
||||||
|
.tile { border:1px solid #f05a28; border-radius:8px; padding:.5rem; background:#000000; text-align:center; cursor:pointer; transition:box-shadow .2s ease; }
|
||||||
|
.tile:hover { box-shadow:0 2px 8px rgba(0,0,0,.1); }
|
||||||
|
img.thumb { width:100%; height:90px; object-fit:cover; border-radius:6px; }
|
||||||
|
.icon { font-size:48px; line-height:90px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<button id="up">⬅️ Up</button>
|
||||||
|
<span class="crumb" id="crumb"></span>
|
||||||
|
</nav>
|
||||||
|
<div class="grid" id="grid"></div>
|
||||||
|
<nav>
|
||||||
|
<button id="prev">Prev</button>
|
||||||
|
<button id="next">Next</button>
|
||||||
|
</nav>
|
||||||
|
`;
|
||||||
|
this.shadowRoot
|
||||||
|
.getElementById("up")
|
||||||
|
.addEventListener("click", () => this.goUp());
|
||||||
|
this.shadowRoot.getElementById("prev").addEventListener("click", () => {
|
||||||
|
if (this.offset > 0) {
|
||||||
|
this.offset -= this.limit;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.shadowRoot.getElementById("next").addEventListener("click", () => {
|
||||||
|
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 data = await r.json();
|
||||||
|
this.renderTiles(data.items);
|
||||||
|
this.updateNav(data.pagination);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Rendering -------------------------------------------------
|
||||||
|
renderTiles(items) {
|
||||||
|
const grid = this.shadowRoot.getElementById("grid");
|
||||||
|
grid.innerHTML = "";
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (item.mimetype?.startsWith("image/")) {
|
||||||
|
tile.innerHTML = `<img class="thumb" src="${item.url}" alt="${item.name}"><div>${item.name}</div>`;
|
||||||
|
} else {
|
||||||
|
tile.innerHTML = `<div class="icon">đź“„</div><div>${item.name}</div>`;
|
||||||
|
}
|
||||||
|
tile.addEventListener("click", () => window.open(item.url, "_blank"));
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.appendChild(tile);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Navigation + pagination ----------------------------------
|
||||||
|
updateNav({ offset, limit, total }) {
|
||||||
|
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 === "";
|
||||||
|
}
|
||||||
|
|
||||||
|
goUp() {
|
||||||
|
if (!this.path) return;
|
||||||
|
this.path = this.path.split("/").slice(0, -1).join("/");
|
||||||
|
this.offset = 0;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("file-manager", FileBrowser);
|
||||||
@@ -40,7 +40,7 @@ class GenericField extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set value(val) {
|
set value(val) {
|
||||||
val = val ?? '';
|
val = val ?? "";
|
||||||
this.inputElement.value = val;
|
this.inputElement.value = val;
|
||||||
this.inputElement.setAttribute("value", val);
|
this.inputElement.setAttribute("value", val);
|
||||||
}
|
}
|
||||||
@@ -62,9 +62,9 @@ class GenericField extends HTMLElement {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({mode: 'open'});
|
this.attachShadow({ mode: "open" });
|
||||||
this.container = document.createElement('div');
|
this.container = document.createElement("div");
|
||||||
this.styleElement = document.createElement('style');
|
this.styleElement = document.createElement("style");
|
||||||
this.styleElement.innerHTML = `
|
this.styleElement.innerHTML = `
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@@ -174,7 +174,7 @@ class GenericField extends HTMLElement {
|
|||||||
|
|
||||||
if (this.inputElement == null && this.field) {
|
if (this.inputElement == null && this.field) {
|
||||||
this.inputElement = document.createElement(this.field.tag);
|
this.inputElement = document.createElement(this.field.tag);
|
||||||
if (this.field.tag === 'button' && this.field.value === "submit") {
|
if (this.field.tag === "button" && this.field.value === "submit") {
|
||||||
this.action = this.field.value;
|
this.action = this.field.value;
|
||||||
}
|
}
|
||||||
this.inputElement.name = this.field.name;
|
this.inputElement.name = this.field.name;
|
||||||
@@ -182,26 +182,39 @@ class GenericField extends HTMLElement {
|
|||||||
|
|
||||||
const me = this;
|
const me = this;
|
||||||
this.inputElement.addEventListener("keyup", (e) => {
|
this.inputElement.addEventListener("keyup", (e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === "Enter") {
|
||||||
const event = new CustomEvent("change", {detail: me, bubbles: true});
|
const event = new CustomEvent("change", {
|
||||||
|
detail: me,
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
me.dispatchEvent(event);
|
me.dispatchEvent(event);
|
||||||
|
|
||||||
me.dispatchEvent(new Event("submit"));
|
me.dispatchEvent(new Event("submit"));
|
||||||
} else if (me.field.value !== e.target.value) {
|
} else if (me.field.value !== e.target.value) {
|
||||||
const event = new CustomEvent("change", {detail: me, bubbles: true});
|
const event = new CustomEvent("change", {
|
||||||
|
detail: me,
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
me.dispatchEvent(event);
|
me.dispatchEvent(event);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.inputElement.addEventListener("click", (e) => {
|
this.inputElement.addEventListener("click", (e) => {
|
||||||
const event = new CustomEvent("click", {detail: me, bubbles: true});
|
const event = new CustomEvent("click", { detail: me, bubbles: true });
|
||||||
me.dispatchEvent(event);
|
me.dispatchEvent(event);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.inputElement.addEventListener("blur", (e) => {
|
this.inputElement.addEventListener(
|
||||||
const event = new CustomEvent("change", {detail: me, bubbles: true});
|
"blur",
|
||||||
me.dispatchEvent(event);
|
(e) => {
|
||||||
}, true);
|
const event = new CustomEvent("change", {
|
||||||
|
detail: me,
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
|
me.dispatchEvent(event);
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
this.container.appendChild(this.inputElement);
|
this.container.appendChild(this.inputElement);
|
||||||
}
|
}
|
||||||
@@ -210,8 +223,8 @@ class GenericField extends HTMLElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.inputElement.setAttribute("type", this.field.type ?? 'input');
|
this.inputElement.setAttribute("type", this.field.type ?? "input");
|
||||||
this.inputElement.setAttribute("name", this.field.name ?? '');
|
this.inputElement.setAttribute("name", this.field.name ?? "");
|
||||||
|
|
||||||
if (this.field.text != null) {
|
if (this.field.text != null) {
|
||||||
this.inputElement.innerText = this.field.text;
|
this.inputElement.innerText = this.field.text;
|
||||||
@@ -239,14 +252,14 @@ class GenericField extends HTMLElement {
|
|||||||
this.inputElement.removeAttribute("required");
|
this.inputElement.removeAttribute("required");
|
||||||
}
|
}
|
||||||
if (!this.footerElement) {
|
if (!this.footerElement) {
|
||||||
this.footerElement = document.createElement('div');
|
this.footerElement = document.createElement("div");
|
||||||
this.footerElement.style.clear = 'both';
|
this.footerElement.style.clear = "both";
|
||||||
this.container.appendChild(this.footerElement);
|
this.container.appendChild(this.footerElement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('generic-field', GenericField);
|
customElements.define("generic-field", GenericField);
|
||||||
|
|
||||||
class GenericForm extends HTMLElement {
|
class GenericForm extends HTMLElement {
|
||||||
fields = {};
|
fields = {};
|
||||||
@@ -254,7 +267,7 @@ class GenericForm extends HTMLElement {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({mode: 'open'});
|
this.attachShadow({ mode: "open" });
|
||||||
this.styleElement = document.createElement("style");
|
this.styleElement = document.createElement("style");
|
||||||
this.styleElement.innerHTML = `
|
this.styleElement.innerHTML = `
|
||||||
|
|
||||||
@@ -281,27 +294,29 @@ class GenericForm extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
this.container = document.createElement('div');
|
this.container = document.createElement("div");
|
||||||
this.container.appendChild(this.styleElement);
|
this.container.appendChild(this.styleElement);
|
||||||
this.container.classList.add("generic-form-container");
|
this.container.classList.add("generic-form-container");
|
||||||
this.shadowRoot.appendChild(this.container);
|
this.shadowRoot.appendChild(this.container);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
const preloadedForm = this.getAttribute('preloaded-structure');
|
const preloadedForm = this.getAttribute("preloaded-structure");
|
||||||
if (preloadedForm) {
|
if (preloadedForm) {
|
||||||
try {
|
try {
|
||||||
const form = JSON.parse(preloadedForm);
|
const form = JSON.parse(preloadedForm);
|
||||||
this.constructForm(form)
|
this.constructForm(form);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error, preloadedForm);
|
console.error(error, preloadedForm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const url = this.getAttribute('url');
|
const url = this.getAttribute("url");
|
||||||
if (url) {
|
if (url) {
|
||||||
const fullUrl = url.startsWith("/") ? window.location.origin + url : new URL(window.location.origin + "/http-get");
|
const fullUrl = url.startsWith("/")
|
||||||
|
? window.location.origin + url
|
||||||
|
: new URL(window.location.origin + "/http-get");
|
||||||
if (!url.startsWith("/")) {
|
if (!url.startsWith("/")) {
|
||||||
fullUrl.searchParams.set('url', url);
|
fullUrl.searchParams.set("url", url);
|
||||||
}
|
}
|
||||||
this.loadForm(fullUrl.toString());
|
this.loadForm(fullUrl.toString());
|
||||||
} else {
|
} else {
|
||||||
@@ -318,10 +333,10 @@ class GenericForm extends HTMLElement {
|
|||||||
let hasAutoFocus = Object.keys(this.fields).length !== 0;
|
let hasAutoFocus = Object.keys(this.fields).length !== 0;
|
||||||
|
|
||||||
fields.sort((a, b) => a.index - b.index);
|
fields.sort((a, b) => a.index - b.index);
|
||||||
fields.forEach(field => {
|
fields.forEach((field) => {
|
||||||
const updatingField = field.name in this.fields
|
const updatingField = field.name in this.fields;
|
||||||
|
|
||||||
this.fields[field.name] ??= document.createElement('generic-field');
|
this.fields[field.name] ??= document.createElement("generic-field");
|
||||||
|
|
||||||
const fieldElement = this.fields[field.name];
|
const fieldElement = this.fields[field.name];
|
||||||
|
|
||||||
@@ -362,7 +377,7 @@ class GenericForm extends HTMLElement {
|
|||||||
window.location.pathname = saveResult.redirect_url;
|
window.location.pathname = saveResult.redirect_url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -374,7 +389,9 @@ class GenericForm extends HTMLElement {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
|
throw new Error(
|
||||||
|
`Failed to fetch: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.constructForm(await response.json());
|
await this.constructForm(await response.json());
|
||||||
@@ -387,15 +404,15 @@ class GenericForm extends HTMLElement {
|
|||||||
const url = this.getAttribute("url");
|
const url = this.getAttribute("url");
|
||||||
|
|
||||||
let response = await fetch(url, {
|
let response = await fetch(url, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({"action": "validate", "form": this.form})
|
body: JSON.stringify({ action: "validate", form: this.form }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = await response.json();
|
const form = await response.json();
|
||||||
Object.values(form.fields).forEach(field => {
|
Object.values(form.fields).forEach((field) => {
|
||||||
if (!this.form.fields[field.name]) {
|
if (!this.form.fields[field.name]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -409,23 +426,23 @@ class GenericForm extends HTMLElement {
|
|||||||
this.fields[field.name].setAttribute("field", field);
|
this.fields[field.name].setAttribute("field", field);
|
||||||
this.fields[field.name].updateAttributes();
|
this.fields[field.name].updateAttributes();
|
||||||
});
|
});
|
||||||
Object.values(form.fields).forEach(field => {
|
Object.values(form.fields).forEach((field) => {
|
||||||
this.fields[field.name].setErrors(field.errors);
|
this.fields[field.name].setErrors(field.errors);
|
||||||
});
|
});
|
||||||
return form['is_valid'];
|
return form["is_valid"];
|
||||||
}
|
}
|
||||||
|
|
||||||
async submit() {
|
async submit() {
|
||||||
const url = this.getAttribute("url");
|
const url = this.getAttribute("url");
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({"action": "submit", "form": this.form})
|
body: JSON.stringify({ action: "submit", form: this.form }),
|
||||||
});
|
});
|
||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('generic-form', GenericForm);
|
customElements.define("generic-form", GenericForm);
|
||||||
|
|||||||
@@ -7,48 +7,50 @@
|
|||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
class HTMLFrame extends HTMLElement {
|
class HTMLFrame extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: 'open' });
|
this.attachShadow({ mode: "open" });
|
||||||
this.container = document.createElement('div');
|
this.container = document.createElement("div");
|
||||||
this.shadowRoot.appendChild(this.container);
|
this.shadowRoot.appendChild(this.container);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.container.classList.add("html_frame");
|
this.container.classList.add("html_frame");
|
||||||
let url = this.getAttribute('url');
|
let url = this.getAttribute("url");
|
||||||
if (!url.startsWith("https")) {
|
if (!url.startsWith("https")) {
|
||||||
url = "https://" + url;
|
url = "https://" + url;
|
||||||
}
|
|
||||||
if (url) {
|
|
||||||
const fullUrl = url.startsWith("/") ? window.location.origin + url : new URL(window.location.origin + "/http-get");
|
|
||||||
if (!url.startsWith("/")) {
|
|
||||||
fullUrl.searchParams.set('url', url);
|
|
||||||
}
|
|
||||||
this.loadAndRender(fullUrl.toString());
|
|
||||||
} else {
|
|
||||||
this.container.textContent = "No source URL!";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (url) {
|
||||||
|
const fullUrl = url.startsWith("/")
|
||||||
|
? window.location.origin + url
|
||||||
|
: new URL(window.location.origin + "/http-get");
|
||||||
|
if (!url.startsWith("/")) {
|
||||||
|
fullUrl.searchParams.set("url", url);
|
||||||
|
}
|
||||||
|
this.loadAndRender(fullUrl.toString());
|
||||||
|
} else {
|
||||||
|
this.container.textContent = "No source URL!";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async loadAndRender(url) {
|
async loadAndRender(url) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Error: ${response.status} ${response.statusText}`);
|
throw new Error(`Error: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
if (url.endsWith(".md")) {
|
if (url.endsWith(".md")) {
|
||||||
const markdownElement = document.createElement('div');
|
const markdownElement = document.createElement("div");
|
||||||
markdownElement.innerHTML = html;
|
markdownElement.innerHTML = html;
|
||||||
this.outerHTML = html;
|
this.outerHTML = html;
|
||||||
} else {
|
} else {
|
||||||
this.container.innerHTML = html;
|
this.container.innerHTML = html;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.container.textContent = `Error: ${error.message}`;
|
this.container.textContent = `Error: ${error.message}`;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('html-frame', HTMLFrame);
|
customElements.define("html-frame", HTMLFrame);
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
@@ -17,12 +17,12 @@
|
|||||||
"start_url": "/web.html",
|
"start_url": "/web.html",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/image/snek1.png",
|
"src": "/image/snek192.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "192x192"
|
"sizes": "192x192"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/image/snek1.png",
|
"src": "/image/snek512.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "512x512"
|
"sizes": "512x512"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,22 +12,22 @@
|
|||||||
class HTMLFrame extends HTMLElement {
|
class HTMLFrame extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: 'open' });
|
this.attachShadow({ mode: "open" });
|
||||||
this.container = document.createElement('div');
|
this.container = document.createElement("div");
|
||||||
this.shadowRoot.appendChild(this.container);
|
this.shadowRoot.appendChild(this.container);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.container.classList.add('html_frame');
|
this.container.classList.add("html_frame");
|
||||||
const url = this.getAttribute('url');
|
const url = this.getAttribute("url");
|
||||||
if (url) {
|
if (url) {
|
||||||
const fullUrl = url.startsWith('/')
|
const fullUrl = url.startsWith("/")
|
||||||
? window.location.origin + url
|
? window.location.origin + url
|
||||||
: new URL(window.location.origin + '/http-get');
|
: new URL(window.location.origin + "/http-get");
|
||||||
if (!url.startsWith('/')) fullUrl.searchParams.set('url', url);
|
if (!url.startsWith("/")) fullUrl.searchParams.set("url", url);
|
||||||
this.loadAndRender(fullUrl.toString());
|
this.loadAndRender(fullUrl.toString());
|
||||||
} else {
|
} else {
|
||||||
this.container.textContent = 'No source URL!';
|
this.container.textContent = "No source URL!";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,4 +45,4 @@ class HTMLFrame extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('markdown-frame', HTMLFrame);
|
customElements.define("markdown-frame", HTMLFrame);
|
||||||
|
|||||||
+98
-100
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
// No external libraries or dependencies are used other than standard web components.
|
// No external libraries or dependencies are used other than standard web components.
|
||||||
|
|
||||||
|
|
||||||
// MIT License
|
// MIT License
|
||||||
//
|
//
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
@@ -13,20 +12,19 @@
|
|||||||
//
|
//
|
||||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
class TileGridElement extends HTMLElement {
|
class TileGridElement extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: 'open' });
|
this.attachShadow({ mode: "open" });
|
||||||
this.gridId = this.getAttribute('grid');
|
this.gridId = this.getAttribute("grid");
|
||||||
this.component = document.createElement('div');
|
this.component = document.createElement("div");
|
||||||
this.shadowRoot.appendChild(this.component);
|
this.shadowRoot.appendChild(this.component);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
console.log('connected');
|
console.log("connected");
|
||||||
this.styleElement = document.createElement('style');
|
this.styleElement = document.createElement("style");
|
||||||
this.styleElement.textContent = `
|
this.styleElement.textContent = `
|
||||||
.grid {
|
.grid {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -47,53 +45,53 @@ class TileGridElement extends HTMLElement {
|
|||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
this.component.appendChild(this.styleElement);
|
this.component.appendChild(this.styleElement);
|
||||||
this.container = document.createElement('div');
|
this.container = document.createElement("div");
|
||||||
this.container.classList.add('gallery');
|
this.container.classList.add("gallery");
|
||||||
this.component.appendChild(this.container);
|
this.component.appendChild(this.container);
|
||||||
}
|
}
|
||||||
|
|
||||||
addImage(src) {
|
addImage(src) {
|
||||||
const item = document.createElement('img');
|
const item = document.createElement("img");
|
||||||
item.src = src;
|
item.src = src;
|
||||||
item.classList.add('tile');
|
item.classList.add("tile");
|
||||||
item.style.width = '100px';
|
item.style.width = "100px";
|
||||||
item.style.height = '100px';
|
item.style.height = "100px";
|
||||||
this.container.appendChild(item);
|
this.container.appendChild(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
addImages(srcs) {
|
addImages(srcs) {
|
||||||
srcs.forEach(src => this.addImage(src));
|
srcs.forEach((src) => this.addImage(src));
|
||||||
}
|
}
|
||||||
|
|
||||||
addElement(element) {
|
addElement(element) {
|
||||||
element.classList.add('tile');
|
element.classList.add("tile");
|
||||||
this.container.appendChild(element);
|
this.container.appendChild(element);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class UploadButton extends HTMLElement {
|
class UploadButton extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: 'open' });
|
this.attachShadow({ mode: "open" });
|
||||||
this.component = document.createElement('div');
|
this.component = document.createElement("div");
|
||||||
this.shadowRoot.appendChild(this.component);
|
this.shadowRoot.appendChild(this.component);
|
||||||
window.u = this;
|
window.u = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
get gridSelector() {
|
get gridSelector() {
|
||||||
return this.getAttribute('grid');
|
return this.getAttribute("grid");
|
||||||
}
|
}
|
||||||
grid = null;
|
grid = null;
|
||||||
|
|
||||||
addImages(urls) {
|
addImages(urls) {
|
||||||
this.grid.addImages(urls);
|
this.grid.addImages(urls);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
console.log('connected');
|
console.log("connected");
|
||||||
this.styleElement = document.createElement('style');
|
this.styleElement = document.createElement("style");
|
||||||
this.styleElement.textContent = `
|
this.styleElement.textContent = `
|
||||||
.upload-button {
|
.upload-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -115,61 +113,61 @@ class UploadButton extends HTMLElement {
|
|||||||
background-color: #999;
|
background-color: #999;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
this.shadowRoot.appendChild(this.styleElement);
|
this.shadowRoot.appendChild(this.styleElement);
|
||||||
this.container = document.createElement('div');
|
this.container = document.createElement("div");
|
||||||
this.container.classList.add('upload-button');
|
this.container.classList.add("upload-button");
|
||||||
this.shadowRoot.appendChild(this.container);
|
this.shadowRoot.appendChild(this.container);
|
||||||
const input = document.createElement('input');
|
const input = document.createElement("input");
|
||||||
input.type = 'file';
|
input.type = "file";
|
||||||
input.accept = 'image/*';
|
input.accept = "image/*";
|
||||||
input.multiple = true;
|
input.multiple = true;
|
||||||
input.addEventListener('change', (e) => {
|
input.addEventListener("change", (e) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
const urls = [];
|
const urls = [];
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
urls.push(e.target.result);
|
urls.push(e.target.result);
|
||||||
if (urls.length === files.length) {
|
if (urls.length === files.length) {
|
||||||
this.addImages(urls);
|
this.addImages(urls);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(files[i]);
|
reader.readAsDataURL(files[i]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const label = document.createElement('label');
|
const label = document.createElement("label");
|
||||||
label.textContent = 'Upload Images';
|
label.textContent = "Upload Images";
|
||||||
label.appendChild(input);
|
label.appendChild(input);
|
||||||
this.container.appendChild(label);
|
this.container.appendChild(label);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('upload-button', UploadButton);
|
customElements.define("upload-button", UploadButton);
|
||||||
customElements.define('tile-grid', TileGridElement);
|
customElements.define("tile-grid", TileGridElement);
|
||||||
|
|
||||||
class MeniaUploadElement extends HTMLElement {
|
class MeniaUploadElement extends HTMLElement {
|
||||||
constructor(){
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: 'open' });
|
this.attachShadow({ mode: "open" });
|
||||||
this.component = document.createElement("div");
|
this.component = document.createElement("div");
|
||||||
alert('aaaa');
|
alert("aaaa");
|
||||||
this.shadowRoot.appendChild(this.component);
|
this.shadowRoot.appendChild(this.component);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.container = document.createElement("div");
|
this.container = document.createElement("div");
|
||||||
this.component.style.height = '100%';
|
this.component.style.height = "100%";
|
||||||
this.component.style.backgroundColor = 'blue';
|
this.component.style.backgroundColor = "blue";
|
||||||
this.shadowRoot.appendChild(this.container);
|
this.shadowRoot.appendChild(this.container);
|
||||||
|
|
||||||
this.tileElement = document.createElement("tile-grid");
|
this.tileElement = document.createElement("tile-grid");
|
||||||
this.tileElement.style.backgroundColor = 'red';
|
this.tileElement.style.backgroundColor = "red";
|
||||||
this.tileElement.style.height = '100%';
|
this.tileElement.style.height = "100%";
|
||||||
this.component.appendChild(this.tileElement);
|
this.component.appendChild(this.tileElement);
|
||||||
|
|
||||||
this.uploadButton = document.createElement('upload-button');
|
this.uploadButton = document.createElement("upload-button");
|
||||||
this.component.appendChild(this.uploadButton);
|
this.component.appendChild(this.uploadButton);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('menia-upload', MeniaUploadElement);
|
customElements.define("menia-upload", MeniaUploadElement);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// This JavaScript source code defines a custom HTML element named "message-list-manager" to manage a list of message lists for different channels obtained asynchronously.
|
// This JavaScript source code defines a custom HTML element named "message-list-manager" to manage a list of message lists for different channels obtained asynchronously.
|
||||||
|
|
||||||
//
|
//
|
||||||
//
|
//
|
||||||
|
|
||||||
// MIT License
|
// MIT License
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
@@ -22,23 +22,22 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
class MessageListManagerElement extends HTMLElement {
|
class MessageListManagerElement extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: 'open' });
|
this.attachShadow({ mode: "open" });
|
||||||
this.container = document.createElement("div");
|
this.container = document.createElement("div");
|
||||||
this.shadowRoot.appendChild(this.container);
|
this.shadowRoot.appendChild(this.container);
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
const channels = await app.rpc.getChannels();
|
const channels = await app.rpc.getChannels();
|
||||||
channels.forEach(channel => {
|
channels.forEach((channel) => {
|
||||||
const messageList = document.createElement("message-list");
|
const messageList = document.createElement("message-list");
|
||||||
messageList.setAttribute("channel", channel.uid);
|
messageList.setAttribute("channel", channel.uid);
|
||||||
this.container.appendChild(messageList);
|
this.container.appendChild(messageList);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("message-list-manager", MessageListManagerElement);
|
customElements.define("message-list-manager", MessageListManagerElement);
|
||||||
|
|||||||
+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.
|
// The code seems to rely on some external dependencies like 'models.Message', 'app', and 'Schedule'. These should be imported or defined elsewhere in your application.
|
||||||
|
|
||||||
// MIT License: This is free software. Permission is granted to use, copy, modify, and/or distribute this software for any purpose with or without fee. The software is provided "as is" without any warranty.
|
// MIT License: This is free software. Permission is granted to use, copy, modify, and/or distribute this software for any purpose with or without fee. The software is provided "as is" without any warranty.
|
||||||
|
import { app } from "../app.js";
|
||||||
|
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 {
|
this.items = [];
|
||||||
static get observedAttributes() {
|
}
|
||||||
return ["messages"];
|
|
||||||
|
connectedCallback() {
|
||||||
|
const messagesContainer = this
|
||||||
|
messagesContainer.addEventListener('click', (e) => {
|
||||||
|
if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return;
|
||||||
|
const img = e.target;
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;justify-content:center;align-items:center;z-index:9999;'
|
||||||
|
const fullImg = document.createElement('img');
|
||||||
|
const urlObj = new URL(img.src); urlObj.search = '';
|
||||||
|
fullImg.src = urlObj.toString();
|
||||||
|
fullImg.alt = img.alt;
|
||||||
|
fullImg.style.maxWidth = '90%';
|
||||||
|
fullImg.style.maxHeight = '90%';
|
||||||
|
overlay.appendChild(fullImg);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
overlay.addEventListener('click', () => document.body.removeChild(overlay));
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
isElementVisible(element) {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
rect.top >= 0 &&
|
||||||
|
rect.left >= 0 &&
|
||||||
|
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
||||||
|
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
isScrolledToBottom() {
|
||||||
|
return this.isElementVisible(this.querySelector(".message-list-bottom"));
|
||||||
|
}
|
||||||
|
scrollToBottom(force) {
|
||||||
|
this.scrollTop = this.scrollHeight;
|
||||||
|
|
||||||
|
this.querySelector(".message-list-bottom").scrollIntoView();
|
||||||
|
setTimeout(() => {
|
||||||
|
|
||||||
|
this.scrollTop = this.scrollHeight;
|
||||||
|
this.querySelector(".message-list-bottom").scrollIntoView();
|
||||||
|
},200)
|
||||||
|
}
|
||||||
|
updateMessageText(uid, message) {
|
||||||
|
const messageDiv = this.querySelector('div[data-uid="' + uid + '"]');
|
||||||
|
|
||||||
|
if (!messageDiv) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const scrollToBottom = this.isScrolledToBottom();
|
||||||
messages = [];
|
const receivedHtml = document.createElement("div");
|
||||||
room = null;
|
receivedHtml.innerHTML = message.html;
|
||||||
url = null;
|
const html = receivedHtml.querySelector(".text").innerHTML;
|
||||||
container = null;
|
const textElement = messageDiv.querySelector(".text");
|
||||||
messageEventSchedule = null;
|
textElement.innerHTML = html;
|
||||||
observer = null;
|
textElement.style.display = message.text == "" ? "none" : "block";
|
||||||
|
if(scrollToBottom)
|
||||||
constructor() {
|
this.scrollToBottom(true)
|
||||||
super();
|
}
|
||||||
this.attachShadow({ mode: 'open' });
|
triggerGlow(uid,color) {
|
||||||
this.component = document.createElement('div');
|
app.starField.glowColor(color)
|
||||||
this.shadowRoot.appendChild(this.component);
|
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) {
|
set data(items) {
|
||||||
const urlRegex = /https?:\/\/[^\s]+/g;
|
this.items = items;
|
||||||
return text.replace(urlRegex, (url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`);
|
this.render();
|
||||||
}
|
}
|
||||||
|
render() {
|
||||||
|
this.innerHTML = "";
|
||||||
|
|
||||||
timeAgo(date1, date2) {
|
//this.insertAdjacentHTML("beforeend", html);
|
||||||
const diffMs = Math.abs(date2 - date1);
|
}
|
||||||
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
|
||||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
|
||||||
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
|
|
||||||
|
|
||||||
if (days) {
|
|
||||||
return `${days} ${days > 1 ? 'days' : 'day'} ago`;
|
|
||||||
}
|
|
||||||
if (hours) {
|
|
||||||
return `${hours} ${hours > 1 ? 'hours' : 'hour'} ago`;
|
|
||||||
}
|
|
||||||
if (minutes) {
|
|
||||||
return `${minutes} ${minutes > 1 ? 'minutes' : 'minute'} ago`;
|
|
||||||
}
|
|
||||||
return 'just now';
|
|
||||||
}
|
|
||||||
|
|
||||||
timeDescription(isoDate) {
|
|
||||||
const date = new Date(isoDate);
|
|
||||||
const hours = String(date.getHours()).padStart(2, "0");
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
||||||
let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;
|
|
||||||
return timeStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
createElement(message) {
|
|
||||||
const element = document.createElement("div");
|
|
||||||
element.dataset.uid = message.uid;
|
|
||||||
element.dataset.color = message.color;
|
|
||||||
element.dataset.channel_uid = message.channel_uid;
|
|
||||||
element.dataset.user_nick = message.user_nick;
|
|
||||||
element.dataset.created_at = message.created_at;
|
|
||||||
element.dataset.user_uid = message.user_uid;
|
|
||||||
element.dataset.message = message.message;
|
|
||||||
|
|
||||||
element.classList.add("message");
|
|
||||||
if (!this.messages.length || this.messages[this.messages.length - 1].user_uid != message.user_uid) {
|
|
||||||
element.classList.add("switch-user");
|
|
||||||
}
|
|
||||||
|
|
||||||
const avatar = document.createElement("div");
|
|
||||||
avatar.classList.add("avatar");
|
|
||||||
avatar.classList.add("no-select");
|
|
||||||
avatar.style.backgroundColor = message.color;
|
|
||||||
avatar.style.color = "black";
|
|
||||||
avatar.innerText = message.user_nick[0];
|
|
||||||
|
|
||||||
const messageContent = document.createElement("div");
|
|
||||||
messageContent.classList.add("message-content");
|
|
||||||
|
|
||||||
const author = document.createElement("div");
|
|
||||||
author.classList.add("author");
|
|
||||||
author.style.color = message.color;
|
|
||||||
author.textContent = message.user_nick;
|
|
||||||
|
|
||||||
const text = document.createElement("div");
|
|
||||||
text.classList.add("text");
|
|
||||||
if (message.html) text.innerHTML = message.html;
|
|
||||||
|
|
||||||
const time = document.createElement("div");
|
|
||||||
time.classList.add("time");
|
|
||||||
time.dataset.created_at = message.created_at;
|
|
||||||
time.textContent = this.timeDescription(message.created_at);
|
|
||||||
|
|
||||||
messageContent.appendChild(author);
|
|
||||||
messageContent.appendChild(text);
|
|
||||||
messageContent.appendChild(time);
|
|
||||||
|
|
||||||
element.appendChild(avatar);
|
|
||||||
element.appendChild(messageContent);
|
|
||||||
|
|
||||||
message.element = element;
|
|
||||||
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
addMessage(message) {
|
|
||||||
const obj = new models.Message(
|
|
||||||
message.uid,
|
|
||||||
message.channel_uid,
|
|
||||||
message.user_uid,
|
|
||||||
message.user_nick,
|
|
||||||
message.color,
|
|
||||||
message.message,
|
|
||||||
message.html,
|
|
||||||
message.created_at,
|
|
||||||
message.updated_at
|
|
||||||
);
|
|
||||||
|
|
||||||
const element = this.createElement(obj);
|
|
||||||
this.messages.push(obj);
|
|
||||||
this.container.appendChild(element);
|
|
||||||
|
|
||||||
this.messageEventSchedule.delay(() => {
|
|
||||||
this.dispatchEvent(new CustomEvent("message", { detail: obj, bubbles: true }));
|
|
||||||
});
|
|
||||||
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollBottom() {
|
|
||||||
this.container.scrollTop = this.container.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
const link = document.createElement('link');
|
|
||||||
link.rel = 'stylesheet';
|
|
||||||
link.href = '/base.css';
|
|
||||||
this.component.appendChild(link);
|
|
||||||
this.component.classList.add("chat-messages");
|
|
||||||
|
|
||||||
this.container = document.createElement('div');
|
|
||||||
this.component.appendChild(this.container);
|
|
||||||
|
|
||||||
this.messageEventSchedule = new Schedule(500);
|
|
||||||
this.messages = [];
|
|
||||||
this.channel_uid = this.getAttribute("channel");
|
|
||||||
|
|
||||||
app.addEventListener(this.channel_uid, (data) => {
|
|
||||||
this.addMessage(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.dispatchEvent(new CustomEvent("rendered", { detail: this, bubbles: true }));
|
|
||||||
|
|
||||||
this.timeUpdateInterval = setInterval(() => {
|
|
||||||
this.messages.forEach((message) => {
|
|
||||||
const newText = this.timeDescription(message.created_at);
|
|
||||||
if (newText != message.element.innerText) {
|
|
||||||
message.element.querySelector(".time").innerText = newText;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 30000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('message-list', MessageListElement);
|
customElements.define("message-list", MessageList);
|
||||||
|
|||||||
+24
-14
@@ -7,20 +7,30 @@
|
|||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
class MessageModel {
|
class MessageModel {
|
||||||
constructor(uid, channel_uid, user_uid, user_nick, color, message, html, created_at, updated_at) {
|
constructor(
|
||||||
this.uid = uid
|
uid,
|
||||||
this.message = message
|
channel_uid,
|
||||||
this.html = html
|
user_uid,
|
||||||
this.user_uid = user_uid
|
user_nick,
|
||||||
this.user_nick = user_nick
|
color,
|
||||||
this.color = color
|
message,
|
||||||
this.channel_uid = channel_uid
|
html,
|
||||||
this.created_at = created_at
|
created_at,
|
||||||
this.updated_at = updated_at
|
updated_at,
|
||||||
this.element = null
|
) {
|
||||||
}
|
this.uid = uid;
|
||||||
|
this.message = message;
|
||||||
|
this.html = html;
|
||||||
|
this.user_uid = user_uid;
|
||||||
|
this.user_nick = user_nick;
|
||||||
|
this.color = color;
|
||||||
|
this.channel_uid = channel_uid;
|
||||||
|
this.created_at = created_at;
|
||||||
|
this.updated_at = updated_at;
|
||||||
|
this.element = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const models = {
|
const models = {
|
||||||
Message: MessageModel
|
Message: MessageModel,
|
||||||
}
|
};
|
||||||
|
|||||||
+32
-28
@@ -1,30 +1,34 @@
|
|||||||
this.onpush = (event) => {
|
this.onpush = (event) => {
|
||||||
console.log(event.data);
|
console.log(event.data);
|
||||||
// From here we can write the data to IndexedDB, send it to any open
|
// From here we can write the data to IndexedDB, send it to any open
|
||||||
// windows, display a notification, etc.
|
// windows, display a notification, etc.
|
||||||
};
|
};
|
||||||
|
|
||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
.register("/service-worker.js")
|
.register("/service-worker.js")
|
||||||
.then((serviceWorkerRegistration) => {
|
.then((serviceWorkerRegistration) => {
|
||||||
serviceWorkerRegistration.pushManager.subscribe().then(
|
serviceWorkerRegistration.pushManager.subscribe().then(
|
||||||
(pushSubscription) => {
|
(pushSubscription) => {
|
||||||
const subscriptionObject = {
|
const subscriptionObject = {
|
||||||
endpoint: pushSubscription.endpoint,
|
endpoint: pushSubscription.endpoint,
|
||||||
keys: {
|
keys: {
|
||||||
p256dh: pushSubscription.getKey('p256dh'),
|
p256dh: pushSubscription.getKey("p256dh"),
|
||||||
auth: pushSubscription.getKey('auth'),
|
auth: pushSubscription.getKey("auth"),
|
||||||
},
|
},
|
||||||
encoding: PushManager.supportedContentEncodings,
|
encoding: PushManager.supportedContentEncodings,
|
||||||
/* other app-specific data, such as user identity */
|
/* other app-specific data, such as user identity */
|
||||||
};
|
};
|
||||||
console.log(pushSubscription.endpoint, pushSubscription, subscriptionObject);
|
console.log(
|
||||||
// The push subscription details needed by the application
|
pushSubscription.endpoint,
|
||||||
// server are now available, and can be sent to it using,
|
pushSubscription,
|
||||||
// for example, the fetch() API.
|
subscriptionObject,
|
||||||
},
|
);
|
||||||
(error) => {
|
// The push subscription details needed by the application
|
||||||
console.error(error);
|
// server are now available, and can be sent to it using,
|
||||||
},
|
// for example, the fetch() API.
|
||||||
);
|
},
|
||||||
});
|
(error) => {
|
||||||
|
console.error(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -51,4 +51,4 @@ export class Schedule {
|
|||||||
me.timeOutCount = 0;
|
me.timeOutCount = 0;
|
||||||
}, this.msDelay);
|
}, this.msDelay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,34 @@
|
|||||||
async function requestNotificationPermission() {
|
async function requestNotificationPermission() {
|
||||||
const permission = await Notification.requestPermission();
|
const permission = await Notification.requestPermission();
|
||||||
return permission === 'granted';
|
return permission === "granted";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to Push Notifications
|
// Subscribe to Push Notifications
|
||||||
async function subscribeUser() {
|
async function subscribeUser() {
|
||||||
const registration = await navigator.serviceWorker.register('/service-worker.js');
|
const registration =
|
||||||
|
await navigator.serviceWorker.register("/service-worker.js");
|
||||||
|
|
||||||
const subscription = await registration.pushManager.subscribe({
|
const subscription = await registration.pushManager.subscribe({
|
||||||
userVisibleOnly: true,
|
userVisibleOnly: true,
|
||||||
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY)
|
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send subscription to your backend
|
// Send subscription to your backend
|
||||||
await fetch('/subscribe', {
|
await fetch("/subscribe", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify(subscription),
|
body: JSON.stringify(subscription),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service Worker (service-worker.js)
|
// Service Worker (service-worker.js)
|
||||||
self.addEventListener('push', event => {
|
self.addEventListener("push", (event) => {
|
||||||
const data = event.data.json();
|
const data = event.data.json();
|
||||||
self.registration.showNotification(data.title, {
|
self.registration.showNotification(data.title, {
|
||||||
body: data.message,
|
body: data.message,
|
||||||
icon: data.icon
|
icon: data.icon,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+131
-117
@@ -1,137 +1,151 @@
|
|||||||
import {EventHandler} from "./event-handler.js";
|
import { EventHandler } from "./event-handler.js";
|
||||||
|
|
||||||
export class Socket extends EventHandler {
|
export class Socket extends EventHandler {
|
||||||
/**
|
/**
|
||||||
* @type {URL}
|
* @type {URL}
|
||||||
*/
|
*/
|
||||||
url
|
url;
|
||||||
/**
|
/**
|
||||||
* @type {WebSocket|null}
|
* @type {WebSocket|null}
|
||||||
*/
|
*/
|
||||||
ws = null
|
ws = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {null|PromiseWithResolvers<Socket>&{resolved?:boolean}}
|
* @type {null|PromiseWithResolvers<Socket>&{resolved?:boolean}}
|
||||||
*/
|
*/
|
||||||
connection = null
|
connection = null;
|
||||||
|
|
||||||
shouldReconnect = true;
|
shouldReconnect = true;
|
||||||
|
|
||||||
get isConnected() {
|
_debug = false;
|
||||||
return this.ws && this.ws.readyState === WebSocket.OPEN;
|
|
||||||
|
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() {
|
if (!this.connection || this.connection.resolved) {
|
||||||
return this.ws && this.ws.readyState === WebSocket.CONNECTING;
|
this.connection = Promise.withResolvers();
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
this.ws = new WebSocket(this.url);
|
||||||
super();
|
this.ws.addEventListener("open", () => {
|
||||||
|
this.connection.resolved = true;
|
||||||
|
this.connection.resolve(this);
|
||||||
|
this.emit("connected");
|
||||||
|
});
|
||||||
|
|
||||||
this.url = new URL('/rpc.ws', window.location.origin);
|
this.ws.addEventListener("close", () => {
|
||||||
this.url.protocol = this.url.protocol.replace('http', 'ws');
|
console.log("Connection closed");
|
||||||
|
this.disconnect();
|
||||||
this.connect()
|
});
|
||||||
}
|
this.ws.addEventListener("error", (e) => {
|
||||||
|
console.error("Connection error", e);
|
||||||
connect() {
|
this.disconnect();
|
||||||
if (this.ws) {
|
});
|
||||||
return this.connection.promise;
|
this.ws.addEventListener("message", (e) => {
|
||||||
|
if (e.data instanceof Blob || e.data instanceof ArrayBuffer) {
|
||||||
|
console.error("Binary data not supported");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
this.onData(JSON.parse(e.data));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse message", e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.connection || this.connection.resolved) {
|
onData(data) {
|
||||||
this.connection = Promise.withResolvers()
|
if (data.success !== undefined && !data.success) {
|
||||||
}
|
console.error(data);
|
||||||
|
|
||||||
this.ws = new WebSocket(this.url);
|
|
||||||
this.ws.addEventListener("open", () => {
|
|
||||||
this.connection.resolved = true;
|
|
||||||
this.connection.resolve(this);
|
|
||||||
this.emit("connected");
|
|
||||||
});
|
|
||||||
|
|
||||||
this.ws.addEventListener("close", () => {
|
|
||||||
console.log("Connection closed");
|
|
||||||
this.disconnect()
|
|
||||||
})
|
|
||||||
this.ws.addEventListener("error", (e) => {
|
|
||||||
console.error("Connection error", e);
|
|
||||||
this.disconnect()
|
|
||||||
})
|
|
||||||
this.ws.addEventListener("message", (e) => {
|
|
||||||
if (e.data instanceof Blob || e.data instanceof ArrayBuffer) {
|
|
||||||
console.error("Binary data not supported");
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
this.onData(JSON.parse(e.data));
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to parse message", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
if (data.callId) {
|
||||||
|
this.emit(data.callId, data.data);
|
||||||
onData(data) {
|
|
||||||
if (data.success !== undefined && !data.success) {
|
|
||||||
console.error(data);
|
|
||||||
}
|
|
||||||
if (data.callId) {
|
|
||||||
this.emit(data.callId, data.data);
|
|
||||||
}
|
|
||||||
if (data.channel_uid) {
|
|
||||||
this.emit(data.channel_uid, data.data);
|
|
||||||
this.emit("channel-message", data);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (data.channel_uid) {
|
||||||
disconnect() {
|
this.emit(data.channel_uid, data.data);
|
||||||
this.ws?.close();
|
if (!data["event"]) this.emit("channel-message", data);
|
||||||
this.ws = null;
|
|
||||||
|
|
||||||
if (this.shouldReconnect) setTimeout(() => {
|
|
||||||
console.log("Reconnecting");
|
|
||||||
return this.connect();
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
|
this.emit("data", data.data);
|
||||||
|
if (data["event"]) {
|
||||||
_camelToSnake(str) {
|
this.emit(data.event, data.data);
|
||||||
return str.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get client() {
|
disconnect() {
|
||||||
const me = this;
|
this.ws?.close();
|
||||||
return new Proxy({}, {
|
this.ws = null;
|
||||||
get(_, prop) {
|
|
||||||
return (...args) => {
|
|
||||||
const functionName = me._camelToSnake(prop);
|
|
||||||
return me.call(functionName, ...args);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
generateCallId() {
|
if (this.shouldReconnect)
|
||||||
return self.crypto.randomUUID();
|
setTimeout(() => {
|
||||||
}
|
console.log("Reconnecting");
|
||||||
|
this.emit("reconnecting");
|
||||||
|
return this.connect();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
async sendJson(data) {
|
_camelToSnake(str) {
|
||||||
await this.connect().then(api => {
|
return str.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
|
||||||
api.ws.send(JSON.stringify(data));
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async call(method, ...args) {
|
get client() {
|
||||||
const call = {
|
const me = this;
|
||||||
callId: this.generateCallId(),
|
return new Proxy(
|
||||||
method,
|
{},
|
||||||
args,
|
{
|
||||||
};
|
get(_, prop) {
|
||||||
const me = this
|
return (...args) => {
|
||||||
return new Promise((resolve) => {
|
const functionName = me._camelToSnake(prop);
|
||||||
me.addEventListener(call.callId, data => resolve(data));
|
if(me._debug){
|
||||||
me.sendJson(call);
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,4 +61,6 @@ div {
|
|||||||
body {
|
body {
|
||||||
|
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,58 +2,62 @@
|
|||||||
|
|
||||||
// This class defines a custom HTML element for an upload button with integrated file upload functionality using XMLHttpRequest.
|
// This class defines a custom HTML element for an upload button with integrated file upload functionality using XMLHttpRequest.
|
||||||
|
|
||||||
|
|
||||||
// MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
// MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
class UploadButtonElement extends HTMLElement {
|
class UploadButtonElement extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: 'open' });
|
this.attachShadow({ mode: "open" });
|
||||||
|
}
|
||||||
|
chatInput = null;
|
||||||
|
async uploadFiles() {
|
||||||
|
const fileInput = this.container.querySelector(".file-input");
|
||||||
|
const uploadButton = this.container.querySelector(".upload-button");
|
||||||
|
|
||||||
|
if (!fileInput.files.length) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
chatInput = null
|
|
||||||
async uploadFiles() {
|
|
||||||
const fileInput = this.container.querySelector('.file-input');
|
|
||||||
const uploadButton = this.container.querySelector('.upload-button');
|
|
||||||
|
|
||||||
if (!fileInput.files.length) {
|
const files = fileInput.files;
|
||||||
return;
|
const formData = new FormData();
|
||||||
}
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
formData.append("files[]", files[i]);
|
||||||
const files = fileInput.files;
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('channel_uid', this.channelUid);
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
formData.append('files[]', files[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = new XMLHttpRequest();
|
|
||||||
request.open('POST', '/drive.bin', true);
|
|
||||||
|
|
||||||
request.upload.onprogress = function (event) {
|
|
||||||
if (event.lengthComputable) {
|
|
||||||
const percentComplete = (event.loaded / event.total) * 100;
|
|
||||||
uploadButton.innerText = `${Math.round(percentComplete)}%`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onload = function () {
|
|
||||||
if (request.status === 200) {
|
|
||||||
uploadButton.innerHTML = '📤';
|
|
||||||
} else {
|
|
||||||
alert('Upload failed');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onerror = function () {
|
|
||||||
alert('Error while uploading.');
|
|
||||||
};
|
|
||||||
|
|
||||||
request.send(formData);
|
|
||||||
}
|
}
|
||||||
channelUid = null
|
const request = new XMLHttpRequest();
|
||||||
connectedCallback() {
|
|
||||||
this.styleElement = document.createElement('style');
|
request.responseType = "json";
|
||||||
this.styleElement.innerHTML = `
|
request.open("POST", `/channel/${this.channelUid}/attachment.bin`, true);
|
||||||
|
|
||||||
|
request.upload.onprogress = function (event) {
|
||||||
|
if (event.lengthComputable) {
|
||||||
|
const percentComplete = (event.loaded / event.total) * 100;
|
||||||
|
uploadButton.innerText = `${Math.round(percentComplete)}%`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const me = this;
|
||||||
|
request.onload = function () {
|
||||||
|
if (request.status === 200) {
|
||||||
|
me.dispatchEvent(
|
||||||
|
new CustomEvent("uploaded", { detail: request.response }),
|
||||||
|
);
|
||||||
|
uploadButton.innerHTML = "📤";
|
||||||
|
} else {
|
||||||
|
alert("Upload failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = function () {
|
||||||
|
alert("Error while uploading.");
|
||||||
|
};
|
||||||
|
|
||||||
|
request.send(formData);
|
||||||
|
const uploadEvent = new Event("upload", {});
|
||||||
|
this.dispatchEvent(uploadEvent);
|
||||||
|
}
|
||||||
|
channelUid = null;
|
||||||
|
connectedCallback() {
|
||||||
|
this.styleElement = document.createElement("style");
|
||||||
|
this.styleElement.innerHTML = `
|
||||||
body {
|
body {
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -94,9 +98,9 @@ class UploadButtonElement extends HTMLElement {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
this.shadowRoot.appendChild(this.styleElement);
|
this.shadowRoot.appendChild(this.styleElement);
|
||||||
this.container = document.createElement('div');
|
this.container = document.createElement("div");
|
||||||
this.container.innerHTML = `
|
this.container.innerHTML = `
|
||||||
<div class="upload-container">
|
<div class="upload-container">
|
||||||
<button class="upload-button">
|
<button class="upload-button">
|
||||||
📤
|
📤
|
||||||
@@ -104,17 +108,17 @@ class UploadButtonElement extends HTMLElement {
|
|||||||
<input class="hidden-input file-input" type="file" multiple />
|
<input class="hidden-input file-input" type="file" multiple />
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
this.shadowRoot.appendChild(this.container);
|
this.shadowRoot.appendChild(this.container);
|
||||||
this.channelUid = this.getAttribute('channel');
|
this.channelUid = this.getAttribute("channel");
|
||||||
this.uploadButton = this.container.querySelector('.upload-button');
|
this.uploadButton = this.container.querySelector(".upload-button");
|
||||||
this.fileInput = this.container.querySelector('.hidden-input');
|
this.fileInput = this.container.querySelector(".hidden-input");
|
||||||
this.uploadButton.addEventListener('click', () => {
|
this.uploadButton.addEventListener("click", () => {
|
||||||
this.fileInput.click();
|
this.fileInput.click();
|
||||||
});
|
});
|
||||||
this.fileInput.addEventListener('change', () => {
|
this.fileInput.addEventListener("change", () => {
|
||||||
this.uploadFiles();
|
this.uploadFiles();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('upload-button', UploadButtonElement);
|
customElements.define("upload-button", UploadButtonElement);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -13,10 +13,15 @@ class Cache:
|
|||||||
self.app = app
|
self.app = app
|
||||||
self.cache = {}
|
self.cache = {}
|
||||||
self.max_items = max_items
|
self.max_items = max_items
|
||||||
|
self.stats = {}
|
||||||
|
self.enabled = False
|
||||||
self.lru = []
|
self.lru = []
|
||||||
self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4
|
self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4
|
||||||
|
|
||||||
async def get(self, args):
|
async def get(self, args):
|
||||||
|
if not self.enabled:
|
||||||
|
return None
|
||||||
|
await self.update_stat(args, "get")
|
||||||
try:
|
try:
|
||||||
self.lru.pop(self.lru.index(args))
|
self.lru.pop(self.lru.index(args))
|
||||||
except:
|
except:
|
||||||
@@ -29,6 +34,33 @@ class Cache:
|
|||||||
# print("Cache hit!", args, flush=True)
|
# print("Cache hit!", args, flush=True)
|
||||||
return self.cache[args]
|
return self.cache[args]
|
||||||
|
|
||||||
|
async def get_stats(self):
|
||||||
|
all_ = []
|
||||||
|
for key in self.lru:
|
||||||
|
all_.append(
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"set": self.stats[key]["set"],
|
||||||
|
"get": self.stats[key]["get"],
|
||||||
|
"delete": self.stats[key]["delete"],
|
||||||
|
"value": str(self.serialize(self.cache[key].record)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return all_
|
||||||
|
|
||||||
|
def serialize(self, obj):
|
||||||
|
cpy = obj.copy()
|
||||||
|
cpy.pop("created_at", None)
|
||||||
|
cpy.pop("deleted_at", None)
|
||||||
|
cpy.pop("email", None)
|
||||||
|
cpy.pop("password", None)
|
||||||
|
return cpy
|
||||||
|
|
||||||
|
async def update_stat(self, key, action):
|
||||||
|
if key not in self.stats:
|
||||||
|
self.stats[key] = {"set": 0, "get": 0, "delete": 0}
|
||||||
|
self.stats[key][action] = self.stats[key][action] + 1
|
||||||
|
|
||||||
def json_default(self, value):
|
def json_default(self, value):
|
||||||
# if hasattr(value, "to_json"):
|
# if hasattr(value, "to_json"):
|
||||||
# return value.to_json()
|
# return value.to_json()
|
||||||
@@ -47,8 +79,11 @@ class Cache:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def set(self, args, result):
|
async def set(self, args, result):
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
is_new = args not in self.cache
|
is_new = args not in self.cache
|
||||||
self.cache[args] = result
|
self.cache[args] = result
|
||||||
|
await self.update_stat(args, "set")
|
||||||
try:
|
try:
|
||||||
self.lru.pop(self.lru.index(args))
|
self.lru.pop(self.lru.index(args))
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
@@ -64,6 +99,9 @@ class Cache:
|
|||||||
# print(f"Cache store! {len(self.lru)} items. New version:", self.version, flush=True)
|
# print(f"Cache store! {len(self.lru)} items. New version:", self.version, flush=True)
|
||||||
|
|
||||||
async def delete(self, args):
|
async def delete(self, args):
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
|
await self.update_stat(args, "delete")
|
||||||
if args in self.cache:
|
if args in self.cache:
|
||||||
try:
|
try:
|
||||||
self.lru.pop(self.lru.index(args))
|
self.lru.pop(self.lru.index(args))
|
||||||
|
|||||||
+18
-10
@@ -1,6 +1,6 @@
|
|||||||
DEFAULT_LIMIT = 30
|
DEFAULT_LIMIT = 30
|
||||||
import typing
|
import typing
|
||||||
|
import asyncio
|
||||||
from snek.system.model import BaseModel
|
from snek.system.model import BaseModel
|
||||||
|
|
||||||
|
|
||||||
@@ -12,13 +12,20 @@ class BaseMapper:
|
|||||||
|
|
||||||
def __init__(self, app):
|
def __init__(self, app):
|
||||||
self.app = app
|
self.app = app
|
||||||
|
|
||||||
self.default_limit = self.__class__.default_limit
|
self.default_limit = self.__class__.default_limit
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def db(self):
|
def db(self):
|
||||||
return self.app.db
|
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):
|
async def new(self):
|
||||||
return self.model_class(mapper=self, app=self.app)
|
return self.model_class(mapper=self, app=self.app)
|
||||||
|
|
||||||
@@ -29,7 +36,8 @@ class BaseMapper:
|
|||||||
async def get(self, uid: str = None, **kwargs) -> BaseModel:
|
async def get(self, uid: str = None, **kwargs) -> BaseModel:
|
||||||
if uid:
|
if uid:
|
||||||
kwargs["uid"] = uid
|
kwargs["uid"] = uid
|
||||||
record = self.table.find_one(**kwargs)
|
|
||||||
|
record = await self.run_in_executor(self.table.find_one,**kwargs)
|
||||||
if not record:
|
if not record:
|
||||||
return None
|
return None
|
||||||
record = dict(record)
|
record = dict(record)
|
||||||
@@ -40,31 +48,31 @@ class BaseMapper:
|
|||||||
return await self.model_class.from_record(mapper=self, record=record)
|
return await self.model_class.from_record(mapper=self, record=record)
|
||||||
|
|
||||||
async def exists(self, **kwargs):
|
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:
|
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:
|
async def save(self, model: BaseModel) -> bool:
|
||||||
if not model.record.get("uid"):
|
if not model.record.get("uid"):
|
||||||
raise Exception(f"Attempt to save without uid: {model.record}.")
|
raise Exception(f"Attempt to save without uid: {model.record}.")
|
||||||
model.updated_at.update()
|
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:
|
async def find(self, **kwargs) -> typing.AsyncGenerator:
|
||||||
if not kwargs.get("_limit"):
|
if not kwargs.get("_limit"):
|
||||||
kwargs["_limit"] = self.default_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()
|
model = await self.new()
|
||||||
for key, value in record.items():
|
for key, value in record.items():
|
||||||
model[key] = value
|
model[key] = value
|
||||||
yield model
|
yield model
|
||||||
|
|
||||||
async def query(self, sql, *args):
|
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)
|
yield dict(record)
|
||||||
|
|
||||||
async def delete(self, kwargs=None) -> int:
|
async def delete(self, **kwargs) -> int:
|
||||||
if not kwargs or not isinstance(kwargs, dict):
|
if not kwargs or not isinstance(kwargs, dict):
|
||||||
raise Exception("Can't execute delete with no filter.")
|
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 app.cache import time_cache_async
|
||||||
from mistune import HTMLRenderer, Markdown
|
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 import highlight
|
||||||
from pygments.formatters import html
|
from pygments.formatters import html
|
||||||
from pygments.lexers import get_lexer_by_name
|
from pygments.lexers import get_lexer_by_name
|
||||||
@@ -14,6 +17,8 @@ class MarkdownRenderer(HTMLRenderer):
|
|||||||
_allow_harmful_protocols = True
|
_allow_harmful_protocols = True
|
||||||
|
|
||||||
def __init__(self, app, template):
|
def __init__(self, app, template):
|
||||||
|
super().__init__(False, True)
|
||||||
|
|
||||||
self.template = template
|
self.template = template
|
||||||
|
|
||||||
self.app = app
|
self.app = app
|
||||||
@@ -46,10 +51,18 @@ class MarkdownRenderer(HTMLRenderer):
|
|||||||
markdown = Markdown(renderer=renderer)
|
markdown = Markdown(renderer=renderer)
|
||||||
return markdown(markdown_string)
|
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):
|
def render_markdown_sync(app, markdown_string):
|
||||||
renderer = MarkdownRenderer(app, None)
|
renderer = MarkdownRenderer(app, None)
|
||||||
markdown = Markdown(renderer=renderer)
|
markdown = Markdown(renderer=renderer, plugins=[url, strikethrough, spoiler])
|
||||||
return markdown(markdown_string)
|
return markdown(markdown_string)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -145,6 +145,9 @@ class Validator:
|
|||||||
raise ValueError(f"Errors: {errors}.")
|
raise ValueError(f"Errors: {errors}.")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return str(self.to_json())
|
||||||
|
|
||||||
@property
|
@property
|
||||||
async def is_valid(self):
|
async def is_valid(self):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,9 +1,55 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
|
||||||
DEFAULT_SALT = b"snekker-de-snek-"
|
DEFAULT_SALT = "snekker-de-snek-"
|
||||||
|
DEFAULT_NS = "snekker-de-snek-"
|
||||||
|
|
||||||
|
|
||||||
async def hash(data, salt=DEFAULT_SALT):
|
class UIDNS:
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
"""Initialize UIDNS with a name."""
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bytes(self) -> bytes:
|
||||||
|
"""Return the bytes representation of the name."""
|
||||||
|
return self.name.encode()
|
||||||
|
|
||||||
|
|
||||||
|
def uid(value: str = None, ns: str = DEFAULT_NS) -> str:
|
||||||
|
"""Generate a UUID based on the provided value and namespace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value (str): The value to generate the UUID from. If None, a new UUID is created.
|
||||||
|
ns (str): The namespace to use for UUID generation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The generated UUID as a string.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ns = ns.decode()
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
if not value:
|
||||||
|
value = str(uuid.uuid4())
|
||||||
|
try:
|
||||||
|
value = value.decode()
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return str(uuid.uuid5(UIDNS(ns), value))
|
||||||
|
|
||||||
|
|
||||||
|
def hash_sync(data: str, salt: str = DEFAULT_SALT) -> str:
|
||||||
|
"""Hash the given data with the specified salt using SHA-256.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data (str): The data to hash.
|
||||||
|
salt (str): The salt to use for hashing.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The hexadecimal representation of the hashed data.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
data = data.encode(errors="ignore")
|
data = data.encode(errors="ignore")
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
@@ -17,6 +63,20 @@ async def hash(data, salt=DEFAULT_SALT):
|
|||||||
obj = hashlib.sha256(salted)
|
obj = hashlib.sha256(salted)
|
||||||
return obj.hexdigest()
|
return obj.hexdigest()
|
||||||
|
|
||||||
|
async def hash(data: str, salt: str = DEFAULT_SALT) -> str:
|
||||||
|
return hash_sync(data, salt)
|
||||||
|
|
||||||
async def verify(string: str, hashed: str):
|
def verify_sync(string: str, hashed: str) -> bool:
|
||||||
return await hash(string) == hashed
|
"""Verify if the given string matches the hashed value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
string (str): The string to verify.
|
||||||
|
hashed (str): The hashed value to compare against.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the string matches the hashed value, False otherwise.
|
||||||
|
"""
|
||||||
|
return hash_sync(string) == hashed
|
||||||
|
|
||||||
|
async def verify(string: str, hashed: str) -> bool:
|
||||||
|
return verify_sync(string, hashed)
|
||||||
|
|||||||
+246
-21
@@ -1,7 +1,11 @@
|
|||||||
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
|
from functools import lru_cache
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
from app.cache import time_cache
|
||||||
import emoji
|
import emoji
|
||||||
|
import requests
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from jinja2 import TemplateSyntaxError, nodes
|
from jinja2 import TemplateSyntaxError, nodes
|
||||||
from jinja2.ext import Extension
|
from jinja2.ext import Extension
|
||||||
@@ -89,34 +93,104 @@ def set_link_target_blank(text):
|
|||||||
def embed_youtube(text):
|
def embed_youtube(text):
|
||||||
soup = BeautifulSoup(text, "html.parser")
|
soup = BeautifulSoup(text, "html.parser")
|
||||||
for element in soup.find_all("a"):
|
for element in soup.find_all("a"):
|
||||||
|
# Check if the link is a YouTube link
|
||||||
|
url = urlparse(element["href"])
|
||||||
if (
|
if (
|
||||||
element.attrs["href"].startswith("https://www.you")
|
url.hostname in ["www.youtu.be", "youtu.be"]
|
||||||
and "?v=" in element.attrs["href"]
|
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"])
|
||||||
):
|
):
|
||||||
video_name = element.attrs["href"].split("?v=")[1].split("&")[0]
|
queries = parse_qs(url.query)
|
||||||
embed_template = f'<iframe width="560" height="315" 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>'
|
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"))
|
element.replace_with(BeautifulSoup(embed_template, "html.parser"))
|
||||||
|
|
||||||
return str(soup)
|
return str(soup)
|
||||||
|
|
||||||
|
|
||||||
def embed_image(text):
|
def embed_image(text):
|
||||||
soup = BeautifulSoup(text, "html.parser")
|
soup = BeautifulSoup(text, "html.parser")
|
||||||
for element in soup.find_all("a"):
|
for element in soup.find_all("a"):
|
||||||
for extension in [
|
file_mime = mimetypes.guess_type(element.attrs["href"])[0]
|
||||||
".png",
|
|
||||||
".jpg",
|
if (
|
||||||
".jpeg",
|
file_mime
|
||||||
".gif",
|
and file_mime.startswith("image/")
|
||||||
".webp",
|
or any(
|
||||||
".svg",
|
ext in element.attrs["href"].lower()
|
||||||
".bmp",
|
for ext in [
|
||||||
".tiff",
|
".png",
|
||||||
".ico",
|
".jpg",
|
||||||
".heif",
|
".jpeg",
|
||||||
]:
|
".gif",
|
||||||
if extension in element.attrs["href"].lower():
|
".webp",
|
||||||
embed_template = f'<img src="{element.attrs["href"]}" title="{element.attrs["href"]}" alt="{element.attrs["href"]}" />'
|
".svg",
|
||||||
element.replace_with(BeautifulSoup(embed_template, "html.parser"))
|
".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)
|
return str(soup)
|
||||||
|
|
||||||
|
|
||||||
@@ -159,6 +233,153 @@ def linkify_https(text):
|
|||||||
return set_link_target_blank(str(soup))
|
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):
|
class EmojiExtension(Extension):
|
||||||
tags = {"emoji"}
|
tags = {"emoji"}
|
||||||
|
|
||||||
@@ -202,6 +423,11 @@ class LinkifyExtension(Extension):
|
|||||||
result = embed_media(result)
|
result = embed_media(result)
|
||||||
result = embed_image(result)
|
result = embed_image(result)
|
||||||
result = embed_youtube(result)
|
result = embed_youtube(result)
|
||||||
|
|
||||||
|
result = enrich_image_rendering(result)
|
||||||
|
|
||||||
|
result = embed_url(result)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -221,7 +447,6 @@ class PythonExtension(Extension):
|
|||||||
).set_lineno(line_number)
|
).set_lineno(line_number)
|
||||||
|
|
||||||
def _to_html(self, md_file, caller):
|
def _to_html(self, md_file, caller):
|
||||||
|
|
||||||
def fn(source):
|
def fn(source):
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
|||||||
+62
-21
@@ -1,6 +1,11 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import pty
|
|
||||||
|
try:
|
||||||
|
import pty
|
||||||
|
except Exception as ex:
|
||||||
|
print("You are not able to run a terminal. See error:")
|
||||||
|
print(ex)
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
commands = {
|
commands = {
|
||||||
@@ -11,20 +16,40 @@ commands = {
|
|||||||
|
|
||||||
class TerminalSession:
|
class TerminalSession:
|
||||||
def __init__(self, command):
|
def __init__(self, command):
|
||||||
self.master, self.slave = pty.openpty()
|
self.master, self.slave = None, None
|
||||||
|
self.process = None
|
||||||
self.sockets = []
|
self.sockets = []
|
||||||
self.history = b""
|
self.history = b""
|
||||||
self.history_size = 1024 * 20
|
self.history_size = 1024 * 20
|
||||||
self.process = subprocess.Popen(
|
self.command = command
|
||||||
command.split(" "),
|
self.start_process(self.command)
|
||||||
stdin=self.slave,
|
|
||||||
stdout=self.slave,
|
def start_process(self, command):
|
||||||
stderr=self.slave,
|
if not self.is_running():
|
||||||
bufsize=0,
|
if self.master:
|
||||||
universal_newlines=True,
|
os.close(self.master)
|
||||||
)
|
os.close(self.slave)
|
||||||
|
self.master = None
|
||||||
|
self.slave = None
|
||||||
|
|
||||||
|
self.master, self.slave = pty.openpty()
|
||||||
|
self.process = subprocess.Popen(
|
||||||
|
command.split(" "),
|
||||||
|
stdin=self.slave,
|
||||||
|
stdout=self.slave,
|
||||||
|
stderr=self.slave,
|
||||||
|
bufsize=0,
|
||||||
|
universal_newlines=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_running(self):
|
||||||
|
if not self.process:
|
||||||
|
return False
|
||||||
|
asyncio.get_event_loop()
|
||||||
|
return self.process.poll() is None
|
||||||
|
|
||||||
async def add_websocket(self, ws):
|
async def add_websocket(self, ws):
|
||||||
|
self.start_process(self.command)
|
||||||
asyncio.create_task(self.read_output(ws))
|
asyncio.create_task(self.read_output(ws))
|
||||||
|
|
||||||
async def read_output(self, ws):
|
async def read_output(self, ws):
|
||||||
@@ -52,21 +77,37 @@ class TerminalSession:
|
|||||||
except:
|
except:
|
||||||
self.sockets.remove(ws)
|
self.sockets.remove(ws)
|
||||||
except Exception:
|
except Exception:
|
||||||
print("Terminating process")
|
await self.close()
|
||||||
self.process.terminate()
|
|
||||||
print("Terminated process")
|
|
||||||
for ws in self.sockets:
|
|
||||||
try:
|
|
||||||
await ws.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
break
|
break
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
print("Terminating process")
|
||||||
|
if self.process:
|
||||||
|
self.process.terminate()
|
||||||
|
self.process = None
|
||||||
|
if self.master:
|
||||||
|
os.close(self.master)
|
||||||
|
os.close(self.slave)
|
||||||
|
self.master = None
|
||||||
|
self.slave = None
|
||||||
|
|
||||||
|
print("Terminated process")
|
||||||
|
for ws in self.sockets:
|
||||||
|
try:
|
||||||
|
await ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.sockets = []
|
||||||
|
|
||||||
async def write_input(self, data):
|
async def write_input(self, data):
|
||||||
try:
|
try:
|
||||||
data = data.encode()
|
data = data.encode()
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
await asyncio.get_event_loop().run_in_executor(
|
try:
|
||||||
None, os.write, self.master, data
|
await asyncio.get_event_loop().run_in_executor(
|
||||||
)
|
None, os.write, self.master, data
|
||||||
|
)
|
||||||
|
except Exception as ex:
|
||||||
|
print(ex)
|
||||||
|
await self.close()
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ class BaseView(web.View):
|
|||||||
login_required = False
|
login_required = False
|
||||||
|
|
||||||
async def _iter(self):
|
async def _iter(self):
|
||||||
if self.login_required and not self.session.get("logged_in"):
|
if self.login_required and (
|
||||||
|
not self.session.get("logged_in") or not self.session.get("uid")
|
||||||
|
):
|
||||||
return web.HTTPFound("/")
|
return web.HTTPFound("/")
|
||||||
return await super()._iter()
|
return await super()._iter()
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<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" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<title>Snek</title>
|
<title>Snek</title>
|
||||||
<style>{{highlight_styles}}</style>
|
<style>{{highlight_styles}}</style>
|
||||||
@@ -15,8 +15,21 @@
|
|||||||
<script src="/generic-form.js" type="module"></script>
|
<script src="/generic-form.js" type="module"></script>
|
||||||
<script src="/html-frame.js" type="module"></script>
|
<script src="/html-frame.js" type="module"></script>
|
||||||
<script src="/app.js" type="module"></script>
|
<script src="/app.js" type="module"></script>
|
||||||
<link rel="stylesheet" href="/base.css">
|
<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"
|
||||||
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
|
||||||
|
integrity="sha512-pBMV+3tn6+5xAZuhI6tyCmQkXh15riZDqGPxAx/U+FuiI5Dh3ZTjM23cZqQ25jJCfi8+ka9gzC2ukNkGkP/Aw=="
|
||||||
|
crossorigin="anonymous"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
/>
|
||||||
<link rel="icon" type="image/png" href="/image/snek1.png" sizes="32x32">
|
<link rel="icon" type="image/png" href="/image/snek1.png" sizes="32x32">
|
||||||
<script defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea"></script>
|
<script defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea"></script>
|
||||||
</head>
|
</head>
|
||||||
@@ -28,6 +41,7 @@
|
|||||||
<div class="logo no-select">{% block header_text %}{% endblock %}</div>
|
<div class="logo no-select">{% block header_text %}{% endblock %}</div>
|
||||||
<nav class="no-select" style="overflow:hidden;scroll-behavior:smooth">
|
<nav class="no-select" style="overflow:hidden;scroll-behavior:smooth">
|
||||||
<a class="no-select" href="/web.html">🏠</a>
|
<a class="no-select" href="/web.html">🏠</a>
|
||||||
|
<a class="no-select" href="/drive.html">đź“‚</a>
|
||||||
<a class="no-select" href="/search-user.html">🔍</a>
|
<a class="no-select" href="/search-user.html">🔍</a>
|
||||||
<a class="no-select" style="display:none" id="install-button" href="#">📥</a>
|
<a class="no-select" style="display:none" id="install-button" href="#">📥</a>
|
||||||
<a class="no-select" href="/threads.html">👥</a>
|
<a class="no-select" href="/threads.html">👥</a>
|
||||||
@@ -36,10 +50,11 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
<main>
|
|
||||||
{% block sidebar %}
|
{% block sidebar %}
|
||||||
{% include "sidebar_channels.html" %}
|
{% include "sidebar_channels.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
<main>
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<chat-window class="chat-area"></chat-window>
|
<chat-window class="chat-area"></chat-window>
|
||||||
@@ -65,5 +80,6 @@ let installPrompt = null
|
|||||||
|
|
||||||
;
|
;
|
||||||
</script>
|
</script>
|
||||||
|
{% include "sandbox.html" %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<script src="/html-frame.js" type="module"></script>
|
<script src="/html-frame.js" type="module"></script>
|
||||||
<script src="/generic-form.js?rid={{ rid }}" type="module"></script>
|
<script src="/generic-form.js?rid={{ rid }}" type="module"></script>
|
||||||
<link rel="stylesheet" href="/html-frame.css">
|
<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 %}
|
{% block head %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</head>
|
</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>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{% extends "app.html" %}
|
||||||
|
|
||||||
|
{% block header_text %}Drive{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<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" />
|
||||||
<title>Snek chat by Molodetz</title>
|
<title>Snek – The Ultimate Web Community</title>
|
||||||
<link rel="stylesheet" href="generic-form.css">
|
<style>
|
||||||
<link rel="stylesheet" href="base.css">
|
/* Base reset */
|
||||||
<style>
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
.registration-container {
|
body {
|
||||||
max-width: 300px;
|
font-family: 'Segoe UI',sans-serif;
|
||||||
margin: 20px auto;
|
background: #111;
|
||||||
padding: 20px;
|
color: #eee;
|
||||||
}
|
line-height:1.5;
|
||||||
</style>
|
}
|
||||||
<script src="/fancy-button.js"></script>
|
a { color: #7ef; text-decoration: none; }
|
||||||
<script defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea"></script>
|
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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="registration-container">
|
|
||||||
|
<header class="container hero">
|
||||||
<h1>Snek</h1>
|
<h1>Snek</h1>
|
||||||
<p style="padding-bottom:20px">Rocket Chat got bloated, too commercialized,
|
<p>The Ultimate Web Community for Devs, Testers & AI Enthusiasts</p>
|
||||||
So Snek came through, lean and optimized.</p>
|
<a href="/login.html" class="btn">Login</a>
|
||||||
<div style="text-align: center;">
|
<a href="/register.html" class="btn">Register</a>
|
||||||
<fancy-button url="/login.html" text="Login"></fancy-button>
|
</header>
|
||||||
<span style="padding:10px;">OR</span>
|
|
||||||
<fancy-button url="/register.html" text="Register"></fancy-button>
|
<main class="container">
|
||||||
</div>
|
|
||||||
</div>
|
<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>
|
</body>
|
||||||
</html>
|
</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"><div class="avatar" style="background-color: {{color}}; color: black;"><img width="40px" height="40px" src="/avatar/{{user_uid}}.svg" /></div><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,82 @@
|
|||||||
|
{% extends "app.html" %}
|
||||||
|
{% block header_text %}{{rel_path}}{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #1c1c1c;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #333;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:hover {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
flex: 0 0 30px;
|
||||||
|
text-align: center;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-type, .file-size {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.file-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-type, .file-size {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="container">
|
||||||
|
<div class="file-list">
|
||||||
|
{% for file in files %}
|
||||||
|
<a href="/repository/{{username}}/{{repo_name}}/{{ file.path }}" class="file-item">
|
||||||
|
<div class="file-icon">
|
||||||
|
{% if file.type == 'tree' %}
|
||||||
|
<i class="fa fa-folder"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="fa fa-file"></i>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="file-name">{{ file.name }}</div>
|
||||||
|
<div class="file-type">{{ file.type }}</div>
|
||||||
|
<div class="file-size">{{ file.size }} B</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
{% block title %}Search{% endblock %}
|
{% block title %}Search{% endblock %}
|
||||||
|
|
||||||
{% block main %}
|
{% block header_text %}<h2 style="color:#fff">Search</h2>{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
<section class="chat-area">
|
<section class="chat-area">
|
||||||
<div class="chat-header">
|
<div class="chat-header">
|
||||||
<h2>Search user</h2>
|
<h2>Search user</h2>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -6,8 +6,6 @@
|
|||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block header_text %}<h2 style="color:#fff">Settings</h2>{% endblock %}
|
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<link href="https://cdn.bootcdn.net/ajax/libs/monaco-editor/0.20.0/min/vs/editor/editor.main.min.css" rel="stylesheet">
|
<link href="https://cdn.bootcdn.net/ajax/libs/monaco-editor/0.20.0/min/vs/editor/editor.main.min.css" rel="stylesheet">
|
||||||
|
|
||||||
@@ -15,23 +13,18 @@
|
|||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block logo %}
|
||||||
|
<h1>Setting page</h1>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
|
|
||||||
|
|
||||||
<div id="profile_description"></div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script type="module">
|
|
||||||
|
|
||||||
require.config({ paths: { 'vs': 'https://cdn.bootcdn.net/ajax/libs/monaco-editor/0.20.0/min/vs' } });
|
|
||||||
|
|
||||||
require(['vs/editor/editor.main'], function () {
|
|
||||||
var editor = monaco.editor.create(document.getElementById('profile_description'), {
|
|
||||||
value: phpCode,
|
|
||||||
language: 'php'
|
|
||||||
});
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock main %}
|
{% endblock main %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,22 @@
|
|||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<section>
|
<section>
|
||||||
<form>
|
<form method="post">
|
||||||
<h2>Nickname</h2>
|
<h2>Nickname</h2>
|
||||||
|
|
||||||
<input type="text" name="nick" placeholder="Your nickname" value="{{ user.nick.value }}" />
|
<input type="text" name="nick" placeholder="Your nickname" value="{{ user.nick.value }}" />
|
||||||
|
|
||||||
</form>
|
|
||||||
<h2>Description</h2>
|
<h2>Description</h2>
|
||||||
|
|
||||||
|
<textarea name="profile" id="profile">{{profile}}</textarea>
|
||||||
|
|
||||||
|
|
||||||
|
<input type="submit" name="action" value="Save" />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<textarea id="profile"></textarea>
|
|
||||||
</section>
|
</section>
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends 'settings/index.html' %}
|
||||||
|
|
||||||
|
{% block header_text %}<h1><i class="fa-solid fa-plus"></i> Create Repository</h1>{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
{% include 'settings/repositories/form.html' %}
|
||||||
|
<div class="container">
|
||||||
|
<form action="/settings/repositories/create.html" method="post">
|
||||||
|
<div>
|
||||||
|
<label for="name"><i class="fa-solid fa-book"></i> Name</label>
|
||||||
|
<input type="text" id="name" name="name" required placeholder="Repository name">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="is_private" value="1">
|
||||||
|
<i class="fa-solid fa-lock"></i> Private
|
||||||
|
</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 %}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends 'settings/index.html' %}
|
||||||
|
|
||||||
|
{% block header_text %}<h1><i class="fa-solid fa-trash-can"></i> Delete Repository</h1>{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
{% include "settings/repositories/form.html" %}
|
||||||
|
<div class="container">
|
||||||
|
<p>Are you sure you want to <strong>delete</strong> the following repository?</p>
|
||||||
|
<div class="repo-name"><i class="fa-solid fa-book"></i> {{ repository.name }}</div>
|
||||||
|
<form method="post" style="margin-top:1.5rem;">
|
||||||
|
<input type="hidden" name="name" value="{{ repository.name }}">
|
||||||
|
<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,106 @@
|
|||||||
|
{% extends 'settings/index.html' %}
|
||||||
|
|
||||||
|
{% block header_text %}<h1><i class="fa-solid fa-database"></i> Repositories</h1>{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Repositories - 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;
|
||||||
|
}
|
||||||
|
.repo-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.repo-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.repo-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
.repo-name {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.repo-row { flex-direction: column; align-items: stretch; }
|
||||||
|
.actions { justify-content: flex-start; }
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
/* justify-content: flex-end;*/
|
||||||
|
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/repositories/create.html">
|
||||||
|
<i class="fa-solid fa-plus"></i> New Repository
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<section class="repo-list">
|
||||||
|
<!-- Example repository entries; replace with your templating/iteration -->
|
||||||
|
{% for repo in repositories %}
|
||||||
|
<div class="repo-row">
|
||||||
|
<div class="repo-info">
|
||||||
|
<span class="repo-name"><i class="fa-solid fa-book"></i> {{ repo.name }}</span>
|
||||||
|
|
||||||
|
<span title="Public">
|
||||||
|
<i class="fa-solid {% if repo.is_private %}fa-lock{% else %}fa-lock-open{% endif %}"></i>
|
||||||
|
{% if repo.is_private %}Private{% else %}Public{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="button browse" href="/repository/{{ user.username.value }}/{{ repo.name }}" target="_blank">
|
||||||
|
<i class="fa-solid fa-folder-open"></i> Browse
|
||||||
|
</a>
|
||||||
|
<a class="button clone" href="/git/{{ user.uid.value }}/{{ repo.name.value }}">
|
||||||
|
<i class="fa-solid fa-code-branch"></i> Clone
|
||||||
|
</a>
|
||||||
|
<a class="button edit" href="/settings/repositories/repository/{{ repo.name }}/update.html">
|
||||||
|
<i class="fa-solid fa-pen"></i> Edit
|
||||||
|
</a>
|
||||||
|
<a class="button delete" href="/settings/repositories/repository/{{ repo.name }}/delete.html">
|
||||||
|
<i class="fa-solid fa-trash"></i> Delete
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<!-- ... -->
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{% endblock %}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user