Merge branch 'main' into bugfix/youtube-embed
This commit is contained in:
		
						commit
						d261f54327
					
				
							
								
								
									
										992
									
								
								gitlog.jsonl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										992
									
								
								gitlog.jsonl
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										289
									
								
								gitlog.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										289
									
								
								gitlog.py
									
									
									
									
									
										Normal 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 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| @ -55,6 +55,7 @@ from snek.view.upload import UploadView | |||||||
| from snek.view.user import UserView | 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 ChannelAttachmentView | ||||||
|  | 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 | from snek.sgit import GitApplication | ||||||
| SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34" | 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/create.html", RepositoriesCreateView) | ||||||
|         self.router.add_view("/settings/repositories/repository/{name}/update.html", RepositoriesUpdateView) |         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/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.git = GitApplication(self) | ||||||
|         self.add_subapp("/webdav", self.webdav) |         self.add_subapp("/webdav", self.webdav) | ||||||
|  | |||||||
| @ -10,10 +10,12 @@ 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.repository import RepositoryMapper | ||||||
| from snek.mapper.channel_attachment import ChannelAttachmentMapper | 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( | ||||||
|         **{ |         **{ | ||||||
| @ -27,6 +29,7 @@ def get_mappers(app=None): | |||||||
|             "user_property": UserPropertyMapper(app=app), |             "user_property": UserPropertyMapper(app=app), | ||||||
|             "repository": RepositoryMapper(app=app), |             "repository": RepositoryMapper(app=app), | ||||||
|             "channel_attachment": ChannelAttachmentMapper(app=app), |             "channel_attachment": ChannelAttachmentMapper(app=app), | ||||||
|  |             "container": ContainerMapper(app=app), | ||||||
|         } |         } | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|  | |||||||
							
								
								
									
										6
									
								
								src/snek/mapper/container.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/snek/mapper/container.py
									
									
									
									
									
										Normal 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" | ||||||
| @ -12,6 +12,7 @@ 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.repository import RepositoryModel | ||||||
| from snek.model.channel_attachment import ChannelAttachmentModel | 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 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @ -29,6 +30,7 @@ def get_models(): | |||||||
|             "user_property": UserPropertyModel, |             "user_property": UserPropertyModel, | ||||||
|             "repository": RepositoryModel, |             "repository": RepositoryModel, | ||||||
|             "channel_attachment": ChannelAttachmentModel, |             "channel_attachment": ChannelAttachmentModel, | ||||||
|  |             "container": Container, | ||||||
|         } |         } | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|  | |||||||
							
								
								
									
										10
									
								
								src/snek/model/container.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/snek/model/container.py
									
									
									
									
									
										Normal 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) | ||||||
| @ -13,6 +13,7 @@ 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.repository import RepositoryService | ||||||
| from snek.service.channel_attachment import ChannelAttachmentService | 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 | from snek.service.db import DBService | ||||||
| 
 | 
 | ||||||
| @ -34,6 +35,7 @@ def get_services(app): | |||||||
|             "repository": RepositoryService(app=app), |             "repository": RepositoryService(app=app), | ||||||
|             "db": DBService(app=app), |             "db": DBService(app=app), | ||||||
|             "channel_attachment": ChannelAttachmentService(app=app), |             "channel_attachment": ChannelAttachmentService(app=app), | ||||||
|  |             "container": ContainerService(app=app), | ||||||
|         } |         } | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -15,7 +15,7 @@ class ChannelAttachmentService(BaseService): | |||||||
|         attachment["mime_type"] = mimetypes.guess_type(name)[0] |         attachment["mime_type"] = mimetypes.guess_type(name)[0] | ||||||
|         attachment['resource_type'] = "file" |         attachment['resource_type'] = "file" | ||||||
|         real_file_name = f"{attachment['uid']}-{name}" |         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_folder = await self.services.channel.get_attachment_folder(channel_uid) | ||||||
|         attachment_path = attachment_folder.joinpath(real_file_name) |         attachment_path = attachment_folder.joinpath(real_file_name) | ||||||
|         attachment["path"] = str(attachment_path) |         attachment["path"] = str(attachment_path) | ||||||
|  | |||||||
							
								
								
									
										29
									
								
								src/snek/service/container.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/snek/service/container.py
									
									
									
									
									
										Normal 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) | ||||||
