diff --git a/.gitignore b/.gitignore index 6c8f109..c777e13 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ -/multi_exodus/__pycache__ +__pycache__/ +*.pyc Multi Exodus.iss diff --git a/README.md b/README.md index c68eb23..0db9f0f 100644 --- a/README.md +++ b/README.md @@ -53,12 +53,22 @@ MultiExodus leverages **PIL for image handling**, **OS-level commands for wallet * βœ… **Safety Prompts** β€” Confirmation dialogs for sensitive actions like deleting or overwriting wallets to prevent accidental loss. * ⭐ **Auto-Updater** β€” Automatically checks for new versions, verifies integrity with SHA-256, downloads updates securely, and installs them with one click. * πŸ–₯ **Discord RPC** β€” Show your friends that you're using MultiExodus. +* 🎨 **Color Customization** β€” Full theme system with built-in presets (Midnight, Nord, Dracula, Solarized, Ocean, Blood), a per-color picker with **live preview**, and theme **import/export** as JSON. +* 🌐 **Modern Web-App** β€” A sleek local web interface (served on `localhost` only) that reuses the same wallet logic and mirrors your desktop theme. Launch it from Settings β†’ Tools or the tray. +* ⏳ **Auto-Lock** β€” Optionally re-encrypts your wallet data and returns to the password screen after a configurable period of inactivity (only when encryption is enabled). +* πŸ“¦ **Encrypted Backups** β€” Export all (or selected) wallets to a single password-protected `.mexbak` archive and restore them anywhere. AES-256, fully local. +* 🟒 **Active Wallet Indicator** β€” Clearly highlights which saved wallet is currently loaded in Exodus. +* πŸ“ **Wallet Groups** β€” Organize wallets into buckets (Trading, Cold, DeFi…) and filter by group. +* 🧾 **Local Action Journal** β€” A private, offline audit trail of every load/delete/import/backup/lock action. Nothing is ever uploaded. +* β˜‘οΈ **Bulk Actions** β€” Multi-select wallets to delete, group, or export them all at once. +* 🧬 **Duplicate Detection** β€” Finds wallets whose data is byte-for-byte identical (the same wallet copied twice). --- ## πŸ•’ Todo (Planned Features) -* πŸŽ‰ Add **Color** Customization β€” Maybe next Update -* 🌐 Create a modern **Web-App** (Upgrade the GUI) β€” Unknown +* βœ… Add **Color** Customization β€” **Done** +* βœ… Create a modern **Web-App** (Upgrade the GUI) β€” **Done** +* πŸ—“οΈ Scheduled per-group backups β€” Maybe next Update > πŸ’‘ View the [`TODO`](https://github.com/SwezyDev/MultiExodus/blob/main/TODO) file in this repository. --- @@ -72,6 +82,9 @@ MultiExodus leverages **PIL for image handling**, **OS-level commands for wallet * πŸ“‚ **F3** β€” Show Data Folder * πŸ›œ **F4** β€” Check for Updates * πŸ” **F5** β€” Reload Wallets +* 🧾 **F6** β€” Show Action Journal +* 🧬 **F7** β€” Find Duplicate Wallets +* πŸ” **CTRL+L** β€” Lock Now (re-encrypt & show password screen) * πŸ‘‹ **M** β€” Show Message of Today * πŸ”’ **P** β€” Encrypt Wallet Data * βž• **+** β€” Add a Wallet @@ -145,19 +158,30 @@ MultiExodus leverages **PIL for image handling**, **OS-level commands for wallet β”‚ └─ title.png βž” Standard wallet preview picture β”œβ”€ multi_exodus/ βž” Scripts that are required to run the Application β”‚ β”œβ”€ __init__.py βž” Exposes main -β”‚ β”œβ”€ app.py βž” Main app window and event loop -β”‚ β”œβ”€ constants.py βž” Defines wallet and Exodus directory paths +β”‚ β”œβ”€ app.py βž” Main app window, event loop, auto-lock & live theming +β”‚ β”œβ”€ backup.py βž” Encrypted .mexbak backup/restore +β”‚ β”œβ”€ constants.py βž” Defines wallet and Exodus directory paths + config keys β”‚ β”œβ”€ dialogs.py βž” Custom input dialog for user prompts +β”‚ β”œβ”€ duplicates.py βž” Detects wallets with identical data +β”‚ β”œβ”€ groups.py βž” Per-wallet group management β”‚ β”œβ”€ info.py βž” Custom Information box +β”‚ β”œβ”€ journal.py βž” Local, offline action journal β”‚ β”œβ”€ motd.py βž” Custom Message of the Day box β”‚ β”œβ”€ protection.py βž” Encryption/Decryption Functions to protect Wallet Data β”‚ β”œβ”€ rpc.py βž” Discord RPC handling -β”‚ β”œβ”€ settings.py βž” Custom Settings box to configure MultiExodus +β”‚ β”œβ”€ settings.py βž” Settings box: theme editor, auto-lock, backups, tools +β”‚ β”œβ”€ theme.py βž” Central color system (presets + custom palettes) β”‚ β”œβ”€ toast.py βž” Windows Toast Notification handle β”‚ β”œβ”€ tray.py βž” Tray Icon on Taskbar to control MultiExodus -β”‚ β”œβ”€ ui.py βž” Builds the scrollable wallet interface and handles UI interactions +β”‚ β”œβ”€ ui.py βž” Scrollable wallet interface (grid/list, groups, bulk select) β”‚ β”œβ”€ update.py βž” Auto-Update Helper Functions for MultiExodus -β”‚ └─ wallet_manager.py βž” Manage, edit, and load Exodus wallets with backup support +β”‚ β”œβ”€ views.py βž” Journal & duplicate-finder popups +β”‚ β”œβ”€ wallet_manager.py βž” Manage, edit, and load Exodus wallets with backup support +β”‚ └─ webapp/ βž” Local web-app (localhost-only) +β”‚ β”œβ”€ __init__.py βž” launch() entry point +β”‚ β”œβ”€ api.py βž” Headless JSON API over the wallet logic +β”‚ β”œβ”€ server.py βž” Tiny http.server backend (token-protected actions) +β”‚ └─ static/ βž” Self-contained SPA (index.html, styles.css, app.js) β”œβ”€ .gitignore βž” Specifies files and directories Git should ignore β”œβ”€ LICENSE βž” License file β”œβ”€ MultiExodus.sha256 βž” SHA256 file for Auto-Updater diff --git a/TODO b/TODO index 1b6a57b..3a29c9e 100644 --- a/TODO +++ b/TODO @@ -1,2 +1,16 @@ -- Add Color Customization ⚠️ -- Create a modern Web-App (Upgrade the GUI) ❓️ +- Add Color Customization βœ… (theme system: presets, per-color picker, live apply, import/export) +- Create a modern Web-App (Upgrade the GUI) βœ… (local web app in multi_exodus/webapp β€” localhost only) + +Newly added: +- Auto-Lock after inactivity (re-encrypts wallet data) βœ… +- Encrypted backup / restore (.mexbak archives) βœ… +- Active wallet indicator (shows which wallet is loaded in Exodus) βœ… +- Wallet groups βœ… +- Local action journal βœ… +- Bulk / multi-select actions βœ… +- Duplicate wallet detection βœ… + +Ideas for later: +- Per-group backups on a schedule +- Wallet folder/subgroup nesting +- Search history & saved filters diff --git a/multi_exodus/app.py b/multi_exodus/app.py index ae7fdc5..2e23a02 100644 --- a/multi_exodus/app.py +++ b/multi_exodus/app.py @@ -1,4 +1,4 @@ -from . import wallet_manager, ui, info, settings, update, motd, tray, constants, rpc, protection # import necessary modules +from . import wallet_manager, ui, info, settings, update, motd, tray, constants, rpc, protection, theme, views, journal # import necessary modules from collections import defaultdict # for defaultdict from .toast import show_toast # for showing toast notifications from datetime import datetime # for date and time handling @@ -12,6 +12,60 @@ import os # for operating system interactions import re # for regular expressions +_last_activity = time.time() # timestamp of the last user interaction (for auto-lock) +_idle_stop = threading.Event() # event to stop the idle monitor thread +_idle_thread = None # holds the idle monitor thread +_locked = False # whether the app is currently locked (showing the password screen) + +def mark_activity(*_args): # record user activity to reset the auto-lock idle timer + global _last_activity # use the module-level timestamp + _last_activity = time.time() # remember when the user last did something + +def start_idle_monitor(root): # start (or restart) the background auto-lock monitor + global _idle_thread # use the module-level thread handle + if not protection.get_session_password(): # auto-lock only makes sense when wallets are encrypted this session + return # nothing to protect -> no monitor + _idle_stop.clear() # allow the monitor to run + _idle_thread = threading.Thread(target=_idle_monitor, args=(root,), daemon=True) # create the monitor thread + _idle_thread.start() # start it + +def _idle_monitor(root): # background loop that locks the app after a period of inactivity + while not _idle_stop.is_set(): # keep running until asked to stop + time.sleep(2) # check a couple of times per few seconds + config = settings.read_config() # read the latest settings each tick + if not config.get("autolock_enabled", False): # if auto-lock is disabled + continue # do nothing this tick + if _locked: # if already locked + continue # nothing to do + if not protection.get_session_password(): # if we no longer hold the session password + continue # cannot re-encrypt -> skip + minutes = config.get("autolock_minutes", 5) # configured idle timeout in minutes + try: # guard against bad values in settings + minutes = max(1, int(minutes)) # at least one minute + except Exception: # if the value is invalid + minutes = 5 # fall back to five minutes + if (time.time() - _last_activity) >= minutes * 60: # if the idle threshold was reached + root.after(0, lock_now, root) # trigger the lock on the UI thread + return # this monitor is done; a new one starts after unlock + +def lock_now(root): # re-encrypt wallet data and show the password screen + global _locked # use the module-level lock flag + password = protection.get_session_password() # the password captured this session + if not password or _locked: # cannot lock without a password / already locked + return # nothing to do + _idle_stop.set() # stop the idle monitor while locking + ok = protection.reencrypt(password) # re-encrypt all wallet files on disk + if not ok: # if re-encryption failed + return # stay unlocked rather than lose access + journal.log("lock", detail="auto-lock" ) # record the lock event + _locked = True # mark the app as locked + try: # tear down the current UI safely + for child in root.winfo_children(): # walk every top-level child widget + child.destroy() # remove it + except Exception: # ignore teardown errors + pass # best-effort + decrypt_app(None) # show the decrypt / password screen again + title_stop_event = threading.Event() # event to signal title updater thread to stop title_thread = None # global variable to hold the title updater thread title_lock = threading.Lock() # make thread start thread-safe @@ -210,12 +264,15 @@ def bind_keybinds(root, first_wallet): # function to bind keybinds to the root w config = settings.read_config() # read settings from settings.json root.bind("", lambda e: root.quit()) # bind escape key to quit the app - root.bind("", lambda e: info.InfoPopup(root, title="Multi Exodus Information", text=info_text, text_color="#FFFFFF", fg_color="#202020", scroll_fg="#202020", scroll_bc="#414141")) # bind F1 key to show info popup - root.bind("", lambda e: settings.SettingsPopup(root, title="Multi Exodus Settings", text_color="#FFFFFF", fg_color="#202020", scroll_fg="#202020", scroll_bc="#414141")) # bind F2 key to open settings popup (not implemented yet) + root.bind("", lambda e: info.InfoPopup(root, title="Multi Exodus Information", text=info_text)) # bind F1 key to show info popup + root.bind("", lambda e: settings.SettingsPopup(root, title="Multi Exodus Settings")) # bind F2 key to open settings popup root.bind("", lambda e: wallet_manager.open_data_location()) # bind F3 key to open data location in file explorer root.bind("", lambda e: update.check_updates(msg_box=True, config=config)) # bind F4 to check for updates root.bind("", lambda e: ui.rebuild(root, extra=False)) # bind F5 key to refresh the wallets ui - root.bind("m", lambda e: motd.MotdPopup(root, title="Message of the Day", text_color="#FFFFFF", fg_color="#202020", scroll_fg="#202020", scroll_bc="#414141")) # bind m key to show message of the day popup + root.bind("", lambda e: views.JournalPopup(root)) # bind F6 key to open the action journal + root.bind("", lambda e: views.DuplicatesPopup(root)) # bind F7 key to open the duplicate finder + root.bind("", lambda e: lock_now(root)) # bind Ctrl+L to lock the app now + root.bind("m", lambda e: motd.MotdPopup(root, title="Message of the Day")) # bind m key to show message of the day popup root.bind("p", lambda e: ui.encrypt_now()) # bind p key to open encryption settings root.bind("+", lambda e: wallet_manager.add_wallet(root, lambda r=root: ui.build_wallets_ui(root, *wallet_manager.detect_wallets()), config.get("show_toasts", True))) # bind + key to add a new wallet root.bind("-", lambda e: wallet_manager.delete_wallet(first_wallet, ui.rebuild(root), config.get("show_toasts", True))) # bind - key to delete a wallet @@ -225,16 +282,16 @@ def bind_keybinds(root, first_wallet): # function to bind keybinds to the root w def main(): # main function to start the application global root # use the global root variable - root = customtkinter.CTk(fg_color="#202020") # create the main window + root = customtkinter.CTk(fg_color=theme.c("bg")) # create the main window center_me(root, 1375, 700) # center the window root.resizable(False, False) # disable resizing root.iconbitmap(constants.APP_ICON) # set window icon root.title("MultiExodus Loading...") # set pre window title - pre_frame = customtkinter.CTkFrame(root, width=1375, height=700, fg_color="#202020", corner_radius=0) # create pre frame + pre_frame = customtkinter.CTkFrame(root, width=1375, height=700, fg_color=theme.c("bg"), corner_radius=0) # create pre frame pre_frame.pack(fill="both", expand=True) # pack pre frame - loading = customtkinter.CTkLabel(pre_frame, text="Loading MultiExodus...", fg_color="#202020", text_color="#FFFFFF", font=("Segoe UI", 32), bg_color="#202020") # create loading label + loading = customtkinter.CTkLabel(pre_frame, text="Loading MultiExodus...", fg_color=theme.c("bg"), text_color=theme.c("text"), font=("Segoe UI", 32), bg_color=theme.c("bg")) # create loading label loading.place(relx=0.5, rely=0.5, anchor="center") # place loading label in center animation(loading, root) # start loading animation @@ -245,6 +302,14 @@ def main(): # main function to start the application root.mainloop() # start the main event loop +def apply_theme_live(root): # re-apply the current theme to the running app without a restart + try: # reconfiguring must never crash the app + theme.load(force=True) # reload the palette from disk + root.configure(fg_color=theme.c("bg")) # recolor the main window background + ui.rebuild(root, extra=False) # rebuild the wallet UI so every widget picks up the new colors + except Exception: # ignore any reconfiguration errors + pass # best-effort live re-theme + def decrypt_app(pre_frame): # function to decrypt wallets on app startup try: # attempt to destroy pre frame pre_frame.destroy() # destroy pre frame @@ -255,20 +320,20 @@ def decrypt_app(pre_frame): # function to decrypt wallets on app startup app_icon_image = Image.open(constants.APP_ICON) # load image using pil multi_exodus_pic = customtkinter.CTkImage(light_image=app_icon_image, dark_image=app_icon_image, size=(100, 100)) # load MultiExodus logo - - multi_exodus_label = customtkinter.CTkLabel(root, image=multi_exodus_pic, text="", fg_color="#202020", bg_color="#202020") # create label for logo + + multi_exodus_label = customtkinter.CTkLabel(root, image=multi_exodus_pic, text="", fg_color=theme.c("bg"), bg_color=theme.c("bg")) # create label for logo multi_exodus_label.place(relx=0.5, rely=0.3, anchor="center") # place logo label - welcome_label = customtkinter.CTkLabel(root, text="Welcome back", fg_color="#202020", text_color="#FFFFFF", font=("Segoe UI", 24), bg_color="#202020") # create welcome label + welcome_label = customtkinter.CTkLabel(root, text="Welcome back", fg_color=theme.c("bg"), text_color=theme.c("text"), font=("Segoe UI", 24), bg_color=theme.c("bg")) # create welcome label welcome_label.place(relx=0.5, rely=0.4, anchor="center") # place welcome label - info_label = customtkinter.CTkLabel(root, text="Enter your password to continue", fg_color="#202020", text_color="#BEBEBE", font=("Segoe UI", 14), bg_color="#202020") # create info label + info_label = customtkinter.CTkLabel(root, text="Enter your password to continue", fg_color=theme.c("bg"), text_color=theme.c("text_dim"), font=("Segoe UI", 14), bg_color=theme.c("bg")) # create info label info_label.place(relx=0.5, rely=0.46, anchor="center") # place info label - password_entry = customtkinter.CTkEntry(root, width=304, height=35, fg_color="#414141", border_color="#414141", border_width=0.6, text_color="#FFFFFF", font=("Segoe UI", 14), show="*", placeholder_text="Type your password") # create password entry + password_entry = customtkinter.CTkEntry(root, width=304, height=35, fg_color=theme.c("surface"), border_color=theme.c("border"), border_width=0.6, text_color=theme.c("text"), font=("Segoe UI", 14), show="*", placeholder_text="Type your password") # create password entry password_entry.place(relx=0.5, rely=0.52, anchor="center") # place password entry - show_password_eye = customtkinter.CTkButton(root, width=30, height=32, fg_color="#414141", hover=False, text_color="#FFFFFF", corner_radius=0, font=("Segoe UI", 14), text="πŸ‘", command=lambda: toggle_vis(password_entry)) # create show password button + show_password_eye = customtkinter.CTkButton(root, width=30, height=32, fg_color=theme.c("surface"), hover=False, text_color=theme.c("text"), corner_radius=0, font=("Segoe UI", 14), text="πŸ‘", command=lambda: toggle_vis(password_entry)) # create show password button show_password_eye.place(relx=0.599, rely=0.52, anchor="center") # place show password button def toggle_vis(entry): # function to toggle password visibility @@ -279,7 +344,7 @@ def toggle_vis(entry): # function to toggle password visibility entry.configure(show="") # show the password show_password_eye.configure(text="πŸ™ˆ") # update button text - forgot_password_label = customtkinter.CTkLabel(root, text="I lost my password", fg_color="#202020", text_color="#424242", font=("Segoe UI", 12, "underline"), cursor="hand2", bg_color="#202020") # create forgot password label + forgot_password_label = customtkinter.CTkLabel(root, text="I lost my password", fg_color=theme.c("bg"), text_color=theme.c("text_faint"), font=("Segoe UI", 12, "underline"), cursor="hand2", bg_color=theme.c("bg")) # create forgot password label forgot_password_label.place(relx=0.5, rely=0.57, anchor="center") # place forgot password label forgot_password_label.bind("", lambda e: protection.lost_password()) # bind click event to lost password handler @@ -290,16 +355,18 @@ def decrypt_now(): # function to decrypt wallets when button is clicked root.after(0, create_app, root, pre_frame) # switch back to UI thread else: # if decryption failed password_entry.delete(0, 'end') # clear password entry - password_entry.configure(border_color="#FF0000") # highlight password entry in red to indicate error + password_entry.configure(border_color=theme.c("danger")) # highlight password entry in red to indicate error password_entry.bind("", lambda e: decrypt_now()) # bind enter key to decrypt function - password_entry.bind("", lambda e: password_entry.configure(border_color="#414141")) # reset border color on focus + password_entry.bind("", lambda e: password_entry.configure(border_color=theme.c("border"))) # reset border color on focus - login_button = customtkinter.CTkButton(root, width=150, height=35, fg_color="#414141", hover_color="#2C2C2C", text_color="#FFFFFF", font=("Segoe UI", 16), text="Decrypt Wallets", command=decrypt_now) # create decrypt button + login_button = customtkinter.CTkButton(root, width=150, height=35, fg_color=theme.c("surface"), hover_color=theme.c("bg_alt"), text_color=theme.c("text"), font=("Segoe UI", 16), text="Decrypt Wallets", command=decrypt_now) # create decrypt button login_button.place(relx=0.5, rely=0.62, anchor="center") # place decrypt button def create_app(root, pre_frame): # function to create and run the MultiExodus application + global _locked # use the module-level lock flag + _locked = False # the app is now unlocked try: # attempt to destroy pre frame pre_frame.destroy() # destroy pre frame except: # ignore exceptions @@ -311,7 +378,7 @@ def create_app(root, pre_frame): # function to create and run the MultiExodus ap config = settings.read_config() # read settings from settings.json - tray.create(root, first_wallet) # create the system tray icon and menu + tray.restart_tray(root, first_wallet) # create/refresh the tray icon (safe to call again after an auto-lock unlock) threading.Thread(target=title_updater, args=(root,), daemon=True).start() # start title updater thread @@ -322,6 +389,11 @@ def create_app(root, pre_frame): # function to create and run the MultiExodus ap bind_keybinds(root, first_wallet) # bind keybinds + for seq in ("", "", " + + + + + `; + + card.addEventListener("click", (ev) => { + const act = ev.target.getAttribute("data-act"); + if (act) handleAction(act, w); + }); + grid.appendChild(card); + } +} + +// ---- actions -------------------------------------------------------------- +async function handleAction(act, w) { + if (act === "load") { + if (!confirm(`Load "${w.name}" into Exodus?\nThis replaces the currently active Exodus wallet.`)) return; + const r = await apiPost("/api/load", { name: w.name }); + toast(r.message || (r.ok ? "Loaded" : "Failed"), r.ok ? "ok" : "err"); + await reload(); + } else if (act === "delete") { + if (!confirm(`Delete wallet "${w.name}"? This cannot be undone.`)) return; + const r = await apiPost("/api/delete", { name: w.name }); + toast(r.message || (r.ok ? "Deleted" : "Failed"), r.ok ? "ok" : "err"); + await reload(); + } else if (act === "star") { + await apiPost("/api/star", { name: w.name }); + await reload(); + } else if (act === "group") { + const g = prompt(`Group for "${w.name}" (empty = Ungrouped):`, w.group === "Ungrouped" ? "" : w.group); + if (g === null) return; + await apiPost("/api/group", { name: w.name, group: g }); + toast("Group updated", "ok"); + await reload(); + } else if (act === "note") { + const n = prompt(`Note for "${w.name}":`, w.note || ""); + if (n === null) return; + await apiPost("/api/note", { name: w.name, note: n }); + await reload(); + } else if (act === "tags") { + const t = prompt(`Tags for "${w.name}" (comma separated, max 5):`, (w.tags || []).join(", ")); + if (t === null) return; + await apiPost("/api/tags", { name: w.name, tags: t.split(",") }); + await reload(); + } else if (act === "name") { + // name editing is desktop-only for safety (renames the wallet folder) + toast("Rename wallets from the desktop app.", ""); + } +} + +// ---- data load ------------------------------------------------------------ +async function reload() { // refresh wallets + summary + group filter + try { + const [wallets, summary, groups] = await Promise.all([ + apiGet("/api/wallets"), apiGet("/api/summary"), apiGet("/api/groups"), + ]); + WALLETS = wallets.wallets || []; + $("summary").textContent = + `${summary.count} wallet(s)` + + (summary.active ? ` Β· active: ${summary.active}` : " Β· none active") + + (summary.starred ? ` Β· ${summary.starred} β˜…` : ""); + // rebuild the group filter, keeping the current selection if still valid + const sel = $("groupFilter"); + const current = GROUP; + sel.innerHTML = ""; + for (const g of groups.groups) { + const opt = document.createElement("option"); + opt.value = g; opt.textContent = g === "All" ? "All groups" : g; + sel.appendChild(opt); + } + GROUP = groups.groups.includes(current) ? current : "All"; + sel.value = GROUP; + render(); + } catch (e) { + toast("Failed to load data", "err"); + } +} + +// ---- modals --------------------------------------------------------------- +function openModal(title, html) { + $("modalTitle").textContent = title; + $("modalBody").innerHTML = html; + $("modal").hidden = false; +} +function closeModal() { $("modal").hidden = true; } + +async function showJournal() { + const data = await apiGet("/api/journal"); + const rows = (data.entries || []).map((e) => + `
${escapeHtml(e.time)}   ${escapeHtml(e.action)}` + + (e.target ? ` β†’ ${escapeHtml(e.target)}` : "") + + (e.detail ? ` (${escapeHtml(e.detail)})` : "") + + `
`).join(""); + openModal("🧾 Action Journal", rows || "

No actions recorded yet.

"); +} + +async function showDuplicates() { + const data = await apiGet("/api/duplicates"); + const groups = data.groups || []; + if (!groups.length) { openModal("🧬 Duplicate Wallets", "

βœ… No duplicate wallets found.

"); return; } + const html = groups.map((g, i) => + `

