Merge branch 'main' into bugfix/youtube-embed

This commit is contained in:
BordedDev 2025-05-20 03:34:05 +02:00
commit d261f54327
22 changed files with 1837 additions and 55 deletions

992
gitlog.jsonl Normal file

File diff suppressed because one or more lines are too long

289
gitlog.py Normal file
View File

@ -0,0 +1,289 @@
import http.server
import socketserver
import json
import os
import subprocess
from urllib.parse import parse_qs, urlparse
import mimetypes
import html
# --- Theme selection (choose one: "light1", "light2", "dark1", "dark2") ---
THEME = "light1" # Change this to "light2", "dark1", or "dark2" as desired
THEMES = {
"light1": """
body { font-family: Arial, sans-serif; background: #f6f8fa; margin: 0; padding: 0; color: #222; }
.container { max-width: 960px; margin: auto; padding: 2em; }
.date-header { font-size: 1.2em; margin-top: 2em; border-bottom: 1px solid #ccc; }
.commit { margin: 1em 0; padding: 1em; background: #fff; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.07); }
.hash { color: #555; font-family: monospace; }
.diff { white-space: pre-wrap; background: #f1f1f1; padding: 1em; border-radius: 4px; overflow-x: auto; }
ul { list-style: none; padding-left: 0; }
li { margin: 0.3em 0; }
a { text-decoration: none; color: #0366d6; }
a:hover { text-decoration: underline; }
""",
"light2": """
body { font-family: 'Segoe UI', sans-serif; background: #fdf6e3; margin: 0; padding: 0; color: #333; }
.container { max-width: 900px; margin: auto; padding: 2em; }
.date-header { font-size: 1.2em; margin-top: 2em; border-bottom: 2px solid #e1c699; color: #b58900; }
.commit { margin: 1em 0; padding: 1em; background: #fffbe6; border-radius: 6px; box-shadow: 0 1px 3px rgba(200,180,100,0.08); }
.hash { color: #b58900; font-family: monospace; }
.diff { white-space: pre-wrap; background: #f5e9c9; padding: 1em; border-radius: 4px; overflow-x: auto; }
ul { list-style: none; padding-left: 0; }
li { margin: 0.3em 0; }
a { text-decoration: none; color: #b58900; }
a:hover { text-decoration: underline; color: #cb4b16; }
""",
"dark1": """
body { font-family: Arial, sans-serif; background: #181a1b; margin: 0; padding: 0; color: #eaeaea; }
.container { max-width: 960px; margin: auto; padding: 2em; }
.date-header { font-size: 1.2em; margin-top: 2em; border-bottom: 1px solid #333; color: #8ab4f8; }
.commit { margin: 1em 0; padding: 1em; background: #23272b; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.18); }
.hash { color: #8ab4f8; font-family: monospace; }
.diff { white-space: pre-wrap; background: #23272b; padding: 1em; border-radius: 4px; overflow-x: auto; }
ul { list-style: none; padding-left: 0; }
li { margin: 0.3em 0; }
a { text-decoration: none; color: #8ab4f8; }
a:hover { text-decoration: underline; color: #bb86fc; }
""",
"dark2": """
body { font-family: 'Fira Sans', sans-serif; background: #121212; margin: 0; padding: 0; color: #d0d0d0; }
.container { max-width: 900px; margin: auto; padding: 2em; }
.date-header { font-size: 1.2em; margin-top: 2em; border-bottom: 2px solid #444; color: #ffb86c; }
.commit { margin: 1em 0; padding: 1em; background: #22223b; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.22); }
.hash { color: #ffb86c; font-family: monospace; }
.diff { white-space: pre-wrap; background: #282a36; padding: 1em; border-radius: 4px; overflow-x: auto; }
ul { list-style: none; padding-left: 0; }
li { margin: 0.3em 0; }
a { text-decoration: none; color: #ffb86c; }
a:hover { text-decoration: underline; color: #8be9fd; }
""",
}
def HTML_TEMPLATE(content, theme=THEME):
return f"""
<!DOCTYPE html>
<html lang=\"en\">
<head>
<meta charset=\"UTF-8\">
<title>Git Log Viewer</title>
<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css\">
<style>
{THEMES[theme]}
</style>
<script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>
<script>hljs.highlightAll();</script>
</head>
<body>
<div class=\"container\">
{content}
</div>
</body>
</html>
"""
REPO_ROOT = os.path.abspath(".")
LOG_FILE = os.path.join(REPO_ROOT, "gitlog.jsonl")
PORT = 8481
def format_diff_to_html(diff_text: str) -> str:
lines = diff_text.strip().splitlines()
html_lines = ['<div style="font-family: monospace; white-space: pre;">']
while not lines[3].startswith('diff'):
lines.pop(3)
lines.insert(3, "")
for line in lines:
escaped = html.escape(line)
if "//" in line:
continue
if "#" in line:
continue
if "/*" in line:
continue
if "*/" in line:
continue
if line.startswith('+++') or line.startswith('---'):
html_lines.append(f'<div style="color: #0000aa;">{escaped}</div>')
elif line.startswith('@@'):
html_lines.append(f'<div style="color: #005cc5;">{escaped}</div>')
elif line.startswith('+'):
html_lines.append(f'<div style="color: #22863a;">{escaped}</div>')
elif line.startswith('-'):
html_lines.append(f'<div style="color: #b31d28;">{escaped}</div>')
elif line.startswith('\\'):
html_lines.append(f'<div style="color: #6a737d;">{escaped}</div>')
else:
html_lines.append(f'<div>{escaped}</div>')
html_lines.append('</div>')
return '\n'.join(html_lines)
def parse_logs():
logs = []
if not os.path.exists(LOG_FILE):
return []
lines = []
with open(LOG_FILE, "r", encoding="utf-8") as f:
for line in f:
if line.strip():
if line.strip() not in lines:
lines.append(line.strip())
logs.append(json.loads(line.strip()))
return logs
def group_by_date(logs):
grouped = {}
for entry in logs:
date = entry["date"]
grouped.setdefault(date, []).append(entry)
return dict(sorted(grouped.items(), reverse=True))
def get_git_diff(commit_hash):
try:
result = subprocess.run(
["git", "-C", REPO_ROOT, "show", commit_hash, "--no-color"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=True,
)
return result.stdout
except subprocess.CalledProcessError as e:
return f"Error retrieving diff: {e.stderr}"
def list_directory(path, base_url="/browse?path="):
try:
entries = os.listdir(path)
except OSError:
return "<div>Cannot access directory.</div>"
entries.sort()
content = "<ul>"
# Parent directory link
parent = os.path.dirname(path)
if os.path.abspath(path) != REPO_ROOT:
parent_rel = os.path.relpath(parent, REPO_ROOT)
content += f"<li><a href='{base_url}{html.escape(parent_rel)}'>.. (parent directory)</a></li>"
for entry in entries:
full_path = os.path.join(path, entry)
rel_path = os.path.relpath(full_path, REPO_ROOT)
if os.path.isdir(full_path):
content += f"<li>📁 <a href='{base_url}{html.escape(rel_path)}'>{html.escape(entry)}/</a></li>"
else:
content += f"<li>📄 <a href='{base_url}{html.escape(rel_path)}'>{html.escape(entry)}</a></li>"
content += "</ul>"
return content
def read_file_content(path):
try:
with open(path, "r", encoding="utf-8") as f:
return f.read()
except Exception as e:
return f"Error reading file: {e}"
def get_language_class(filename):
ext = os.path.splitext(filename)[1].lower()
return {
'.py': 'python',
'.js': 'javascript',
'.html': 'html',
'.css': 'css',
'.json': 'json',
'.sh': 'bash',
'.md': 'markdown',
'.c': 'c',
'.cpp': 'cpp',
'.h': 'cpp',
'.java': 'java',
'.rb': 'ruby',
'.go': 'go',
'.php': 'php',
'.rs': 'rust',
'.ts': 'typescript',
'.xml': 'xml',
'.yml': 'yaml',
'.yaml': 'yaml',
}.get(ext, '')
class GitLogHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/":
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
logs = parse_logs()
grouped = group_by_date(logs)
content = "<p><a href='/browse'>Browse Files</a></p>"
for date, commits in grouped.items():
content += f"<div class='date-header'>{date}</div>"
for c in commits:
commit_link = f"/diff?hash={c['commit']}"
content += f"""
<div class='commit'>
<div><strong>{c['line'].splitlines()[0]}</strong></div>
<div class='hash'><a href='{commit_link}'>{c['commit']}</a></div>
</div>
"""
self.wfile.write(HTML_TEMPLATE(content).encode("utf-8"))
elif parsed.path == "/diff":
qs = parse_qs(parsed.query)
commit = qs.get("hash", [""])[0]
diff = format_diff_to_html(get_git_diff(html.escape(commit)))
diff_html = f"<h2>Commit: {commit}</h2><div class='diff'>{diff}</div><p><a href='/'>← Back to commits</a></p>"
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(HTML_TEMPLATE(diff_html).encode("utf-8"))
elif parsed.path == "/browse":
qs = parse_qs(parsed.query)
rel_path = qs.get("path", [""])[0]
abs_path = os.path.abspath(os.path.join(REPO_ROOT, rel_path))
# Security: prevent escaping the repo root
if not abs_path.startswith(REPO_ROOT):
self.send_error(403, "Forbidden")
return
if os.path.isdir(abs_path):
content = f"<h2>Browsing: /{html.escape(rel_path)}</h2>"
content += list_directory(abs_path)
content += "<p><a href='/'>← Back to commits</a></p>"
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(HTML_TEMPLATE(content).encode("utf-8"))
elif os.path.isfile(abs_path):
file_content = read_file_content(abs_path)
lang_class = get_language_class(abs_path)
content = f"<h2>File: /{html.escape(rel_path)}</h2>"
content += (
f"<pre style='background:#f1f1f1; padding:1em; border-radius:4px; overflow-x:auto;'>"
f"<code class='{lang_class}'>{html.escape(file_content)}</code></pre>"
)
content += "<p><a href='{}'>← Back to directory</a></p>".format(
f"/browse?path={html.escape(os.path.dirname(rel_path))}"
)
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(HTML_TEMPLATE(content).encode("utf-8"))
else:
self.send_error(404, "Not found")
else:
self.send_error(404)
if __name__ == "__main__":
while True:
try:
with socketserver.TCPServer(("", PORT), GitLogHandler) as httpd:
print(f"Serving at http://localhost:{PORT}")
httpd.serve_forever()
break
except Exception as ex:
print(ex)
PORT += 1

View File

@ -55,6 +55,7 @@ from snek.view.upload import UploadView
from snek.view.user import UserView
from snek.view.web import WebView
from snek.view.channel import ChannelAttachmentView
from snek.view.settings.containers import ContainersIndexView, ContainersCreateView, ContainersUpdateView, ContainersDeleteView
from snek.webdav import WebdavApplication
from snek.sgit import GitApplication
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
@ -208,6 +209,10 @@ class Application(BaseApplication):
self.router.add_view("/settings/repositories/create.html", RepositoriesCreateView)
self.router.add_view("/settings/repositories/repository/{name}/update.html", RepositoriesUpdateView)
self.router.add_view("/settings/repositories/repository/{name}/delete.html", RepositoriesDeleteView)
self.router.add_view("/settings/containers/index.html", ContainersIndexView)
self.router.add_view("/settings/containers/create.html", ContainersCreateView)
self.router.add_view("/settings/containers/container/{uid}/update.html", ContainersUpdateView)
self.router.add_view("/settings/containers/container/{uid}/delete.html", ContainersDeleteView)
self.webdav = WebdavApplication(self)
self.git = GitApplication(self)
self.add_subapp("/webdav", self.webdav)

View File

@ -10,10 +10,12 @@ from snek.mapper.user import UserMapper
from snek.mapper.user_property import UserPropertyMapper
from snek.mapper.repository import RepositoryMapper
from snek.mapper.channel_attachment import ChannelAttachmentMapper
from snek.mapper.container import ContainerMapper
from snek.system.object import Object
@functools.cache
def get_mappers(app=None):
return Object(
**{
@ -27,6 +29,7 @@ def get_mappers(app=None):
"user_property": UserPropertyMapper(app=app),
"repository": RepositoryMapper(app=app),
"channel_attachment": ChannelAttachmentMapper(app=app),
"container": ContainerMapper(app=app),
}
)

View File

@ -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"

View File

@ -12,6 +12,7 @@ from snek.model.user import UserModel
from snek.model.user_property import UserPropertyModel
from snek.model.repository import RepositoryModel
from snek.model.channel_attachment import ChannelAttachmentModel
from snek.model.container import Container
from snek.system.object import Object
@ -29,6 +30,7 @@ def get_models():
"user_property": UserPropertyModel,
"repository": RepositoryModel,
"channel_attachment": ChannelAttachmentModel,
"container": Container,
}
)