| @ -53,7 +53,8 @@ class ChatInputComponent extends HTMLElement { | |||||||
|         this.textarea.focus(); |         this.textarea.focus(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     connectedCallback() { |     async connectedCallback() { | ||||||
|  |         this.user = await app.rpc.getUser(null); | ||||||
|         this.liveType = this.getAttribute("live-type") === "true"; |         this.liveType = this.getAttribute("live-type") === "true"; | ||||||
|         this.liveTypeInterval = parseInt(this.getAttribute("live-type-interval")) || 3; |         this.liveTypeInterval = parseInt(this.getAttribute("live-type-interval")) || 3; | ||||||
|         this.channelUid = this.getAttribute("channel"); |         this.channelUid = this.getAttribute("channel"); | ||||||
| @ -193,7 +194,7 @@ class ChatInputComponent extends HTMLElement { | |||||||
|         if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) > 1) { |         if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) > 1) { | ||||||
|             this.lastUpdateEvent = new Date(); |             this.lastUpdateEvent = new Date(); | ||||||
|             if (typeof app !== "undefined" && app.rpc && typeof app.rpc.set_typing === "function") { |             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); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -1,28 +1,42 @@ | |||||||
| /* each star */ | 
 | ||||||
|     .star { | .star { | ||||||
|   position: absolute; |   position: absolute; | ||||||
|   width: 2px; |   width: 2px; | ||||||
|   height: 2px; |   height: 2px; | ||||||
|       background: #fff; |   background: var(--star-color, #fff); | ||||||
|   border-radius: 50%; |   border-radius: 50%; | ||||||
|   opacity: 0; |   opacity: 0; | ||||||
|       /* flicker animation */ |   transition: background 0.5s ease; | ||||||
|   animation: twinkle ease-in-out infinite; |   animation: twinkle ease-in-out infinite; | ||||||
|     } | } | ||||||
| 
 | 
 | ||||||
|     @keyframes twinkle { | @keyframes twinkle { | ||||||
|   0%, 100% { opacity: 0; } |   0%, 100% { opacity: 0; } | ||||||
|   50%      { opacity: 1; } |   50%      { opacity: 1; } | ||||||
|     } | } | ||||||
| 
 | 
 | ||||||
|     /* optional page content */ | @keyframes star-glow-frames { | ||||||
|     .content { |     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; |   position: relative; | ||||||
|   z-index: 1; |   z-index: 1; | ||||||
|       color: #eee; |   color: var(--star-content-color, #eee); | ||||||
|   font-family: sans-serif; |   font-family: sans-serif; | ||||||
|   text-align: center; |   text-align: center; | ||||||
|   top: 40%; |   top: 40%; | ||||||
|   transform: translateY(-40%); |   transform: translateY(-40%); | ||||||
|     } | } | ||||||
| 
 |  | ||||||
|  | |||||||
| @ -3,7 +3,7 @@ | |||||||
| {% block header_text %}Drive{% endblock %} | {% block header_text %}Drive{% endblock %} | ||||||
| 
 | 
 | ||||||
| {% block main %} | {% block main %} | ||||||
| <div class="container"> | <div class="container" style="overflow-y: auto;"> | ||||||
| <file-manager path="{{path}}" style="flex: 1"></file-manager> | <file-manager path="{{path}}" style="flex: 1"></file-manager> | ||||||
| </div> | </div> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  | |||||||
| @ -14,7 +14,7 @@ | |||||||
|     * { margin:0; padding:0; box-sizing:border-box; } |     * { margin:0; padding:0; box-sizing:border-box; } | ||||||
|     body { |     body { | ||||||
|       font-family: 'Segoe UI',sans-serif; |       font-family: 'Segoe UI',sans-serif; | ||||||
|       background: #111; |       background: #000; | ||||||
|       color: #eee; |       color: #eee; | ||||||
|       line-height:1.5; |       line-height:1.5; | ||||||
|     } |     } | ||||||
| @ -187,6 +187,7 @@ | |||||||
|       .btn { width: 100%; box-sizing: border-box; text-align:center; } |       .btn { width: 100%; box-sizing: border-box; text-align:center; } | ||||||
|     } |     } | ||||||
|   </style> |   </style> | ||||||
|  |   <link rel="stylesheet" href="/static/sandbox.css" /> | ||||||
| </head> | </head> | ||||||
| <body> | <body> | ||||||
| 
 | 
 | ||||||
| @ -284,5 +285,35 @@ snek serve | |||||||
|     <p>© 2025 Snek – Join our global community of developers, testers & AI enthusiasts.</p> |     <p>© 2025 Snek – Join our global community of developers, testers & AI enthusiasts.</p> | ||||||
|   </footer> |   </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,31 +1,115 @@ | |||||||
| 
 | 
 | ||||||
| 							<script> | <script type="module"> | ||||||
|  | import { app } from "/app.js"; | ||||||
| 
 | 
 | ||||||
|     // number of stars you want | const STAR_COUNT = 200; | ||||||
|     const STAR_COUNT = 200; | const body = document.body; | ||||||
|     const body = document.body; |  | ||||||
| 
 | 
 | ||||||
|     for (let i = 0; i < STAR_COUNT; i++) { | function createStar() { | ||||||
|   const star = document.createElement('div'); |   const star = document.createElement('div'); | ||||||
|   star.classList.add('star'); |   star.classList.add('star'); | ||||||
| 
 |   star.style.left = `${Math.random() * 100}%`; | ||||||
|       // random position within the viewport |   star.style.top = `${Math.random() * 100}%`; | ||||||
|       star.style.left  = Math.random() * 100 + '%'; |   const size = Math.random() * 2 + 1; | ||||||
|       star.style.top   = Math.random() * 100 + '%'; |   star.style.width = `${size}px`; | ||||||
| 
 |   star.style.height = `${size}px`; | ||||||
|       // random size (optional) |   const duration = Math.random() * 3 + 2; | ||||||
|       const size = Math.random() * 2 + 1; // between 1px and 3px |   const delay = Math.random() * 5; | ||||||
|       star.style.width  = size + 'px'; |   star.style.animationDuration = `${duration}s`; | ||||||
|       star.style.height = size + 'px'; |   star.style.animationDelay = `${delay}s`; | ||||||
| 
 |  | ||||||
|       // 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); |   body.appendChild(star); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | Array.from({ length: STAR_COUNT }, createStar); | ||||||
|  | 
 | ||||||
|  | function lightenColor(hex, percent) { | ||||||
|  |   const num = parseInt(hex.replace("#", ""), 16); | ||||||
|  |   let r = (num >> 16) + Math.round(255 * percent / 100); | ||||||
|  |   let g = ((num >> 8) & 0x00FF) + Math.round(255 * percent / 100); | ||||||
|  |   let b = (num & 0x0000FF) + Math.round(255 * percent / 100); | ||||||
|  |   r = Math.min(255, r); | ||||||
|  |   g = Math.min(255, g); | ||||||
|  |   b = Math.min(255, b); | ||||||
|  |   return `#${(1 << 24 | r << 16 | g << 8 | b).toString(16).slice(1)}`; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const originalColor = document.documentElement.style.getPropertyValue("--star-color").trim(); | ||||||
|  | 
 | ||||||
|  | function glowCSSVariable(varName, glowColor, duration = 500) { | ||||||
|  |   const root = document.documentElement; | ||||||
|  | 
 | ||||||
|  |   //igetComputedStyle(root).getPropertyValue(varName).trim(); | ||||||
|  |   glowColor = lightenColor(glowColor, 10); | ||||||
|  |   root.style.setProperty(varName, glowColor); | ||||||
|  |   setTimeout(() => { | ||||||
|  |     root.style.setProperty(varName, originalColor); | ||||||
|  |   }, duration); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function updateStarColorDelayed(color) { | ||||||
|  |   glowCSSVariable('--star-color', color, 2500); | ||||||
|  | } | ||||||
|  | app.updateStarColor = updateStarColorDelayed; | ||||||
|  | app.ws.addEventListener("set_typing", (data) => { | ||||||
|  |   updateStarColorDelayed(data.data.color); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | /* | ||||||
|  | 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 }; | ||||||
|  |   } | ||||||
| 
 | 
 | ||||||
| 							</script> |   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); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const starField = new StarField(document.body, { | ||||||
|  |   starCount: 200, | ||||||
|  |   minSize: 1, | ||||||
|  |   maxSize: 3, | ||||||
|  |   speed: 5, | ||||||
|  |   color: "white"     | ||||||
|  | }); | ||||||
|  | */ | ||||||
|  | </script> | ||||||
|  | |||||||
							
								
								
									
										39
									
								
								src/snek/templates/settings/containers/create.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/snek/templates/settings/containers/create.html
									
									
									
									
									
										Normal 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 %} | ||||||
							
								
								
									
										17
									
								
								src/snek/templates/settings/containers/delete.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/snek/templates/settings/containers/delete.html
									
									
									
									
									
										Normal 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 %} | ||||||
							
								
								
									
										28
									
								
								src/snek/templates/settings/containers/form.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/snek/templates/settings/containers/form.html
									
									
									
									
									
										Normal 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> | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
							
								
								
									
										96
									
								
								src/snek/templates/settings/containers/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								src/snek/templates/settings/containers/index.html
									
									
									
									
									
										Normal 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 %} | ||||||
							
								
								
									
										40
									
								
								src/snek/templates/settings/containers/update.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/snek/templates/settings/containers/update.html
									
									
									
									
									
										Normal 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 %} | ||||||
| @ -36,9 +36,11 @@ class RPCView(BaseView): | |||||||
|         async def db_update(self, table_name, record): |         async def db_update(self, table_name, record): | ||||||
|             self._require_login() |             self._require_login() | ||||||
|             return await self.services.db.update(self.user_uid, table_name, record) |             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() |             self._require_login() | ||||||
|             user = await self.services.user.get(self.user_uid) |             user = await self.services.user.get(self.user_uid) | ||||||
|  |             if not color: | ||||||
|  |                 color = user["color"] | ||||||
|             return await self.services.socket.broadcast(channel_uid, { |             return await self.services.socket.broadcast(channel_uid, { | ||||||
|                 "channel_uid": "293ecf12-08c9-494b-b423-48ba1a2d12c2", |                 "channel_uid": "293ecf12-08c9-494b-b423-48ba1a2d12c2", | ||||||
|                 "event": "set_typing", |                 "event": "set_typing", | ||||||
| @ -47,7 +49,8 @@ class RPCView(BaseView): | |||||||
|                     "user_uid": user['uid'], |                     "user_uid": user['uid'], | ||||||
|                     "username": user["username"], |                     "username": user["username"], | ||||||
|                     "nick": user["nick"], |                     "nick": user["nick"], | ||||||
|                     "channel_uid": channel_uid |                     "channel_uid": channel_uid, | ||||||
|  |                     "color": color | ||||||
|                 } |                 } | ||||||
|             }) |             }) | ||||||
| 
 | 
 | ||||||
|  | |||||||
							
								
								
									
										91
									
								
								src/snek/view/settings/containers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/snek/view/settings/containers.py
									
									
									
									
									
										Normal 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") | ||||||
		Loading…
	
		Reference in New Issue
	
	Block a user