feature: work_dir file manager

Implemented the file browser for work_dir, we need to: - move endpoints away from run_ui.py - make the "Up" (parent dir) button work Extra: - Now when under 768px in width, you can touch outside of the sidebar to collapse it.

Alessandro committed Nov 21, 2024 at 10:58 UTC 9c968ba1cff3bcbde20a1484e4e647b520f762a8
17 files changed +1232 -77
python/helpers/file_browser.py new
+170
@@ -0,0 +1,170 @@
1 +import os
2 +from pathlib import Path
3 +from typing import Dict, List, Tuple, Optional, Any
4 +from werkzeug.utils import secure_filename
5 +from datetime import datetime
6 +
7 +class FileBrowser:
8 + ALLOWED_EXTENSIONS = {
9 + 'image': {'jpg', 'jpeg', 'png', 'bmp'},
10 + 'code': {'py', 'js', 'sh', 'html', 'css'},
11 + 'document': {'md', 'pdf', 'txt', 'csv', 'json'}
12 + }
13 +
14 + MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
15 +
16 + def __init__(self, base_dir: str):
17 + self.base_dir = Path(base_dir).resolve()
18 +
19 + def _check_file_size(self, file) -> bool:
20 + try:
21 + file.seek(0, os.SEEK_END)
22 + size = file.tell()
23 + file.seek(0)
24 + return size <= self.MAX_FILE_SIZE
25 + except (AttributeError, IOError):
26 + return False
27 +
28 + def save_files(self, files: List, current_path: str = "") -> Tuple[List[str], List[str]]:
29 + """Save uploaded files and return successful and failed filenames"""
30 + successful = []
31 + failed = []
32 +
33 + try:
34 + # Resolve the target directory path
35 + target_dir = (self.base_dir / current_path).resolve()
36 + if not str(target_dir).startswith(str(self.base_dir)):
37 + raise ValueError("Invalid target directory")
38 +
39 + os.makedirs(target_dir, exist_ok=True)
40 +
41 + for file in files:
42 + try:
43 + if file and self._is_allowed_file(file.filename, file):
44 + filename = secure_filename(file.filename)
45 + file_path = target_dir / filename
46 +
47 + file.save(str(file_path))
48 + successful.append(filename)
49 + else:
50 + failed.append(file.filename)
51 + except Exception as e:
52 + print(f"Error saving file {file.filename}: {e}")
53 + failed.append(file.filename)
54 +
55 + return successful, failed
56 +
57 + except Exception as e:
58 + print(f"Error in save_files: {e}")
59 + return successful, failed
60 +
61 + def delete_file(self, file_path: str) -> bool:
62 + """Delete a file or empty directory"""
63 + try:
64 + # Resolve the full path while preventing directory traversal
65 + full_path = (self.base_dir / file_path).resolve()
66 + if not str(full_path).startswith(str(self.base_dir)):
67 + raise ValueError("Invalid path")
68 +
69 + if os.path.exists(full_path):
70 + if os.path.isfile(full_path):
71 + os.remove(full_path)
72 + elif os.path.isdir(full_path) and not os.listdir(full_path):
73 + os.rmdir(full_path)
74 + else:
75 + raise ValueError("Can only delete files or empty directories")
76 + return True
77 +
78 + return False
79 +
80 + except Exception as e:
81 + print(f"Error deleting {file_path}: {e}")
82 + return False
83 +
84 + def _is_allowed_file(self, filename: str, file) -> bool:
85 + if not filename:
86 + return False
87 + ext = self._get_file_extension(filename)
88 + all_allowed = set().union(*self.ALLOWED_EXTENSIONS.values())
89 + if ext not in all_allowed:
90 + return False
91 +
92 + return True # Allow the file if it passes the checks
93 +
94 + def _get_file_extension(self, filename: str) -> str:
95 + return filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
96 +
97 + def get_files(self, current_path: str = "") -> Dict:
98 + try:
99 + # Resolve the full path while preventing directory traversal
100 + full_path = (self.base_dir / current_path).resolve()
101 + if not str(full_path).startswith(str(self.base_dir)):
102 + raise ValueError("Invalid path")
103 +
104 + files = []
105 + folders = []
106 +
107 + # List all entries in the current directory
108 + for entry in os.scandir(full_path):
109 + entry_data: Dict[str, Any] = {
110 + "name": entry.name,
111 + "path": str(Path(entry.path).relative_to(self.base_dir)),
112 + "modified": datetime.fromtimestamp(entry.stat().st_mtime).isoformat()
113 + }
114 +
115 + if entry.is_file():
116 + entry_data.update({
117 + "type": self._get_file_type(entry.name),
118 + "size": entry.stat().st_size,
119 + "is_dir": False
120 + })
121 + files.append(entry_data)
122 + else:
123 + entry_data.update({
124 + "type": "folder",
125 + "size": 0, # Directories show as 0 bytes
126 + "is_dir": True
127 + })
128 + folders.append(entry_data)
129 +
130 + # Combine folders and files, folders first
131 + all_entries = folders + files
132 +
133 + # Get parent directory path if not at root
134 + parent_path = ""
135 + if current_path:
136 + parent = (Path(current_path).parent)
137 + parent_path = str(parent) if parent != Path(".") else ""
138 +
139 + return {
140 + "entries": all_entries,
141 + "current_path": current_path,
142 + "parent_path": parent_path
143 + }
144 +
145 + except Exception as e:
146 + print(f"Error reading directory: {e}")
147 + return {"entries": [], "current_path": "", "parent_path": ""}
148 +
149 + def get_file_path(self, file_path: str) -> Optional[Path]:
150 + """Get full file path if it exists and is within base_dir"""
151 + try:
152 + full_path = (self.base_dir / file_path).resolve()
153 + if not str(full_path).startswith(str(self.base_dir)):
154 + raise ValueError("Invalid path")
155 +
156 + if os.path.isfile(full_path):
157 + return full_path
158 + return None
159 +
160 + except Exception as e:
161 + print(f"Error accessing file {file_path}: {e}")
162 + return None
163 +
164 + def _get_file_type(self, filename: str) -> str:
165 + ext = self._get_file_extension(filename)
166 + for file_type, extensions in self.ALLOWED_EXTENSIONS.items():
167 + if ext in extensions:
168 + return file_type
169 + return 'unknown'
170 +
\ No newline at end of file
run_ui.py
+132 -16
@@ -4,7 +4,7 @@ import os
4 from pathlib import Path
5 import threading
6 import uuid
7 -from flask import Flask, request, jsonify, Response
7 +from flask import Flask, request, jsonify, Response, send_file
8 from flask_basicauth import BasicAuth
9 from agent import AgentContext
10 from initialize import initialize
@@ -16,6 +16,7 @@ from python.helpers import persist_chat, settings, whisper, rfc, runtime, dotenv
16 import base64
17 from werkzeug.utils import secure_filename
18 from python.helpers.cloudflare_tunnel import CloudflareTunnel
19 +from python.helpers.file_browser import FileBrowser
20
21
22 # initialize the internal Flask server
@@ -76,7 +77,7 @@ async def upload_file():
77
78 for file in files:
79 if file and allowed_file(file.filename): # Check file type
79 - filename = secure_filename(file.filename)
80 + filename = secure_filename(file.filename) # type: ignore
81 file.save(os.path.join(UPLOAD_FOLDER, filename))
82 saved_filenames.append(filename)
83
@@ -103,7 +104,7 @@ async def import_knowledge():
104
105 for file in files:
106 if file:
106 - filename = secure_filename(file.filename)
107 + filename = secure_filename(file.filename) # type: ignore
108 file.save(os.path.join(KNOWLEDGE_FOLDER, filename))
109 saved_filenames.append(filename)
110
@@ -112,22 +113,135 @@ async def import_knowledge():
113 )
114
115
115 -@app.route("/work_dir", methods=["GET"]) # Correct route
116 +@app.route("/getWorkDirFiles", methods=["GET"])
117 @requires_auth
117 -async def browse_work_dir():
118 - work_dir = os.path.join(os.getcwd(), "work_dir")
118 +async def get_work_dir_files():
119 try:
120 - files = [
121 - f for f in os.listdir(work_dir) if os.path.isfile(os.path.join(work_dir, f))
122 - ]
123 - return jsonify({"ok": True, "files": files})
124 - except FileNotFoundError:
125 - return jsonify({"ok": False, "message": "work_dir not found"}), 404
120 + current_path = request.args.get('path', '')
121 + work_dir = files.get_abs_path("work_dir")
122 + browser = FileBrowser(work_dir)
123 + result = browser.get_files(current_path)
124 +
125 + response = {
126 + "ok": True,
127 + "data": result
128 + }
129 +
130 except Exception as e:
127 - return (
128 - jsonify({"ok": False, "message": f"Error browsing work_dir: {str(e)}"}),
129 - 500,
130 - )
131 + response = {
132 + "ok": False,
133 + "message": str(e)
134 + }
135 + PrintStyle.error(str(e))
136 +
137 + return jsonify(response)
138 +
139 +
140 +@app.route("/uploadWorkDirFiles", methods=["POST"])
141 +@requires_auth
142 +async def upload_work_dir_files():
143 + try:
144 + if "files[]" not in request.files:
145 + return jsonify({"ok": False, "message": "No files uploaded"}), 400
146 +
147 + current_path = request.form.get('path', '')
148 + uploaded_files = request.files.getlist("files[]")
149 +
150 + work_dir = files.get_abs_path("work_dir")
151 + browser = FileBrowser(work_dir)
152 +
153 + successful, failed = browser.save_files(uploaded_files, current_path)
154 +
155 + if not successful and failed:
156 + return jsonify({
157 + "ok": False,
158 + "message": "All uploads failed",
159 + "failed": failed
160 + }), 400
161 +
162 + result = browser.get_files(current_path)
163 +
164 + response = {
165 + "ok": True,
166 + "message": "Files uploaded successfully" if not failed else "Some files failed to upload",
167 + "data": result,
168 + "successful": successful,
169 + "failed": failed
170 + }
171 +
172 + except Exception as e:
173 + response = {
174 + "ok": False,
175 + "message": str(e)
176 + }
177 + PrintStyle.error(str(e))
178 +
179 + return jsonify(response)
180 +
181 +
182 +@app.route("/downloadWorkDirFile", methods=["GET"])
183 +@requires_auth
184 +async def download_work_dir_file():
185 + try:
186 + file_path = request.args.get('path', '')
187 + if not file_path:
188 + raise ValueError("No file path provided")
189 +
190 + work_dir = files.get_abs_path("work_dir")
191 + browser = FileBrowser(work_dir)
192 +
193 + full_path = browser.get_file_path(file_path)
194 + if full_path:
195 + return send_file(
196 + full_path,
197 + as_attachment=True,
198 + download_name=os.path.basename(file_path)
199 + )
200 +
201 + return jsonify({
202 + "ok": False,
203 + "message": "File not found"
204 + }), 404
205 +
206 + except Exception as e:
207 + return jsonify({
208 + "ok": False,
209 + "message": str(e)
210 + }), 500
211 +
212 +
213 +@app.route("/deleteWorkDirFile", methods=["POST"])
214 +@requires_auth
215 +async def delete_work_dir_file():
216 + try:
217 + data = request.get_json()
218 + file_path = data.get('path', '')
219 + current_path = data.get('currentPath', '')
220 +
221 + work_dir = files.get_abs_path("work_dir")
222 + browser = FileBrowser(work_dir)
223 +
224 + if browser.delete_file(file_path):
225 + # Get updated file list
226 + result = browser.get_files(current_path)
227 + response = {
228 + "ok": True,
229 + "data": result
230 + }
231 + else:
232 + response = {
233 + "ok": False,
234 + "message": "File not found or could not be deleted"
235 + }
236 +
237 + except Exception as e:
238 + response = {
239 + "ok": False,
240 + "message": str(e)
241 + }
242 + PrintStyle.error(str(e))
243 +
244 + return jsonify(response)
245
246
247 # handle default address, load index
@@ -174,6 +288,8 @@ async def handle_message(sync: bool):
288 if attachments:
289 os.makedirs(upload_folder, exist_ok=True)
290 for attachment in attachments:
291 + if attachment.filename is None:
292 + continue
293 filename = secure_filename(attachment.filename)
294 save_path = files.get_abs_path(upload_folder, filename)
295 attachment.save(save_path)
webui/file_browser.css new
+363
@@ -0,0 +1,363 @@
1 +/* Work Directory Modal Styles */
2 +.modal-overlay {
3 + position: fixed;
4 + top: 0;
5 + left: 0;
6 + right: 0;
7 + bottom: 0;
8 + background-color: rgba(0, 0, 0, 0.75);
9 + display: flex;
10 + justify-content: center;
11 + align-items: center;
12 + z-index: 2002;
13 +}
14 +
15 +.modal-container {
16 + background-color: var(--color-panel);
17 + border-radius: 12px;
18 + width: 90%;
19 + max-width: 800px;
20 + max-height: 80vh;
21 + overflow: hidden;
22 + box-shadow: 0 4px 23px 0 rgba(0, 0, 0, 0.2);
23 +}
24 +
25 +.modal-header {
26 + padding: 1rem 2rem;
27 + border-bottom: 1px solid var(--color-border);
28 + display: flex;
29 + justify-content: space-between;
30 + align-items: center;
31 +}
32 +
33 +.modal-subheader {
34 + padding: 0.7rem 1.5rem;
35 + display: inline;
36 + justify-content: space-between;
37 + align-items: center;
38 +}
39 +
40 +.modal-title {
41 + font-size: 1.25rem;
42 + font-weight: 500;
43 + color: var(--color-text);
44 +}
45 +
46 +.modal-close {
47 + background: none;
48 + font-size: xx-large;
49 + border: none;
50 + color: var(--color-text);
51 + opacity: 0.7;
52 + cursor: pointer;
53 + padding: 0.5rem;
54 + transition: opacity 0.2s;
55 +}
56 +
57 +.modal-close:hover {
58 + opacity: 1;
59 +}
60 +
61 +.modal-content {
62 + padding: 0.5rem 1.5rem 0rem 1.5rem;
63 + overflow-y: auto;
64 + max-height: calc(80vh - 4rem);
65 +}
66 +
67 +.modal-footer {
68 + padding: var(--spacing-md);
69 + border-top: 1px solid var(--color-border);
70 + display: flex;
71 + justify-content: flex-end;
72 + background: var(--color-background);
73 +}
74 +
75 +h2 {
76 + color: var(--color-primary);
77 +}
78 +
79 +/* File Browser Styles */
80 +.files-list {
81 + width: 100%;
82 + border-radius: 4px;
83 + overflow: hidden;
84 +}
85 +
86 +.file-header {
87 + display: grid;
88 + grid-template-columns: 2fr 0.6fr 1.0fr 80px;
89 + background: var(--secondary-bg);
90 + padding: 8px 0;
91 + font-weight: bold;
92 + border-bottom: 1px solid var(--border-color);
93 +}
94 +
95 +.file-cell {
96 + padding: 4px;
97 + cursor: pointer;
98 +}
99 +
100 +.file-item {
101 + display: grid;
102 + grid-template-columns: 2fr 0.6fr 1.0fr 80px;
103 + align-items: center;
104 + padding: 8px 0;
105 + border-top: 1px solid var(--color-border);
106 + transition: background-color 0.2s;
107 + white-space: nowrap;
108 + overflow: hidden;
109 +}
110 +
111 +.file-item:hover {
112 + background-color: var(--hover-bg);
113 + cursor: pointer;
114 +}
115 +
116 +.file-icon {
117 + font-size: var(--font-size-sm);
118 + width: 1.8rem;
119 + height: 1.8rem;
120 + vertical-align: middle;
121 + margin-right: 1rem;
122 +}
123 +
124 +.file-name {
125 + display: flex;
126 + align-items: center;
127 + font-weight: 500;
128 + overflow: hidden;
129 + margin-right: var(--spacing-sm);
130 +}
131 +
132 +.file-name > span {
133 + white-space: nowrap;
134 + overflow: hidden;
135 + text-overflow: ellipsis;
136 +}
137 +
138 +.file-size {
139 + color: var(--text-secondary);
140 +}
141 +
142 +.file-date {
143 + color: var(--text-secondary);
144 +}
145 +
146 +.no-files {
147 + padding: 32px;
148 + text-align: center;
149 + color: var(--text-secondary);
150 +}
151 +
152 +/* Light mode adjustments */
153 +.light-mode .modal-container {
154 + background-color: var(--color-panel-light);
155 +}
156 +
157 +.light-mode .file-item {
158 + background-color: var(--color-background-light);
159 +}
160 +
161 +.light-mode .file-item:hover {
162 + background-color: var(--color-secondary-light);
163 +}
164 +
165 +/* Path Navigator */
166 +
167 +.path-navigator {
168 + padding: 8px 0;
169 + display: flex;
170 + align-items: center;
171 + gap: 24px;
172 + background-color: var(--color-message-bg);
173 + padding: 0.70rem var(--spacing-sm) 0.7rem var(--spacing-sm);
174 + margin-bottom: 0.3rem;
175 + border: 1px solid var(--color-border);
176 + border-radius: 8px;
177 +}
178 +
179 +.nav-button {
180 + padding: 4px 12px;
181 + border: 1px solid var(--color-border);
182 + border-radius: 4px;
183 + background: var(--color-background);
184 + color: var(--color-text);
185 + cursor: pointer;
186 +}
187 +
188 +.nav-button:hover {
189 + background: var(--hover-bg);
190 +}
191 +
192 +.nav-button.back-button {
193 + background-color: var(--color-secondary);
194 + color: var(--color-text);
195 +}
196 +
197 +.nav-button.back-button:hover {
198 + background-color: var(--color-secondary-dark);
199 +}
200 +
201 +.current-path {
202 + color: var(--color-text);
203 + font-family: monospace;
204 +}
205 +
206 +/* Update file-item for folders */
207 +.file-item[data-is-dir="true"] {
208 + cursor: pointer;
209 +}
210 +
211 +.file-item[data-is-dir="true"]:hover {
212 + background-color: var(--hover-bg);
213 +}
214 +
215 +/* Button Section */
216 +
217 +.upload-button {
218 +display: inline-block;
219 +padding: 8px 16px;
220 +background-color: var(--color-primary);
221 +color: white;
222 +border-radius: 4px;
223 +cursor: pointer;
224 +transition: background-color 0.2s;
225 +}
226 +
227 +.btn-upload {
228 + background: #3270e2;
229 + color: white;
230 + display: flex;
231 + transition: background 0.3s ease-in-out;
232 + margin: 0 auto;
233 + text-wrap: wrap;
234 + gap: 0.5rem;
235 + align-items:center;
236 +}
237 +
238 + .btn-upload > svg {
239 + width: 20px;
240 + }
241 +
242 +.upload-button:hover {
243 +background-color: var(--color-primary-dark);
244 +}
245 +
246 +/* Delete Button */
247 +
248 +.delete-button {
249 + background: none;
250 + border: none;
251 + color: var(--color-primary);
252 + cursor: pointer;
253 + width: 32px;
254 + padding: 4px 8px;
255 + border-radius: 4px;
256 + transition: opacity 0.2s, background-color 0.2s;}
257 +
258 +.delete-button:hover {
259 + color: #ff7878
260 +}
261 +
262 +.delete-button:active {
263 + opacity: 0.6;
264 +}
265 +
266 +.file-actions {
267 + display: flex;
268 + gap: var(--spacing-xs);
269 + transition: opacity 0.2s;
270 + }
271 +
272 +.action-button {
273 + background: none;
274 + border: none;
275 + cursor: pointer;
276 + width: 32px;
277 + padding: 6px 8px;
278 + border-radius: 4px;
279 + transition: background-color 0.2s;
280 +}
281 +
282 +.download-button {
283 + color: var(--color-primary);
284 +}
285 +
286 +.download-button:hover {
287 + background-color: var(--color-primary-light);
288 +}
289 +
290 +.button-section {
291 + gap: 1rem;
292 +}
293 +
294 +@media (max-width: 768px) {
295 + .file-header {
296 + display: grid;
297 + grid-template-columns: 1fr 0.5fr 80px;
298 + background: var(--secondary-bg);
299 + padding: 8px 0;
300 + font-weight: bold;
301 + border-bottom: 1px solid var(--border-color);
302 + }
303 + .file-item {
304 + display: grid;
305 + grid-template-columns: 1fr 0.5fr 80px;
306 + align-items: center;
307 + padding: 8px 0;
308 + border-top: 1px solid var(--color-border);
309 + transition: background-color 0.2s;
310 + }
311 + .file-cell-date {
312 + display: none;
313 + }
314 + .file-date {
315 + display: none;
316 + }
317 +}
318 +
319 +@media (max-width: 540px) {
320 + .file-header {
321 + display: grid;
322 + grid-template-columns: 1fr 80px;
323 + background: var(--secondary-bg);
324 + padding: 8px 0;
325 + font-weight: bold;
326 + border-bottom: 1px solid var(--border-color);
327 + }
328 + .file-item {
329 + display: grid;
330 + grid-template-columns: 1fr 80px;
331 + align-items: center;
332 + padding: 8px 0;
333 + border-top: 1px solid var(--color-border);
334 + transition: background-color 0.2s;
335 + }
336 + .file-cell-size {
337 + display: none;
338 + }
339 + .file-size {
340 + display: none;
341 + }
342 + .file-cell-date {
343 + display: none;
344 + }
345 + .file-date {
346 + display: none;
347 + }
348 +
349 + #buttons-container {
350 + max-height: 50px;
351 + }
352 +
353 + .btn-upload {
354 + margin: 0 auto;
355 + text-wrap: wrap;
356 + gap: 0.5rem;
357 + align-items:center;
358 + }
359 +
360 + .btn-upload > svg {
361 + width: 20px;
362 + }
363 +}
\ No newline at end of file
webui/file_browser.js new
+249
@@ -0,0 +1,249 @@
1 +const fileBrowserModalProxy = {
2 + isOpen: false,
3 + isLoading: false,
4 +
5 + browser: {
6 + title: "Work Directory Browser",
7 + currentPath: "",
8 + entries: [],
9 + parentPath: "",
10 + sortBy: "name",
11 + sortDirection: "asc"
12 + },
13 +
14 + // Initialize navigation history
15 + history: [],
16 +
17 + async openModal() {
18 + const modalEl = document.getElementById('fileBrowserModal');
19 + const modalAD = Alpine.$data(modalEl);
20 +
21 + modalAD.isOpen = true;
22 + modalAD.isLoading = true;
23 + modalAD.history = []; // reset history when opening modal
24 +
25 + // Initialize currentPath to root if it's empty
26 + if (!modalAD.browser.currentPath) {
27 + modalAD.browser.currentPath = "";
28 + }
29 +
30 + await modalAD.fetchFiles(modalAD.browser.currentPath);
31 + },
32 +
33 + isArchive(filename) {
34 + const archiveExts = ['zip', 'tar', 'gz', 'rar', '7z'];
35 + const ext = filename.split('.').pop().toLowerCase();
36 + return archiveExts.includes(ext);
37 + },
38 +
39 + async fetchFiles(path = "") {
40 + this.isLoading = true;
41 + try {
42 + const response = await fetch(`/getWorkDirFiles?path=${encodeURIComponent(path)}`);
43 + const data = await response.json();
44 +
45 + if (data.ok) {
46 + this.browser.entries = data.data.entries;
47 + this.browser.currentPath = data.data.current_path;
48 + this.browser.parentPath = data.data.parent_path;
49 + } else {
50 + console.error('Error fetching files:', data.message);
51 + this.browser.entries = [];
52 + }
53 + } catch (error) {
54 + console.error('Error fetching files:', error);
55 + this.browser.entries = [];
56 + } finally {
57 + this.isLoading = false;
58 + }
59 + },
60 +
61 + async navigateToFolder(path) {
62 + // Push current path to history before navigating
63 + if (this.browser.currentPath !== path) {
64 + this.history.push(this.browser.currentPath);
65 + }
66 + await this.fetchFiles(path);
67 + },
68 +
69 + async navigateUp() {
70 + if (this.browser.parentPath !== "") {
71 + // Push current path to history before navigating up
72 + this.history.push(this.browser.currentPath);
73 + await this.fetchFiles(this.browser.parentPath);
74 + }
75 + },
76 +
77 + sortFiles(entries) {
78 + return [...entries].sort((a, b) => {
79 + // Folders always come first
80 + if (a.is_dir !== b.is_dir) {
81 + return a.is_dir ? -1 : 1;
82 + }
83 +
84 + const direction = this.browser.sortDirection === 'asc' ? 1 : -1;
85 + switch (this.browser.sortBy) {
86 + case 'name':
87 + return direction * a.name.localeCompare(b.name);
88 + case 'size':
89 + return direction * (a.size - b.size);
90 + case 'date':
91 + return direction * (new Date(a.modified) - new Date(b.modified));
92 + default:
93 + return 0;
94 + }
95 + });
96 + },
97 +
98 + toggleSort(column) {
99 + if (this.browser.sortBy === column) {
100 + this.browser.sortDirection = this.browser.sortDirection === 'asc' ? 'desc' : 'asc';
101 + } else {
102 + this.browser.sortBy = column;
103 + this.browser.sortDirection = 'asc';
104 + }
105 + },
106 +
107 + async deleteFile(file) {
108 + if (!confirm(`Are you sure you want to delete ${file.name}?`)) {
109 + return;
110 + }
111 +
112 + try {
113 + const response = await fetch('/deleteWorkDirFile', {
114 + method: 'POST',
115 + headers: {
116 + 'Content-Type': 'application/json',
117 + },
118 + body: JSON.stringify({
119 + path: file.path,
120 + currentPath: this.browser.currentPath
121 + })
122 + });
123 +
124 + const data = await response.json();
125 + if (data.ok) {
126 + this.browser.entries = this.browser.entries.filter(entry => entry.path !== file.path);
127 + alert('File deleted successfully.');
128 + } else {
129 + alert(`Error deleting file: ${data.message}`);
130 + }
131 + } catch (error) {
132 + console.error('Error deleting file:', error);
133 + alert('Error deleting file');
134 + }
135 + },
136 +
137 + handleFileUpload(event) {
138 + const files = event.target.files;
139 + if (!files.length) return;
140 +
141 + const formData = new FormData();
142 + formData.append('path', this.browser.currentPath);
143 +
144 + for (let i = 0; i < files.length; i++) {
145 + const ext = files[i].name.split('.').pop().toLowerCase();
146 + if (!['zip', 'tar', 'gz', 'rar', '7z'].includes(ext)) {
147 + if (files[i].size > 100 * 1024 * 1024) { // 100MB
148 + alert(`File ${files[i].name} exceeds the maximum allowed size of 100MB.`);
149 + continue;
150 + }
151 + }
152 + formData.append('files[]', files[i]);
153 + }
154 +
155 + // Proceed with upload after validation
156 + fetch('/uploadWorkDirFiles', {
157 + method: 'POST',
158 + body: formData
159 + })
160 + .then(response => response.json())
161 + .then(data => {
162 + if (data.ok) {
163 + // Update the file list with new data
164 + this.browser.entries = data.data.entries.map(entry => ({
165 + ...entry,
166 + uploadStatus: data.failed.includes(entry.name) ? 'failed' : 'success'
167 + }));
168 + this.browser.currentPath = data.data.current_path;
169 + this.browser.parentPath = data.data.parent_path;
170 +
171 + // Show success message
172 + if (data.failed && data.failed.length > 0) {
173 + const failedFiles = data.failed.map(file => `${file.name}: ${file.error}`).join('\n');
174 + alert(`Some files failed to upload:\n${failedFiles}`);
175 + }
176 + } else {
177 + alert(data.message);
178 + }
179 + })
180 + .catch(error => {
181 + console.error('Error uploading files:', error);
182 + alert('Error uploading files');
183 + });
184 + },
185 +
186 + downloadFile(file) {
187 + if (file.is_dir) return;
188 +
189 + const downloadUrl = `/downloadWorkDirFile?path=${encodeURIComponent(file.path)}`;
190 +
191 + fetch(downloadUrl)
192 +
193 + .then(response => {
194 + if (!response.ok) {
195 + throw new Error('Network response was not ok');
196 + }
197 + return response.blob();
198 + })
199 + .then(blob => {
200 + const link = document.createElement('a');
201 + link.href = window.URL.createObjectURL(blob);
202 + link.download = file.name;
203 + document.body.appendChild(link);
204 + link.click();
205 + document.body.removeChild(link);
206 + window.URL.revokeObjectURL(link.href);
207 + })
208 + .catch(error => {
209 + console.error('Error downloading file:', error);
210 + alert('Error downloading file');
211 + });
212 + },
213 +
214 + // Helper Functions
215 + formatFileSize(size) {
216 + if (size === 0) return '0 Bytes';
217 + const k = 1024;
218 + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
219 + const i = Math.floor(Math.log(size) / Math.log(k));
220 + return parseFloat((size / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
221 + },
222 +
223 + formatDate(dateString) {
224 + const options = { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
225 + return new Date(dateString).toLocaleDateString(undefined, options);
226 + },
227 +
228 + handleClose() {
229 + this.isOpen = false;
230 + }
231 +};
232 +
233 +// Wait for Alpine to be ready
234 +document.addEventListener('alpine:init', () => {
235 + Alpine.data('fileBrowserModalProxy', () => ({
236 + init() {
237 + Object.assign(this, fileBrowserModalProxy);
238 + // Ensure immediate file fetch when modal opens
239 + this.$watch('isOpen', async (value) => {
240 + if (value) {
241 + await this.fetchFiles(this.browser.currentPath);
242 + }
243 + });
244 + }
245 + }));
246 +});
247 +
248 +// Keep the global assignment for backward compatibility
249 +window.fileBrowserModalProxy = fileBrowserModalProxy;
webui/index.css
+20 -2
@@ -684,7 +684,7 @@ font-size: var(--font-size-small)
684 -webkit-font-optical-sizing: auto;
685 font-size: 0.875rem;
686 max-height: 7rem;
687 - min-height: 2.7rem;
687 + min-height: 2.8rem;
688 padding: var(--spacing-xs) var(--spacing-sm);
689 overflow-y: auto;
690 scroll-behavior: smooth;
@@ -722,6 +722,7 @@ font-size: var(--font-size-small)
722 #chat-input:focus {
723 outline: 0.05rem solid rgba(155, 155, 155, 0.3);
724 font-size: 0.955rem;
725 + padding-top: 0.58rem;
726 background-color: var(--color-input-focus);
727 }
728
@@ -1033,7 +1034,7 @@ font-size: var(--font-size-small)
1034 flex-grow: 1;
1035 min-height: 2.7rem;
1036 padding: var(--spacing-sm) var(--spacing-sm);
1036 - padding-top: 0.70rem;
1037 + padding-top: 0.65rem;
1038 border: 1px solid var(--color-border);
1039 border-radius: 8px;
1040 resize: none;
@@ -1540,6 +1541,23 @@ input:checked + .slider:before {
1541 }
1542 }
1543
1544 +.sidebar-overlay {
1545 + display: none;
1546 + position: fixed;
1547 + top: 0;
1548 + left: 0;
1549 + right: 0;
1550 + bottom: 0;
1551 + background-color: rgba(0, 0, 0, 0);
1552 + opacity: 0;
1553 + z-index: 999;
1554 +}
1555 +
1556 +.sidebar-overlay.visible {
1557 + display: block;
1558 +}
1559 +
1560 +
1561 @media (max-width: 768px) {
1562 #left-panel {
1563 position: fixed;
webui/index.html
+111 -17
@@ -8,6 +8,7 @@
8 <link rel="stylesheet" href="index.css">
9 <link rel="stylesheet" href="toast.css">
10 <link rel="stylesheet" href="settings.css">
11 + <link rel="stylesheet" href="file_browser.css">
12 <link rel="stylesheet" href="speech.css">
13
14 <script>
@@ -16,24 +17,27 @@
17 }
18 </script>
19
20 + <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
21 + <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
22 +
23 <!-- KaTeX CSS -->
24 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.css" crossorigin="anonymous">
25
26 <!-- KaTeX javascript -->
27 <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.js" crossorigin="anonymous"></script>
24 - <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/contrib/auto-render.min.js"
25 - crossorigin="anonymous"></script>
28 + <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/contrib/auto-render.min.js" crossorigin="anonymous"></script>
29
27 - <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
28 - <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
30 +
31 <script type="module" src="index.js"></script>
32 <script type="text/javascript" src="settings.js"></script>
33 + <script type="text/javascript" src="file_browser.js"></script>
34 <script type="module" src="speech.js"></script>
35
36 </head>
37
38 <body>
39 <div class="container">
40 + <div id="sidebar-overlay" class="sidebar-overlay hidden"></div>
41 <div class="icons-section" id="hide-button" x-data="{ connected: true }">
42 <!--Sidebar-->
43 <!-- Sidebar Toggle Button -->
@@ -307,7 +311,7 @@
311 d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5">
312 </path>
313 </svg>Import knowledge</button>
310 - <button class="text-button" @click="workDirModalProxy.openModal()">
314 + <button class="text-button" id="work_dir_browser" @click="fileBrowserModalProxy.openModal()">
315 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 123.37 92.59">
316 <path
317 d="m5.72,11.5l-3.93,8.73h119.77s-3.96-8.73-3.96-8.73h-60.03c-1.59,0-2.88-1.29-2.88-2.88V1.75H13.72v6.87c0,1.59-1.29,2.88-2.88,2.88h-5.12Z"
@@ -340,10 +344,13 @@
344 </div>
345 <div id="settingsModal" x-data="settingsModalProxy">
346 <template x-teleport="body">
343 - <div x-show="isOpen" class="modal-overlay" @click.self="handleCancel()" x-transition>
347 + <div x-show="isOpen" class="modal-overlay" @click.self="handleCancel()" @keydown.escape.window="handleClose()" x-transition>
348 <div class="modal-container">
345 - <div class="modal-header">
349 + <div class="modal-header" id="settings-title">
350 <h2 x-text="settings.title"></h2>
351 + <button class="modal-close" @click="handleCancel()">&times;</button>
352 + </div>
353 + <div id="settings-sections">
354 <!-- Dynamically generated navigation -->
355 <nav>
356 <ul>
@@ -450,26 +457,113 @@
457 </div>
458 </template>
459 </div>
460 +
461 + <!-- work_dir Browser Modal -->
462
454 - <div id="workDirModal" x-data="workDirModalProxy">
463 + <div id="fileBrowserModal" x-data="fileBrowserModalProxy">
464 <template x-teleport="body">
456 - <div x-show="isOpen" class="modal-overlay" @click.self="close()" x-transition>
465 + <div x-show="isOpen" class="modal-overlay" @click.self="handleClose()" @keydown.escape.window="handleClose()" x-transition>
466 <div class="modal-container">
467 <div class="modal-header">
459 - <h2>Work Directory</h2>
468 + <h2 class="modal-title" x-text="browser.title"></h2>
469 + <button class="modal-close" @click="handleClose()">&times;</button>
470 </div>
471 <div class="modal-content">
462 - <ul>
463 - <template x-for="file in files" :key="file">
464 - <li x-text="file"></li>
465 - </template>
466 - </ul>
472 + <div x-show="isLoading" class="loading-spinner">
473 + Loading...
474 + </div>
475 + <div x-show="!isLoading">
476 + <div class="path-navigator">
477 + <!-- Up Button -->
478 + <button class="text-button back-button"
479 + @click="navigateUp()"
480 + aria-label="Navigate Up">
481 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10.5 15">
482 + <path d="m.75,5.25L5.25.75m0,0l4.5,4.5M5.25.75v13.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path>
483 + </svg>
484 + Up
485 + </button>
486 +
487 + <span class="current-path" x-text="browser.currentPath || 'Current Path: /'"></span>
488 + </div>
489 +
490 + <div class="files-list">
491 + <!-- Header -->
492 + <div class="file-header">
493 + <div class="file-cell" @click="toggleSort('name')">
494 + Name
495 + <span x-show="browser.sortBy === 'name'"
496 + x-text="browser.sortDirection === 'asc' ? '↑' : '↓'">
497 + </span>
498 + </div>
499 + <div class="file-cell-size" @click="toggleSort('size')">
500 + Size
501 + <span x-show="browser.sortBy === 'size'"
502 + x-text="browser.sortDirection === 'asc' ? '↑' : '↓'">
503 + </span>
504 + </div>
505 + <div class="file-cell-date" @click="toggleSort('date')">
506 + Modified
507 + <span x-show="browser.sortBy === 'date'"
508 + x-text="browser.sortDirection === 'asc' ? '↑' : '↓'">
509 + </span>
510 + </div>
511 + </div>
512 +
513 + <!-- File List -->
514 + <template x-if="browser.entries.length">
515 + <template x-for="file in sortFiles(browser.entries)" :key="file.path">
516 + <div class="file-item" :data-is-dir="file.is_dir">
517 + <div class="file-name" @click="file.is_dir ? navigateToFolder(file.path) : downloadFile(file)">
518 + <img :src="'/public/' + (file.type === 'unknown' ? 'file' : (isArchive(file.name) ? 'archive' : file.type)) + '.svg'" class="file-icon" :alt="file.type">
519 + <span x-text="file.name"></span>
520 + </div>
521 + <div class="file-size" x-text="formatFileSize(file.size)"></div>
522 + <div class="file-date" x-text="formatDate(file.modified)"></div>
523 +
524 + <div class="file-actions">
525 + <button class="action-button download-button" @click.stop="downloadFile(file)" x-show="!file.is_dir">
526 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.5 19.5">
527 + <path d="m.75,14.25v2.25c0,1.24,1.01,2.25,2.25,2.25h13.5c1.24,0,2.25-1.01,2.25-2.25v-2.25m-4.5-4.5l-4.5,4.5m0,0l-4.5-4.5m4.5,4.5V.75" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path>
528 + </svg>
529 + </button>
530 + <button class="delete-button" @click.stop="deleteFile(file)" x-show="!file.is_dir || (file.is_dir &amp;&amp; file.size === 0)">
531 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15.03 22.53" fill="currentColor">
532 + <path d="m14.55,7.82H4.68L14.09,3.19c.83-.41,1.17-1.42.77-2.25-.41-.83-1.42-1.17-2.25-.77l-3.16,1.55-.15-.31c-.22-.44-.59-.76-1.05-.92-.46-.16-.96-.13-1.39.09l-2.08,1.02c-.9.44-1.28,1.54-.83,2.44l.15.31-3.16,1.55c-.83.41-1.17,1.42-.77,2.25.29.59.89.94,1.51.94.25,0,.5-.06.74-.17l.38-.19s.09.03.14.03h11.14v11.43c0,.76-.62,1.38-1.38,1.38h-.46v-11.28c0-.26-.21-.47-.47-.47s-.47.21-.47.47v11.28h-2.39v-11.28c0-.26-.21-.47-.47-.47s-.47.21-.47.47v11.28h-2.39v-11.28c0-.26-.21-.47-.47-.47s-.47.21-.47.47v11.28h-.46c-.76,0-1.38-.62-1.38-1.38v-9.9c0-.26-.21-.47-.47-.47s-.47.21-.47.47v9.9c0,1.28,1.04,2.32,2.32,2.32h8.55c1.28,0,2.32-1.04,2.32-2.32v-11.91c0-.26-.21-.47-.47-.47ZM5.19,2.46l2.08-1.02c.12-.06.25-.09.39-.09.09,0,.19.02.28.05.22.08.4.23.5.44l.15.31-.19.09-3.46,1.7-.15-.31c-.21-.43-.03-.96.4-1.17Zm-3.19,5.62c-.36.18-.8.03-.98-.33-.18-.36-.03-.8.33-.98l5.8-2.85,2.72-1.34,3.16-1.55c.1-.05.21-.07.32-.07.27,0,.53.15.66.41.09.17.1.37.04.56-.06.18-.19.33-.37.42L2,8.08Z" stroke-width="0"></path>
533 + </svg>
534 + </button>
535 + </div>
536 + </div>
537 + </template>
538 + </template>
539 +
540 + <!-- Empty State -->
541 + <template x-if="!browser.entries.length">
542 + <div class="no-files">
543 + No files found
544 + </div>
545 + </template>
546 + </div>
547 + </div>
548 </div>
549 <div class="modal-footer">
469 - <button @click="close()">Close</button>
550 + <div id="buttons-container">
551 + <label class="btn btn-upload"><svg xmlns="http://www.w3.org/2000/svg"
552 + fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
553 + <path stroke-linecap="round" stroke-linejoin="round"
554 + d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5">
555 + </path>
556 + </svg>
557 + Upload Files
558 + <input type="file" multiple=""
559 + accept="all"
560 + @change="handleFileUpload"
561 + style="display: none;">
562 + </label>
563 + <button class="btn btn-cancel" @click="handleClose()">Close Browser</button>
564 + </div>
565 </div>
566 </div>
472 - </div>
567 </template>
568 </div>
569
webui/index.js
+23 -42
@@ -23,24 +23,44 @@ function isMobile() {
23 return window.innerWidth <= 768;
24 }
25
26 -function toggleSidebar() {
27 - leftPanel.classList.toggle('hidden');
28 - rightPanel.classList.toggle('expanded');
26 +function toggleSidebar(show) {
27 + const overlay = document.getElementById('sidebar-overlay');
28 + if (typeof show === 'boolean') {
29 + leftPanel.classList.toggle('hidden', !show);
30 + rightPanel.classList.toggle('expanded', !show);
31 + overlay.classList.toggle('visible', show);
32 + } else {
33 + leftPanel.classList.toggle('hidden');
34 + rightPanel.classList.toggle('expanded');
35 + overlay.classList.toggle('visible', !leftPanel.classList.contains('hidden'));
36 + }
37 }
38
39 function handleResize() {
40 + const overlay = document.getElementById('sidebar-overlay');
41 if (isMobile()) {
42 leftPanel.classList.add('hidden');
43 rightPanel.classList.add('expanded');
44 + overlay.classList.remove('visible');
45 } else {
46 leftPanel.classList.remove('hidden');
47 rightPanel.classList.remove('expanded');
48 + overlay.classList.remove('visible');
49 }
50 }
51
52 window.addEventListener('load', handleResize);
53 window.addEventListener('resize', handleResize);
54
55 +document.addEventListener('DOMContentLoaded', () => {
56 + const overlay = document.getElementById('sidebar-overlay');
57 + overlay.addEventListener('click', () => {
58 + if (isMobile()) {
59 + toggleSidebar(false);
60 + }
61 + });
62 +});
63 +
64 function setupSidebarToggle() {
65 const leftPanel = document.getElementById('left-panel');
66 const rightPanel = document.getElementById('right-panel');
@@ -249,45 +269,6 @@ window.loadKnowledge = async function () {
269 }
270
271
252 -const workDirModalProxy = {
253 - isOpen: false,
254 - files: [],
255 -
256 - async openModal() { // Define openModal
257 - // Inside openModal, call the existing open method:
258 - await this.open(); // Or directly include the fetching logic here
259 - },
260 -
261 - async open() {
262 - const response = await sendJsonData('/work_dir');
263 - if (response.ok) {
264 - this.files = response.files;
265 - this.isOpen = true;
266 - } else {
267 - toast(response.message, 'error');
268 - }
269 - },
270 -
271 - close() {
272 - this.isOpen = false;
273 - }
274 -};
275 -
276 -// Make the proxy available globally
277 -window.workDirModalProxy = workDirModalProxy;
278 -
279 -// Ensure correct setup for Alpine.js x-data.
280 -window.workDirModal = function () {
281 - return workDirModalProxy; // Returns the proxy object for the Work Dir modal
282 -}
283 -
284 -
285 -document.addEventListener('alpine:init', () => {
286 - // Make workDirModalProxy available as an Alpine component/store
287 - Alpine.data('workDirModal', workDirModal);
288 -});
289 -
290 -
272 function adjustTextareaHeight() {
273 chatInput.style.height = 'auto';
274 chatInput.style.height = (chatInput.scrollHeight) + 'px';
webui/public/archive.svg new
+24
@@ -0,0 +1,24 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<!-- Generator: Adobe Illustrator 27.9.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3 +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
4 + viewBox="0 0 22.5 23" style="enable-background:new 0 0 22.5 23;" xml:space="preserve">
5 +<style type="text/css">
6 + .st0{fill:#6495ED;}
7 +</style>
8 +<g>
9 + <path class="st0" d="M19.6,5.8H16c-0.3,0-0.5-0.2-0.5-0.5V2.6c0-0.3-0.2-0.5-0.5-0.5c-0.3,0-0.5,0.2-0.5,0.5v2.6
10 + c0,0.9,0.7,1.6,1.6,1.6h3.1v9.4v5.2c0,0.3-0.2,0.5-0.5,0.5H4c-0.3,0-0.5-0.2-0.5-0.5V1.6C3.4,1.3,3.7,1,4,1h6.3v1h1V1h4l4,3.5
11 + c0.2,0.2,0.5,0.2,0.7,0c0.2-0.2,0.2-0.5,0-0.7l-4.2-3.7C15.7,0,15.6,0,15.5,0H4C3.1,0,2.4,0.7,2.4,1.6v19.9C2.4,22.3,3.1,23,4,23
12 + h14.6c0.9,0,1.6-0.7,1.6-1.6v-5.2V6.3C20.2,6,19.9,5.8,19.6,5.8z"/>
13 + <rect x="11.3" y="2.1" class="st0" width="1" height="1"/>
14 + <rect x="10.2" y="3.1" class="st0" width="1" height="1"/>
15 + <rect x="11.3" y="4.2" class="st0" width="1" height="1"/>
16 + <rect x="10.2" y="5.2" class="st0" width="1" height="1"/>
17 + <rect x="11.3" y="6.3" class="st0" width="1" height="1"/>
18 + <rect x="10.2" y="7.3" class="st0" width="1" height="1"/>
19 + <rect x="11.3" y="8.4" class="st0" width="1" height="1"/>
20 + <rect x="10.2" y="9.4" class="st0" width="1" height="1"/>
21 + <rect x="11.3" y="10.5" class="st0" width="1" height="1"/>
22 + <path class="st0" d="M10.2,14.6c0,0.6,0.5,1,1,1c0.6,0,1-0.5,1-1v-2.1h-2.1V14.6z"/>
23 +</g>
24 +</svg>
webui/public/code.svg new
+20
@@ -0,0 +1,20 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<!-- Generator: Adobe Illustrator 27.9.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3 +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
4 + viewBox="0 0 22.5 23" style="enable-background:new 0 0 22.5 23;" xml:space="preserve">
5 +<style type="text/css">
6 + .st0{fill:#F2B700;}
7 +</style>
8 +<g>
9 + <path class="st0" d="M19.6,5.8H16c-0.3,0-0.5-0.2-0.5-0.5V2.6c0-0.3-0.2-0.5-0.5-0.5c-0.3,0-0.5,0.2-0.5,0.5v2.6
10 + c0,0.9,0.7,1.6,1.6,1.6h3.1v9.4v5.2c0,0.3-0.2,0.5-0.5,0.5H4c-0.3,0-0.5-0.2-0.5-0.5V1.6C3.4,1.3,3.7,1,4,1h11.3l4,3.5
11 + c0.2,0.2,0.5,0.2,0.7,0c0.2-0.2,0.2-0.5,0-0.7l-4.2-3.7C15.7,0,15.6,0,15.5,0H4C3.1,0,2.4,0.7,2.4,1.6v19.9C2.4,22.3,3.1,23,4,23
12 + h14.6c0.9,0,1.6-0.7,1.6-1.6v-5.2V6.3C20.2,6,19.9,5.8,19.6,5.8z"/>
13 + <path class="st0" d="M11.8,11.3l-2.1,5.2c-0.1,0.3,0,0.6,0.3,0.7c0.1,0,0.1,0,0.2,0c0.2,0,0.4-0.1,0.5-0.3l2.1-5.2
14 + c0.1-0.3,0-0.6-0.3-0.7C12.2,10.9,11.9,11,11.8,11.3z"/>
15 + <path class="st0" d="M14.1,17.1c0.1,0.1,0.2,0.1,0.3,0.1c0.2,0,0.3-0.1,0.4-0.2l2.1-2.6c0.2-0.2,0.2-0.5,0-0.7l-2.1-2.6
16 + c-0.2-0.2-0.5-0.3-0.7-0.1c-0.2,0.2-0.3,0.5-0.1,0.7l1.8,2.3L14,16.4C13.8,16.6,13.9,17,14.1,17.1z"/>
17 + <path class="st0" d="M8.5,11.1c-0.2-0.2-0.6-0.1-0.7,0.1l-2.1,2.6c-0.2,0.2-0.2,0.5,0,0.7l2.1,2.6c0.1,0.1,0.3,0.2,0.4,0.2
18 + c0.1,0,0.2,0,0.3-0.1c0.2-0.2,0.3-0.5,0.1-0.7l-1.8-2.3l1.8-2.3C8.7,11.6,8.7,11.3,8.5,11.1z"/>
19 +</g>
20 +</svg>
webui/public/deletefile.svg new
+13
@@ -0,0 +1,13 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<!-- Generator: Adobe Illustrator 27.9.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3 +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
4 + viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
5 +<path d="M18.2,8.8H8.4l9.4-4.6c0.8-0.4,1.2-1.4,0.8-2.2c-0.4-0.8-1.4-1.2-2.2-0.8l-3.2,1.6L13,2.4c-0.2-0.4-0.6-0.8-1-0.9
6 + c-0.5-0.2-1-0.1-1.4,0.1l-2.1,1C7.5,3,7.2,4.1,7.6,5l0.2,0.3L4.6,6.9C3.8,7.3,3.4,8.3,3.9,9.1c0.3,0.6,0.9,0.9,1.5,0.9
7 + c0.2,0,0.5-0.1,0.7-0.2l0.4-0.2c0,0,0.1,0,0.1,0h11.1v11.4c0,0.8-0.6,1.4-1.4,1.4h-0.5V11.3c0-0.3-0.2-0.5-0.5-0.5S15,11,15,11.3
8 + v11.3h-2.4V11.3c0-0.3-0.2-0.5-0.5-0.5c-0.3,0-0.5,0.2-0.5,0.5v11.3H9.2V11.3c0-0.3-0.2-0.5-0.5-0.5c-0.3,0-0.5,0.2-0.5,0.5v11.3
9 + H7.8c-0.8,0-1.4-0.6-1.4-1.4v-9.9c0-0.3-0.2-0.5-0.5-0.5S5.5,11,5.5,11.3v9.9c0,1.3,1,2.3,2.3,2.3h8.5c1.3,0,2.3-1,2.3-2.3V9.3
10 + C18.7,9,18.5,8.8,18.2,8.8z M8.9,3.4l2.1-1c0.1-0.1,0.3-0.1,0.4-0.1c0.1,0,0.2,0,0.3,0c0.2,0.1,0.4,0.2,0.5,0.4l0.2,0.3l-0.2,0.1
11 + L8.6,4.9L8.5,4.6C8.3,4.2,8.4,3.6,8.9,3.4z M5.7,9.1c-0.4,0.2-0.8,0-1-0.3c-0.2-0.4,0-0.8,0.3-1l5.8-2.9l2.7-1.3L16.7,2
12 + c0.1-0.1,0.2-0.1,0.3-0.1c0.3,0,0.5,0.1,0.7,0.4c0.1,0.2,0.1,0.4,0,0.6c-0.1,0.2-0.2,0.3-0.4,0.4L5.7,9.1z"/>
13 +</svg>
webui/public/document.svg new
+29
@@ -0,0 +1,29 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<!-- Generator: Adobe Illustrator 27.9.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3 +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
4 + viewBox="0 0 22.5 23" style="enable-background:new 0 0 22.5 23;" xml:space="preserve">
5 +<style type="text/css">
6 + .st0{fill:#A0A0A0;}
7 +</style>
8 +<g>
9 + <path class="st0" d="M19.6,5.8H16c-0.3,0-0.5-0.2-0.5-0.5V2.6c0-0.3-0.2-0.5-0.5-0.5s-0.5,0.2-0.5,0.5v2.6c0,0.9,0.7,1.6,1.6,1.6
10 + h3.1v9.4v5.2c0,0.3-0.2,0.5-0.5,0.5H4c-0.3,0-0.5-0.2-0.5-0.5V1.6C3.4,1.3,3.7,1,4,1h11.3l4,3.5c0.2,0.2,0.5,0.2,0.7,0
11 + s0.2-0.5,0-0.7l-4.2-3.7C15.7,0,15.6,0,15.5,0H4C3.1,0,2.4,0.7,2.4,1.6v19.9C2.4,22.3,3.1,23,4,23h14.6c0.9,0,1.6-0.7,1.6-1.6v-5.2
12 + V6.3C20.2,6,19.9,5.8,19.6,5.8z"/>
13 + <path class="st0" d="M16.5,16.7c0-0.3-0.2-0.5-0.5-0.5H6c-0.3,0-0.5,0.2-0.5,0.5s0.2,0.5,0.5,0.5h10C16.3,17.2,16.5,17,16.5,16.7z"
14 + />
15 + <path class="st0" d="M6,8.9h5.2c0.3,0,0.5-0.2,0.5-0.5s-0.2-0.5-0.5-0.5H6c-0.3,0-0.5,0.2-0.5,0.5S5.8,8.9,6,8.9z"/>
16 + <path class="st0" d="M16.5,9.9h-6.3c-0.3,0-0.5,0.2-0.5,0.5s0.2,0.5,0.5,0.5h6.3c0.3,0,0.5-0.2,0.5-0.5C17,10.2,16.8,9.9,16.5,9.9z
17 + "/>
18 + <path class="st0" d="M16.5,7.8h-3.1c-0.3,0-0.5,0.2-0.5,0.5s0.2,0.5,0.5,0.5h3.1c0.3,0,0.5-0.2,0.5-0.5C17,8.1,16.8,7.8,16.5,7.8z"
19 + />
20 + <path class="st0" d="M6,13.1h4.7c0.3,0,0.5-0.2,0.5-0.5s-0.2-0.5-0.5-0.5H6c-0.3,0-0.5,0.2-0.5,0.5C5.5,12.8,5.8,13.1,6,13.1z"/>
21 + <path class="st0" d="M6,15.2h2.1c0.3,0,0.5-0.2,0.5-0.5s-0.2-0.5-0.5-0.5H6c-0.3,0-0.5,0.2-0.5,0.5C5.5,14.9,5.8,15.2,6,15.2z"/>
22 + <path class="st0" d="M6,11h2.1c0.3,0,0.5-0.2,0.5-0.5S8.4,10,8.1,10H6c-0.3,0-0.5,0.2-0.5,0.5C5.5,10.7,5.8,11,6,11z"/>
23 + <path class="st0" d="M17,12.5c0-0.3-0.2-0.5-0.5-0.5h-3.7c-0.3,0-0.5,0.2-0.5,0.5s0.2,0.5,0.5,0.5h3.7C16.8,13.1,17,12.8,17,12.5z"
24 + />
25 + <path class="st0" d="M9.7,14.6c0,0.3,0.2,0.5,0.5,0.5h4.2c0.3,0,0.5-0.2,0.5-0.5s-0.2-0.5-0.5-0.5h-4.2C9.9,14.1,9.7,14.3,9.7,14.6
26 + z"/>
27 + <path class="st0" d="M6,18.3c-0.3,0-0.5,0.2-0.5,0.5s0.2,0.5,0.5,0.5h5.2c0.3,0,0.5-0.2,0.5-0.5s-0.2-0.5-0.5-0.5H6z"/>
28 +</g>
29 +</svg>
webui/public/downloadfile.svg new
+11
@@ -0,0 +1,11 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<!-- Generator: Adobe Illustrator 27.9.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3 +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
4 + viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
5 +<style type="text/css">
6 + .st0{fill:none;stroke:#000000;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;}
7 +</style>
8 +<g>
9 + <path class="st0" d="M3,16.5v2.2C3,20,4,21,5.2,21h13.5c1.2,0,2.2-1,2.2-2.2v-2.2 M16.5,12L12,16.5 M12,16.5L7.5,12 M12,16.5V3"/>
10 +</g>
11 +</svg>
webui/public/file.svg new
+14
@@ -0,0 +1,14 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<!-- Generator: Adobe Illustrator 27.9.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3 +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
4 + viewBox="0 0 19.3 23" style="enable-background:new 0 0 19.3 23;" xml:space="preserve">
5 +<style type="text/css">
6 + .st0{fill:#A0A0A0;}
7 +</style>
8 +<g>
9 + <path class="st0" d="M18,5.8h-3.6c-0.3,0-0.5-0.2-0.5-0.5V2.6c0-0.3-0.2-0.5-0.5-0.5s-0.5,0.2-0.5,0.5v2.6c0,0.9,0.7,1.6,1.6,1.6
10 + h3.1v9.4v5.2c0,0.3-0.2,0.5-0.5,0.5H2.4c-0.3,0-0.5-0.2-0.5-0.5V1.6C1.8,1.3,2.1,1,2.4,1h11.3l4,3.5c0.2,0.2,0.5,0.2,0.7,0
11 + s0.2-0.5,0-0.7l-4.2-3.7C14.1,0,14,0,13.9,0H2.4C1.5,0,0.8,0.7,0.8,1.6v19.9c0,0.8,0.7,1.5,1.6,1.5H17c0.9,0,1.6-0.7,1.6-1.6v-5.2
12 + V6.3C18.6,6,18.3,5.8,18,5.8z"/>
13 +</g>
14 +</svg>
webui/public/folder.svg new
+13
@@ -0,0 +1,13 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<!-- Generator: Adobe Illustrator 27.9.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3 +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
4 + viewBox="0 0 23 16.3" style="enable-background:new 0 0 23 16.3;" xml:space="preserve">
5 +<style type="text/css">
6 + .st0{fill:#A0A0A0;}
7 +</style>
8 +<path class="st0" d="M21.9,5.3h-0.7V3.8c0-0.6-0.5-1.1-1.1-1.1h-8.9l-1.3-2C9.6,0.2,9.3,0,8.8,0H3C2.4,0,1.9,0.5,1.9,1.2v4.2H1.1
9 + c-0.3,0-0.6,0.1-0.9,0.4C0.1,5.9,0,6.3,0,6.6l1,8.8c0.1,0.6,0.6,1,1.1,1h18.6c0.6,0,1.1-0.4,1.1-1l1-8.8c0-0.3-0.1-0.7-0.3-0.9
10 + C22.5,5.4,22.2,5.3,21.9,5.3L21.9,5.3z M2.8,1.1C2.8,1,2.9,1,3,1h5.8C8.9,1,8.9,1,9,1.1l1.4,2.3c0.1,0.1,0.2,0.2,0.4,0.2H20
11 + c0.1,0,0.2,0.1,0.2,0.2v1.5H2.8V1.1z M21,15.2c0,0.1-0.1,0.1-0.2,0.1H2.2c-0.1,0-0.2-0.1-0.2-0.1L1,6.5c0-0.1,0-0.1,0-0.1
12 + s0.1-0.1,0.1-0.1h20.7c0.1,0,0.1,0,0.1,0.1s0,0.1,0,0.1L21,15.2L21,15.2z"/>
13 +</svg>
webui/public/image.svg new
+20
@@ -0,0 +1,20 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<!-- Generator: Adobe Illustrator 27.9.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3 +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
4 + viewBox="0 0 22.5 23" style="enable-background:new 0 0 22.5 23;" xml:space="preserve">
5 +<style type="text/css">
6 + .st0{fill:#00DD7F;}
7 +</style>
8 +<g>
9 + <path class="st0" d="M19.6,5.8H16c-0.3,0-0.5-0.2-0.5-0.5V2.6c0-0.3-0.2-0.5-0.5-0.5s-0.5,0.2-0.5,0.5v2.6c0,0.9,0.7,1.6,1.6,1.6
10 + h3.1v9.4v5.2c0,0.3-0.2,0.5-0.5,0.5H4c-0.3,0-0.5-0.2-0.5-0.5V1.6C3.4,1.3,3.7,1,4,1h11.3l4,3.5c0.2,0.2,0.5,0.2,0.7,0
11 + s0.2-0.5,0-0.7l-4.2-3.7C15.7,0,15.6,0,15.5,0H4C3.1,0,2.4,0.7,2.4,1.6v19.9C2.4,22.3,3.1,23,4,23h14.6c0.9,0,1.6-0.7,1.6-1.6v-5.2
12 + V6.3C20.2,6,19.9,5.8,19.6,5.8z"/>
13 + <path class="st0" d="M7.8,14.8c0.1-0.2,0.5-0.3,0.6,0L9.8,17c0.1,0.2,0.3,0.2,0.5,0.3c0.2,0,0.4-0.1,0.4-0.3l2.4-4.3
14 + c0.1-0.2,0.5-0.2,0.6,0l2.8,5.5c0.1,0.3,0.4,0.4,0.7,0.2c0.3-0.1,0.4-0.4,0.2-0.7l-2.8-5.5c-0.2-0.5-0.7-0.8-1.2-0.8
15 + s-1,0.3-1.2,0.7l-1.9,3.5l-0.9-1.4c-0.2-0.4-0.7-0.7-1.2-0.7s-1,0.3-1.2,0.8l-1.7,3.4C5.1,18,5,18.3,5,18.6c0,1,0.8,1.8,1.8,1.8H17
16 + c0.3,0,0.5-0.2,0.5-0.5s-0.2-0.5-0.5-0.5H6.8C6.4,19.3,6,19,6,18.6c0-0.1,0-0.2,0.1-0.3L7.8,14.8z"/>
17 + <path class="st0" d="M10.2,9.4c0-1.2-0.9-2.1-2.1-2.1S6,8.3,6,9.4s0.9,2.1,2.1,2.1S10.2,10.6,10.2,9.4z M7.1,9.4c0-0.6,0.5-1,1-1
18 + c0.6,0,1,0.5,1,1c0,0.6-0.5,1-1,1C7.6,10.5,7.1,10,7.1,9.4z"/>
19 +</g>
20 +</svg>
webui/public/settings.svg new
+2
@@ -0,0 +1,2 @@
1 +<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
2 +<svg fill="#000000" width="800px" height="800px" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M25 34c-5 0-9-4-9-9s4-9 9-9 9 4 9 9-4 9-9 9zm0-16c-3.9 0-7 3.1-7 7s3.1 7 7 7 7-3.1 7-7-3.1-7-7-7z"/><path d="M27.7 44h-5.4l-1.5-4.6c-1-.3-2-.7-2.9-1.2l-4.4 2.2-3.8-3.8 2.2-4.4c-.5-.9-.9-1.9-1.2-2.9L6 27.7v-5.4l4.6-1.5c.3-1 .7-2 1.2-2.9l-2.2-4.4 3.8-3.8 4.4 2.2c.9-.5 1.9-.9 2.9-1.2L22.3 6h5.4l1.5 4.6c1 .3 2 .7 2.9 1.2l4.4-2.2 3.8 3.8-2.2 4.4c.5.9.9 1.9 1.2 2.9l4.6 1.5v5.4l-4.6 1.5c-.3 1-.7 2-1.2 2.9l2.2 4.4-3.8 3.8-4.4-2.2c-.9.5-1.9.9-2.9 1.2L27.7 44zm-4-2h2.6l1.4-4.3.5-.1c1.2-.3 2.3-.8 3.4-1.4l.5-.3 4 2 1.8-1.8-2-4 .3-.5c.6-1 1.1-2.2 1.4-3.4l.1-.5 4.3-1.4v-2.6l-4.3-1.4-.1-.5c-.3-1.2-.8-2.3-1.4-3.4l-.3-.5 2-4-1.8-1.8-4 2-.5-.3c-1.1-.6-2.2-1.1-3.4-1.4l-.5-.1L26.3 8h-2.6l-1.4 4.3-.5.1c-1.2.3-2.3.8-3.4 1.4l-.5.3-4-2-1.8 1.8 2 4-.3.5c-.6 1-1.1 2.2-1.4 3.4l-.1.5L8 23.7v2.6l4.3 1.4.1.5c.3 1.2.8 2.3 1.4 3.4l.3.5-2 4 1.8 1.8 4-2 .5.3c1.1.6 2.2 1.1 3.4 1.4l.5.1 1.4 4.3z"/></svg>
\ No newline at end of file
webui/settings.css
+18
@@ -32,6 +32,7 @@ select {
32 }
33
34 .modal-header h2 {
35 + color: var(--color-primary);
36 font-size: 1.25rem;
37 margin: 0;
38 }
@@ -41,6 +42,10 @@ select {
42 line-height: 2em;
43 }
44
45 +#settings-title {
46 + padding: 0.5rem 2rem 0.5rem 2rem;
47 +}
48 +
49 .modal-content {
50 padding: var(--spacing-sm);
51 overflow-y: auto;
@@ -240,6 +245,10 @@ input[type="range"] {
245 transition: background 0.3s ease-in-out;
246 }
247
248 +.btn-ok > svg {
249 + max-width: 20px;
250 +}
251 +
252 .btn-ok:hover {
253 background: #3265c0;
254 }
@@ -311,6 +320,15 @@ nav ul li a:hover {
320 text-decoration: underline;
321 }
322
323 +#settings-sections {
324 + background-color: var(--color-secondary);
325 + padding: 0.3rem 2rem;
326 + line-height: 1.6rem;
327 + font-size: var(--font-size-normal);
328 + border-bottom: 1px solid var(--color-border);
329 +}
330 +
331 +
332 @media (max-width: 768px) {
333 .modal-header {
334 padding: var(--spacing-sm);