View File

@ -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)

View File

@ -13,6 +13,7 @@ from snek.service.user_property import UserPropertyService
from snek.service.util import UtilService
from snek.service.repository import RepositoryService
from snek.service.channel_attachment import ChannelAttachmentService
from snek.service.container import ContainerService
from snek.system.object import Object
from snek.service.db import DBService
@ -34,6 +35,7 @@ def get_services(app):
"repository": RepositoryService(app=app),
"db": DBService(app=app),
"channel_attachment": ChannelAttachmentService(app=app),
"container": ContainerService(app=app),
}
)

View File

@ -15,7 +15,7 @@ class ChannelAttachmentService(BaseService):
attachment["mime_type"] = mimetypes.guess_type(name)[0]
attachment['resource_type'] = "file"
real_file_name = f"{attachment['uid']}-{name}"
attachment["relative_url"] = urllib.parse.quote(f"{attachment['uid']}/{name}")
attachment["relative_url"] = (f"{attachment['uid']}-{name}")
attachment_folder = await self.services.channel.get_attachment_folder(channel_uid)
attachment_path = attachment_folder.joinpath(real_file_name)
attachment["path"] = str(attachment_path)

View File

@ -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)

View File

@ -53,7 +53,8 @@ class ChatInputComponent extends HTMLElement {
this.textarea.focus();
}
connectedCallback() {
async connectedCallback() {
this.user = await app.rpc.getUser(null);
this.liveType = this.getAttribute("live-type") === "true";
this.liveTypeInterval = parseInt(this.getAttribute("live-type-interval")) || 3;
this.channelUid = this.getAttribute("channel");
@ -193,7 +194,7 @@ class ChatInputComponent extends HTMLElement {
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);
app.rpc.set_typing(this.channelUid,this.user.color);
}
}
}

