diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..b77e709 Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index ebd586d..1c9a8b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ # Config file app/settings.json app/settings.json.bak - +app/views/views_backup.json +metadata/* # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/README.md b/README.md index efa998b..652932c 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,19 @@ A simple sidebar for ComfyUI. For what i know nobody did it, so i did it. Maybe you don't need it. I need it >.< + # Updates +- 04-09-2024 + - Changed the resize system of the n-sidebar (it can now be resized directly from its sides NOTE: the top side wilL only move the bar) + - Added the ability to directly download CIVITAI models into chosen folders (this includes Checkpoint, Textual Inversion, Hypernetwork, Aesthetic Gradient, LORA, LoCon, DoRA, Controlnet, Upscaler, Motion Module, VAE, Poses, Wildcards, Workflows). [More Info](#assets-downloader). + - Added the ability to import/export your settings. + - EXPERIMENTAL: You can now embed ComfyUI's native bar within the n-sidebar + - EXPERIMENTAL: Added a small console (useful if you don’t have direct access to the machine). If you want to remove this feature, simply delete the `terminal` folder within `ComfyUI-N-Sidebar/app/panels` + - EXPERIMENTAL: Added the ability to edit the layout of the bar, this functionality is highly unstable and its use may lead to unexpected behaviors + - Redesigned configuration menu + - Fixed some bugs + - 01-08-2024 - A lot of people are having trouble with the CSS class change in the last update because of a browser cache issue: the old CSS file keeps loading from disk instead of the new one with the latest changes (thanks, modern browsers!). To fix this, I've changed the name of the CSS file, which SHOULD force browsers to load the new one. If this doesn't fix the issue, I'm not sure what will 😭. - Added the new option `Show at Startup` in the settings. This lets you have the sidebar open at startup (if it's not in fixed mode). @@ -171,7 +182,16 @@ This feature is enabled by default, you can disable it in the settings. I've use ### 🎨 New Settings Panel 🎨  -PS: Workflow Path +### ASSETS DOWNLOADER + + +This new panel allows you to download CIVITAI models (Checkpoint, TextualInversion, Hypernetwork, AestheticGradient, LORA, LoCon, DoRA, Controlnet, Upscaler, MotionModule, VAE, Poses, Wildcards, Workflows). +The various models will be downloaded into the folders set in the settings. +There is also the option to rebuild the "My Library" section (the dots at the top right corner): +once the reference folders are set in the settings, by launching the rebuild, the already existing models will be identified and added to the library (if they exist in the CivitAI database). + +NOTE: you can set your own API key for models that are not publicly available. + @@ -184,8 +204,8 @@ PS: Workflow Path - [x] Custom Categories!! - [x] Workflows - [x] Templates +- [x] Export and Import Settings - [ ] Custom Shortcuts -- [ ] Export and Import Settings - [ ] Touch Support diff --git a/__init__.py b/__init__.py index 8e169f7..5382b23 100644 --- a/__init__.py +++ b/__init__.py @@ -3,10 +3,50 @@ import folder_paths from aiohttp import web import os import json -from collections import OrderedDict from pathlib import Path import base64 import shutil +import zipfile +from io import BytesIO +import io +import asyncio +import subprocess +import requests +from urllib.parse import urlencode +import importlib.util +import sys +from packaging import version + +def check_and_install(package, import_name="", desired_version=None,reboot=False): + if import_name == "": + import_name = package + try: + library_module = importlib.import_module(import_name) + current_version = getattr(library_module, '__version__', None) + if current_version : + if current_version: + print(f"Current version of {import_name}: {current_version}") + if desired_version: + if version.parse(current_version) < version.parse(desired_version): + print(f"Updating {import_name} to version {desired_version}...") + install_package(f"{package}=={desired_version}") + print(f"{import_name} updated successfully to version {desired_version}") + #else: + # print(f"{import_name} is already up-to-date with version {current_version}") + + else: + print(f"Version of {import_name}: Not found") + + + except ImportError: + print(f"Installing {import_name}...") + install_package(package) + + +def install_package(package): + subprocess.check_call([sys.executable, "-m", "pip", "install", "--no-cache-dir", package]) + +check_and_install('aiofiles') @@ -28,6 +68,14 @@ class JSONConfigManager: self.data = json.loads(self.initial_config) self.write_json_file() + def get_internal_settings(self): + try: + internal_settings = { + "current_path": os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))), + } + return internal_settings + except FileNotFoundError: + return {} def read_json_file(self): try: with open(self.file_name, 'r') as file: @@ -100,6 +148,92 @@ def scan_directory(directory, workflows_dict, existing_names, main_path=''): +def check_model_existence(metadata_path, config_manager, id_model=None): + metadata_current_path = os.path.join(metadata_path, "models") + models_report = [] + + # Scansione del primo livello della directory + for model_dir in os.listdir(metadata_current_path): + model_path = os.path.join(metadata_current_path, model_dir) + if id_model and id_model != model_dir: + continue + if os.path.isdir(model_path): + # Cerca i file JSON e immagine all'interno della directory del modello + json_file = None + image_file = None + + for file_name in os.listdir(model_path): + if file_name.endswith('.json'): + json_file = os.path.join(model_path, file_name) + elif file_name.endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif')): + image_file = os.path.join(model_path, file_name) + + if json_file: + try: + # Leggi il contenuto del file JSON + with open(json_file, 'r') as f: + model_data = json.load(f) + + model_type = model_data.get('model_type').lower() + model_name = model_data.get('name') + expected_size = model_data.get('size') + model_hash = model_data.get('hash') + model_file_name = model_data.get('model_file_name') + # Recupera il percorso corretto dal config manager + config_key = f"sb_w_{model_type}" + model_base_path = config_manager.read_item(config_key) + if model_base_path: + full_model_path = os.path.join(model_base_path, model_file_name) + + model_info = { + "id": model_data.get('id'), + "name": model_name, + "size": expected_size, + "author": model_data.get('author'), + "url": model_data.get('url'), + "image_type": model_data.get('image_type'), + "image_name": model_data.get('image_name'), + "model_type": model_type, + "model_file_name": model_file_name, + "hash": model_hash, + "base_model" : model_data.get('base_model'), + "full_model_path": full_model_path, + "nsfw": model_data.get('nsfw'), + "status": "" + } + + # Verifica se il modello esiste e confronta la dimensione del file + if os.path.isfile(full_model_path): + actual_size = os.path.getsize(full_model_path) + #get last edit date + last_edit = os.path.getctime(json_file) + + model_info['last_edit'] = last_edit + + # Confronto delle dimensioni + if actual_size == expected_size: + model_info['status'] = "valid" + else: + model_info['status'] = f"size_mismatch (expected: {expected_size} bytes, found: {actual_size} bytes)" + else: + model_info['status'] = "missing" + else: + model_info = { + "name": model_name, + "status": f"model_type_path_missing for {model_type}" + } + + # Aggiungi il report del modello + models_report.append(model_info) + + except Exception as e: + print(f"Error processing file '{json_file}': {e}") + model_info['status'] = "error" + continue + + return models_report + + @@ -122,8 +256,10 @@ else: file_path = os.path.join(os.path.dirname(os.path.realpath(__file__)),"app","settings.json") - - +views_path = os.path.join(os.path.dirname(os.path.realpath(__file__)),"app","views","views.json") +views_backup_path = os.path.join(os.path.dirname(os.path.realpath(__file__)),"app","views","views_backup.json") +views_default_path = os.path.join(os.path.dirname(os.path.realpath(__file__)),"app","views","views_default.json") +metadata_path = os.path.join(os.path.dirname(os.path.realpath(__file__)),"metadata") config_manager = JSONConfigManager(file_path) NODE_CLASS_MAPPINGS = {} @@ -232,13 +368,6 @@ async def s_get(request): - - - - - - - @server.PromptServer.instance.routes.get("/sidebar/panels" ) async def s_get(request): result = {"panels": list_panels_array} @@ -287,10 +416,990 @@ async def s_get(request): return web.json_response(result, content_type='application/json') +@server.PromptServer.instance.routes.get("/sidebar/internal_settings" ) +async def s_get(request): + get_internal_settings = config_manager.get_internal_settings() - + return web.json_response(get_internal_settings, content_type='application/json') + + +# Endpoint export config +@server.PromptServer.instance.routes.post('/sidebar/export') +async def export_config(request): + data = await request.json() + selected_items = data['selected_items'] + export_data = {} + all_keys = set(config_manager.data.keys()) + keys_to_exclude = set( ['sb_pinnedItems', 'sb_categoryNodeMap','sb_ColorCustomCategories', 'sb_workflowNodeMap','sb_ColorCustomWorkflows', 'sb_templateNodeMap','sb_ColorCustomTemplates'] ) + + + for item in selected_items: + if item == '': + continue + elif '|' in item: + keys = item.split('|') + for key in keys: + export_data[key] = config_manager.read_item(key) + keys_to_exclude.add(key) + else: + export_data[item] = config_manager.read_item(item) + keys_to_exclude.add(item) + + + if '' in selected_items: + remaining_keys = all_keys - keys_to_exclude + for key in remaining_keys: + export_data[key] = config_manager.read_item(key) + + json_data = json.dumps(export_data, indent=4) + + zip_buffer = BytesIO() + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: + if "model_templates" in selected_items: + zip_path = export_model_templates() + zip_file.write(zip_path, 'model_templates.zip') + + zip_file.writestr('config_export.json', json_data) + + zip_buffer.seek(0) + return web.Response(body=zip_buffer.read(), headers={ + 'Content-Disposition': 'attachment; filename="config_export.zip"', + 'Content-Type': 'application/zip' + }) + + +@server.PromptServer.instance.routes.post('/sidebar/import') +async def import_config(request): + # Crea un lettore di multipart per leggere file e altri dati + reader = await request.multipart() + metadata_current_path = os.path.join(metadata_path, "models") + if not os.path.exists(metadata_current_path): + os.makedirs(metadata_current_path) + # Dati per salvare l'importazione + selected_items = [] + config_data = None + + # Itera sulle parti della richiesta multipart + while True: + part = await reader.next() + if part is None: + break + + if part.name == 'file': + # Carica il file inviato (può essere JSON o ZIP) + filename = part.filename + file_data = await part.read() + + # Verifica se è un file zip + if zipfile.is_zipfile(io.BytesIO(file_data)): + # Estrai i file dallo zip + with zipfile.ZipFile(io.BytesIO(file_data)) as zip_file: + for file_name in zip_file.namelist(): + with zip_file.open(file_name) as f: + if file_name == 'config_export.json': + config_data = json.load(f) + elif file_name == 'model_templates.zip': + + with zipfile.ZipFile(io.BytesIO(f.read())) as model_zip: + model_zip.extractall(metadata_current_path) + + + else: + + config_data = json.loads(file_data) + + elif part.name == 'selected_items': + # Leggi i dati selezionati come JSON + selected_items = json.loads(await part.text()) + + # Ora `config_data` contiene il JSON importato dal file (sia ZIP che JSON singolo) + # e `selected_items` contiene le chiavi da importare + + if config_data: + for item in selected_items: + if item: + # Separa le chiavi multiple + keys = item.split('|') + for key in keys: + if key in config_data: + config_manager.update_item(key, config_data[key]) + else: + # Importa tutti i dati tranne quelli specifici + for key, value in config_data.items(): + if key not in ['sb_pinnedItems', 'sb_categoryNodeMap', 'sb_workflowNodeMap', 'sb_templateNodeMap', 'sb_ColorCustomCategories', 'sb_ColorCustomWorkflows', 'sb_ColorCustomTemplates']: + config_manager.update_item(key, value) + + return web.json_response({'status': 'success'}) + +@server.PromptServer.instance.routes.post("/sidebar/restorelayout") +async def restore_layout(request): + try: + # Check if the backup file exists + if not Path(views_default_path).exists(): + result = {"result": "Error", "message": "Backup file not found."} + return web.json_response(result, content_type='application/json', status=404) + + # Restore views.json from the backup + with open(Path(views_default_path), 'r', encoding='utf-8') as f: + backup_data = json.load(f) + + # Write the backup data to views.json + with open(Path(views_path), 'w', encoding='utf-8') as f: + json.dump(backup_data, f, ensure_ascii=False, indent=4) + + # Respond with success + result = {"result": "OK", "message": "Layout successfully restored."} + return web.json_response(result, content_type='application/json') + + except Exception as e: + # Error handling + print(f"Error restoring layout: {e}") + result = {"result": "Error", "message": str(e)} + return web.json_response(result, content_type='application/json', status=500) - +@server.PromptServer.instance.routes.post("/sidebar/savelayout") +async def save_layout(request): + try: + # Otteniamo i dati JSON dalla richiesta POST + data = await request.json() + + + # If views.json exists, create a backup + if Path(views_path).exists(): + os.replace(Path(views_path), Path(views_backup_path)) + + # Save the new layout to views.json + with open(Path(views_path), 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=4) + + # Success response + result = {"result": "OK", "message": "Layout saved successfully."} + return web.json_response(result, content_type='application/json') + + except Exception as e: + # Error handling + print(f"Error saving layout: {e}") + result = {"result": "Error", "message": str(e)} + return web.json_response(result, content_type='application/json', status=500) + + +@server.PromptServer.instance.routes.get("/sidebar/views") +async def s_get(request): + # if file exists + if os.path.isfile(views_path): + + return web.FileResponse(views_path) + else: + return web.Response(status=403) + +@server.PromptServer.instance.routes.get("/sidebar/panels") +async def s_get_panels(request): + # Definisce il percorso della directory "panels" + panels_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "app", "panels") + + # Verifica se la directory esiste + if os.path.isdir(panels_path): + # Crea una lista con i nomi delle cartelle nella directory "panels" + folder_names = [folder for folder in os.listdir(panels_path) if os.path.isdir(os.path.join(panels_path, folder))] + + # Restituisce la lista di nomi di cartelle in formato JSON + return web.json_response(folder_names) + else: + # Se la directory non esiste, restituisce un errore 403 + return web.Response(status=403) + + + + + +@server.PromptServer.instance.routes.post("/sidebar/execute") +async def execute(request): + try: + data = await request.json() + command = data.get('command', '') + + # Esegui il comando + process = await asyncio.create_subprocess_shell( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + stdout, stderr = await process.communicate() + + # Ritorna l'output del comando + output = stdout.decode() + stderr.decode() + return web.json_response({'output': output}) + except Exception as e: + return web.json_response({'output': str(e)}, status=500) + + +async def fetch_data(request): + api_url = "https://civitai.com/api/v1/models" + params = request.rel_url.query + response = requests.get(api_url, params=params) + data = response.text + + return web.Response(text=data, content_type='application/json') + + +@server.PromptServer.instance.routes.get("/sidebar/proxy/models") +async def proxy_models(request): + try: + + params = request.query + api_url = "https://civitai.com/api/v1/models" + headers = { + "Content-Type": "application/json" + } + + full_url = f"{api_url}?{urlencode(params)}" + + async with aiohttp.ClientSession() as session: + async with session.get(full_url, headers=headers) as response: + response_data = await response.json() + for item in response_data.get("items", []): + json_data = check_model_existence(metadata_path, config_manager, str(item["id"])) + + if len(json_data) > 0: + item["nsbStatus"] = json_data[0].get("status") + + return web.json_response(response_data) + + except Exception as e: + return web.json_response({'error': str(e)}, status=500) + +def export_model_templates(): + metadata_current_path = os.path.join(metadata_path, "models") + zip_name = "model_templates.zip" + + zip_path = os.path.join(metadata_path, zip_name) + + + if os.path.exists(zip_path): + os.remove(zip_path) + + shutil.make_archive(os.path.join(metadata_path, zip_name.replace('.zip', '')), 'zip', metadata_current_path) + + return zip_path + + + + +def createJSONInfo(metadata_current_path, data,total_size,image_name): + if not os.path.exists(os.path.join(metadata_current_path, f"{data['id']}.json")): + info_path = os.path.join(metadata_current_path, f"{data['id']}.json") + + #remove from url ?token=... + data['url'] = data['url'].split('?token=')[0] + + info = { + "id": data['id'], + "name": data['name'], + "size": total_size, + "author": data['author'], + "url": data['url'], + "image_type": data['image_type'], + "image_name": image_name, + "model_type": data['model_type'], + "model_file_name" : data['model_file_name'], + "base_model": data['base_model'], + "nsfw": data['nsfw'], + "hash": data['hash'] + } + with open(info_path, 'w') as f: + json.dump(info, f) + +import aiohttp +import aiofiles +import traceback + +download_progress = {} +download_tasks = {} +downloads = {} +@server.PromptServer.instance.routes.post('/sidebar/download') +async def download_model(request): + try: + data = await request.json() + id_download = data['id'] + name = data['name'] + model_url = data['url'] + save_path = data['path'] + image_url = data['image_url'] + image_type = data['image_type'] + nsfw = data['nsfw'] + metadata_current_path = os.path.join(metadata_path,"models", str(id_download)) + if not os.path.exists(metadata_current_path): + os.makedirs(metadata_current_path) + image_name = '' + + + + + # Scarica l'immagine o il video e salvalo + if image_url: + image_ext = os.path.splitext(image_url)[1] + if image_type == "image": + image_name = str(id_download) + image_ext + else: + image_name = str(id_download) + ".mp4" + + image_path = os.path.join(metadata_current_path, image_name) + + if not os.path.exists(image_path): + async with aiohttp.ClientSession() as session: + async with session.get(image_url) as image_response: + image_data = await image_response.read() + with open(image_path, 'wb') as f: + f.write(image_data) + + + + # Verifica se il file finale esiste già + if os.path.exists(save_path) and os.path.getsize(save_path) > 200: + downloads[id_download] = { + "name": name, + "progress": 100, + "task": None, + "temp_save_path": None, + "file_size": None, + "downloaded_size": None, + "status": "finished", + "nsfw": nsfw + } + headers = { + 'Range': f'bytes=0-' + } + #only for check size + async with aiohttp.ClientSession() as session: + async with session.get(model_url, headers=headers) as response: + total_size = int(response.headers.get('content-length', 0)) + + createJSONInfo(metadata_current_path, data,total_size,image_name) + + return web.Response(text=json.dumps({'status': 'already_exists'}), content_type='application/json') + + # Percorso del file temporaneo con estensione .part + temp_save_path = save_path + '.part' + + # Inizializza il progresso + if os.path.exists(temp_save_path): + # Se il file .part esiste, riprende dal punto interrotto + downloaded_size = os.path.getsize(temp_save_path) + else: + # Se non esiste, inizia da zero + downloaded_size = 0 + + headers = { + 'Range': f'bytes={downloaded_size}-' + } + + # Definizione della coroutine del download + async def download_coroutine(downloaded_size): + try: + async with aiohttp.ClientSession() as session: + async with session.get(model_url, headers=headers) as response: + total_size = int(response.headers.get('content-length', 0)) + downloaded_size + + # Gestione della cartella dei metadati e del file immagine + + + createJSONInfo(metadata_current_path, data,total_size,image_name) + downloads[id_download]['name'] = name + # Imposta la progressione del download + downloads[id_download]['file_size'] = total_size + downloads[id_download]['downloaded_size'] = downloaded_size + downloads[id_download]['progress'] = (downloaded_size / total_size) * 100 + + if total_size < 200: + downloads[id_download]['status'] = "api_error" + raise Exception("Probably API error") + # Effettua il download del contenuto e aggiorna il progresso + async with aiofiles.open(temp_save_path, 'ab') as f: + while True: + chunk = await response.content.read(1024) + if not chunk: + break + downloaded_size += len(chunk) + downloads[id_download]['downloaded_size'] = downloaded_size + downloads[id_download]['progress'] = (downloaded_size / total_size) * 100 + downloads[id_download]['status'] = "running" + + await f.write(chunk) + + # Download completato, rinomina il file .part al nome finale + downloads[id_download]['progress'] = 100 # Completo + if (downloaded_size / total_size) * 100 == 100: + try: + # change name only if download is complete + file_path = data['path'] + + os.rename(temp_save_path, file_path) + # if is a zip file, unzip it and delete it + if file_path.endswith('.zip'): + saveInSubfolder = config_manager.read_item('sb_save_in_subfolder') + + if saveInSubfolder == True: + + save_path_zip = file_path.replace('.zip', '') + if not os.path.exists(os.path.dirname(save_path_zip)): + os.makedirs(save_path_zip) + shutil.unpack_archive(file_path, save_path_zip) + + else: + shutil.unpack_archive(file_path, os.path.dirname(file_path)) + os.remove(file_path) + + except Exception as e: + print(e) + traceback.print_exc() + + + + downloads[id_download]['status'] = "completed" + + return web.Response(text=json.dumps({'status': 'success'}), content_type='application/json') + + except Exception as e: + if downloads[id_download]['status'] != "api_error": + downloads[id_download]['status'] = "error" + return web.json_response({'error': str(e)}, status=500) + + # Avvia il download e memorizza il task e il percorso temporaneo nel dizionario downloads + task = server.PromptServer.instance.loop.create_task(download_coroutine(downloaded_size)) + downloads[id_download] = { + "task": task, + "progress": 0, + "temp_save_path": temp_save_path + } + + return web.Response(text=json.dumps({'status': 'downloading'}), content_type='application/json') + + except Exception as e: + return web.json_response({'error': str(e)}, status=500) + +@server.PromptServer.instance.routes.post('/sidebar/pause') +async def pause_download(request): + try: + data = await request.json() + id_download = data['id'] + + download_info = downloads.get(id_download) + if download_info: + task = download_info['task'] + + # Cancella il task del download + if task: + task.cancel() + + # Aggiorna lo stato del progresso al 50% o a un altro stato indicativo di pausa, se necessario + downloads[id_download]['progress'] = downloads[id_download].get('progress', 0) + downloads[id_download]['status'] = 'paused' + + return web.Response(text=json.dumps({'status': 'paused'}), content_type='application/json') + else: + return web.json_response({'error': 'Download not found'}, status=404) + + except Exception as e: + return web.json_response({'error': str(e)}, status=500) + + +@server.PromptServer.instance.routes.post('/sidebar/cancel') +async def cancel_download(request): + try: + data = await request.json() + id_download = data['id'] + + download_info = downloads.get(id_download) + if download_info: + task = download_info['task'] + temp_save_path = download_info['temp_save_path'] + + # Cancella il task del download + if task: + task.cancel() + + # Rimuove il file .part se esiste + if temp_save_path and os.path.exists(temp_save_path): + os.remove(temp_save_path) + + # Rimuove il download dal dizionario + downloads.pop(id_download, None) + + return web.Response(text=json.dumps({'status': 'canceled'}), content_type='application/json') + else: + return web.json_response({'error': 'Download not found'}, status=404) + + except Exception as e: + return web.json_response({'error': str(e)}, status=500) + +@server.PromptServer.instance.routes.get('/sidebar/download/progress') +async def get_progress(request): + id_download = request.query.get('id') + + # Se viene passato un ID specifico + if id_download: + download_info = downloads.get(id_download) + if download_info: + # Restituisci tutte le informazioni del download per l'ID specifico + return web.json_response({ + 'id': id_download, + 'progress': download_info.get('progress', 0), + 'temp_save_path': download_info.get('temp_save_path'), + 'task_status': download_info.get('status'), + 'file_size': download_info.get('file_size', 'Unknown'), + 'downloaded_size': download_info.get('downloaded_size', 0) + + }) + else: + return web.json_response({'error': 'Download not found'}, status=404) + + # Se non viene passato un ID, restituisci tutte le informazioni per tutti i download attivi + all_progress = [ + { + 'id': key, + 'progress': value.get('progress', 0), + 'temp_save_path': value.get('temp_save_path'), + 'task_status': value.get('status'), + 'file_size': value.get('file_size', 'Unknown'), + 'downloaded_size': value.get('downloaded_size', 0) + + } + for key, value in downloads.items() + ] + + return web.json_response({'progress_list': all_progress}) + + + +################################################################################################################ +@server.PromptServer.instance.routes.get('/sidebar/ws/progress') +async def websocket_handler(request): + ws = web.WebSocketResponse() + await ws.prepare(request) + + try: + while True: + # Controlla se la connessione è ancora aperta + if ws.closed: + print("Client disconnected.") + break + + # Costruisci la lista di progressi da inviare al client + all_progress = [ + { + 'id': key, + 'name': value.get('name'), + 'progress': value.get('progress', 0), + 'temp_save_path': value.get('temp_save_path'), + 'task_status': value.get('status'), + 'file_size': value.get('file_size', 'Unknown'), + 'downloaded_size': value.get('downloaded_size', 0) + } + for key, value in downloads.items() + ] + + try: + # Invia i progressi tramite WebSocket + await ws.send_json({'progress_list': all_progress}) + + # remove the id with progress 100 + for id_download in list(downloads.keys()): + if downloads[id_download]['progress'] == 100: + downloads.pop(id_download, None) + + except ConnectionResetError: + #print("Connection was closed while sending data.") + break + + # Attendere un secondo prima di inviare nuovamente + await asyncio.sleep(1) + except asyncio.CancelledError: + pass + #print("WebSocket connection closed.") + finally: + pass + #print("WebSocket handler terminated.") + + return ws + + + + + + +################################################################################################################ + + + + +@server.PromptServer.instance.routes.get('/sidebar/checkintegrity') +async def get_progress(request): + id_model = request.query.get('id') + if id_model: + res= check_model_existence(metadata_path, config_manager, id_model) + else: + res= check_model_existence(metadata_path, config_manager) + return web.json_response(res, content_type='application/json') + +@server.PromptServer.instance.routes.get('/sidebar/models/image') +async def show_image(request): + id_model = request.query.get('id') + image_name = request.query.get('image_name') + image_current_path = os.path.join(metadata_path,"models", str(id_model), image_name) + + return web.FileResponse(image_current_path) + + +@server.PromptServer.instance.routes.get('/sidebar/models/delete') +async def delete_model(request): + id_model = request.query.get('id') + res = check_model_existence(metadata_path, config_manager, id_model) + + model_info_current_path = os.path.join(metadata_path,"models", str(id_model)) + model_current_path = res[0].get("full_model_path") + model_current_path_part = res[0].get("full_model_path")+".part" + + if os.path.exists(model_current_path): + #delete file + os.remove(model_current_path) + + #if some part are still there + if os.path.exists(model_current_path_part): + os.remove(model_current_path_part) + + + if os.path.exists(model_info_current_path): + shutil.rmtree(model_info_current_path) + + return web.Response(text=json.dumps({'status': 'deleted'}), content_type='application/json') + + +import hashlib + +def hash_files_in_directory_SHA(file_path): + try: + hasher = hashlib.sha256() + with open(file_path, "rb") as f: + while chunk := f.read(8192): + hasher.update(chunk) + + return hasher.hexdigest() + + except Exception as e: + print(f"Error hashing {file_path}: {e}") + return None + +async def get_model_info(model_id): + full_url = f"https://civitai.com/api/v1/model-versions/models/{model_id}/" + headers = { + "Content-Type": "application/json" + } + + async with aiohttp.ClientSession() as session: + async with session.get(full_url, headers=headers) as response: + if response.status == 200: + data = await response.json() + return data + else: + return None +""" +@server.PromptServer.instance.routes.get('/sidebar/models/rebuild') +async def rebuild_library(request): + try: + if downloads[0]['status'] == "running": + return web.json_response({'error': 'Model rebuilding is already in progress'}, status=400) + except KeyError: + pass + async def coroutine(): + + loop = asyncio.get_event_loop() + model_found = 0 + models_currently_done = 0 + + downloads[0] = { + 'name': "sb_rebuilding_task", + 'file_size': model_found, + 'downloaded_size': models_currently_done, + 'progress': 0, # initialization with 0 + 'status': "running", + 'temp_save_path': '' + + } + + try: + # Model directory + allowed_directories = [config_manager.read_item("sb_w_checkpoint"), + config_manager.read_item("sb_w_textualinversion"), + config_manager.read_item("sb_w_hypernetwork"), + config_manager.read_item("sb_w_lora"), + config_manager.read_item("sb_w_controlnet"), + config_manager.read_item("sb_w_upscaler"), + config_manager.read_item("sb_w_vae")] + files_path = [] + for directory in allowed_directories: + for root, _, files in os.walk(directory): + #model_found += sum(1 for file in files if file.endswith(('.safetensors', '.ckpt'))) + for file in files: + model_found += 1 + if file.endswith(('.safetensors', '.ckpt')): + files_path.append(os.path.join(root, file)) + + + downloads[0]['status'] = "running" + downloads[0]['file_size']= model_found + + if model_found == 0: + return web.json_response({'error': 'No models found'}, status=404) + + # Loop through models + for file_path in files_path: + file_name = os.path.basename(file_path) + # Get file size in a non-blocking way + total_size = await loop.run_in_executor(None, os.path.getsize, file_path) + + try: + # Run hash calculation in a thread + h = await loop.run_in_executor(None, hash_files_in_directory_SHA, file_path) + + full_url = f"https://civitai.com/api/v1/model-versions/by-hash/{h}/" + headers = {"Content-Type": "application/json"} + + async with aiohttp.ClientSession() as session: + async with session.get(full_url, headers=headers) as response: + response_data = await response.json() + + id_download = response_data.get("id") + id_model = response_data.get("modelId") + image_url = response_data.get("images", [])[0].get("url") + image_type = response_data.get("images", [])[0].get("type") + + # Get more model information asynchronously + info = await get_model_info(id_model) + creator = info.get("creator").get("username") if info else "Unknown" + + # Create metadata directory in a non-blocking way + metadata_current_path = os.path.join(metadata_path, "models", str(id_model)) + await loop.run_in_executor(None, os.makedirs, metadata_current_path) + + image_name = '' + + # Download image asynchronously + if image_url: + image_ext = os.path.splitext(image_url)[1] + image_name = str(id_model) + (image_ext if image_type == "image" else ".mp4") + image_path = os.path.join(metadata_current_path, image_name) + + if not os.path.exists(image_path): + async with session.get(image_url) as image_response: + image_data = await image_response.read() + # Write image to file asynchronously + await loop.run_in_executor(None, lambda: open(image_path, 'wb').write(image_data)) + + # Create JSON with model information asynchronously + data = { + "id": id_model, + "name": response_data.get("model").get("name"), + "size": total_size, + "author": creator, + "url": response_data.get("downloadUrl"), + "image_type": image_type, + "image_name": image_name, + "model_type": response_data.get("model").get("type"), + "model_file_name": file_name, + "base_model": response_data.get("baseModel"), + "nsfw": response_data.get("model").get("nsfw"), + "hash": h + } + + # Write JSON asynchronously + await loop.run_in_executor(None, createJSONInfo, metadata_current_path, data, total_size, image_name) + + except Exception as e: + print(f"Error hashing {file_path}: {e}") + + # Increment processed models + models_currently_done += 1 + downloads[0]['downloaded_size'] = models_currently_done + downloads[0]['progress'] = (models_currently_done / model_found) * 100 + + downloads[0]['status'] = "ended" + + # Return response after completion + return web.json_response({'status': 'building complete'}, status=200) + + except Exception as e: + + downloads[0]['status'] = f"error" + return web.json_response({'error': str(e)}, status=500) + + # Start the coroutine as a task + task = server.PromptServer.instance.loop.create_task(coroutine()) + + # Immediate response to the client while the task is running + return web.Response(text=json.dumps({'status': 'building'}), content_type='application/json') +""" + +@server.PromptServer.instance.routes.get('/sidebar/models/rebuild') +async def rebuild_library(request): + try: + # Check if rebuilding is already in progress + if downloads[0]['status'] == "running": + return web.json_response({'error': 'Model rebuilding is already in progress'}, status=400) + except KeyError: + pass + + try: + clear_flag = request.query.get('clear', '0').lower() == '1' + processed_models_file = os.path.join(metadata_path, "processed_models.txt") + + # If clear=1, delete the temporary file and start from scratch + if clear_flag == 1 and os.path.exists(processed_models_file): + os.remove(processed_models_file) + #clear models folder + shutil.rmtree(os.path.join(metadata_path, "models")) + except Exception as e: + print(f"Error deleting temporary file: {e}") + + + async def coroutine(): + loop = asyncio.get_event_loop() + model_found = 0 + models_currently_done = 0 + + # Read already processed models from the temporary file + processed_models = set() + if os.path.exists(processed_models_file): + with open(processed_models_file, "r") as f: + processed_models = set(line.strip() for line in f) + + downloads[0] = { + 'name': "sb_rebuilding_task", + 'file_size': model_found, + 'downloaded_size': models_currently_done, + 'progress': 0, # initialization with 0 + 'status': "running", + 'temp_save_path': '' + } + + try: + # Allowed directories + allowed_directories = [ + config_manager.read_item("sb_w_checkpoint"), + config_manager.read_item("sb_w_textualinversion"), + config_manager.read_item("sb_w_hypernetwork"), + config_manager.read_item("sb_w_lora"), + config_manager.read_item("sb_w_controlnet"), + config_manager.read_item("sb_w_upscaler"), + config_manager.read_item("sb_w_vae") + ] + + files_path = [] + for directory in allowed_directories: + for root, _, files in os.walk(directory): + for file in files: + full_path = os.path.join(root, file) + model_found += 1 + + # Add to file_path only models not yet processed + if file.endswith(('.safetensors', '.ckpt')) and full_path not in processed_models: + files_path.append(full_path) + + downloads[0]['status'] = "running" + downloads[0]['file_size'] = model_found + + if model_found == 0: + return web.json_response({'error': 'No models found'}, status=404) + + # Loop through the models + for file_path in files_path: + file_name = os.path.basename(file_path) + + # Get the file size in a non-blocking way + total_size = await loop.run_in_executor(None, os.path.getsize, file_path) + + try: + # Run hash calculation in a thread + h = await loop.run_in_executor(None, hash_files_in_directory_SHA, file_path) + + full_url = f"https://civitai.com/api/v1/model-versions/by-hash/{h}/" + headers = {"Content-Type": "application/json"} + + async with aiohttp.ClientSession() as session: + async with session.get(full_url, headers=headers) as response: + response_data = await response.json() + + id_download = response_data.get("id") + id_model = response_data.get("modelId") + image_url = response_data.get("images", [])[0].get("url") + image_type = response_data.get("images", [])[0].get("type") + + # Get more model information asynchronously + info = await get_model_info(id_model) + creator = info.get("creator").get("username") if info else "Unknown" + + metadata_current_path = os.path.join(metadata_path, "models", str(id_model)) + if os.path.exists(metadata_current_path): + shutil.rmtree(metadata_current_path) + await loop.run_in_executor(None, os.makedirs, metadata_current_path) + + image_name = '' + if image_url: + image_ext = os.path.splitext(image_url)[1] + image_name = str(id_model) + (image_ext if image_type == "image" else ".mp4") + image_path = os.path.join(metadata_current_path, image_name) + + if not os.path.exists(image_path): + async with session.get(image_url) as image_response: + image_data = await image_response.read() + await loop.run_in_executor(None, lambda: open(image_path, 'wb').write(image_data)) + + # Create JSON with model information + data = { + "id": id_model, + "name": response_data.get("model").get("name"), + "size": total_size, + "author": creator, + "url": response_data.get("downloadUrl"), + "image_type": image_type, + "image_name": image_name, + "model_type": response_data.get("model").get("type"), + "model_file_name": file_name, + "base_model": response_data.get("baseModel"), + "nsfw": response_data.get("model").get("nsfw"), + "hash": h + } + + # Write JSON to file + await loop.run_in_executor(None, createJSONInfo, metadata_current_path, data, total_size, image_name) + + # Update the temporary file with the processed model + with open(processed_models_file, "a") as f: + f.write(file_path + "\n") + + except Exception as e: + print(f"Error hashing {file_path}: {e}") + + # Increment the processed models count + models_currently_done += 1 + downloads[0]['downloaded_size'] = models_currently_done + downloads[0]['progress'] = (models_currently_done / model_found) * 100 + + downloads[0]['status'] = "ended" + downloads[0]['progress'] = 100 + + # Final response + return web.json_response({'status': 'building complete'}, status=200) + + except Exception as e: + downloads[0]['status'] = f"error" + return web.json_response({'error': str(e)}, status=500) + + # Start the coroutine as a task + task = server.PromptServer.instance.loop.create_task(coroutine()) + + # Immediate response to the client + return web.Response(text=json.dumps({'status': 'building'}), content_type='application/json') + WEB_DIRECTORY = "./app" \ No newline at end of file diff --git a/app/.DS_Store b/app/.DS_Store new file mode 100644 index 0000000..b530ecb Binary files /dev/null and b/app/.DS_Store differ diff --git a/app/css/base_style_sb.css b/app/css/base_style_sb.css index 3443a86..000f388 100644 --- a/app/css/base_style_sb.css +++ b/app/css/base_style_sb.css @@ -5,6 +5,11 @@ z-index: 100 !important; } + :root { + --n-sidebar-width: 30px; + --sidebar-menu-item-width: 24px; + --main-menubar-height:35px; + } .sidebar { position: absolute; @@ -21,11 +26,16 @@ user-select: none; box-sizing: content-box; + + display: flex; + flex-direction: row; + justify-content: space-around; + align-items: flex-start; } sidebar * { - box-sizing: revert; /* o qualsiasi valore tu voglia usare */ + box-sizing: revert; } .sidebar ul { @@ -45,6 +55,7 @@ cursor: pointer; user-select: none; color: var(--input-text); + margin-left: 4px; } .content_sidebar { @@ -56,20 +67,43 @@ float: left; backdrop-filter: blur(5px); width: calc(100% - 45px); + + + + + } .dragHandle { position: relative; float: left; - right: 0; + right: 5; top: 0; height: 100%; width: 5px; cursor: ew-resize; - background: rgb(62, 62, 62); - background: linear-gradient(90deg, rgb(62 62 62 / 46%) 0%, rgb(39 39 39 / 47%) 50%, rgb(28 28 28 / 31%) 100%); + background: rgb(119 111 111 / 0%); + /*background: linear-gradient(90deg, rgb(62 62 62 / 46%) 0%, rgb(39 39 39 / 47%) 50%, rgb(28 28 28 / 31%) 100%);*/ } + .dragHandleTB { + background: #ffffff00; + height: 10px; + width: calc(100% - 10px); + position: absolute; + cursor: n-resize; + + } + + #dragHandleV { + bottom: -5px; + } + + #dragHandleT { + + top: 15px; + z-index: 999999; + } #searchInputSB { box-sizing: border-box; width: 100%; @@ -88,10 +122,12 @@ width: calc(100% - 75px); margin-top: 5px; margin-bottom: 10px; - margin-left: 44px; + margin-left: var(--n-sidebar-width); z-index: 400; - + width: calc(100% - 60px); + margin-top: 5px; + left: 11px; } .clearIcon, .searchCategoryIcon, .clearIconCustomCategory, .searchCategoryIconCustomCategory, .clearIconCustomTemplate, .searchTemplateIconCustomTemplate, .clearIconCustomWorkflow, .searchWorkflowIconCustomWorkflow { @@ -99,12 +135,21 @@ padding: 5px; right: 30px; color: var(--input-text); - font-size: x-large; + font-size: larger; cursor: pointer; opacity: 0.5; user-select: none; - line-height: 1.4; + line-height: 1.6; top: 0; + + + display: flex; + align-content: center; + justify-content: center; + align-items: center; + height: -moz-available; /* WebKit-based browsers will ignore this. */ + height: -webkit-fill-available; /* Mozilla-based browsers will ignore this. */ + height: fill-available; } @@ -112,21 +157,31 @@ opacity: 1.0; } .clearIcon,.clearIconCustomTemplate,.clearIconCustomWorkflow { - line-height: 1.25 !important + line-height: 1.6 !important } .searchCategoryIcon, .searchCategoryIconCustomCategory, .searchTemplateIconCustomTemplate, .searchWorkflowIconCustomWorkflow { right: 0px; - padding: 3px; + padding: 6px; font-size: larger; background: var(--border-color); color: var(--drag-text); margin: 4px; border-radius: 5px; - width: 18px; - text-align: center; - top: 0; - box-sizing: content-box; + + /* text-align: center; */ + height: -moz-available; /* WebKit-based browsers will ignore this. */ + height: -webkit-fill-available; /* Mozilla-based browsers will ignore this. */ + height: fill-available; + + /* top: 0; */ + /* box-sizing: content-box; */ + display: flex; + align-content: center; + justify-content: center; + line-height: 1; + align-items: center; + } @@ -200,29 +255,46 @@ width: 100%; text-overflow: ellipsis; - overflow: hidden; - - - + overflow: hidden; } + /* Firefox-specific styles */ + @-moz-document url-prefix() { + .content_sidebar,#settingsContainer { + scrollbar-width: thin; /* Scrollbar thickness */ + scrollbar-color: hsla(0, 0%, 50%, .8) transparent; /* Thumb and track colors */ + } + #settingsContainer::-moz-scrollbar-thumb:hover, + .content_sidebar::-moz-scrollbar-thumb:hover { + background-color: rgba(150, 150, 150, 1); /* Thumb color on hover */ + } + + #settingsContainer::-moz-scrollbar-thumb, + .content_sidebar::-moz-scrollbar-thumb { + background-color: hsla(0, 0%, 50%, .8); /* Thumb color */ + border-radius: 9px; + } + } + #settingsContainer::-webkit-scrollbar, .content_sidebar::-webkit-scrollbar { margin-top: 0.5rem; height: 1rem; width: .5rem; top: 10px; } - + #settingsContainer::-webkit-scrollbar:horizontal, .content_sidebar::-webkit-scrollbar:horizontal { height: .5rem; width: 1rem } + #settingsContainer::-webkit-scrollbar-track, .content_sidebar::-webkit-scrollbar-track { - background-color: transparent; + background-color: transparent !important; border-radius: 9999px } + #settingsContainer::-webkit-scrollbar-thumb, .content_sidebar::-webkit-scrollbar-thumb { --tw-border-opacity: 1; background-color: hsla(0, 0%, 50%, .8); @@ -231,6 +303,7 @@ border-width: 1px } + #settingsContainer::-webkit-scrollbar-thumb:hover, .content_sidebar::-webkit-scrollbar-thumb:hover { --tw-bg-opacity: 1; @@ -247,19 +320,32 @@ float: unset; cursor: pointer; user-select: none; - padding: 5px; + padding: 5px 0 5px; font-size: 20px; - background-color: var(--comfy-input-bg); + /*background-color: var(--comfy-input-bg);*/ opacity: 0.7; - border-radius: 5px; + /*border-radius: 5px;*/ margin-top:2px; box-sizing: content-box; + + display: flex; + justify-content: center; + align-items: center; + height: var(--sidebar-menu-item-width); + /*border-right: 2px solid var(--content-bg);*/ + border-color: var(--content-bg); + } .view_button:hover { opacity: 1.0; } + + .sb_button_active { + border-color: var(--content-hover-fg); + color: var(--content-hover-fg); + } #sidebar_views { position: relative; float: left; @@ -269,16 +355,16 @@ user-select: none; padding: 0px 2px 2px 2px; font-size: 20px; - border-bottom-right-radius: 5px; - border-top-right-radius: 5px; + height: 100%; - width: 30px; + width: var(--n-sidebar-width); border: 0; border-left: 2px rgb(from var(--comfy-menu-bg) r g b / 100%); border-style: solid; padding-bottom: 0px; backdrop-filter: blur(16px) !important; background: rgb(from var(--comfy-menu-bg) r g b / 100%); + z-index: 99999; } @@ -364,11 +450,14 @@ #sidebarBookmarks .sidebarItem { - margin-left: 10px; - margin-right: 29px; + margin-left: 0px; + margin-right: 0px; } - + #sidebarBookmarks { + width: calc(100% - 16px); + margin-left: 3px; + } .sb_label { font-family: 'Open Sans', sans-serif; position: relative; @@ -380,14 +469,20 @@ padding-left: 6px; padding-right: 6px; display: block; + display: flex; width: calc(100% - 20px); text-align: center; user-select: none; cursor: pointer; + + align-items: center; + height: 28px; + justify-content: center; + padding-top: 1px; } .expand_node, .pin_node { - position: absolute; + position: absolute !important; right: 5px; background: transparent; border: 0; @@ -497,9 +592,9 @@ display: grid; grid-column-gap: 10px; - /* Spazio tra le colonne */ + width: 100%; - /* Imposta la larghezza della tabella al 100% del contenitore */ + } .sb_row { @@ -661,13 +756,16 @@ #switch_settings{ bottom: 5px; position: absolute; - width: 19px; + width: -moz-available; /* WebKit-based browsers will ignore this. */ + width: -webkit-fill-available; /* Mozilla-based browsers will ignore this. */ + width: fill-available; height: 20px; + border: 0 !important; line-height: 1; box-sizing: content-box; } - #sb_settingsDiv{ + #sb_settingsDiv,#sb_settingsDiv_export, #sb_settingsDiv_import { outline: 0; border: 0; border-radius: 6px; @@ -675,6 +773,7 @@ color: var(--input-text); box-shadow: inset 1px 1px 0px rgba(255, 255, 255, 0.05), inset -1px -1px 0px rgba(0, 0, 0, 0.5), 2px 2px 20px rgb(0, 0, 0); max-width: 800px; + width: 650px; box-sizing: border-box; font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; font-size: 0.8rem; @@ -687,9 +786,13 @@ text-transform: uppercase; font-weight: bold; } + #sb_settingsDiv_export, #sb_settingsDiv_import { + z-index: 1005; + width: 30vh; + } #sb_settingsDiv .setting{ height: 28px; - width: 450px; + width: 100%; margin-bottom: 7px; } #sb_settingsDiv .setting input[type="range"]{ @@ -702,8 +805,8 @@ color: var(--drag-text); padding: 5px; float: right; - width: 129px; - margin-right: 47px; + width: 60%; + margin-right: 0px; border-radius: 5px; border: solid 1px var(--descrip-text); } @@ -717,12 +820,16 @@ margin-right: 47px; border-radius: 5px; border: solid 1px var(--descrip-text); + margin-bottom: 20px; } - #sb_settingsDiv .setting input[type="checkbox"]{ + #sb_settingsDiv .setting input[type="checkbox"], #sb_settingsDiv_export .setting input[type="checkbox"], #sb_settingsDiv_import .setting input[type="checkbox"]{ width: 18px; height: 18px; } - #sb_settingsDiv .setting input{ + #sb_settingsDiv_export .setting, #sb_settingsDiv_import .setting{ + height: 36px; + } + #sb_settingsDiv .setting input, #sb_settingsDiv_export .setting input , #sb_settingsDiv_import .setting input{ float: right; } #sb_settingsDiv .setting label{ @@ -738,7 +845,12 @@ margin: 5px auto; } - #sb_settingsDiv h1{ + #sb_settingsDiv_export h1, #sb_settingsDiv_import h1{ + font-size: medium; + + } + + #sb_settingsDiv h1,#sb_settingsDiv_export h1, #sb_settingsDiv_import h1{ margin: 0 0 20px 0; padding: 0 0 5px 0; text-align: center; @@ -746,7 +858,7 @@ text-transform: uppercase; } - #sb_settingsDiv button{ + #settingsContainer button{ padding: 9px; /* position: absolute; */ @@ -761,9 +873,53 @@ text-transform: uppercase; font-weight: bold; } - #sb_settingsDiv button:hover{ + #settingsContainer button:hover{ filter: brightness(120%); } + #settingsContainer{ + height: 50vh; + overflow-x: hidden; + overflow-y: auto; + margin-right: -10px; + padding-right: 10px; + } + #sb_buttonsContainer { + display: grid; + grid-template-columns: repeat(2, 1fr); /* Due colonne, larghezza uguale */ + grid-template-rows: auto; /* Le righe si adattano all'altezza degli elementi */ + gap: 5px; /* Spaziatura tra i bottoni */ + width: 100%; /* Si adatta alla larghezza del contenitore padre */ + margin-bottom: 8px; + } + + #sb_buttonsContainer button, #sb_buttonsContainerCustomize button { + padding: 9px; + width: 100%; /* Il bottone si adatta alla colonna */ + background: var(--comfy-menu-bg); + color: var(--input-text); + border: 1px solid var(--border-color); + margin: 0 0 0px 0; + border-radius: 5px; + cursor: pointer; + text-transform: uppercase; + font-weight: bold; + box-sizing: border-box; /* Include il padding e il bordo nel calcolo della larghezza */ + } + + #sb_buttonsContainer #sb_runExportButton{ + width: 200%; + } + + + +#sb_buttonsContainerCustomize{ + display: grid; + grid-template-columns: repeat(3, 1fr); /* Due colonne, larghezza uguale */ + grid-template-rows: auto; /* Le righe si adattano all'altezza degli elementi */ + gap: 5px; /* Spaziatura tra i bottoni */ + width: 100%; /* Si adatta alla larghezza del contenitore padre */ + margin-bottom: 8px; +} #sb_settingsDiv i{ margin-left: 4px; @@ -772,6 +928,12 @@ font-size: larger; } + .remove-section-btn{ + padding: 5px; + position: static; + float: right; + right: 25px; +} /* MODALS */ .sb-modal-title{ position: absolute; @@ -818,11 +980,11 @@ - #switch_home,#switch_custom_view{ + /*#switch_home,#switch_custom_view{ width: 1.3rem; height: 1.3rem; - } + }*/ #switch_sidebar{ @@ -832,4 +994,353 @@ #sbModal{ display: none; z-index: 9999; + } + + .full_rounded{ + border-radius: 5px; + } + + .p-button { + overflow: visible !important; + +} + /*************/ + /*ALPHA: INGLOBE SIDEBAR */ + +.splitter-overlay{ + /*width: 486px !important;*/ + z-index: 100 !important; + + +} + + + +#official_button{ + display: flex; + flex-direction: column; + position: absolute; + height: auto; + bottom: 50%; + +} + +#official_button button{ + width: var(--n-sidebar-width); + height: var(--n-sidebar-width); +} + +#official_button i { +font-size: 20px !important; +} + +/* setup tooltips */ + +#openModalButton{ + z-index: 1000 !important; +} + + +[data-tooltip] { + --arrow-size: 5px; + position: relative; + /*z-index: 10;*/ + } + + /* Positioning and visibility settings of the tooltip */ + [data-tooltip]:before, + [data-tooltip]:after { + position: absolute; + visibility: hidden; + opacity: 0; + left: 50%; + bottom: calc(100% + var(--arrow-size)); + pointer-events: none; + transition: 0.2s; + will-change: transform; + } + + /* The actual tooltip with a dynamic width */ + [data-tooltip]:before { + content: attr(data-tooltip); + padding: 10px 18px; + min-width: 50px; + max-width: 350px; + width: max-content; + width: -moz-max-content; + border-radius: 6px; + font-size: 14px; + background-color: var(--border-color);; + background-image: linear-gradient(30deg, + rgba(59, 72, 80, 0.44), + rgba(59, 68, 75, 0.44), + rgba(60, 82, 88, 0.44)); + box-shadow: 0px 0px 24px rgba(0, 0, 0, 0.2); + color: #fff; + white-space: pre-wrap; + transform: translate(-50%, calc(0px - var(--arrow-size))) scale(0.5); + z-index: 10; + } + + /* Tooltip arrow */ + [data-tooltip]:after { + content: ''; + border-style: solid; + border-width: var(--arrow-size) var(--arrow-size) 0px var(--arrow-size); /* CSS triangle */ + border-color: var(--border-color) transparent transparent transparent; + transition-duration: 0s; /* If the mouse leaves the element, + the transition effects for the + tooltip arrow are "turned off" */ + transform-origin: top; /* Orientation setting for the + slide-down effect */ + transform: translateX(-50%) scaleY(0); + } + + /* Tooltip becomes visible at hover */ + [data-tooltip]:hover:before, + [data-tooltip]:hover:after { + visibility: visible; + opacity: 1; + } + /* Scales from 0.5 to 1 -> grow effect */ + [data-tooltip]:hover:before { + transition-delay: 0.8s; + transform: translate(-50%, calc(0px - var(--arrow-size))) scale(1); + } + /* + Arrow slide down effect only on mouseenter (NOT on mouseleave) + */ + [data-tooltip]:hover:after { + transition-delay: 0.8s; /* Starting after the grow effect */ + transition-duration: 0.2s; + transform: translateX(-50%) scaleY(1); + } + /* + That's it for the basic tooltip. + + If you want some adjustability + here are some orientation settings you can use: + */ + + /* LEFT */ + /* Tooltip + arrow */ + .left:before, + .left:after { + left: auto; + right: calc(100% + var(--arrow-size)); + bottom: 50%; + } + + /* Tooltip */ + .left:before { + transform: translate(calc(0px - var(--arrow-size)), 50%) scale(0.5); + } + .left:hover:before { + transform: translate(calc(0px - var(--arrow-size)), 50%) scale(1); + } + + /* Arrow */ + .left:after { + border-width: var(--arrow-size) 0px var(--arrow-size) var(--arrow-size); + border-color: transparent transparent transparent var(--border-color);; + transform-origin: left; + transform: translateY(50%) scaleX(0); + } + .left:hover:after { + transform: translateY(50%) scaleX(1); + } + + + + /* RIGHT */ + .right:before, + .right:after { + left: calc(100% + var(--arrow-size)); + bottom: 50%; + } + + .right:before { + transform: translate(var(--arrow-size), 50%) scale(0.5); + } + .right:hover:before { + transform: translate(var(--arrow-size), 50%) scale(1); + } + + .right:after { + border-width: var(--arrow-size) var(--arrow-size) var(--arrow-size) 0px; + border-color: transparent var(--border-color) transparent transparent; + transform-origin: right; + transform: translateY(50%) scaleX(0); + } + .right:hover:after { + transform: translateY(50%) scaleX(1); + } + + + + /* BOTTOM */ + [data-tooltip-location="bottom"]:before, + [data-tooltip-location="bottom"]:after { + top: calc(100% + var(--arrow-size)); + bottom: auto; + } + + [data-tooltip-location="bottom"]:before { + transform: translate(-50%, var(--arrow-size)) scale(0.5); + } + [data-tooltip-location="bottom"]:hover:before { + transform: translate(-50%, var(--arrow-size)) scale(1); + } + + [data-tooltip-location="bottom"]:after { + border-width: 0px var(--arrow-size) var(--arrow-size) var(--arrow-size); + border-color: transparent transparent var(--border-color) transparent; + transform-origin: bottom; + } + + + + /* Settings that make the pen look nicer */ + + @keyframes moveFocus { + 0% { background-position: 0% 100% } + 100% { background-position: 100% 0% } + } + + + + @media (max-height: 450px) { + main { + margin: 2rem 0; + } + } + + @media (max-width: 800px) { + html { + font-size: 0.9em; + } + } + + + + + + + + + + + /*SETTINGS TAB&CUSTOMIZE*/ + + .n-sb-tab-container { + margin-bottom: 1rem; + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-template-rows: auto; + gap: 5px; + width: 100%; + margin-bottom: 8px; + } + + .n-sb-tab { + padding: 10px 20px; + cursor: pointer; + margin-right: 5px; + border-bottom: 3px solid rgb(255, 255, 255); + text-align: center; + } + + .n-sb-tab.n-sb-active { + + border-color: #0075ff; + } + + + .n-sb-tab-content { + display: none; + height: 50vh; + overflow-y: auto; + } + +.n-sb-active { + display: block !important; + } + + .sb_hidden { + display: none; + } + + .n-sb-tab-container .n-sb-tab { + padding: 9px; + width: 100%; + color: var(--input-text); + margin: 0 0 0px 0; + cursor: pointer; + text-transform: uppercase; + font-weight: bold; + box-sizing: border-box; + font-size: medium; + } + + .n-sb-section { + display: flex; + flex-direction: column; + margin-bottom: 20px; + padding: 10px; + + border-radius: 5px; + background-color: var(--comfy-menu-bg); + } + .n-sb-section-header { + display: flex; + align-items: center; + margin-bottom: 10px; + } + .n-sb-section-header input { + margin-left: 10px; + height: 23px; + } + .n-sb-section-panels { + + margin-bottom: 10px; + } + .n-sb-section-panels .add-panel-btn{ + padding: 5px; + float: right; + } + .draggable { + cursor: move; + } + .panel-list { + list-style-type: none; + padding: 0; + } + .panel-list li { + margin-bottom: 5px; + margin-right: 5px; + padding: 5px !important; + background: var(--bg-color); + } + .panel-select{ + margin-left: 9px !important; + width: 70% !important; + float: left !important; + } + .remove-panel { + bottom: 0; + float: right; + background: var(--comfy-menu-bg); + color: var(--input-text); + border: 1px solid var(--border-color); + border-radius: 5px; + cursor: pointer; + text-transform: uppercase; + font-weight: bold; + } + .n-sb-section-header label { + padding: 0 0 0px 10px; + } + .icon-input{ + width: 20px; + text-align: center; } \ No newline at end of file diff --git a/app/html/sidebar.html b/app/html/sidebar.html index 5ae06b1..58baf97 100644 --- a/app/html/sidebar.html +++ b/app/html/sidebar.html @@ -1,28 +1,23 @@ +