Group ${i + 1} β€” ${g.length} identical wallets

` + + g.map((n) => `
β€’ ${escapeHtml(n)}
`).join("") + `
`).join(""); + openModal("🧬 Duplicate Wallets", html); +} + +// ---- wiring --------------------------------------------------------------- +function init() { + $("search").addEventListener("input", (e) => { QUERY = e.target.value; render(); }); + $("groupFilter").addEventListener("change", (e) => { GROUP = e.target.value; render(); }); + $("viewToggle").addEventListener("click", () => { + VIEW = VIEW === "grid" ? "list" : "grid"; + $("viewToggle").textContent = VIEW === "grid" ? "β–¦" : "☰"; + render(); + }); + $("refreshBtn").addEventListener("click", reload); + $("journalBtn").addEventListener("click", showJournal); + $("dupBtn").addEventListener("click", showDuplicates); + $("modalClose").addEventListener("click", closeModal); + $("modal").addEventListener("click", (e) => { if (e.target.id === "modal") closeModal(); }); + + applyTheme().then(reload); + setInterval(reload, 15000); // keep the active-wallet badge fresh +} + +init(); diff --git a/multi_exodus/webapp/static/index.html b/multi_exodus/webapp/static/index.html new file mode 100644 index 0000000..d873bc5 --- /dev/null +++ b/multi_exodus/webapp/static/index.html @@ -0,0 +1,49 @@ + + + + + + MultiExodus β€” Web + + + + + + +
+
+ +
+
MultiExodus
+
Loading…
+
+
+
+ + + + + + +
+
+ +
+ + + + + +
+ + + + diff --git a/multi_exodus/webapp/static/styles.css b/multi_exodus/webapp/static/styles.css new file mode 100644 index 0000000..3b8b7a3 --- /dev/null +++ b/multi_exodus/webapp/static/styles.css @@ -0,0 +1,190 @@ +/* Colors are injected at runtime from the desktop theme (see app.js). */ +:root { + --bg: #202020; + --bg-alt: #2C2C2C; + --bg-dark: #181818; + --surface: #414141; + --border: #414141; + --hover: #292929; + --text: #FFFFFF; + --text-muted: #666666; + --accent: #006EFF; + --danger: #FF0000; + --success: #4CAF50; + --warning: #FFC400; + --radius: 14px; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: "Segoe UI", system-ui, -apple-system, sans-serif; + min-height: 100vh; +} + +/* ---- top bar ---- */ +.topbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 20px; + background: color-mix(in srgb, var(--bg) 88%, black); + border-bottom: 1px solid var(--border); + backdrop-filter: blur(6px); + flex-wrap: wrap; +} +.brand { display: flex; align-items: center; gap: 12px; } +.logo { font-size: 28px; } +.brand-title { font-size: 20px; font-weight: 700; letter-spacing: .2px; } +.brand-sub { font-size: 12px; color: var(--text-muted); } +.controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + +.search { + background: var(--bg-alt); + border: 1px solid var(--border); + color: var(--text); + border-radius: 10px; + padding: 9px 12px; + width: 260px; + outline: none; + font-size: 14px; +} +.search:focus { border-color: var(--accent); } + +.select { + background: var(--bg-alt); + border: 1px solid var(--border); + color: var(--text); + border-radius: 10px; + padding: 9px 10px; + font-size: 14px; + outline: none; + cursor: pointer; +} + +.icon-btn { + background: var(--bg-alt); + border: 1px solid var(--border); + color: var(--text); + border-radius: 10px; + width: 40px; + height: 40px; + font-size: 17px; + cursor: pointer; + transition: background .15s, border-color .15s; +} +.icon-btn:hover { background: var(--surface); border-color: var(--accent); } + +/* ---- grid ---- */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 16px; + padding: 20px; +} +.grid.list { grid-template-columns: 1fr; } + +.card { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; + position: relative; + transition: transform .12s, border-color .15s, box-shadow .15s; +} +.card:hover { transform: translateY(-2px); border-color: var(--accent); box-shadow: 0 8px 24px rgba(0,0,0,.35); } +.card.active { border-color: var(--success); box-shadow: 0 0 0 1px var(--success) inset; } + +.grid.list .card { flex-direction: row; align-items: center; gap: 16px; } +.grid.list .card .card-main { flex: 1; } + +.card-img { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + border-radius: 10px; + background: var(--bg-dark); +} +.grid.list .card-img { width: 84px; height: 84px; aspect-ratio: unset; } +.card-img.placeholder { + display: flex; align-items: center; justify-content: center; + font-size: 34px; color: var(--text-muted); +} + +.card-top { display: flex; align-items: center; gap: 8px; } +.card-name { font-size: 16px; font-weight: 700; cursor: pointer; word-break: break-word; } +.card-name:hover { color: var(--accent); } +.badge { + font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 999px; + background: color-mix(in srgb, var(--success) 22%, transparent); + color: var(--success); white-space: nowrap; +} +.card-note { font-size: 13px; color: var(--text-muted); cursor: pointer; min-height: 16px; } +.card-note:hover { color: var(--text); } + +.chips { display: flex; flex-wrap: wrap; gap: 6px; } +.chip { + font-size: 11px; padding: 2px 8px; border-radius: 999px; + background: var(--bg-alt); color: var(--text-muted); cursor: pointer; + border: 1px solid var(--border); +} +.chip.group { color: var(--warning); } +.chip:hover { color: var(--text); border-color: var(--accent); } + +.card-actions { display: flex; gap: 6px; margin-top: 4px; } +.grid.list .card-actions { margin-top: 0; } +.btn { + flex: 1; border: none; border-radius: 9px; padding: 9px 10px; + font-size: 13px; font-weight: 600; cursor: pointer; color: var(--text); + background: var(--surface); transition: filter .15s; +} +.btn:hover { filter: brightness(1.15); } +.btn.primary { background: var(--accent); } +.btn.ghost { background: var(--bg-alt); flex: 0 0 auto; width: 40px; } +.btn.danger { background: var(--bg-alt); color: var(--danger); flex: 0 0 auto; width: 40px; } +.btn.star { background: var(--bg-alt); color: var(--accent); flex: 0 0 auto; width: 40px; } + +.empty { text-align: center; color: var(--text-muted); padding: 60px; font-size: 16px; } + +/* ---- modal ---- */ +.modal { + position: fixed; inset: 0; background: rgba(0,0,0,.6); + display: flex; align-items: center; justify-content: center; z-index: 50; padding: 20px; +} +.modal-card { + background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); + width: min(640px, 100%); max-height: 80vh; display: flex; flex-direction: column; +} +.modal-head { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 18px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 17px; +} +.modal-body { padding: 16px 18px; overflow-y: auto; } + +.jrow { font-family: Consolas, monospace; font-size: 12.5px; padding: 6px 4px; border-bottom: 1px solid var(--bg-alt); } +.jrow .jaction { color: var(--accent); } +.dupgroup { background: var(--bg-alt); border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; margin-bottom: 10px; } +.dupgroup h4 { margin: 0 0 6px; color: var(--warning); font-size: 14px; } +.dupgroup div { font-size: 13px; padding: 2px 0; } + +/* ---- toasts ---- */ +.toasts { position: fixed; right: 18px; bottom: 18px; display: flex; flex-direction: column; gap: 10px; z-index: 100; } +.toast { + background: var(--bg-alt); border: 1px solid var(--border); border-left: 4px solid var(--accent); + color: var(--text); padding: 12px 16px; border-radius: 10px; font-size: 14px; + box-shadow: 0 8px 24px rgba(0,0,0,.4); animation: slidein .2s ease; + max-width: 340px; +} +.toast.ok { border-left-color: var(--success); } +.toast.err { border-left-color: var(--danger); } +@keyframes slidein { from { transform: translateX(20px); opacity: 0; } to { transform: none; opacity: 1; } }