View File

@ -1,28 +1,42 @@
/* each star */
.star {
position: absolute;
width: 2px;
height: 2px;
background: #fff;
border-radius: 50%;
opacity: 0;
/* flicker animation */
animation: twinkle ease-in-out infinite;
}
@keyframes twinkle {
0%, 100% { opacity: 0; }
50% { opacity: 1; }
}
.star {
position: absolute;
width: 2px;
height: 2px;
background: var(--star-color, #fff);
border-radius: 50%;
opacity: 0;
transition: background 0.5s ease;
animation: twinkle ease-in-out infinite;
}
/* optional page content */
.content {
position: relative;
z-index: 1;
color: #eee;
font-family: sans-serif;
text-align: center;
top: 40%;
transform: translateY(-40%);
}
@keyframes twinkle {
0%, 100% { opacity: 0; }
50% { opacity: 1; }
}
@keyframes star-glow-frames {
0% {
box-shadow: 0 0 5px --star-color;
}
50% {
box-shadow: 0 0 20px --star-color, 0 0 30px --star-color;
}
100% {
box-shadow: 0 0 5px --star-color;
}
}
.star-glow {
animation: star-glow-frames 1s;
}
.content {
position: relative;
z-index: 1;
color: var(--star-content-color, #eee);
font-family: sans-serif;
text-align: center;
top: 40%;
transform: translateY(-40%);
}

View File

@ -3,7 +3,7 @@
{% block header_text %}Drive{% endblock %}
{% block main %}
<div class="container">
<div class="container" style="overflow-y: auto;">
<file-manager path="{{path}}" style="flex: 1"></file-manager>
</div>
{% endblock %}

View File

@ -14,7 +14,7 @@
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: 'Segoe UI',sans-serif;
background: #111;
background: #000;
color: #eee;
line-height:1.5;
}
@ -187,6 +187,7 @@
.btn { width: 100%; box-sizing: border-box; text-align:center; }
}
</style>
<link rel="stylesheet" href="/static/sandbox.css" />
</head>
<body>
@ -284,5 +285,35 @@ snek serve
<p>&copy; 2025 Snek Join our global community of developers, testers &amp; 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; // 2s5s
const delay = Math.random() * 5; // 0s5s
star.style.animationDuration = duration + 's';
star.style.animationDelay = delay + 's';
body.appendChild(star);
}
</script>
</body>
</html>

View File

@ -1,31 +1,115 @@
<script>
// number of stars you want
const STAR_COUNT = 200;
const body = document.body;
<script type="module">
import { app } from "/app.js";
for (let i = 0; i < STAR_COUNT; i++) {
const star = document.createElement('div');
star.classList.add('star');
const STAR_COUNT = 200;
const body = document.body;
// random position within the viewport
star.style.left = Math.random() * 100 + '%';
star.style.top = Math.random() * 100 + '%';
function createStar() {
const star = document.createElement('div');
star.classList.add('star');
star.style.left = `${Math.random() * 100}%`;
star.style.top = `${Math.random() * 100}%`;
const size = Math.random() * 2 + 1;
star.style.width = `${size}px`;
star.style.height = `${size}px`;
const duration = Math.random() * 3 + 2;
const delay = Math.random() * 5;
star.style.animationDuration = `${duration}s`;
star.style.animationDelay = `${delay}s`;
body.appendChild(star);
}
// random size (optional)
const size = Math.random() * 2 + 1; // between 1px and 3px
star.style.width = size + 'px';
star.style.height = size + 'px';
Array.from({ length: STAR_COUNT }, createStar);
// random animation timing for natural flicker
const duration = Math.random() * 3 + 2; // 2s5s
const delay = Math.random() * 5; // 0s5s
star.style.animationDuration = duration + 's';
star.style.animationDelay = delay + 's';
function lightenColor(hex, percent) {
const num = parseInt(hex.replace("#", ""), 16);
let r = (num >> 16) + Math.round(255 * percent / 100);
let g = ((num >> 8) & 0x00FF) + Math.round(255 * percent / 100);
let b = (num & 0x0000FF) + Math.round(255 * percent / 100);
r = Math.min(255, r);
g = Math.min(255, g);
b = Math.min(255, b);
return `#${(1 << 24 | r << 16 | g << 8 | b).toString(16).slice(1)}`;
}
body.appendChild(star);
const originalColor = document.documentElement.style.getPropertyValue("--star-color").trim();
function glowCSSVariable(varName, glowColor, duration = 500) {
const root = document.documentElement;
//igetComputedStyle(root).getPropertyValue(varName).trim();
glowColor = lightenColor(glowColor, 10);
root.style.setProperty(varName, glowColor);
setTimeout(() => {
root.style.setProperty(varName, originalColor);
}, duration);
}
function updateStarColorDelayed(color) {
glowCSSVariable('--star-color', color, 2500);
}
app.updateStarColor = updateStarColorDelayed;
app.ws.addEventListener("set_typing", (data) => {
updateStarColorDelayed(data.data.color);
});
/*
class StarField {
constructor(container = document.body, options = {}) {
this.container = container;
this.stars = [];
this.setOptions(options);
}
setOptions({
starCount = 200,
minSize = 1,
maxSize = 3,
speed = 5,
color = "white"
}) {
this.options = { starCount, minSize, maxSize, speed, color };
}
clear() {
this.stars.forEach(star => star.remove());
this.stars = [];
}
generate() {
this.clear();
const { starCount, minSize, maxSize, speed, color } = this.options;
for (let i = 0; i < starCount; i++) {
const star = document.createElement("div");
star.classList.add("star");
const size = Math.random() * (maxSize - minSize) + minSize;
Object.assign(star.style, {
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
width: `${size}px`,
height: `${size}px`,
backgroundColor: color,
position: "absolute",
borderRadius: "50%",
opacity: "0.8",
animation: `twinkle ${speed}s ease-in-out infinite`,
});
this.container.appendChild(star);
this.stars.push(star);
}
}
}
</script>
const starField = new StarField(document.body, {
starCount: 200,
minSize: 1,
maxSize: 3,
speed: 5,
color: "white"
});
*/
</script>

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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>

View File

@ -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 %}

View File

@ -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 %}

View File

@ -36,9 +36,11 @@ class RPCView(BaseView):
async def db_update(self, table_name, record):
self._require_login()
return await self.services.db.update(self.user_uid, table_name, record)
async def set_typing(self,channel_uid):
async def set_typing(self,channel_uid,color=None):
self._require_login()
user = await self.services.user.get(self.user_uid)
if not color:
color = user["color"]
return await self.services.socket.broadcast(channel_uid, {
"channel_uid": "293ecf12-08c9-494b-b423-48ba1a2d12c2",
"event": "set_typing",
@ -47,7 +49,8 @@ class RPCView(BaseView):
"user_uid": user['uid'],
"username": user["username"],
"nick": user["nick"],
"channel_uid": channel_uid
"channel_uid": channel_uid,
"color": color
}
})

View File

@ -0,0 +1,91 @@
import asyncio
from aiohttp import web
from snek.system.view import BaseFormView
import pathlib
class ContainersIndexView(BaseFormView):
login_required = True
async def get(self):
user_uid = self.session.get("uid")
containers = []
async for container in self.services.container.find(user_uid=user_uid):
containers.append(container.record)
user = await self.services.user.get(uid=self.session.get("uid"))
return await self.render_template("settings/containers/index.html", {"containers": containers, "user": user})
class ContainersCreateView(BaseFormView):
login_required = True
async def get(self):
return await self.render_template("settings/containers/create.html")
async def post(self):
data = await self.request.post()
container = await self.services.container.create(
user_uid=self.session.get("uid"),
name=data['name'],
status=data['status'],
resources=data.get('resources', ''),
path=data.get('path', ''),
readonly=bool(data.get('readonly', False))
)
return web.HTTPFound("/settings/containers/index.html")
class ContainersUpdateView(BaseFormView):
login_required = True
async def get(self):
container = await self.services.container.get(
user_uid=self.session.get("uid"), uid=self.request.match_info["uid"]
)
if not container:
return web.HTTPNotFound()
return await self.render_template("settings/containers/update.html", {"container": container.record})
async def post(self):
data = await self.request.post()
container = await self.services.container.get(
user_uid=self.session.get("uid"), uid=self.request.match_info["uid"]
)
container['status'] = data['status']
container['resources'] = data.get('resources', '')
container['path'] = data.get('path', '')
container['readonly'] = bool(data.get('readonly', False))
await self.services.container.save(container)
return web.HTTPFound("/settings/containers/index.html")
class ContainersDeleteView(BaseFormView):
login_required = True
async def get(self):
container = await self.services.container.get(
user_uid=self.session.get("uid"), uid=self.request.match_info["uid"]
)
if not container:
return web.HTTPNotFound()
return await self.render_template("settings/containers/delete.html", {"container": container.record})
async def post(self):
user_uid = self.session.get("uid")
uid = self.request.match_info["uid"]
container = await self.services.container.get(
user_uid=user_uid, uid=uid
)
if not container:
return web.HTTPNotFound()
await self.services.container.delete(user_uid=user_uid, uid=uid)
return web.HTTPFound("/settings/containers/index.html")