diff --git a/css_browserhook.py b/css_browserhook.py index 6286447..2c2dec2 100644 --- a/css_browserhook.py +++ b/css_browserhook.py @@ -25,6 +25,10 @@ async def _init(self): if res != None: self.title = res["title"] self.html_classes = res["classes"] + else: + Log(f"Failed to connect to tab with id {self.id}") + self.hook.connected_tabs.remove(self) + return self.init_done = True Log(f"Connected to tab: {self.title}") @@ -214,7 +218,6 @@ def __init__(self): self.ws_url = None self.ws_response : List[asyncio.Queue] = [] self.connected_tabs : List[BrowserTabHook] = [] - self.tab_names = {} asyncio.create_task(self.on_new_tab()) asyncio.create_task(self.on_tab_update()) @@ -222,6 +225,7 @@ def __init__(self): asyncio.create_task(self.on_tab_detach()) asyncio.create_task(self.health_check()) asyncio.create_task(self.css_health_check()) + asyncio.create_task(self.sanity_check_tabs()) def get_id(self) -> int: self.current_id += 1 @@ -265,7 +269,7 @@ async def send_command(self, method : str, params : dict, sessionId : str|None, result = await queue.get() if (start_time + 5) < time.time(): - Result(False, f"Request for {method} took more than 5s. Assuming it failed") + Result(False, f"Request for {method} took more than 5s. Assuming it failed ({len(self.connected_tabs)})") self.ws_response.remove(queue) del queue return None @@ -278,6 +282,10 @@ async def send_command(self, method : str, params : dict, sessionId : str|None, return None raise RuntimeError("Websocket not opened") + async def _tab_exists(self, tab_id : str): + result = await self.send_command("Target.getTargets", {}, None) + return tab_id in [x["targetId"] for x in result["result"]["targetInfos"] if x["type"] == "page"] + async def on_new_tab(self): queue = asyncio.Queue(maxsize=MAX_QUEUE_SIZE) self.ws_response.append(queue) @@ -289,6 +297,9 @@ async def on_new_tab(self): if message["params"]["targetInfo"]["type"] != "page": continue + if not await self._tab_exists(message["params"]["targetInfo"]["targetId"]): + continue + await self.send_command("Target.attachToTarget", {"targetId": message["params"]["targetInfo"]["targetId"], "flatten": True}, None, False) async def on_tab_update(self): @@ -301,6 +312,9 @@ async def on_tab_update(self): if "method" in message and message["method"] == "Target.targetInfoChanged": target_info = message["params"]["targetInfo"] + if not await self._tab_exists(message["params"]["targetInfo"]["targetId"]): + continue + for connected_tab in self.connected_tabs: if target_info["targetId"] == connected_tab.id: reinject = False @@ -350,6 +364,42 @@ async def on_tab_detach(self): Log(f"Disconnected from tab: {tab.title}") self.connected_tabs.remove(tab) + async def sanity_check_tabs(self): + while True: + try: + result = await self.send_command("Target.getTargets", {}, None, True) + target_infos = result["result"]["targetInfos"] + target_ids = [x["targetId"] for x in target_infos if x["type"] == "page"] + for x in self.connected_tabs: # Remove tabs that are no longer connected + if x.id not in target_ids: + Log(f"Disconnected from tab: {x.title}") + self.connected_tabs.remove(x) + + connected_ids = [x.id for x in self.connected_tabs] + for x in target_infos: + if x["targetId"] not in connected_ids: # Attach tabs that are not connected + await self.send_command("Target.attachToTarget", {"targetId": x["targetId"], "flatten": True}, None, False) + else: + for connected_tab in self.connected_tabs: # Update info on tabs that are connected + if connected_tab.id == x["targetId"]: + reinject = False + if (x["title"] != connected_tab.title): + connected_tab.title = x["title"] + reinject = True + + if (x["url"] != connected_tab.url): + connected_tab.url = x["url"] + reinject = True + + if reinject: + asyncio.create_task(connected_tab.force_reinject()) + + break + except: + pass + + await asyncio.sleep(5) + async def css_health_check(self): while True: for tab in self.connected_tabs: @@ -365,7 +415,7 @@ async def health_check(self): await asyncio.sleep(3) try: async with aiohttp.ClientSession() as web: - res = await web.get(f"http://localhost:8080/json/version", timeout=3) + res = await web.get(f"http://127.0.0.1:8080/json/version", timeout=3) if (res.status != 200): raise Exception(f"/json/version returned {res.status}") @@ -384,7 +434,7 @@ async def health_check(self): x.put_nowait(data) except Exception as e: - Log(f"[Browser Health Check] {str(e)}") + Result(False, f"[Health Check] {str(e)}") try: await self.close_websocket() @@ -404,8 +454,8 @@ def get_tabs(tab_name : str) -> List[BrowserTabHook]: if tab.compare(tab_name): tabs.append(tab) - if tabs == []: - Log(f"[Warn] get_tabs({tab_name}) returned []. All tabs: {str([x.title for x in HOOK.connected_tabs])}") + #if tabs == []: + # Log(f"[Warn] get_tabs({tab_name}) returned []. All tabs: {str([x.title for x in HOOK.connected_tabs])}") return tabs diff --git a/css_inject.py b/css_inject.py index ebcb8ab..2e49d92 100644 --- a/css_inject.py +++ b/css_inject.py @@ -38,7 +38,6 @@ async def load(self) -> Result: async def inject(self) -> Result: for tab_name in self.tabs: for uuid in self.uuids[tab_name]: - Log(f"-{uuid} @ {tab_name}") res = await remove(tab_name, uuid) if (self.css is None): @@ -51,7 +50,7 @@ async def inject(self) -> Result: if not res.success: return res - Log(f"+{str(res.message)} @ {tab_name}") + # Log(f"+{str(res.message)} @ {tab_name}") self.uuids[tab_name].append(str(res.message)) except Exception as e: return Result(False, str(e)) @@ -75,7 +74,7 @@ async def inject_with_tab(self, tab : CssTab) -> Result: if not res.success: return res - Log(f"+{str(res.message)} @ {tab_name}") + # Log(f"+{str(res.message)} @ {tab_name}") self.uuids[tab_name].append(str(res.message)) except Exception as e: return Result(False, str(e)) @@ -89,7 +88,6 @@ async def remove(self) -> Result: try: for x in self.uuids[tab_name]: - Log(f"-{x} @ {tab_name}") res = await remove(tab_name, x) #if not res["success"]: # return Result(False, res["result"]) @@ -103,7 +101,7 @@ async def remove(self) -> Result: return Result(True) DEFAULT_MAPPINGS = { - "desktop": ["Steam.*"], + "desktop": ["Steam|SteamLibraryWindow"], "desktopchat": ["!friendsui-container"], "desktoppopup": ["OverlayBrowser_Browser", "SP Overlay:.*", "notificationtoasts_.*", "SteamBrowser_Find", "OverlayTab\\d+_Find", "!ModalDialogPopup", "!FullModalOverlay"], "desktopoverlay": ["desktoppopup"], diff --git a/css_loader.py b/css_loader.py new file mode 100644 index 0000000..4730fc7 --- /dev/null +++ b/css_loader.py @@ -0,0 +1,361 @@ +from css_utils import Log, Result, get_theme_path, FLAG_KEEP_DEPENDENCIES, FLAG_PRESET +from css_inject import Inject, ALL_INJECTS +from css_theme import Theme, CSS_LOADER_VER +from css_themepatch import ThemePatch +from css_browserhook import remove_all, commit_all + +from asyncio import sleep +from os import listdir, path, mkdir +import json + +class Loader: + def __init__(self): + self.busy = False + self.themes = [] + self.scores = {} + self.last_load_errors = [] + + async def lock(self): + while self.busy: + await sleep(.1) + + self.busy = True + + async def unlock(self): + self.busy = False + + async def load(self, inject_now : bool = True): + Log("Loading themes...") + self.themes : list[Theme] = [] + + themesPath = get_theme_path() + self.last_load_errors = await self._parse_themes(themesPath) + + self.scores = {} + for x in self.themes: + await self._set_theme_score(x) + + Log(self.scores) + self.themes.sort(key=lambda d: self.scores[d.name]) + + for x in self.themes: + Log(f"Loading theme {x.name}") + await x.load(inject_now) + + await self._cache_lists() + self.themes.sort(key=lambda d: d.get_display_name()) + + async def set_theme_state(self, name : str, state : bool, set_deps : bool = True, set_deps_value : bool = True) -> Result: + Log(f"Setting state for {name} to {state}") + theme = await self._get_theme(name) + + if theme == None: + return Result(False, f"Did not find theme {name}") + + try: + if state: + result = await self._enable_theme(theme, set_deps, set_deps_value) + else: + result = await self._disable_theme(theme, FLAG_KEEP_DEPENDENCIES in theme.flags) + + await commit_all() + return result + except Exception as e: + return Result(False, str(e)) + + async def set_patch_of_theme(self, themeName : str, patchName : str, value : str) -> Result: + try: + themePatch = await self._get_patch_of_theme(themeName, patchName) + except Exception as e: + return Result(False, str(e)) + + if (themePatch.value == value): + return Result(True, "Already injected") + + if (value in themePatch.options): + themePatch.value = value + + if (themePatch.theme.enabled): + await themePatch.remove() + await themePatch.inject() + + await themePatch.theme.save() + await commit_all() + return Result(True) + + async def set_component_of_theme_patch(self, themeName : str, patchName : str, componentName : str, value : str) -> Result: + try: + themePatch = await self._get_patch_of_theme(themeName, patchName) + except Exception as e: + return Result(False, str(e)) + + component = None + for x in themePatch.components: + if x.name == componentName: + component = x + break + + if component == None: + return Result(False, f"Failed to find component '{componentName}'") + + component.value = value + result = await component.generate_and_reinject() + if not result.success: + return result + + await themePatch.theme.save() + await commit_all() + return Result(True) + + async def reset(self) -> dict: + await self.lock() + try: + await remove_all() + await self.load() + await commit_all() + except Exception as e: + await self.unlock() + Result(False, str(e)) + + await self.unlock() + + return { + "fails": self.last_load_errors + } + + async def delete_theme(self, themeName : str) -> Result: + theme = await self._get_theme(themeName) + + if (theme == None): + return Result(False, f"Could not find theme {themeName}") + + result = await theme.delete() + if not result.success: + return result.to_dict() + + self.themes.remove(theme) + await self._cache_lists() + return Result(True) + + async def generate_preset_theme(self, name : str) -> Result: + try: + deps = {} + + for x in self.themes: + if x.enabled and FLAG_PRESET not in x.flags: + deps[x.name] = {} + for y in x.patches: + deps[x.name][y.name] = y.get_value() + + result = await self._generate_preset_theme_internal(name, deps) + return result + except Exception as e: + return Result(False, str(e)) + + async def generate_preset_theme_from_theme_names(self, name : str, themeNames : list) -> Result: + try: + deps = {} + + for x in self.themes: + if x.name in themeNames and FLAG_PRESET not in x.flags: + deps[x.name] = {} + for y in x.patches: + deps[x.name][y.name] = y.get_value() + + result = await self._generate_preset_theme_internal(name, deps) + return result + except Exception as e: + return Result(False, str(e)) + + async def _enable_theme(self, theme : Theme, set_deps : bool = True, set_deps_value : bool = True, ignore_dependencies : list = []) -> Result: + if theme is None: + return Result(False) + + if set_deps: + theme_dependencies = [x for x in theme.dependencies] + # Make the top level control all dependencies it defines + ignore_dependencies_next = ignore_dependencies.copy() + ignore_dependencies_next.extend(theme_dependencies) + + # Disallow dependencies of a preset to override anything + if (FLAG_PRESET in theme.flags): + set_deps = False + + # Make sure higher priority themes are sorted right + theme_dependencies.sort(key=lambda d: self.scores[d] if d in self.scores else 0) + + for dependency_name in theme_dependencies: + # Skip any themes that the previous iteration has control over + if (dependency_name in ignore_dependencies): + continue + + dependency = await self._get_theme(dependency_name) + if dependency == None: + continue + + if set_deps_value: + if dependency.enabled: + await dependency.remove() + + for dependency_patch_name in theme.dependencies[dependency_name]: + dependency_patch_value = theme.dependencies[dependency_name][dependency_patch_name] + for dependency_patch in dependency.patches: + if dependency_patch.name == dependency_patch_name: + dependency_patch.set_value(dependency_patch_value) + + await self._enable_theme(dependency, set_deps, set_deps_value, ignore_dependencies_next) + + result = await theme.inject() + return result + + async def _disable_theme(self, theme : Theme, keep_dependencies : bool) -> Result: + if theme is None: + return Result(False) + + result = await theme.remove() + + if keep_dependencies or not result.success: + return result + + for dependency_name in theme.dependencies: + dependency = await self._get_theme(dependency_name) + + if dependency == None: + continue + + used = False + + for x in self.themes: + if x.enabled and dependency.name in [y for y in x.dependencies]: + used = True + break + + if not used: + await self._disable_theme(dependency, False) + + return result + + async def _set_theme_score(self, theme : Theme): + if theme.name not in self.scores: + self.scores[theme.name] = theme.priority_mod + + for x in theme.dependencies: + dependency = await self._get_theme(x) + if dependency is not None: + await self._set_theme_score(dependency) + self.scores[dependency.name] -= 1 + + async def _cache_lists(self): + ALL_INJECTS.clear() + + for x in self.themes: + injects = x.get_all_injects() + ALL_INJECTS.extend(injects) + + async def _get_theme(self, themeName : str) -> Theme | None: + for x in self.themes: + if x.name == themeName: + return x + + return None + + async def _get_patch_of_theme(self, themeName : str, patchName : str) -> ThemePatch: + theme = await self._get_theme(themeName) + + if theme is None: + raise Exception(f"Did not find theme '{themeName}'") + + themePatch = None + for x in theme.patches: + if (x.name == patchName): + themePatch = x + break + + if themePatch is None: + raise Exception(f"Did not find patch '{patchName}' for theme '{themeName}'") + + return themePatch + + async def _parse_themes(self, themesDir : str, configDir : str = None) -> list[tuple[str, str]]: + if (configDir is None): + configDir = themesDir + + possibleThemeDirs = [str(x) for x in listdir(themesDir)] + fails = [] + + for x in possibleThemeDirs: + themePath = themesDir + "/" + x + configPath = configDir + "/" + x + themeDataPath = themePath + "/theme.json" + + if not path.isdir(themePath): + continue + + try: + theme = None + if path.exists(themeDataPath): + with open(themeDataPath, "r") as fp: + theme = json.load(fp) + + themeData = Theme(themePath, theme, configPath) + + theme_names = [x.name for x in self.themes] + if (themeData.name in theme_names): + for x in range(5): + new_name = themeData.name + f"_{x}" + if (new_name not in theme_names): + themeData.add_prefix(x) + break + + if (themeData.name not in theme_names): + self.themes.append(themeData) + Log(f"Found theme {themeData.name}") + + except Exception as e: + Result(False, f"Failed parsing '{x}': {e}") # Couldn't properly parse everything + fails.append((x, str(e))) + + return fails + + async def _generate_preset_theme_internal(self, name : str, deps : dict) -> Result: + display_name = name + + if (display_name.endswith(".profile")): + display_name = display_name[:-8] + + name = f"{display_name}.profile" + Log(f"Generating theme preset '{display_name}'...") + + existing_theme = await self._get_theme(name) + if existing_theme is not None and FLAG_PRESET not in existing_theme.flags: + return Result(False, f"Theme '{name}' already exists") + + if existing_theme is None: + existing_theme = await self._get_theme(display_name) + if existing_theme is not None: + if FLAG_PRESET in existing_theme.flags: + name = existing_theme.name + else: + return Result(False, f"Theme '{display_name}' already exists") + + theme_path = path.join(get_theme_path(), name) + + if not path.exists(theme_path): + mkdir(theme_path) + + with open(path.join(theme_path, "theme.json"), "w") as fp: + json.dump({ + "display_name": display_name, + "name": name, + "manifest_version": CSS_LOADER_VER, + "flags": [FLAG_PRESET], + "dependencies": deps + }, fp) + + for x in self.themes: + if x.name == name: # Hotpatch preset in memory + Log(f"Updating dependencies for {name}: {deps}") + x.dependencies = deps + break + + return Result(True) \ No newline at end of file diff --git a/css_server.py b/css_server.py index e62b0c1..b9fa067 100644 --- a/css_server.py +++ b/css_server.py @@ -21,7 +21,12 @@ def start_server(plugin): PLUGIN_CLASS = plugin loop = asyncio.get_running_loop() - create_cef_flag() + + try: + create_cef_flag() + except Exception as e: + Log(f"Failed to create steam cef flag. {str(e)}") + app = aiohttp.web.Application(loop=loop) app.router.add_route('POST', '/req', handle) loop.create_task(aiohttp.web._run_app(app, host="127.0.0.1", port=35821)) diff --git a/css_theme.py b/css_theme.py index 47b1d7c..a27d6e9 100644 --- a/css_theme.py +++ b/css_theme.py @@ -6,11 +6,12 @@ from css_themepatch import ThemePatch from css_sfp_compat import is_folder_sfp_theme, convert_to_css_theme -CSS_LOADER_VER = 8 +CSS_LOADER_VER = 9 class Theme: def __init__(self, themePath : str, json : dict, configPath : str = None): self.configPath = configPath if (configPath is not None) else themePath + self.display_name = None self.configJsonPath = self.configPath + "/config" + ("_ROOT.json" if USER == "root" else "_USER.json") self.patches = [] self.injects = [] @@ -25,7 +26,7 @@ def __init__(self, themePath : str, json : dict, configPath : str = None): self.modified = path.getmtime(self.configJsonPath) if path.exists(self.configJsonPath) else None try: - if (os.path.join(themePath, "PRIORITY")): + if os.path.exists(os.path.join(themePath, "PRIORITY")): with open(os.path.join(themePath, "PRIORITY")) as fp: self.priority_mod = int(fp.readline().strip()) except: @@ -54,6 +55,7 @@ def __init__(self, themePath : str, json : dict, configPath : str = None): self.created = path.getmtime(jsonPath) self.name = json["name"] + self.display_name = json["display_name"] if ("display_name" in json) else None self.id = json["id"] if ("id" in json) else self.name self.version = json["version"] if ("version" in json) else "v1.0" self.author = json["author"] if ("author" in json) else "" @@ -171,6 +173,7 @@ def to_dict(self) -> dict: return { "id": self.id, "name": self.name, + "display_name": self.get_display_name(), "version": self.version, "author": self.author, "enabled": self.enabled, @@ -181,4 +184,13 @@ def to_dict(self) -> dict: "flags": self.flags, "created": self.created, "modified": self.modified, - } \ No newline at end of file + } + + def get_display_name(self) -> str: + return self.display_name if (self.display_name is not None) else self.name + + def add_prefix(self, id : int): + if self.display_name is None: + self.display_name = self.name + + self.name += f"_{id}" \ No newline at end of file diff --git a/css_themepatchcomponent.py b/css_themepatchcomponent.py index b7517bb..51592cb 100644 --- a/css_themepatchcomponent.py +++ b/css_themepatchcomponent.py @@ -30,12 +30,12 @@ def get_value_from_masks(m1 : float, m2 : float, hue : float) -> int: def hsl_to_rgb(hue : int, saturation : int, lightness : int) -> tuple[int, int, int]: ONE_THIRD = 1.0/3.0 - h = float(hue) / 255.0 - l = float(lightness) / 255.0 - s = float(saturation) / 255.0 + h = float(hue) / 360.0 + l = float(lightness) / 100.0 + s = float(saturation) / 100.0 if s == 0.0: - return (int(l * 255.0), int(l * 255.0), int(l * 255.0)) + return (int(l * 100.0), int(l * 100.0), int(l * 100.0)) m2 = l * (1.0 + s) if l <= 0.5 else l + s - (l * s) m1 = 2.0 * l - m2 @@ -109,18 +109,32 @@ def generate(self) -> Result: if self.type == "color-picker": try: - if self.value[0] == "#": + if self.value[0] == "#": # Try to parse as hex value (r, g, b) = hex_to_rgb(self.value) - else: - hsl_vals = self.value.split(", ") - h = hsl_vals[0][5:] - s = hsl_vals[1][:len(hsl_vals[1]) - 1] - l = hsl_vals[2][:len(hsl_vals[2]) - 1] + elif (self.value.startswith("hsla(") or self.value.startswith("hsl(")) and self.value.endswith(")"): # Try to parse as hsl(a) value + hsl_vals = self.value[self.value.find("(") + 1:-1].split(",") + # Start: hsla(39, 100%, 50%, 1) + # .find: Removes hsla(. Result: '39, 100%, 50%, 1)' + # -1: Removes ). Result: '39, 100%, 50%, 1' + # Split Result: '39', ' 100%', ' 50%', ' 1' + + h = hsl_vals[0].strip() + # .strip: Removes any whitespace, just in case + + s = hsl_vals[1].strip()[:-1] + # .strip: Removes any whitespace (' 100%' -> '100%') + # -1: Removes % ('100%' -> '100') + + l = hsl_vals[2].strip()[:-1] + # .strip: Removes any whitespace (' 50%' -> '50%') + # -1: Removes % ('50%' -> '50') (r, g, b) = hsl_to_rgb(h, s, l) + else: + raise Exception(f"Unable to parse color-picker value '{self.value}'") self.inject.css = f":root {{ {self.css_variable}: {self.value}; {self.css_variable}_r: {r}; {self.css_variable}_g: {g}; {self.css_variable}_b: {b}; {self.css_variable}_rgb: {r}, {g}, {b}; }}" - except e: + except Exception as e: self.inject.css = f":root {{ {self.css_variable}: {self.value}; }}" elif self.type == "image-picker": try: diff --git a/css_utils.py b/css_utils.py index 35bafc8..59882c6 100644 --- a/css_utils.py +++ b/css_utils.py @@ -1,5 +1,5 @@ from logging import getLogger -import os, platform +import os, platform, traceback HOME = os.getenv("HOME") @@ -35,8 +35,11 @@ def __init__(self, success : bool, message : str = "Success", log : bool = True) self.success = success self.message = message + stack = traceback.extract_stack() + function_above = stack[-2] + if log and not self.success: - Log(f"Result failed! {message}") + Log(f"[FAIL] [{os.path.basename(function_above.filename)}:{function_above.lineno}] {message}") def raise_on_failure(self): if not self.success: @@ -94,7 +97,7 @@ def get_steam_path() -> str: except Exception as e: return "C:\\Program Files (x86)\\Steam" # Taking a guess here else: - return f"{get_user_home()}/.local/share/Steam" + return f"{get_user_home()}/.steam/steam" def create_steam_symlink() -> Result: return create_symlink(get_theme_path(), os.path.join(get_steam_path(), "steamui", "themes_custom")) diff --git a/main.py b/main.py index 5c3d420..f978b5c 100644 --- a/main.py +++ b/main.py @@ -1,19 +1,18 @@ -import os, json, asyncio, sys, time -from os import path, mkdir +import os, asyncio, sys, time from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer sys.path.append(os.path.dirname(__file__)) -from css_utils import Log, create_dir, create_steam_symlink, Result, get_user_home, get_theme_path, store_read as util_store_read, store_write as util_store_write, FLAG_KEEP_DEPENDENCIES, FLAG_PRESET, store_or_file_config -from css_inject import Inject, ALL_INJECTS -from css_theme import Theme, CSS_LOADER_VER -from css_themepatch import ThemePatch +from css_utils import Log, create_steam_symlink, Result, get_theme_path, store_read as util_store_read, store_write as util_store_write, store_or_file_config +from css_inject import ALL_INJECTS +from css_theme import CSS_LOADER_VER from css_remoteinstall import install from css_server import start_server -from css_browserhook import initialize, remove_all, commit_all +from css_browserhook import initialize +from css_loader import Loader ALWAYS_RUN_SERVER = False IS_STANDALONE = False @@ -27,8 +26,8 @@ Initialized = False class FileChangeHandler(FileSystemEventHandler): - def __init__(self, plugin, loop): - self.plugin = plugin + def __init__(self, loader : Loader, loop): + self.loader = loader self.loop = loop self.last = 0 self.delay = 1 @@ -40,10 +39,10 @@ def on_modified(self, event): #Log("FS Event is not on a CSS file. Ignoring!") return - if ((self.last + self.delay) < time.time() and not self.plugin.busy): + if ((self.last + self.delay) < time.time() and not self.loader.busy): self.last = time.time() Log("Reloading themes due to FS event") - self.loop.create_task(self.plugin.reset(self.plugin)) + self.loop.create_task(self.loader.reset()) class Plugin: @@ -64,18 +63,26 @@ async def enable_server(self) -> dict: self.server_loaded = True return Result(True).to_dict() - async def toggle_watch_state(self, enable : bool = True) -> dict: + async def toggle_watch_state(self, enable : bool = True, only_this_session : bool = False) -> dict: if enable and self.observer == None: Log("Observing themes folder for file changes") self.observer = Observer() - self.handler = FileChangeHandler(self, asyncio.get_running_loop()) + self.handler = FileChangeHandler(self.loader, asyncio.get_running_loop()) self.observer.schedule(self.handler, get_theme_path(), recursive=True) self.observer.start() + + if not only_this_session: + util_store_write("watch", "1") + return Result(True).to_dict() elif self.observer != None and not enable: Log("Stopping observer") self.observer.stop() self.observer = None + + if not only_this_session: + util_store_write("watch", "0") + return Result(True).to_dict() return Result(False, "Nothing to do!").to_dict() @@ -87,187 +94,35 @@ async def fetch_theme_path(self) -> str: return get_theme_path() async def get_themes(self) -> list: - return [x.to_dict() for x in self.themes] + return [x.to_dict() for x in self.loader.themes] async def set_theme_state(self, name : str, state : bool, set_deps : bool = True, set_deps_value : bool = True) -> dict: - Log(f"Setting state for {name} to {state}") - theme = await self._get_theme(self, name) - - if theme == None: - return Result(False, f"Did not find theme {name}").to_dict() - - try: - if state: - result = await self._enable_theme(self, theme, set_deps, set_deps_value) - else: - result = await self._disable_theme(self, theme, FLAG_KEEP_DEPENDENCIES in theme.flags) - - await commit_all() - return result.to_dict() - except Exception as e: - return Result(False, str(e)) - - async def _enable_theme(self, theme : Theme, set_deps : bool = True, set_deps_value : bool = True) -> Result: - if theme is None: - return Result(False) - - if set_deps: - for dependency_name in theme.dependencies: - dependency = await self._get_theme(self, dependency_name) - if dependency == None: - continue - - if set_deps_value: - if dependency.enabled: - await dependency.remove() - - for dependency_patch_name in theme.dependencies[dependency_name]: - dependency_patch_value = theme.dependencies[dependency_name][dependency_patch_name] - for dependency_patch in dependency.patches: - if dependency_patch.name == dependency_patch_name: - dependency_patch.set_value(dependency_patch_value) - - - await self._enable_theme(self, dependency) - - result = await theme.inject() - return result - - async def _disable_theme(self, theme : Theme, keep_dependencies : bool) -> Result: - if theme is None: - return Result(False) - - result = await theme.remove() - - if keep_dependencies or not result.success: - return result - - for dependency_name in theme.dependencies: - dependency = await self._get_theme(self, dependency_name) - - if dependency == None: - continue - - used = False - - for x in self.themes: - if x.enabled and dependency.name in [y for y in x.dependencies]: - used = True - break - - if not used: - await self._disable_theme(self, dependency, False) - - return result - + return (await self.loader.set_theme_state(name, state, set_deps, set_deps_value)).to_dict() async def download_theme_from_url(self, id : str, url : str) -> dict: - local_themes = [x.name for x in self.themes] + local_themes = [x.name for x in self.loader.themes] return (await install(id, url, local_themes)).to_dict() async def get_backend_version(self) -> int: return CSS_LOADER_VER - - async def _get_theme(self, themeName : str) -> Theme | None: - for x in self.themes: - if x.name == themeName: - return x - - return None - - async def _get_patch_of_theme(self, themeName : str, patchName : str) -> ThemePatch: - theme = None - for x in self.themes: - if (x.name == themeName): - theme = x - break - - if theme is None: - raise Exception(f"Did not find theme '{themeName}'") - - themePatch = None - for x in theme.patches: - if (x.name == patchName): - themePatch = x - break - - if themePatch is None: - raise Exception(f"Did not find patch '{patchName}' for theme '{themeName}'") - - return themePatch async def set_patch_of_theme(self, themeName : str, patchName : str, value : str) -> dict: - try: - themePatch = await self._get_patch_of_theme(self, themeName, patchName) - except Exception as e: - return Result(False, str(e)) - - if (themePatch.value == value): - return Result(True, "Already injected").to_dict() - - if (value in themePatch.options): - themePatch.value = value - - if (themePatch.theme.enabled): - await themePatch.remove() - await themePatch.inject() - - await themePatch.theme.save() - await commit_all() - return Result(True).to_dict() + return (await self.loader.set_patch_of_theme(themeName, patchName, value)).to_dict() async def set_component_of_theme_patch(self, themeName : str, patchName : str, componentName : str, value : str) -> dict: - try: - themePatch = await self._get_patch_of_theme(self, themeName, patchName) - except Exception as e: - return Result(False, str(e)) - - component = None - for x in themePatch.components: - if x.name == componentName: - component = x - break - - if component == None: - return Result(False, f"Failed to find component '{componentName}'") - - component.value = value - result = await component.generate_and_reinject() - if not result.success: - return result - - await themePatch.theme.save() - await commit_all() - return Result(True).to_dict() + return (await self.loader.set_component_of_theme_patch(themeName, patchName, componentName, value)).to_dict() async def reset(self) -> dict: - self.busy = True - - await remove_all() - await self._load(self) - await self._load_stage_2(self) - await commit_all() - self.busy = False - return Result(True).to_dict() + return (await self.loader.reset()) async def delete_theme(self, themeName : str) -> dict: - theme = None + return (await self.loader.delete_theme(themeName)).to_dict() + + async def generate_preset_theme(self, name : str) -> Result: + return (await self.loader.generate_preset_theme(name)).to_dict() - for x in self.themes: - if x.name == themeName: - theme = x - break - - if (theme == None): - return Result(False, f"Could not find theme {themeName}").to_dict() - - result = await theme.delete() - if not result.success: - return result.to_dict() - - self.themes.remove(theme) - await self._cache_lists(self) - return Result(True).to_dict() + async def generate_preset_theme_from_theme_names(self, name : str, themeNames : list) -> Result: + return (await self.loader.generate_preset_theme_from_theme_names(name, themeNames)).to_dict() async def store_read(self, key : str) -> str: return util_store_read(key) @@ -276,136 +131,6 @@ async def store_write(self, key : str, val : str) -> dict: util_store_write(key, val) return Result(True).to_dict() - async def generate_preset_theme(self, name : str) -> dict: - try: - deps = {} - - for x in self.themes: - if x.enabled and FLAG_PRESET not in x.flags: - deps[x.name] = {} - for y in x.patches: - deps[x.name][y.name] = y.get_value() - - result = await self._generate_preset_theme_internal(self, name, deps) - return result.to_dict() - except Exception as e: - return Result(False, str(e)) - - async def generate_preset_theme_from_theme_names(self, name : str, themeNames : list) -> dict: - try: - deps = {} - - for x in self.themes: - if x.name in themeNames and FLAG_PRESET not in x.flags: - deps[x.name] = {} - for y in x.patches: - deps[x.name][y.name] = y.get_value() - - result = await self._generate_preset_theme_internal(self, name, deps) - return result.to_dict() - except Exception as e: - return Result(False, str(e)) - - async def _generate_preset_theme_internal(self, name : str, deps : dict) -> Result: - Log(f"Generating theme preset '{name}'...") - a = await self._get_theme(self, name) - if a != None and FLAG_PRESET not in a.flags: - return Result(False, f"Theme '{name}' already exists") - - theme_path = path.join(get_theme_path(), name) - - if not path.exists(theme_path): - mkdir(theme_path) - - with open(path.join(theme_path, "theme.json"), "w") as fp: - json.dump({ - "name": name, - "manifest_version": CSS_LOADER_VER, - "flags": [FLAG_PRESET], - "dependencies": deps - }, fp) - - for x in self.themes: - if x.name == name: # Hotpatch preset in memory - Log(f"Updating dependencies for {name}: {deps}") - x.dependencies = deps - break - - return Result(True) - - async def _parse_themes(self, themesDir : str, configDir : str = None): - if (configDir is None): - configDir = themesDir - - possibleThemeDirs = [str(x) for x in os.listdir(themesDir)] - - for x in possibleThemeDirs: - themePath = themesDir + "/" + x - configPath = configDir + "/" + x - themeDataPath = themePath + "/theme.json" - - if not os.path.isdir(themePath): - continue - - Log(f"Analyzing theme {x}") - - try: - theme = None - if path.exists(themeDataPath): - with open(themeDataPath, "r") as fp: - theme = json.load(fp) - - themeData = Theme(themePath, theme, configPath) - - if (themeData.name not in [x.name for x in self.themes]): - self.themes.append(themeData) - Log(f"Adding theme {themeData.name}") - - except Exception as e: - Log(f"Exception while parsing a theme: {e}") # Couldn't properly parse everything - - async def _cache_lists(self): - ALL_INJECTS.clear() - - for x in self.themes: - injects = x.get_all_injects() - ALL_INJECTS.extend(injects) - - async def _load(self): - Log("Loading themes...") - self.themes = [] - - themesPath = get_theme_path() - - await self._parse_themes(self, themesPath) - - async def _set_theme_score(self, theme : Theme): - if theme.name not in self.scores: - self.scores[theme.name] = 0 - - self.scores[theme.name] += theme.priority_mod - - for x in theme.dependencies: - dependency = await self._get_theme(self, x) - if dependency is not None: - await self._set_theme_score(self, dependency) - self.scores[dependency.name] -= 1 - - async def _load_stage_2(self, inject_now : bool = True): - self.scores = {} - for x in self.themes: - await self._set_theme_score(self, x) - - Log(self.scores) - self.themes.sort(key=lambda d: self.scores[d.name]) - - for x in self.themes: - Log(f"Loading theme {x.name}") - await x.load(inject_now) - - await self._cache_lists(self) - self.themes.sort(key=lambda d: d.name) - async def exit(self): try: import css_win_tray @@ -414,6 +139,11 @@ async def exit(self): pass sys.exit(0) + + async def get_last_load_errors(self): + return { + "fails": self.loader.last_load_errors + } async def _main(self): global Initialized @@ -424,25 +154,20 @@ async def _main(self): self.observer = None self.server_loaded = False - await asyncio.sleep(1) - - self.busy = False - self.themes = [] Log("Initializing css loader...") Log(f"Max supported manifest version: {CSS_LOADER_VER}") create_steam_symlink() - await self._load(self) - #await self._inject_test_element(self, "SP", 9999, "test_ui_loaded") - await self._load_stage_2(self, False) + self.loader = Loader() + await self.loader.load(False) if (store_or_file_config("watch")): await self.toggle_watch_state(self) else: Log("Not observing themes folder for file changes") - Log(f"Initialized css loader. Found {len(self.themes)} themes. Total {len(ALL_INJECTS)} injects, {len([x for x in ALL_INJECTS if x.enabled])} injected") + Log(f"Initialized css loader. Found {len(self.loader.themes)} themes. Total {len(ALL_INJECTS)} injects, {len([x for x in ALL_INJECTS if x.enabled])} injected") if (ALWAYS_RUN_SERVER or store_or_file_config("server")): await self.enable_server(self) diff --git a/package-lock.json b/package-lock.json index f5c63b0..90234f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "SDH-CssLoader", - "version": "1.7.1", + "version": "1.8.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "SDH-CssLoader", - "version": "1.7.1", + "version": "1.8.0", "license": "GPL-2.0-or-later", "dependencies": { "color": "^4.2.3", - "decky-frontend-lib": "^3.20.7", + "decky-frontend-lib": "^3.22.0", "lodash": "^4.17.21", "react-icons": "^4.3.1" }, @@ -982,9 +982,9 @@ "dev": true }, "node_modules/decky-frontend-lib": { - "version": "3.20.7", - "resolved": "https://registry.npmjs.org/decky-frontend-lib/-/decky-frontend-lib-3.20.7.tgz", - "integrity": "sha512-Zwwbo50cqpTbCfSCZaqITgTRvWs7pK9KO1A+Oo2sCC/DqOfyUtEH5niNPid4Qxu+yh4lsbEjTurJk1nCfd+nZw==" + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/decky-frontend-lib/-/decky-frontend-lib-3.22.0.tgz", + "integrity": "sha512-MJ0y0bhNMHJyMVxHht3O0L0GxdT9sckUmh35HG7/ERqyZQsfKpDqOeW6pC1R07SnuWwgbl4fY3tzjlrb7qUeoA==" }, "node_modules/decode-uri-component": { "version": "0.2.2", @@ -3637,9 +3637,9 @@ "dev": true }, "decky-frontend-lib": { - "version": "3.20.7", - "resolved": "https://registry.npmjs.org/decky-frontend-lib/-/decky-frontend-lib-3.20.7.tgz", - "integrity": "sha512-Zwwbo50cqpTbCfSCZaqITgTRvWs7pK9KO1A+Oo2sCC/DqOfyUtEH5niNPid4Qxu+yh4lsbEjTurJk1nCfd+nZw==" + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/decky-frontend-lib/-/decky-frontend-lib-3.22.0.tgz", + "integrity": "sha512-MJ0y0bhNMHJyMVxHht3O0L0GxdT9sckUmh35HG7/ERqyZQsfKpDqOeW6pC1R07SnuWwgbl4fY3tzjlrb7qUeoA==" }, "decode-uri-component": { "version": "0.2.2", diff --git a/package.json b/package.json index 02ff5b7..bd8f588 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "SDH-CssLoader", - "version": "1.8.0", + "version": "2.0.0", "description": "A css loader", "scripts": { "build": "shx rm -rf dist && rollup -c", @@ -43,7 +43,7 @@ }, "dependencies": { "color": "^4.2.3", - "decky-frontend-lib": "^3.20.7", + "decky-frontend-lib": "^3.22.0", "lodash": "^4.17.21", "react-icons": "^4.3.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8db3076..38e78fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,8 +5,8 @@ dependencies: specifier: ^4.2.3 version: 4.2.3 decky-frontend-lib: - specifier: ^3.20.7 - version: 3.20.7 + specifier: ^3.22.0 + version: 3.22.0 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -702,8 +702,8 @@ packages: resolution: {integrity: sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==} dev: true - /decky-frontend-lib@3.20.7: - resolution: {integrity: sha512-Zwwbo50cqpTbCfSCZaqITgTRvWs7pK9KO1A+Oo2sCC/DqOfyUtEH5niNPid4Qxu+yh4lsbEjTurJk1nCfd+nZw==} + /decky-frontend-lib@3.22.0: + resolution: {integrity: sha512-MJ0y0bhNMHJyMVxHht3O0L0GxdT9sckUmh35HG7/ERqyZQsfKpDqOeW6pC1R07SnuWwgbl4fY3tzjlrb7qUeoA==} dev: false /decode-uri-component@0.2.2: diff --git a/rollup.config.js b/rollup.config.js index 68d7332..788620f 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -16,6 +16,7 @@ export default defineConfig({ nodeResolve(), typescript(), json(), + styles(), replace({ preventAssignment: false, "process.env.NODE_ENV": JSON.stringify("production"), @@ -23,16 +24,15 @@ export default defineConfig({ importAssets({ publicPath: `http://127.0.0.1:1337/plugins/${name}/`, }), - styles(), ], context: "window", - external: ["react", "react-dom", "decky-frontend-lib"], + external: ["react", "react-dom"], output: { file: "dist/index.js", globals: { react: "SP_REACT", "react-dom": "SP_REACTDOM", - "decky-frontend-lib": "DFL", + // "decky-frontend-lib": "DFL", }, format: "iife", exports: "default", diff --git a/src/ThemeTypes.ts b/src/ThemeTypes.ts index e3043ae..cbab186 100644 --- a/src/ThemeTypes.ts +++ b/src/ThemeTypes.ts @@ -4,6 +4,7 @@ export interface Theme { id: string; enabled: boolean; // used to be called checked name: string; + display_name: string; author: string; bundled: boolean; // deprecated require: number; @@ -33,7 +34,12 @@ export enum Flags { "isPreset" = "PRESET", "dontDisableDeps" = "KEEP_DEPENDENCIES", "optionalDeps" = "OPTIONAL_DEPENDENCIES", + "navPatch" = "REQUIRE_NAV_PATCH", } export type LocalThemeStatus = "installed" | "outdated" | "local"; export type UpdateStatus = [string, LocalThemeStatus, false | MinimalCSSThemeInfo]; + +type ThemeErrorTitle = string; +type ThemeErrorDescription = string; +export type ThemeError = [ThemeErrorTitle, ThemeErrorDescription]; diff --git a/src/api.ts b/src/api.ts index da85a53..ffe2778 100644 --- a/src/api.ts +++ b/src/api.ts @@ -23,12 +23,14 @@ export function logOut(): void { storeWrite("shortToken", ""); } -export function logInWithShortToken(shortTokenInterimValue?: string | undefined): void { +export async function logInWithShortToken( + shortTokenInterimValue?: string | undefined +): Promise { const { apiUrl, apiShortToken } = globalState!.getPublicState(); const shortTokenValue = shortTokenInterimValue ? shortTokenInterimValue : apiShortToken; const setGlobalState = globalState!.setGlobalState.bind(globalState); if (shortTokenValue.length === 12) { - server! + return server! .fetchNoCors(`${apiUrl}/auth/authenticate_token`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -75,7 +77,7 @@ export function logInWithShortToken(shortTokenInterimValue?: string | undefined) } // This returns the token that is intended to be used in whatever call -export function refreshToken(): Promise { +export function refreshToken(onError: () => void = () => {}): Promise { const { apiFullToken, apiTokenExpireDate, apiUrl } = globalState!.getPublicState(); const setGlobalState = globalState!.setGlobalState.bind(globalState); if (!apiFullToken) { @@ -121,13 +123,15 @@ export function refreshToken(): Promise { }) .catch((err) => { console.error(`Error Refreshing Token!`, err); + onError(); }); } export async function genericGET( fetchPath: string, requiresAuth: boolean = false, - customAuthToken: string | undefined = undefined + customAuthToken: string | undefined = undefined, + onError: () => void = () => {} ) { const { apiUrl } = globalState!.getPublicState(); function doTheFetching(authToken: string | undefined = undefined) { @@ -161,13 +165,14 @@ export async function genericGET( }) .catch((err) => { console.error(`Error fetching ${fetchPath}`, err); + onError(); }); } if (requiresAuth) { if (customAuthToken) { return doTheFetching(customAuthToken); } - return refreshToken().then((token) => { + return refreshToken(onError).then((token) => { if (token) { return doTheFetching(token); } else { @@ -214,7 +219,8 @@ export function getThemes( }); } -export function toggleStar(themeId: string, isStarred: boolean, authToken: string, apiUrl: string) { +export function toggleStar(themeId: string, isStarred: boolean, authToken: string) { + const { apiUrl } = globalState!.getPublicState(); return server! .fetchNoCors(`${apiUrl}/users/me/stars/${themeId}`, { method: isStarred ? "DELETE" : "POST", diff --git a/src/apiTypes/CSSThemeTypes.ts b/src/apiTypes/CSSThemeTypes.ts index 5d87613..cc411d5 100644 --- a/src/apiTypes/CSSThemeTypes.ts +++ b/src/apiTypes/CSSThemeTypes.ts @@ -5,13 +5,16 @@ export interface UserInfo { id: string; username: string; avatar: string; + premiumTier: string; } export interface MinimalCSSThemeInfo { id: string; name: string; + displayName: string; version: string; target: string; + targets: string[]; manifestVersion: number; specifiedAuthor: string; type: "Css" | "Audio"; diff --git a/src/backend/backendHelpers/index.ts b/src/backend/backendHelpers/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/backendHelpers/toggleTheme.tsx b/src/backend/backendHelpers/toggleTheme.tsx new file mode 100644 index 0000000..d213080 --- /dev/null +++ b/src/backend/backendHelpers/toggleTheme.tsx @@ -0,0 +1,77 @@ +import { Dispatch, SetStateAction } from "react"; +import { Flags, Theme } from "../../ThemeTypes"; +import * as python from "../../python"; +import { OptionalDepsModalRoot } from "../../components"; +import { showModal } from "decky-frontend-lib"; +import { enableNavPatch } from "../../deckyPatches/NavPatch"; +import { NavPatchInfoModalRoot } from "../../deckyPatches/NavPatchInfoModal"; + +// rerender and setCollapsed only apply to the QAM list version of the ThemeToggle, not the one in the fullscreen 'Your Themes' modal +export async function toggleTheme( + data: Theme, + enabled: boolean, + rerender: () => void = () => {}, + setCollapsed: Dispatch> = () => {} +) { + const { selectedPreset, navPatchInstance } = python.globalState!.getPublicState(); + + // Optional Deps Themes + if (enabled && data.flags.includes(Flags.optionalDeps)) { + showModal(); + rerender && rerender(); + } else { + // Actually enabling the theme + await python.setThemeState(data.name, enabled); + await python.getInstalledThemes(); + } + + // Re-collapse menu + setCollapsed && setCollapsed(true); + + // Dependency Toast + if (data.dependencies.length > 0) { + if (enabled) { + python.toast( + `${data.display_name} enabled other themes`, + // This lists out the themes by name, but often overflowed off screen + // @ts-ignore + // `${new Intl.ListFormat().format(data.dependencies)} ${ + // data.dependencies.length > 1 ? "are" : "is" + // } required for this theme` + // This just gives the number of themes + `${ + data.dependencies.length === 1 + ? `1 other theme is required by ${data.display_name}` + : `${data.dependencies.length} other themes are required by ${data.display_name}` + }` + ); + } + if (!enabled && !data.flags.includes(Flags.dontDisableDeps)) { + python.toast( + `${data.display_name} disabled other themes`, + `${ + data.dependencies.length === 1 + ? `1 theme was originally enabled by ${data.display_name}` + : `${data.dependencies.length} themes were originally enabled by ${data.display_name}` + }` + ); + } + } + + // Nav Patch + if (enabled && data.flags.includes(Flags.navPatch) && !navPatchInstance) { + showModal(); + } + + // Preset Updating + if (!selectedPreset) return; + // Fetch this here so that the data is up to date + const { localThemeList } = python.globalState!.getPublicState(); + + // This is copied from the desktop codebase + // If we refactor the desktop version of this function (which we probably should) this should also be refactored + await python.generatePresetFromThemeNames( + selectedPreset.name, + localThemeList.filter((e) => e.enabled && !e.flags.includes(Flags.isPreset)).map((e) => e.name) + ); +} diff --git a/src/backend/pythonMethods/pluginSettingsMethods.ts b/src/backend/pythonMethods/pluginSettingsMethods.ts new file mode 100644 index 0000000..e2fbc37 --- /dev/null +++ b/src/backend/pythonMethods/pluginSettingsMethods.ts @@ -0,0 +1,20 @@ +import { server, globalState } from "../pythonRoot"; + +export function enableServer() { + return server!.callPluginMethod("enable_server", {}); +} +export function getServerState() { + return server!.callPluginMethod<{}, boolean>("get_server_state", {}); +} +export function getWatchState() { + return server!.callPluginMethod<{}, boolean>("get_watch_state", {}); +} +export function toggleWatchState(bool: boolean, onlyThisSession: boolean = false) { + return server!.callPluginMethod<{ enable: boolean; only_this_session: boolean }, void>( + "toggle_watch_state", + { + enable: bool, + only_this_session: onlyThisSession, + } + ); +} diff --git a/src/backend/pythonRoot.ts b/src/backend/pythonRoot.ts new file mode 100644 index 0000000..d2edb2c --- /dev/null +++ b/src/backend/pythonRoot.ts @@ -0,0 +1,4 @@ +import { server } from "../python"; +import { globalState } from "../python"; + +export { server, globalState }; diff --git a/src/components/AllThemes/AllThemesModal.tsx b/src/components/AllThemes/AllThemesModal.tsx deleted file mode 100644 index 4dbbb96..0000000 --- a/src/components/AllThemes/AllThemesModal.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useMemo } from "react"; -import { - ButtonItem, - Focusable, - ModalRoot, - PanelSection, - PanelSectionRow, - gamepadDialogClasses, - showModal, -} from "decky-frontend-lib"; -import { CssLoaderContextProvider, CssLoaderState, useCssLoaderState } from "../../state"; -import { Flags } from "../../ThemeTypes"; -import { AllThemesSingleEntry } from "./AllThemesSingleEntry"; -import { PresetSelectionDropdown } from "../QAMTab/PresetSelectionDropdown"; -import { globalState } from "../../python"; - -export function AllThemesModalRoot({ closeModal }: { closeModal: any }) { - return ( - - {/* @ts-ignore */} - - - - - ); -} - -export function AllThemesModal({ closeModal }: { closeModal: any }) { - const { localThemeList, unpinnedThemes } = useCssLoaderState(); - - const sortedList = useMemo(() => { - return localThemeList - .filter((e) => !e.flags.includes(Flags.isPreset)) - .sort((a, b) => { - const aPinned = !unpinnedThemes.includes(a.id); - const bPinned = !unpinnedThemes.includes(b.id); - // This sorts the pinned themes alphabetically, then the non-pinned alphabetically - if (aPinned === bPinned) { - return a.name.localeCompare(b.name); - } - return Number(bPinned) - Number(aPinned); - }); - }, [localThemeList.length]); - - return ( - <> - {/*

Your Themes

*/} - -
- - - {sortedList.map((e) => ( - - ))} - - - - - -
- - { - closeModal(); - }} - > - Close - - - - ); -} diff --git a/src/components/AllThemes/AllThemesSingleEntry.tsx b/src/components/AllThemes/AllThemesSingleEntry.tsx deleted file mode 100644 index 8ee1126..0000000 --- a/src/components/AllThemes/AllThemesSingleEntry.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { DialogButton, ToggleField, showModal } from "decky-frontend-lib"; -import { Flags, Theme } from "../../ThemeTypes"; -import { CssLoaderState, useCssLoaderState } from "../../state"; -import * as python from "../../python"; -import { ImCog } from "react-icons/im"; -import { AiFillEye, AiOutlineEyeInvisible } from "react-icons/ai"; -import { ThemeSettingsModalRoot } from "./ThemeSettingsModal"; - -export function AllThemesSingleEntry({ data: e }: { data: Theme }) { - const { unpinnedThemes } = useCssLoaderState(); - const isPinned = !unpinnedThemes.includes(e.id); - return ( - <> -
-
- {e.name}} - checked={e.enabled} - onChange={(switchValue: boolean) => { - // Actually enabling the theme - python.resolve(python.setThemeState(e.name, switchValue), () => { - python.getInstalledThemes(); - }); - // Dependency Toast - if (e.dependencies.length > 0) { - if (switchValue === true) { - python.toast( - `${e.name} enabled other themes`, - // This lists out the themes by name, but often overflowed off screen - // @ts-ignore - // `${new Intl.ListFormat().format(data.dependencies)} ${ - // data.dependencies.length > 1 ? "are" : "is" - // } required for this theme` - // This just gives the number of themes - `${ - e.dependencies.length === 1 - ? `1 other theme is required by ${e.name}` - : `${e.dependencies.length} other themes are required by ${e.name}` - }` - ); - return; - } - if (!e.flags.includes(Flags.dontDisableDeps)) { - python.toast( - `${e.name} disabled other themes`, - // @ts-ignore - `${ - e.dependencies.length === 1 - ? `1 theme was originally enabled by ${e.name}` - : `${e.dependencies.length} themes were originally enabled by ${e.name}` - }` - ); - return; - } - } - }} - /> -
- { - if (isPinned) { - python.unpinTheme(e.id); - } else { - python.pinTheme(e.id); - } - }} - > - {isPinned ? ( - - ) : ( - - )} - - { - showModal( - // @ts-ignore - - ); - }} - > - - -
- - ); -} diff --git a/src/components/AllThemes/ThemeSettingsModal.tsx b/src/components/AllThemes/ThemeSettingsModal.tsx deleted file mode 100644 index 495359d..0000000 --- a/src/components/AllThemes/ThemeSettingsModal.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useState, useEffect } from "react"; - -import { DialogButton, ModalRoot } from "decky-frontend-lib"; -import { CssLoaderContextProvider, useCssLoaderState } from "../../state"; -import { ThemeToggle } from "../ThemeToggle"; -import { Theme } from "../../ThemeTypes"; -import { globalState } from "../../python"; -export function ThemeSettingsModalRoot({ - closeModal, - selectedTheme, -}: { - closeModal: any; - selectedTheme: string; -}) { - return ( - - {/* @ts-ignore */} - - - - - ); -} - -export function ThemeSettingsModal({ - closeModal, - selectedTheme, -}: { - closeModal: any; - selectedTheme: string; -}) { - const { localThemeList } = useCssLoaderState(); - const [themeData, setThemeData] = useState( - localThemeList.find((e) => e.id === selectedTheme) - ); - useEffect(() => { - setThemeData(localThemeList.find((e) => e.id === selectedTheme)); - return () => { - setThemeData(undefined); - }; - }, [selectedTheme, localThemeList]); - return ( - <> -
- {themeData ? ( -
- -
- ) : ( - No Theme Data - )} - { - closeModal(); - }} - > - Close - -
- - ); -} diff --git a/src/components/Modals/AuthorViewModal.tsx b/src/components/Modals/AuthorViewModal.tsx new file mode 100644 index 0000000..7f2bf68 --- /dev/null +++ b/src/components/Modals/AuthorViewModal.tsx @@ -0,0 +1,152 @@ +import { useEffect, useRef, useState } from "react"; +import * as python from "../../python"; +import { CssLoaderContextProvider, useCssLoaderState } from "../../state"; +import { Focusable, ModalRoot } from "decky-frontend-lib"; +import { genericGET } from "../../api"; +import { PartialCSSThemeInfo, ThemeQueryResponse, UserInfo } from "../../apiTypes"; +import { ImSpinner5 } from "react-icons/im"; +import { VariableSizeCard } from "../ThemeManager"; +import { ThemeBrowserCardStyles } from "../Styles"; +import { SupporterIcon } from "../SupporterIcon"; + +export function AuthorViewModalRoot({ + closeModal, + authorData, +}: { + closeModal?: any; + authorData: UserInfo; +}) { + return ( + <> + + {/* @ts-ignore */} + + + + + + ); +} + +function AuthorViewModal({ + authorData, + closeModal, +}: { + authorData: UserInfo; + closeModal: () => {}; +}) { + const { setGlobalState } = useCssLoaderState(); + + const [loaded, setLoaded] = useState(false); + const [themes, setThemes] = useState([]); + + const firstThemeRef = useRef(); + + async function fetchThemeData() { + const data: ThemeQueryResponse = await genericGET( + `/users/${authorData.id}/themes?page=1&perPage=50&filters=CSS&order=Most Downloaded` + ); + if (data?.total && data.total > 0) { + setThemes(data.items); + setLoaded(true); + } + } + useEffect(() => { + fetchThemeData(); + }, []); + + useEffect(() => { + if (firstThemeRef?.current) { + setTimeout(() => { + firstThemeRef?.current?.focus(); + }, 10); + } + }, [loaded]); + + return ( + + {loaded ? ( + <> + + +
+ + {authorData.username} +
+ +
+
+ + {themes.map((e, i) => { + return ( + { + setGlobalState("currentExpandedTheme", e); + closeModal(); + }} + refPassthrough={i === 0 ? firstThemeRef : null} + cols={4} + data={e} + /> + ); + })} + + + ) : ( + <> + +
+ + Loading +
+ + )} +
+ ); +} diff --git a/src/components/AllThemes/CreatePresetModal.tsx b/src/components/Modals/CreatePresetModal.tsx similarity index 89% rename from src/components/AllThemes/CreatePresetModal.tsx rename to src/components/Modals/CreatePresetModal.tsx index 5c72969..3731880 100644 --- a/src/components/AllThemes/CreatePresetModal.tsx +++ b/src/components/Modals/CreatePresetModal.tsx @@ -14,7 +14,7 @@ export function CreatePresetModalRoot({ closeModal }: { closeModal: any }) { ); } -export function CreatePresetModal({ closeModal }: { closeModal: () => void }) { +function CreatePresetModal({ closeModal }: { closeModal: () => void }) { const { localThemeList, selectedPreset } = useCssLoaderState(); const [presetName, setPresetName] = useState(""); const enabledNumber = localThemeList.filter((e) => e.enabled).length; @@ -35,11 +35,10 @@ export function CreatePresetModal({ closeModal }: { closeModal: () => void }) { await python.reloadBackend(); if (selectedPreset) { await python.setThemeState(selectedPreset?.name, false); - - await python.setThemeState(presetName, true); - await python.getInstalledThemes(); - closeModal(); } + await python.setThemeState(presetName + ".profile", true); + await python.getInstalledThemes(); + closeModal(); }} >
diff --git a/src/components/Modals/DeleteConfirmationModal.tsx b/src/components/Modals/DeleteConfirmationModal.tsx new file mode 100644 index 0000000..cd2e48e --- /dev/null +++ b/src/components/Modals/DeleteConfirmationModal.tsx @@ -0,0 +1,45 @@ +import { CssLoaderContextProvider } from "../../state"; +import * as python from "../../python"; +import { ConfirmModal, ModalRoot } from "decky-frontend-lib"; + +export function DeleteConfirmationModalRoot({ + themesToBeDeleted, + closeModal, + leaveDeleteMode, +}: { + themesToBeDeleted: string[]; + closeModal?: any; + leaveDeleteMode?: () => void; +}) { + async function deleteThemes() { + for (let i = 0; i < themesToBeDeleted.length; i++) { + await python.deleteTheme(themesToBeDeleted[i]); + } + await python.getInstalledThemes(); + leaveDeleteMode && leaveDeleteMode(); + closeModal(); + } + + return ( + + {/* @ts-ignore */} + + + + + ); +} + +function DeleteConfirmationModal({ themesToBeDeleted }: { themesToBeDeleted: string[] }) { + return ( +
+ Are you sure you want to delete{" "} + {themesToBeDeleted.length === 1 ? `this theme` : `these ${themesToBeDeleted.length} themes`}? +
+ ); +} diff --git a/src/components/Modals/ThemeSettingsModal/ThemeSettingsModal.tsx b/src/components/Modals/ThemeSettingsModal/ThemeSettingsModal.tsx new file mode 100644 index 0000000..290e70e --- /dev/null +++ b/src/components/Modals/ThemeSettingsModal/ThemeSettingsModal.tsx @@ -0,0 +1,166 @@ +import { useState, useEffect, useMemo } from "react"; + +import { DialogButton, Focusable, ModalRoot, Toggle } from "decky-frontend-lib"; +import { CssLoaderContextProvider, useCssLoaderState } from "../../../state"; +import { Theme } from "../../../ThemeTypes"; +import { globalState } from "../../../python"; +import { ThemeSettingsModalButtons } from "./ThemeSettingsModalButtons"; +import { toggleTheme } from "../../../backend/backendHelpers/toggleTheme"; +import { ThemePatch } from "../../ThemePatch"; +export function ThemeSettingsModalRoot({ + closeModal, + selectedTheme, +}: { + closeModal?: any; + selectedTheme: string; +}) { + return ( + + {/* @ts-ignore */} + + + + + ); +} + +function ThemeSettingsModal({ + closeModal, + selectedTheme, +}: { + closeModal: any; + selectedTheme: string; +}) { + const { localThemeList, updateStatuses } = useCssLoaderState(); + const [themeData, setThemeData] = useState( + localThemeList.find((e) => e.id === selectedTheme) + ); + + useEffect(() => { + setThemeData(localThemeList.find((e) => e.id === selectedTheme)); + return () => { + setThemeData(undefined); + }; + }, [selectedTheme, localThemeList]); + + return ( + <> + + + {themeData ? ( + <> + +
+ {themeData.name} + + {themeData.version} | {themeData.author} + +
+ { + toggleTheme(themeData, checked); + }} + /> +
+ {themeData.enabled && themeData.patches.length > 0 && ( + <> + + {themeData.patches.map((x, i, arr) => ( + + ))} + + + )} + + ) : ( + No Theme Data + )} + + { + closeModal(); + }} + > + Close + + {themeData && } + +
+ + ); +} diff --git a/src/components/Modals/ThemeSettingsModal/ThemeSettingsModalButtons.tsx b/src/components/Modals/ThemeSettingsModal/ThemeSettingsModalButtons.tsx new file mode 100644 index 0000000..165c895 --- /dev/null +++ b/src/components/Modals/ThemeSettingsModal/ThemeSettingsModalButtons.tsx @@ -0,0 +1,142 @@ +import { DialogButton, Focusable, showModal } from "decky-frontend-lib"; +import { LocalThemeStatus, Theme } from "../../../ThemeTypes"; +import { FaTrashAlt } from "react-icons/fa"; +import { DeleteConfirmationModalRoot } from "../DeleteConfirmationModal"; +import { useCssLoaderState } from "../../../state"; +import * as python from "../../../python"; +import { AiFillEye, AiOutlineEyeInvisible } from "react-icons/ai"; +import { + genericGET, + logInWithShortToken, + refreshToken, + toggleStar as apiToggleStar, + installTheme, +} from "../../../api"; +import { useState, useEffect } from "react"; +import { BsStarFill, BsStar, BsFillCloudDownloadFill } from "react-icons/bs"; + +export function ThemeSettingsModalButtons({ + themeData, + closeModal, +}: { + themeData: Theme; + closeModal: () => void; +}) { + const { unpinnedThemes, apiShortToken, apiFullToken, updateStatuses, setGlobalState } = + useCssLoaderState(); + const isPinned = !unpinnedThemes.includes(themeData.id); + const [starFetchLoaded, setStarFetchLoaded] = useState(false); + const [isStarred, setStarred] = useState(false); + const [blurButtons, setBlurButtons] = useState(false); + + const [updateStatus, setUpdateStatus] = useState("installed"); + useEffect(() => { + if (!themeData) return; + const themeArrPlace = updateStatuses.find((f) => f[0] === themeData.id); + if (themeArrPlace) { + setUpdateStatus(themeArrPlace[1]); + } + }, [themeData]); + + async function toggleStar() { + if (apiFullToken) { + setBlurButtons(true); + const newToken = await refreshToken(); + if (themeData && newToken) { + apiToggleStar(themeData.id, isStarred, newToken).then((bool) => { + if (bool) { + setStarred((cur) => !cur); + setBlurButtons(false); + } + }); + } + } else { + python.toast("Not Logged In!", "You can only star themes if logged in."); + } + } + + async function getStarredStatus() { + if (themeData && apiShortToken) { + if (!apiFullToken) { + await logInWithShortToken(); + } + const data = (await genericGET(`/users/me/stars/${themeData.id}`, true, undefined)) as { + starred: boolean; + }; + if (data) { + console.log("DATA", data); + setStarFetchLoaded(true); + setStarred(data.starred); + } + } + } + useEffect(() => { + getStarredStatus(); + }, []); + + return ( + <> + + {updateStatus === "outdated" && ( + { + await installTheme(themeData.id); + // This just updates the updateStatuses arr to know that this theme now is up to date, no need to re-fetch the API to know that + setGlobalState( + "updateStatuses", + updateStatuses.map((e) => + e[0] === themeData.id ? [themeData.id, "installed", false] : e + ) + ); + }} + > + + Update + + )} + { + if (isPinned) { + python.unpinTheme(themeData.id); + } else { + python.pinTheme(themeData.id); + } + }} + > + {isPinned ? ( + + ) : ( + + )} + + {starFetchLoaded && ( + + {isStarred ? : } + + )} + { + showModal( + + ); + }} + > + + + + + ); +} diff --git a/src/components/Modals/ThemeSettingsModal/index.ts b/src/components/Modals/ThemeSettingsModal/index.ts new file mode 100644 index 0000000..ae9aacf --- /dev/null +++ b/src/components/Modals/ThemeSettingsModal/index.ts @@ -0,0 +1 @@ +export * from "./ThemeSettingsModal"; diff --git a/src/components/AllThemes/index.ts b/src/components/Modals/index.ts similarity index 50% rename from src/components/AllThemes/index.ts rename to src/components/Modals/index.ts index ae47092..e6ef5d1 100644 --- a/src/components/AllThemes/index.ts +++ b/src/components/Modals/index.ts @@ -1,4 +1,2 @@ -export * from "./AllThemesModal"; export * from "./CreatePresetModal"; export * from "./ThemeSettingsModal"; -export * from "./AllThemesSingleEntry"; diff --git a/src/components/OptionalDepsModal.tsx b/src/components/OptionalDepsModal.tsx index e0eb7f2..3518822 100644 --- a/src/components/OptionalDepsModal.tsx +++ b/src/components/OptionalDepsModal.tsx @@ -6,7 +6,7 @@ export function OptionalDepsModalRoot({ closeModal, }: { themeData: Theme; - closeModal: any; + closeModal?: any; }) { return ( diff --git a/src/components/QAMTab/PresetSelectionDropdown.tsx b/src/components/QAMTab/PresetSelectionDropdown.tsx index 009abdd..d1528fd 100644 --- a/src/components/QAMTab/PresetSelectionDropdown.tsx +++ b/src/components/QAMTab/PresetSelectionDropdown.tsx @@ -3,7 +3,7 @@ import { useCssLoaderState } from "../../state"; import { Flags } from "../../ThemeTypes"; import { useMemo } from "react"; import { changePreset, getInstalledThemes } from "../../python"; -import { CreatePresetModalRoot } from "../AllThemes/CreatePresetModal"; +import { CreatePresetModalRoot } from "../Modals/CreatePresetModal"; import { FiPlusCircle } from "react-icons/fi"; import { useRerender } from "../../hooks"; @@ -31,7 +31,7 @@ export function PresetSelectionDropdown() { ? [{ data: "Invalid State", label: "Invalid State" }] : []), { data: "None", label: "None" }, - ...presets.map((e) => ({ label: e.name, data: e.name })), + ...presets.map((e) => ({ label: e.display_name, data: e.name })), // This is a jank way of only adding it if creatingNewProfile = false { data: "New Profile", @@ -59,8 +59,6 @@ export function PresetSelectionDropdown() { rerender(); return; } - // This is kind of abusing the system because if you select "None" it attempts to enable a theme called "None" - // But it works await changePreset(data, localThemeList); getInstalledThemes(); }} diff --git a/src/components/QAMTab/QAMThemeToggleList.tsx b/src/components/QAMTab/QAMThemeToggleList.tsx index b98588a..0c826f1 100644 --- a/src/components/QAMTab/QAMThemeToggleList.tsx +++ b/src/components/QAMTab/QAMThemeToggleList.tsx @@ -1,8 +1,8 @@ -import { ButtonItem, Focusable, PanelSectionRow, showModal } from "decky-frontend-lib"; -import { CssLoaderState, useCssLoaderState } from "../../state"; +import { Focusable } from "decky-frontend-lib"; +import { useCssLoaderState } from "../../state"; import { ThemeToggle } from "../ThemeToggle"; -import { AllThemesModalRoot } from "../AllThemes"; import { Flags } from "../../ThemeTypes"; +import { ThemeErrorCard } from "../ThemeErrorCard"; export function QAMThemeToggleList() { const { localThemeList, unpinnedThemes } = useCssLoaderState(); @@ -26,9 +26,14 @@ export function QAMThemeToggleList() { align-items: stretch; width: 100%; } + /* PRE Aug 18th Beta */ .CSSLoader_QAM_CollapseButton_Container > div > div > div > button { height: 10px !important; } + /* POST Aug 18th Beta */ + .CSSLoader_QAM_CollapseButton_Container > div > div > div > div > button { + height: 10px !important; + } `} @@ -48,17 +53,6 @@ export function QAMThemeToggleList() { ))} )} - - { - // @ts-ignore - showModal(); - }} - > - Your Themes - - ); diff --git a/src/components/Styles/ExpandedViewStyles.tsx b/src/components/Styles/ExpandedViewStyles.tsx new file mode 100644 index 0000000..575be5b --- /dev/null +++ b/src/components/Styles/ExpandedViewStyles.tsx @@ -0,0 +1,211 @@ +export function ExpandedViewStyles({ + gapBetweenCarouselAndImage, + imageAreaPadding, + imageAreaWidth, + selectedImageHeight, + selectedImageWidth, + imageCarouselEntryHeight, + imageCarouselEntryWidth, +}: { + gapBetweenCarouselAndImage: number; + imageAreaPadding: number; + imageAreaWidth: number; + selectedImageHeight: number; + selectedImageWidth: number; + imageCarouselEntryHeight: number; + imageCarouselEntryWidth: number; +}) { + return ( + + ); +} diff --git a/src/components/Styles/ThemeBrowserCardStyles.tsx b/src/components/Styles/ThemeBrowserCardStyles.tsx new file mode 100644 index 0000000..7b1a766 --- /dev/null +++ b/src/components/Styles/ThemeBrowserCardStyles.tsx @@ -0,0 +1,129 @@ +import { useCssLoaderState } from "../../state"; + +export function ThemeBrowserCardStyles({ customCardSize }: { customCardSize?: number }) { + const { browserCardSize } = customCardSize + ? { browserCardSize: customCardSize } + : useCssLoaderState(); + + return ( + + ); +} diff --git a/src/components/Styles/index.ts b/src/components/Styles/index.ts new file mode 100644 index 0000000..3d66ae3 --- /dev/null +++ b/src/components/Styles/index.ts @@ -0,0 +1,2 @@ +export * from "./ExpandedViewStyles"; +export * from "./ThemeBrowserCardStyles"; diff --git a/src/components/SupporterIcon.tsx b/src/components/SupporterIcon.tsx new file mode 100644 index 0000000..0efd4b0 --- /dev/null +++ b/src/components/SupporterIcon.tsx @@ -0,0 +1,47 @@ +import { RiMedalFill } from "react-icons/ri"; +import { UserInfo } from "../apiTypes/CSSThemeTypes"; + +export function SupporterIcon({ author }: { author: UserInfo }) { + const randId = Math.trunc(Math.random() * 69420); + return ( + <> + {author?.premiumTier && author?.premiumTier !== "None" && ( +
+ + + + + + + + {`Tier ${author?.premiumTier?.slice(-1)} Patreon Supporter`} +
+ )} + + ); +} diff --git a/src/components/ThemeErrorCard.tsx b/src/components/ThemeErrorCard.tsx new file mode 100644 index 0000000..48e5fad --- /dev/null +++ b/src/components/ThemeErrorCard.tsx @@ -0,0 +1,30 @@ +import { Focusable, PanelSectionRow } from "decky-frontend-lib"; +import { ThemeError } from "../ThemeTypes"; + +export function ThemeErrorCard({ errorData }: { errorData: ThemeError }) { + return ( + {}} + style={{ + width: "100%", + margin: 0, + padding: 0, + }} + > +
+ + {errorData[0]} + + {errorData[1]} +
+
+ ); +} diff --git a/src/components/ThemeManager/BrowserItemCard.tsx b/src/components/ThemeManager/BrowserItemCard.tsx index 93db313..dd73e31 100644 --- a/src/components/ThemeManager/BrowserItemCard.tsx +++ b/src/components/ThemeManager/BrowserItemCard.tsx @@ -1,84 +1,32 @@ import { FC } from "react"; import { useCssLoaderState } from "../../state"; import { Theme } from "../../ThemeTypes"; -import { Focusable, Router } from "decky-frontend-lib"; +import { Focusable, Navigation } from "decky-frontend-lib"; import { AiOutlineDownload } from "react-icons/ai"; import { PartialCSSThemeInfo, ThemeQueryRequest } from "../../apiTypes"; - -const topMargin = { - 5: "2px", - 4: "3px", - 3: "5px", -}; - -const bottomMargin = { - 5: "4px", - 4: "6px", - 3: "8px", -}; +import { BsCloudDownload, BsStar } from "react-icons/bs"; +import { FiTarget } from "react-icons/fi"; const cardWidth = { - 5: "152px", - 4: "195px", - 3: "260px", -}; - -const imgWidth = { - 5: "140.2px", - 4: "180px", - 3: "240px", -}; - -const imgHeight = { - 5: "87.6px", - 4: "112.5px", - 3: "150px", -}; - -const targetHeight = { - 5: "12px", - 4: "18px", - 3: "25px", -}; - -const bubbleOffset = { - 5: "-5px", - 4: "-7.5px", - 3: "-10px", -}; - -const bubblePadding = { - 5: "4px 5px 2.5px", - 4: "4px 6px 2px", - 3: "5px 8px 2.5px 8px", -}; - -const bigText = { - 5: "0.75em", - 4: "1em", - 3: "1.25em", -}; - -const smallText = { - 5: "0.5em", - 4: "0.75em", - 3: "1em", + 5: 152, + 4: 195, + 3: 260, }; export const VariableSizeCard: FC<{ data: PartialCSSThemeInfo; cols: number; - showTarget: boolean; - searchOpts: ThemeQueryRequest; - prevSearchOptsVarName: string; + searchOpts?: ThemeQueryRequest; + prevSearchOptsVarName?: string; refPassthrough?: any; + onClick?: () => void; }> = ({ data: e, cols: size, - showTarget = true, refPassthrough = undefined, searchOpts, prevSearchOptsVarName, + onClick, }) => { const { localThemeList, apiUrl, setGlobalState } = useCssLoaderState(); function checkIfThemeInstalled(themeObj: PartialCSSThemeInfo) { @@ -104,141 +52,61 @@ export const VariableSizeCard: FC<{ } const installStatus = checkIfThemeInstalled(e); + return ( - // The outer 2 most divs are the background darkened/blurred image, and everything inside is the text/image/buttons <>
{installStatus === "outdated" && ( -
- +
+
)} { - setGlobalState(prevSearchOptsVarName, searchOpts); + if (onClick) { + onClick(); + return; + } + if (searchOpts && prevSearchOptsVarName) { + setGlobalState(prevSearchOptsVarName, searchOpts); + } setGlobalState("currentExpandedTheme", e); - Router.Navigate("/cssloader/expanded-view"); - }} - className="CssLoader_ThemeBrowser_SingleItem_BgImage" - style={{ - backgroundImage: imageURLCreator(), - backgroundSize: "cover", - backgroundRepeat: "no-repeat", - backgroundPosition: "center", - width: cardWidth[size], - borderRadius: "5px", + Navigation.Navigate("/cssloader/expanded-view"); }} > -
- - {e.name} - - {showTarget && ( - - {e.target} - - )} -
+ -
- - {e.specifiedAuthor} - - - {e.version} - +
+
+
+ + {e.download.downloadCount} +
+
+ + {e.starCount} +
+
+ + {e.target} +
+
+ {e.displayName} + + {e.version} - Last Updated {new Date(e.updated).toLocaleDateString()} + + By {e.specifiedAuthor} +
diff --git a/src/components/ThemeManager/BrowserSearchFields.tsx b/src/components/ThemeManager/BrowserSearchFields.tsx index 42e1d5f..9c5ea32 100644 --- a/src/components/ThemeManager/BrowserSearchFields.tsx +++ b/src/components/ThemeManager/BrowserSearchFields.tsx @@ -182,15 +182,26 @@ export function BrowserSearchFields({
diff --git a/src/components/ThemeSettings/DeleteMenu.tsx b/src/components/ThemeSettings/DeleteMenu.tsx new file mode 100644 index 0000000..ba5c747 --- /dev/null +++ b/src/components/ThemeSettings/DeleteMenu.tsx @@ -0,0 +1,54 @@ +import { + DialogButton, + DialogCheckbox, + Focusable, + PanelSection, + showModal, +} from "decky-frontend-lib"; +import { Theme } from "../../ThemeTypes"; +import { useState } from "react"; +import { DeleteConfirmationModalRoot } from "../Modals/DeleteConfirmationModal"; + +export function DeleteMenu({ + themeList, + leaveDeleteMode, +}: { + themeList: Theme[]; + leaveDeleteMode: () => void; +}) { + let [choppingBlock, setChoppingBlock] = useState([]); // name arr + return ( + + + {themeList.map((e) => ( +
+ { + if (checked) { + setChoppingBlock([...choppingBlock, e.name]); + } else { + setChoppingBlock(choppingBlock.filter((f) => f !== e.name)); + } + }} + checked={choppingBlock.includes(e.name)} + label={e.name} + /> +
+ ))} + { + showModal( + + ); + }} + > + Delete + +
+
+ ); +} diff --git a/src/components/ThemeSettings/FullscreenProfileEntry.tsx b/src/components/ThemeSettings/FullscreenProfileEntry.tsx new file mode 100644 index 0000000..2192945 --- /dev/null +++ b/src/components/ThemeSettings/FullscreenProfileEntry.tsx @@ -0,0 +1,86 @@ +import { DialogButton, Focusable, PanelSectionRow } from "decky-frontend-lib"; +import { Flags, LocalThemeStatus, Theme } from "../../ThemeTypes"; +import { useCssLoaderState } from "../../state"; +import { AiOutlineDownload } from "react-icons/ai"; +import { FaTrash } from "react-icons/fa"; +import { installTheme } from "../../api"; + +export function FullscreenProfileEntry({ + data: e, + handleUninstall, + isInstalling, + handleUpdate, +}: { + data: Theme; + handleUninstall: (e: Theme) => void; + handleUpdate: (e: Theme) => void; + isInstalling: boolean; +}) { + const { updateStatuses } = useCssLoaderState(); + let [updateStatus]: [LocalThemeStatus] = ["installed"]; + const themeArrPlace = updateStatuses.find((f) => f[0] === e.id); + if (themeArrPlace) { + updateStatus = themeArrPlace[1]; + } + return ( + +
+ {e.display_name} + + {/* Update Button */} + {updateStatus === "outdated" && ( + handleUpdate(e)} + disabled={isInstalling} + > + + + )} + {/* This shows when a theme is local, but not a preset */} + {updateStatus === "local" && !e.flags.includes(Flags.isPreset) && ( + + Local Theme + + )} + handleUninstall(e)} + disabled={isInstalling} + > + + + +
+
+ ); +} diff --git a/src/components/ThemeSettings/FullscreenSingleThemeEntry.tsx b/src/components/ThemeSettings/FullscreenSingleThemeEntry.tsx new file mode 100644 index 0000000..1b015ec --- /dev/null +++ b/src/components/ThemeSettings/FullscreenSingleThemeEntry.tsx @@ -0,0 +1,114 @@ +import { DialogButton, Focusable, ToggleField, showModal } from "decky-frontend-lib"; +import { LocalThemeStatus, Theme } from "../../ThemeTypes"; +import { useCssLoaderState } from "../../state"; +import * as python from "../../python"; +import { ImCog } from "react-icons/im"; +import { AiFillEye, AiOutlineEyeInvisible } from "react-icons/ai"; +import { toggleTheme } from "../../backend/backendHelpers/toggleTheme"; +import { ThemeSettingsModalRoot } from "../Modals/ThemeSettingsModal"; +import { FaTrash } from "react-icons/fa"; + +export function FullscreenSingleThemeEntry({ + data: e, + showModalButtonPrompt = false, + handleUninstall, + handleUpdate, + isInstalling, +}: { + data: Theme; + showModalButtonPrompt?: boolean; + handleUninstall: (e: Theme) => void; + handleUpdate: (e: Theme) => void; + isInstalling: boolean; +}) { + const { unpinnedThemes, updateStatuses } = useCssLoaderState(); + const isPinned = !unpinnedThemes.includes(e.id); + + let [updateStatus]: [LocalThemeStatus] = ["installed"]; + const themeArrPlace = updateStatuses.find((f) => f[0] === e.id); + if (themeArrPlace) { + updateStatus = themeArrPlace[1]; + } + + // I extracted these here as doing conditional props inline sucks + const modalButtonProps = showModalButtonPrompt + ? { + onOptionsActionDescription: "Expand Settings", + onOptionsButton: () => { + showModal(); + }, + } + : {}; + + const updateButtonProps = + updateStatus === "outdated" + ? { + onSecondaryButton: () => { + handleUpdate(e); + }, + onSecondaryActionDescription: "Update Theme", + } + : {}; + + return ( + <> +
+ {updateStatus === "outdated" && ( +
+ )} + + {e.display_name}} + checked={e.enabled} + onChange={(switchValue: boolean) => { + toggleTheme(e, switchValue); + }} + /> + + { + if (isPinned) { + python.unpinTheme(e.id); + } else { + python.pinTheme(e.id); + } + }} + > + {isPinned ? ( + + ) : ( + + )} + + { + showModal(); + }} + > + + +
+ + ); +} diff --git a/src/components/ThemeSettings/UpdateAllThemesButton.tsx b/src/components/ThemeSettings/UpdateAllThemesButton.tsx new file mode 100644 index 0000000..2a09fe7 --- /dev/null +++ b/src/components/ThemeSettings/UpdateAllThemesButton.tsx @@ -0,0 +1,31 @@ +import { DialogButton } from "decky-frontend-lib"; +import { useCssLoaderState } from "../../state"; +import { Theme } from "../../ThemeTypes"; +import { BsFillCloudDownloadFill } from "react-icons/bs"; + +export function UpdateAllThemesButton({ + handleUpdate, +}: { + handleUpdate: (entry: Theme) => Promise; +}) { + const { updateStatuses, localThemeList } = useCssLoaderState(); + + async function updateAll() { + const themesToBeUpdated = updateStatuses.filter((e) => e[1] === "outdated"); + for (let i = 0; i < themesToBeUpdated.length; i++) { + const entry = localThemeList.find((f) => f.id === themesToBeUpdated[i][0]); + if (!entry) break; + await handleUpdate(entry); + } + } + return ( + <> + {updateStatuses.filter((e) => e[1] === "outdated").length > 0 && ( + + + Update All Themes + + )} + + ); +} diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx index 8e52606..711a8cb 100644 --- a/src/components/ThemeToggle.tsx +++ b/src/components/ThemeToggle.tsx @@ -2,25 +2,22 @@ import { ButtonItem, Focusable, PanelSectionRow, ToggleField, showModal } from " import { VFC, useState, useMemo } from "react"; import { Flags, LocalThemeStatus, Theme, UpdateStatus } from "../ThemeTypes"; -import * as python from "../python"; import { ThemePatch } from "./ThemePatch"; import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; -import { OptionalDepsModalRoot } from "./OptionalDepsModal"; import { useCssLoaderState } from "../state"; import { useRerender } from "../hooks"; // This has to be a direct import to avoid the circular dependency -import { ThemeSettingsModalRoot } from "./AllThemes/ThemeSettingsModal"; -import { MinimalCSSThemeInfo } from "../apiTypes"; -import { AiOutlineDownload } from "react-icons/ai"; +import { ThemeSettingsModalRoot } from "./Modals/ThemeSettingsModal"; import { installTheme } from "../api"; +import { toggleTheme } from "../backend/backendHelpers/toggleTheme"; export const ThemeToggle: VFC<{ data: Theme; collapsible?: boolean; showModalButtonPrompt?: boolean; -}> = ({ data, collapsible = false, showModalButtonPrompt = false }) => { - const { selectedPreset, localThemeList, updateStatuses, setGlobalState, isInstalling } = - useCssLoaderState(); + isFullscreen?: boolean; +}> = ({ data, collapsible = false, showModalButtonPrompt = false, isFullscreen = false }) => { + const { updateStatuses, setGlobalState, isInstalling } = useCssLoaderState(); const [collapsed, setCollapsed] = useState(true); const [render, rerender] = useRerender(); @@ -33,14 +30,10 @@ export const ThemeToggle: VFC<{ // This might not actually memoize it as data.flags is an array, so idk if it deep checks the values here }, [data.flags]); - let [updateStatus, remoteEntry]: [LocalThemeStatus, false | MinimalCSSThemeInfo] = [ - "installed", - false, - ]; + let [updateStatus]: [LocalThemeStatus] = ["installed"]; const themeArrPlace = updateStatuses.find((f) => f[0] === data.id); if (themeArrPlace) { updateStatus = themeArrPlace[1]; - remoteEntry = themeArrPlace[2]; } // I extracted these here as doing conditional props inline sucks @@ -88,8 +81,8 @@ export const ThemeToggle: VFC<{ style={{ position: "absolute", top: "0", - right: "-1em", - // This creates the traiangle effect + right: isFullscreen ? "-1.5em" : "-1em", + // This creates the triangle effect background: "linear-gradient(45deg, transparent 49%, #fca904 50%)", // The focusRing has a z index of 10000, so this is just to be cheeky zIndex: "10001", @@ -102,7 +95,7 @@ export const ThemeToggle: VFC<{ disabled={isInstalling} bottomSeparator={data.enabled && data?.patches?.length > 0 ? "none" : "standard"} checked={data.enabled} - label={data.name} + label={data.display_name} description={ isPreset ? `Preset` @@ -111,66 +104,7 @@ export const ThemeToggle: VFC<{ }` } onChange={async (switchValue: boolean) => { - if (switchValue === true && data.flags.includes(Flags.optionalDeps)) { - // @ts-ignore - showModal(); - rerender(); - return; - } - // Actually enabling the theme - await python.setThemeState(data.name, switchValue); - await python.getInstalledThemes(); - // Re-collapse menu - setCollapsed(true); - // Dependency Toast - if (data.dependencies.length > 0) { - if (switchValue) { - python.toast( - `${data.name} enabled other themes`, - // This lists out the themes by name, but often overflowed off screen - // @ts-ignore - // `${new Intl.ListFormat().format(data.dependencies)} ${ - // data.dependencies.length > 1 ? "are" : "is" - // } required for this theme` - // This just gives the number of themes - `${ - data.dependencies.length === 1 - ? `1 other theme is required by ${data.name}` - : `${data.dependencies.length} other themes are required by ${data.name}` - }` - ); - } - if (!switchValue && !data.flags.includes(Flags.dontDisableDeps)) { - python.toast( - `${data.name} disabled other themes`, - // @ts-ignore - `${ - data.dependencies.length === 1 - ? `1 theme was originally enabled by ${data.name}` - : `${data.dependencies.length} themes were originally enabled by ${data.name}` - }` - ); - } - } - if (!selectedPreset) return; - // This is copied from the desktop codebase - // If we refactor the desktop version of this function (which we probably should) this should also be refactored - await python.generatePresetFromThemeNames( - selectedPreset.name, - switchValue - ? [ - ...localThemeList - .filter((e) => e.enabled && !e.flags.includes(Flags.isPreset)) - .map((e) => e.name), - data.name, - ] - : localThemeList - .filter( - (e) => - e.enabled && !e.flags.includes(Flags.isPreset) && e.name !== data.name - ) - .map((e) => e.name) - ); + toggleTheme(data, switchValue, rerender, setCollapsed); }} /> diff --git a/src/components/TitleView.tsx b/src/components/TitleView.tsx index 8cfb9f4..2e13c7e 100644 --- a/src/components/TitleView.tsx +++ b/src/components/TitleView.tsx @@ -1,44 +1,45 @@ -import { DialogButton, Router, staticClasses, Focusable } from "decky-frontend-lib"; +import { DialogButton, Navigation, staticClasses, Focusable } from "decky-frontend-lib"; import { BsGearFill } from "react-icons/bs"; -import { FaStore } from "react-icons/fa"; +import { FaInfo, FaStore } from "react-icons/fa"; -export function TitleView() { - // const onSettingsClick = () => { - // Router.CloseSideMenus(); - // Router.Navigate("/decky/settings"); - // }; +export function TitleView({ onDocsClick }: { onDocsClick?: () => {} }) { + const onSettingsClick = () => { + Navigation.CloseSideMenus(); + Navigation.Navigate("/cssloader/settings"); + }; const onStoreClick = () => { - Router.CloseSideMenus(); - Router.Navigate("/cssloader/theme-manager"); + Navigation.CloseSideMenus(); + Navigation.Navigate("/cssloader/theme-manager"); }; + console.log("HEELO WUMPUS", onDocsClick); + return ( -
CSS Loader
+
CSS Loader
- {/* - */} +
); } diff --git a/src/components/index.ts b/src/components/index.ts index 8339d87..1c3c511 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,6 +3,6 @@ export * from "./ThemePatch"; export * from "./PatchComponent"; export * from "./TitleView"; export * from "./ThemeManager"; -export * from "./AllThemes"; export * from "./OptionalDepsModal"; export * from "./QAMTab"; +export * from "./Modals"; diff --git a/src/deckyPatches/NavControllerFinder.tsx b/src/deckyPatches/NavControllerFinder.tsx new file mode 100644 index 0000000..f7920e6 --- /dev/null +++ b/src/deckyPatches/NavControllerFinder.tsx @@ -0,0 +1,3 @@ +import { Module, findModuleChild } from "decky-frontend-lib"; + +export const NavController = findModuleChild((m: Module) => m?.CFocusNavNode); diff --git a/src/deckyPatches/NavPatch.tsx b/src/deckyPatches/NavPatch.tsx new file mode 100644 index 0000000..83b9163 --- /dev/null +++ b/src/deckyPatches/NavPatch.tsx @@ -0,0 +1,42 @@ +import { replacePatch } from "decky-frontend-lib"; +import { NavController } from "./NavControllerFinder"; +import { globalState, toast } from "../python"; + +export function enableNavPatch() { + const setGlobalState = globalState!.setGlobalState.bind(globalState); + const { navPatchInstance } = globalState!.getPublicState(); + // Don't patch twice + if (navPatchInstance) return; + const patch = replacePatch( + NavController.prototype, + "FindNextFocusableChildInDirection", + function (args) { + const e = args[0]; + const t = args[1]; + const r = args[2]; + let n = t == 1 ? 1 : -1; + // @ts-ignore + for (let t = e + n; t >= 0 && t < this.m_rgChildren.length; t += n) { + // @ts-ignore + const e = this.m_rgChildren[t].FindFocusableNode(r); + if (e && window.getComputedStyle(e.m_element).display !== "none") return e; + } + return null; + } + ); + setGlobalState("navPatchInstance", patch); + toast("CSS Loader", "Nav Patch Enabled"); + return; +} + +export function disableNavPatch() { + const setGlobalState = globalState!.setGlobalState.bind(globalState); + const { navPatchInstance } = globalState!.getPublicState(); + // Don't unpatch something that doesn't exist + // Probably the closest thing JS can get to null dereference + if (!navPatchInstance) return; + navPatchInstance.unpatch(); + setGlobalState("navPatchInstance", undefined); + toast("CSS Loader", "Nav Patch Disabled"); + return; +} diff --git a/src/deckyPatches/NavPatchInfoModal.tsx b/src/deckyPatches/NavPatchInfoModal.tsx new file mode 100644 index 0000000..bab8716 --- /dev/null +++ b/src/deckyPatches/NavPatchInfoModal.tsx @@ -0,0 +1,24 @@ +import { DialogButton, Focusable, ConfirmModal } from "decky-frontend-lib"; +import { Theme } from "../ThemeTypes"; +import { disableNavPatch, enableNavPatch } from "./NavPatch"; +export function NavPatchInfoModalRoot({ + themeData, + closeModal, +}: { + themeData: Theme; + closeModal?: any; +}) { + function onButtonClick() { + enableNavPatch(); + closeModal(); + } + return ( + + + {themeData.name} hides elements that can be selected using a controller. + For this to work correctly, CSS Loader needs to patch controller navigation. + Not enabling this feature will cause visually hidden elements to be able to be selected using a controller. + + + ); +} \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index 442c60b..03fb94d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,20 +4,25 @@ import { PanelSection, PanelSectionRow, ServerAPI, - Router, + DialogButton, + Focusable, + Navigation, } from "decky-frontend-lib"; import { useEffect, useState, FC } from "react"; import * as python from "./python"; import * as api from "./api"; import { RiPaintFill } from "react-icons/ri"; -import { ThemeManagerRouter } from "./theme-manager"; +import { ThemeManagerRouter } from "./pages/theme-manager"; import { CssLoaderContextProvider, CssLoaderState, useCssLoaderState } from "./state"; import { PresetSelectionDropdown, QAMThemeToggleList, TitleView } from "./components"; -import { ExpandedViewPage } from "./theme-manager/ExpandedView"; +import { ExpandedViewPage } from "./pages/theme-manager/ExpandedView"; import { Flags, Theme } from "./ThemeTypes"; import { dummyFunction, getInstalledThemes, reloadBackend } from "./python"; import { bulkThemeUpdateCheck } from "./logic/bulkThemeUpdateCheck"; +import { disableNavPatch, enableNavPatch } from "./deckyPatches/NavPatch"; +import { FaCog, FaStore } from "react-icons/fa"; +import { SettingsPageRouter } from "./pages/settings/SettingsPageRouter"; function Content() { const { localThemeList, setGlobalState } = useCssLoaderState(); @@ -37,17 +42,9 @@ function Content() { function reload() { reloadBackend(); dummyFuncTest(); - bulkThemeUpdateCheck().then((data) => [setGlobalState("updateStatuses", data)]); + bulkThemeUpdateCheck().then((data) => setGlobalState("updateStatuses", data)); } - // This will likely only run on a user's first run - // todo: potentially there's a way to make this run without an expensive stringify useEffect running always - // however, I want to make sure that someone can't delete the folder "Default Profile", as that would be bad - useEffect(() => { - // This happens before state prefilled - if (localThemeList.length === 0) return; - }, [JSON.stringify(localThemeList.filter((e) => e.flags.includes(Flags.isPreset)))]); - useEffect(() => { setGlobalState( "selectedPreset", @@ -64,17 +61,30 @@ function Content() { {dummyFuncResult ? ( <> - - { - Router.CloseSideMenus(); - Router.Navigate("/cssloader/theme-manager"); - }} - > - Download Themes - - + @@ -109,6 +119,8 @@ export default definePlugin((serverApi: ServerAPI) => { "selectedPreset", allThemes.find((e) => e.flags.includes(Flags.isPreset) && e.enabled) ); + + // Check for updates, and schedule a check 24 hours from now bulkThemeUpdateCheck(allThemes).then((data) => { state.setGlobalState("updateStatuses", data); }); @@ -134,18 +146,32 @@ export default definePlugin((serverApi: ServerAPI) => { }); }); + // Api Token python.resolve(python.storeRead("shortToken"), (token: string) => { if (token) { state.setGlobalState("apiShortToken", token); } }); + // Nav Patch + python.resolve(python.storeRead("enableNavPatch"), (value: string) => { + if (value === "true") { + enableNavPatch(); + } + }); + serverApi.routerHook.addRoute("/cssloader/theme-manager", () => ( )); + serverApi.routerHook.addRoute("/cssloader/settings", () => ( + + + + )); + serverApi.routerHook.addRoute("/cssloader/expanded-view", () => ( @@ -153,7 +179,7 @@ export default definePlugin((serverApi: ServerAPI) => { )); return { - // titleView: , + titleView: , title:
CSSLoader
, alwaysRender: true, content: ( @@ -165,6 +191,7 @@ export default definePlugin((serverApi: ServerAPI) => { onDismount: () => { const { updateCheckTimeout } = state.getPublicState(); if (updateCheckTimeout) clearTimeout(updateCheckTimeout); + disableNavPatch(); }, }; }); diff --git a/src/pages/settings/Credits.tsx b/src/pages/settings/Credits.tsx new file mode 100644 index 0000000..fadf189 --- /dev/null +++ b/src/pages/settings/Credits.tsx @@ -0,0 +1,40 @@ +export function Credits() { + return ( +
+
+
+

Developers

+
    +
  • + SuchMeme - github.com/suchmememanyskill +
  • +
  • + Beebles - github.com/beebls +
  • +
  • + EMERALD - github.com/EMERALD0874 +
  • +
+
+
+

Support

+ + See the DeckThemes Discord server for support. +
+ discord.gg/HsU72Kfnpf +
+
+
+

+ Create and Submit Your Own Theme +

+ + Instructions for theme creation/submission are available DeckThemes' docs website. +
+ docs.deckthemes.com +
+
+
+
+ ); +} diff --git a/src/pages/settings/DonatePage.tsx b/src/pages/settings/DonatePage.tsx new file mode 100644 index 0000000..6d60556 --- /dev/null +++ b/src/pages/settings/DonatePage.tsx @@ -0,0 +1,164 @@ +import { + DialogButton, + Focusable, + Navigation, + Panel, + PanelSection, + ScrollPanelGroup, +} from "decky-frontend-lib"; +import { useEffect, useMemo, useState } from "react"; +import { SiKofi, SiPatreon } from "react-icons/si"; +import { server } from "../../python"; + +export function DonatePage() { + const [loaded, setLoaded] = useState(false); + const [supporters, setSupporters] = useState(""); + + const formattedSupporters = useMemo(() => { + const numOfNamesPerPage = 10; + const supportersArr = supporters.split("\n"); + const newArr = []; + for (let i = 0; i < supportersArr.length; i += numOfNamesPerPage) { + newArr.push(supportersArr.slice(i, i + numOfNamesPerPage).join("\n")); + } + return newArr; + }, [supporters]); + + function fetchSupData() { + server! + .fetchNoCors("https://api.deckthemes.com/patrons", { method: "GET" }) + .then((deckyRes) => { + if (deckyRes.success) { + return deckyRes.result; + } + throw new Error("unsuccessful"); + }) + .then((res) => { + if (res.status === 200) { + return res.body; + } + throw new Error("Res not OK"); + }) + .then((text) => { + if (text) { + setLoaded(true); + setSupporters(text); + } + }) + .catch((err) => { + console.error("CSS Loader - Error Fetching Supporter Data", err); + }); + } + useEffect(() => { + fetchSupData(); + }, []); + return ( +
+ +

+ Donations help to cover the costs of hosting the store, as well as funding development for + CSS Loader and its related projects. +

+ + Navigation.NavigateToExternalWeb("https://patreon.com/deckthemes")} + focusWithinClassName="gpfocuswithin" + className="patreon-or-kofi-container patreon" + > +
+ + Patreon +
+ Recurring Donation + patreon.com/deckthemes + Perks: +
    +
  • + {/* Potentially could expand this to add it to deckthemes and audioloader */} + Your name in CSS Loader +
  • +
  • Patreon badge on deckthemes.com
  • +
  • + {/* Could also impl. this on deck store to make it more meaningful */} + Colored name + VIP channel in the DeckThemes Discord +
  • +
+
+ Navigation.NavigateToExternalWeb("https://ko-fi.com/suchmememanyskill")} + focusWithinClassName="gpfocuswithin" + className="patreon-or-kofi-container" + > +
+ + Ko-Fi +
+ One-time Donation + ko-fi.com/suchmememanyskill +
+
+ {loaded ? ( +
+ + {formattedSupporters.map((e) => { + return ( + {}} focusWithinClassName="gpfocuswithin"> +

{e}

+
+ ); + })} +
+
+ ) : null} +
+ ); +} diff --git a/src/pages/settings/PluginSettings.tsx b/src/pages/settings/PluginSettings.tsx new file mode 100644 index 0000000..47f1ce5 --- /dev/null +++ b/src/pages/settings/PluginSettings.tsx @@ -0,0 +1,89 @@ +import { Focusable, ToggleField } from "decky-frontend-lib"; +import { useMemo, useState, useEffect } from "react"; +import { useCssLoaderState } from "../../state"; +import { storeWrite } from "../../python"; +import { disableNavPatch, enableNavPatch } from "../../deckyPatches/NavPatch"; +import { + getWatchState, + getServerState, + enableServer, + toggleWatchState, +} from "../../backend/pythonMethods/pluginSettingsMethods"; + +export function PluginSettings() { + const { navPatchInstance } = useCssLoaderState(); + const [serverOn, setServerOn] = useState(false); + const [watchOn, setWatchOn] = useState(false); + + const navPatchEnabled = useMemo(() => !!navPatchInstance, [navPatchInstance]); + + function setNavPatch(value: boolean) { + value ? enableNavPatch() : disableNavPatch(); + storeWrite("enableNavPatch", value + ""); + } + + useEffect(() => { + getServerState().then((res) => { + if (res.success) { + setServerOn(res.result); + return; + } + setServerOn(false); + }); + getWatchState().then((res) => { + if (res.success) { + setWatchOn(res.result); + return; + } + setWatchOn(false); + }); + }, []); + + async function setWatch(enabled: boolean) { + console.log("VALUE", enabled); + await toggleWatchState(enabled, false); + console.log("TOGGLED"); + const res = await getWatchState(); + console.log("RES FETCHED", res); + if (res.success && res.result) setWatchOn(res.result); + } + + async function setServer(enabled: boolean) { + if (enabled) await enableServer(); + const res = await storeWrite("server", enabled ? "1" : "0"); + if (!res.success) return; + const res2 = await getServerState(); + if (res2.success && res2.result) setServerOn(res2.result); + } + + return ( +
+ + { + setServer(value); + }} + /> + + + + + + + +
+ ); +} diff --git a/src/pages/settings/PresetSettings.tsx b/src/pages/settings/PresetSettings.tsx new file mode 100644 index 0000000..b1ddc20 --- /dev/null +++ b/src/pages/settings/PresetSettings.tsx @@ -0,0 +1,52 @@ +import { Focusable, PanelSection } from "decky-frontend-lib"; +import { useCssLoaderState } from "../../state"; +import { Flags, Theme } from "../../ThemeTypes"; +import { useState } from "react"; +import { PresetSelectionDropdown } from "../../components"; +import { FullscreenProfileEntry } from "../../components/ThemeSettings/FullscreenProfileEntry"; +import { installTheme } from "../../api"; +import * as python from "../../python"; + +export function PresetSettings() { + const { localThemeList, setGlobalState, updateStatuses } = useCssLoaderState(); + + const [isInstalling, setInstalling] = useState(false); + + async function handleUpdate(e: Theme) { + setInstalling(true); + await installTheme(e.id); + // This just updates the updateStatuses arr to know that this theme now is up to date, no need to re-fetch the API to know that + setGlobalState( + "updateStatuses", + updateStatuses.map((f) => (f[0] === e.id ? [e.id, "installed", false] : e)) + ); + setInstalling(false); + } + + async function handleUninstall(listEntry: Theme) { + setInstalling(true); + await python.deleteTheme(listEntry.name); + await python.reloadBackend(); + setInstalling(false); + } + + return ( +
+ + + + {localThemeList + .filter((e) => e.flags.includes(Flags.isPreset)) + .map((e) => ( + + ))} + + +
+ ); +} diff --git a/src/pages/settings/SettingsPageRouter.tsx b/src/pages/settings/SettingsPageRouter.tsx new file mode 100644 index 0000000..322ffed --- /dev/null +++ b/src/pages/settings/SettingsPageRouter.tsx @@ -0,0 +1,75 @@ +import { SidebarNavigation } from "decky-frontend-lib"; +import { BsFolderFill } from "react-icons/bs"; +import { RiPaintFill, RiSettings2Fill } from "react-icons/ri"; +import { ThemeSettings } from "./ThemeSettings"; +import { PresetSettings } from "./PresetSettings"; +import { PluginSettings } from "./PluginSettings"; +import { Credits } from "./Credits"; +import { AiFillGithub, AiFillHeart } from "react-icons/ai"; +import { DonatePage } from "./DonatePage"; + +export function SettingsPageRouter() { + return ( + <> + + , content: }, + { title: "Profiles", icon: , content: }, + { title: "Settings", icon: , content: }, + { title: "Donate", icon: , content: }, + { title: "Credits", icon: , content: }, + ]} + > + + ); +} diff --git a/src/pages/settings/ThemeSettings.tsx b/src/pages/settings/ThemeSettings.tsx new file mode 100644 index 0000000..8caff78 --- /dev/null +++ b/src/pages/settings/ThemeSettings.tsx @@ -0,0 +1,122 @@ +import { DialogButton, DialogCheckbox, Focusable, PanelSection } from "decky-frontend-lib"; +import { useCssLoaderState } from "../../state"; +import { useMemo, useState } from "react"; +import { Flags, Theme } from "../../ThemeTypes"; +import { FullscreenSingleThemeEntry } from "../../components/ThemeSettings/FullscreenSingleThemeEntry"; +import { ThemeErrorCard } from "../../components/ThemeErrorCard"; +import { installTheme } from "../../api"; +import * as python from "../../python"; +import { DeleteMenu } from "../../components/ThemeSettings/DeleteMenu"; +import { UpdateAllThemesButton } from "../../components/ThemeSettings/UpdateAllThemesButton"; + +export function ThemeSettings() { + const { localThemeList, unpinnedThemes, themeErrors, setGlobalState, updateStatuses } = + useCssLoaderState(); + + const [isInstalling, setInstalling] = useState(false); + const [mode, setMode] = useState<"view" | "delete">("view"); + + const sortedList = useMemo(() => { + return localThemeList + .filter((e) => !e.flags.includes(Flags.isPreset)) + .sort((a, b) => { + const aPinned = !unpinnedThemes.includes(a.id); + const bPinned = !unpinnedThemes.includes(b.id); + // This sorts the pinned themes alphabetically, then the non-pinned alphabetically + if (aPinned === bPinned) { + return a.name.localeCompare(b.name); + } + return Number(bPinned) - Number(aPinned); + }); + }, [localThemeList.length]); + + async function handleUpdate(e: Theme) { + setInstalling(true); + await installTheme(e.id); + // This just updates the updateStatuses arr to know that this theme now is up to date, no need to re-fetch the API to know that + setGlobalState( + "updateStatuses", + updateStatuses.map((f) => (f[0] === e.id ? [e.id, "installed", false] : e)) + ); + setInstalling(false); + } + + async function handleUninstall(listEntry: Theme) { + setInstalling(true); + await python.deleteTheme(listEntry.name); + if (unpinnedThemes.includes(listEntry.id)) { + // This isn't really pinning it, it's just removing its name from the unpinned list. + python.pinTheme(listEntry.id); + } + await python.reloadBackend(); + setInstalling(false); + } + + return ( +
+ + + + + (mode === "delete" ? setMode("view") : setMode("delete"))} + > + {mode === "delete" ? "Go Back" : "Delete Themes"} + + + + {mode === "view" && ( + <> + + {sortedList.map((e) => ( + + ))} + + + )} + {mode === "delete" && ( + setMode("view")} themeList={sortedList} /> + )} + + + {themeErrors.length > 0 && ( + + + {themeErrors.map((e) => { + return ; + })} + + + )} +
+ ); +} diff --git a/src/pages/theme-manager/ExpandedView.tsx b/src/pages/theme-manager/ExpandedView.tsx new file mode 100644 index 0000000..df7298c --- /dev/null +++ b/src/pages/theme-manager/ExpandedView.tsx @@ -0,0 +1,414 @@ +import { + DialogButton, + Focusable, + Navigation, + showModal, + ScrollPanelGroup, +} from "decky-frontend-lib"; +import { useEffect, useRef, useState, VFC } from "react"; +import { ImCog, ImSpinner5 } from "react-icons/im"; +import { BsStar, BsStarFill } from "react-icons/bs"; + +import * as python from "../../python"; +import { genericGET, refreshToken, toggleStar as apiToggleStar, installTheme } from "../../api"; + +import { useCssLoaderState } from "../../state"; +import { Theme } from "../../ThemeTypes"; +import { FullCSSThemeInfo, PartialCSSThemeInfo } from "../../apiTypes"; +import { ThemeSettingsModalRoot } from "../../components/Modals/ThemeSettingsModal"; +import { AuthorViewModalRoot } from "../../components/Modals/AuthorViewModal"; +import { ExpandedViewStyles } from "../../components/Styles"; + +export const ExpandedViewPage: VFC = () => { + const { + localThemeList: installedThemes, + currentExpandedTheme, + isInstalling, + apiFullToken, + themeSearchOpts, + setGlobalState, + } = useCssLoaderState(); + + const [fullThemeData, setFullData] = useState(); + const [loaded, setLoaded] = useState(false); + const [isStarred, setStarred] = useState(false); + const [blurStarButton, setBlurStar] = useState(false); + + async function getStarredStatus() { + if (fullThemeData) { + genericGET(`/users/me/stars/${fullThemeData.id}`, true).then((data) => { + if (data.starred) { + setStarred(data.starred); + } + if (data.starred && fullThemeData?.starCount === 0) { + setFullData({ + ...fullThemeData, + starCount: 1, + }); + } + }); + } + } + + async function toggleStar() { + if (apiFullToken) { + setBlurStar(true); + const newToken = await refreshToken(); + if (fullThemeData && newToken) { + apiToggleStar(fullThemeData.id, isStarred, newToken).then((bool) => { + if (bool) { + setFullData({ + ...fullThemeData, + starCount: isStarred + ? fullThemeData.starCount === 0 + ? // This stops it from going below 0 + fullThemeData.starCount + : fullThemeData.starCount - 1 + : fullThemeData.starCount + 1, + }); + setStarred((cur) => !cur); + setBlurStar(false); + } + }); + } + } else { + python.toast("Not Logged In!", "You can only star themes if logged in."); + } + } + + function checkIfThemeInstalled(themeObj: PartialCSSThemeInfo) { + const filteredArr: Theme[] = installedThemes.filter( + (e: Theme) => e.name === themeObj.name && e.author === themeObj.specifiedAuthor + ); + if (filteredArr.length > 0) { + if (filteredArr[0].version === themeObj.version) { + return "installed"; + } else { + return "outdated"; + } + } else { + return "uninstalled"; + } + } + // These are just switch statements I use to determine text/css for the buttons + // I put them up here just because I find it clearer to read when they aren't inline + function calcButtonText(installStatus: string) { + let buttonText = ""; + switch (installStatus) { + case "installed": + buttonText = "Reinstall"; + break; + case "outdated": + buttonText = "Update"; + break; + default: + buttonText = "Install"; + break; + } + return buttonText; + } + + // For some reason, setting the ref as the useEffect dependency didn't work... + const downloadButtonRef = useRef(null); + const [hasBeenFocused, setHasFocused] = useState(false); + useEffect(() => { + if (downloadButtonRef?.current && !hasBeenFocused) { + downloadButtonRef.current.focus(); + setHasFocused(true); + } + }); + + useEffect(() => { + if (currentExpandedTheme?.id) { + setLoaded(false); + setFocusedImage(0); + genericGET(`/themes/${currentExpandedTheme.id}`).then((data) => { + setFullData(data); + setLoaded(true); + }); + } + }, [currentExpandedTheme]); + + useEffect(() => { + if (apiFullToken && fullThemeData) { + getStarredStatus(); + } + }, [apiFullToken, fullThemeData]); + + const [focusedImage, setFocusedImage] = useState(0); + + if (!loaded) { + return ( + <> + +
+ + Loading +
+ + ); + } + + // if theres no theme in the detailed view + if (fullThemeData) { + const imageAreaWidth = 556; + const imageAreaPadding = 16; + const gapBetweenCarouselAndImage = 8; + const selectedImageWidth = + fullThemeData.images.length > 1 ? 434.8 : imageAreaWidth - imageAreaPadding * 2; + const selectedImageHeight = (selectedImageWidth / 16) * 10; + const imageCarouselEntryWidth = + imageAreaWidth - imageAreaPadding * 2 - selectedImageWidth - gapBetweenCarouselAndImage; + const imageCarouselEntryHeight = (imageCarouselEntryWidth / 16) * 10; + + // This returns 'installed', 'outdated', or 'uninstalled' + const installStatus = checkIfThemeInstalled(fullThemeData); + return ( + <> + + + { + if (!evt?.detail?.button) return; + if (evt.detail.button === 2) { + Navigation.NavigateBack(); + } + }} + > + {/* Img + Info */} + + {/* Images */} + + {/* Vertical Image Carousel */} + {fullThemeData.images.length > 1 && ( + + {fullThemeData.images.map((e, id) => { + return ( + { + setFocusedImage(id); + }} + className="image-carousel-entry" + focusWithinClassName="gpfocuswithin" + onActivate={() => {}} + > + + + ); + })} + + )} + + {/* Selected Image Display */} + {}} + > + + {fullThemeData.images.length > 1 && ( +
+ + {focusedImage + 1}/{fullThemeData.images.length} + +
+ )} +
+
+ + + {/* Info */} +
+
+ {fullThemeData.displayName} + {fullThemeData.version} +
+
+ { + showModal(); + }} + > + By {fullThemeData.specifiedAuthor} + + Last Updated {new Date(fullThemeData.updated).toLocaleDateString()} +
+
+ {/* Description */} + {}} + > + Description + 400 ? "text-sm" : ""}> + {fullThemeData?.description || ( + + No description provided. + + )} + + + {/* Targets */} + + Targets + + {fullThemeData.targets.map((e) => ( + { + setGlobalState("themeSearchOpts", { ...themeSearchOpts, filters: e }); + setGlobalState("currentTab", "ThemeBrowser"); + setGlobalState("forceScrollBackUp", true); + Navigation.NavigateBack(); + }} + className="target-text" + > + {e} + + ))} + + +
+
+
+ {/* Buttons */} + +
+
+ {isStarred ? : } + {/* Need to make the text size smaller or else it wraps */} + = 100 ? "0.75em" : "1em" }}> + {fullThemeData.starCount} Star{fullThemeData.starCount === 1 ? "" : "s"} + +
+ +
+ + {!apiFullToken ? "Log In to Star" : isStarred ? "Unstar Theme" : "Star Theme"} + +
+
+
+
+ Install {fullThemeData.displayName} + + {fullThemeData.download.downloadCount} Download + {fullThemeData.download.downloadCount === 1 ? "" : "s"} + + + { + installTheme(fullThemeData.id); + }} + > + + {calcButtonText(installStatus)} + + + {installStatus === "installed" && ( + { + showModal( + e.id === fullThemeData.id)?.id || + // using name here because in submissions id is different + installedThemes.find((e) => e.name === fullThemeData.name)!.id + } + /> + ); + }} + className="configure-button" + > + + + )} + +
+
+
+ + ); + } + return ( + <> +
+ Error fetching selected theme, please go back and retry. +
+ + ); +}; diff --git a/src/theme-manager/LogInPage.tsx b/src/pages/theme-manager/LogInPage.tsx similarity index 50% rename from src/theme-manager/LogInPage.tsx rename to src/pages/theme-manager/LogInPage.tsx index d0b0911..26056dd 100644 --- a/src/theme-manager/LogInPage.tsx +++ b/src/pages/theme-manager/LogInPage.tsx @@ -1,34 +1,15 @@ import { DialogButton, Focusable, TextField, ToggleField } from "decky-frontend-lib"; import { SiWebauthn } from "react-icons/si"; -import { useEffect, useState, VFC } from "react"; -import { logInWithShortToken, logOut } from "../api"; -import { useCssLoaderState } from "../state"; -import { enableServer, getServerState, storeRead, storeWrite } from "../python"; +import { useEffect, useMemo, useState, VFC } from "react"; +import { logInWithShortToken, logOut } from "../../api"; +import { useCssLoaderState } from "../../state"; +import { enableServer, getServerState, storeWrite } from "../../python"; +import { disableNavPatch, enableNavPatch } from "../../deckyPatches/NavPatch"; export const LogInPage: VFC = () => { const { apiShortToken, apiFullToken, apiMeData } = useCssLoaderState(); const [shortTokenInterimValue, setShortTokenIntValue] = useState(apiShortToken); - const [serverOn, setServerOn] = useState(false); - - useEffect(() => { - getServerState().then((res) => { - if (res.success) { - setServerOn(res.result); - return; - } - setServerOn(false); - }); - }, []); - - async function setServer(enabled: boolean) { - if (enabled) await enableServer(); - const res = await storeWrite("server", enabled ? "1" : "0"); - if (!res.success) return; - const res2 = await getServerState(); - if (res2.success && res2.result) setServerOn(res2.result); - } - return ( // The outermost div is to push the content down into the visible area
@@ -37,8 +18,7 @@ export const LogInPage: VFC = () => {

Your Account

) : (

- Log In - Create an account - on deckthemes.com and generate a deck token on your account page. + Log In

)} {apiFullToken ? ( @@ -102,63 +82,14 @@ export const LogInPage: VFC = () => { )} +

+ Logging in gives you access to star themes, saving them to their own page where you can + quickly find them. +
+ Create an account on deckthemes.com and generate an account key on your profile page. +
+

- - { - setServer(value); - }} - /> - - -
- {/* Removed to ensure the whole page fits without scrolling */} - {/*

- About CSSLoader -

*/} -
-
-

- Developers -

-
    -
  • - SuchMeme - github.com/suchmememanyskill -
  • -
  • - EMERALD - github.com/EMERALD0874 -
  • -
  • - Beebles - github.com/beebls -
  • -
-
-
-

- Support -

- - See the DeckThemes Discord server for support. -
- discord.gg/HsU72Kfnpf -
-
-
-

- Create and Submit Your Own Theme -

- - Instructions for theme creation/submission are available DeckThemes' docs website. -
- docs.deckthemes.com -
-
-
-
-
); }; diff --git a/src/theme-manager/StarredThemesPage.tsx b/src/pages/theme-manager/StarredThemesPage.tsx similarity index 94% rename from src/theme-manager/StarredThemesPage.tsx rename to src/pages/theme-manager/StarredThemesPage.tsx index 08a3d60..1f96d8f 100644 --- a/src/theme-manager/StarredThemesPage.tsx +++ b/src/pages/theme-manager/StarredThemesPage.tsx @@ -1,10 +1,10 @@ import { Focusable } from "decky-frontend-lib"; -import { useCssLoaderState } from "../state"; -import * as python from "../python"; -import { BrowserSearchFields, LoadMoreButton, VariableSizeCard } from "../components"; +import { useCssLoaderState } from "../../state"; +import * as python from "../../python"; +import { BrowserSearchFields, LoadMoreButton, VariableSizeCard } from "../../components"; import { useEffect, useRef, useState } from "react"; import { isEqual } from "lodash"; -import { getThemes } from "../api"; +import { getThemes } from "../../api"; export function StarredThemesPage() { const { @@ -83,7 +83,6 @@ export function StarredThemesPage() { refPassthrough={i === indexToSnapTo ? endOfPageRef : undefined} data={e} cols={browserCardSize} - showTarget={true} searchOpts={searchOpts} prevSearchOptsVarName="prevStarSearchOpts" /> diff --git a/src/theme-manager/SubmissionBrowserPage.tsx b/src/pages/theme-manager/SubmissionBrowserPage.tsx similarity index 94% rename from src/theme-manager/SubmissionBrowserPage.tsx rename to src/pages/theme-manager/SubmissionBrowserPage.tsx index 8555604..1b89cb1 100644 --- a/src/theme-manager/SubmissionBrowserPage.tsx +++ b/src/pages/theme-manager/SubmissionBrowserPage.tsx @@ -1,10 +1,10 @@ import { Focusable } from "decky-frontend-lib"; -import { useCssLoaderState } from "../state"; -import * as python from "../python"; -import { BrowserSearchFields, LoadMoreButton, VariableSizeCard } from "../components"; +import { useCssLoaderState } from "../../state"; +import * as python from "../../python"; +import { BrowserSearchFields, LoadMoreButton, VariableSizeCard } from "../../components"; import { useEffect, useRef, useState } from "react"; import { isEqual } from "lodash"; -import { getThemes } from "../api"; +import { getThemes } from "../../api"; export function SubmissionsPage() { const { @@ -83,7 +83,6 @@ export function SubmissionsPage() { refPassthrough={i === indexToSnapTo ? endOfPageRef : undefined} data={e} cols={browserCardSize} - showTarget={true} searchOpts={searchOpts} prevSearchOptsVarName="setPrevSubSearchOpts" /> diff --git a/src/theme-manager/ThemeBrowserPage.tsx b/src/pages/theme-manager/ThemeBrowserPage.tsx similarity index 80% rename from src/theme-manager/ThemeBrowserPage.tsx rename to src/pages/theme-manager/ThemeBrowserPage.tsx index 2cebd3f..1cfe69f 100644 --- a/src/theme-manager/ThemeBrowserPage.tsx +++ b/src/pages/theme-manager/ThemeBrowserPage.tsx @@ -1,13 +1,13 @@ import { Focusable } from "decky-frontend-lib"; import { useLayoutEffect, useState, FC, useEffect, useRef } from "react"; -import * as python from "../python"; -import { getThemes } from "../api"; -import { logInWithShortToken } from "../api"; +import * as python from "../../python"; +import { getThemes } from "../../api"; +import { logInWithShortToken } from "../../api"; import { isEqual } from "lodash"; // Interfaces for the JSON objects the lists work with -import { useCssLoaderState } from "../state"; -import { BrowserSearchFields, VariableSizeCard, LoadMoreButton } from "../components"; +import { useCssLoaderState } from "../../state"; +import { BrowserSearchFields, VariableSizeCard, LoadMoreButton } from "../../components"; export const ThemeBrowserPage: FC = () => { const { @@ -19,6 +19,8 @@ export const ThemeBrowserPage: FC = () => { browserCardSize = 3, prevSearchOpts, backendVersion, + forceScrollBackUp, + setGlobalState, } = useCssLoaderState(); function reloadThemes() { @@ -43,6 +45,17 @@ export const ThemeBrowserPage: FC = () => { }, []); const endOfPageRef = useRef(); + const firstCardRef = useRef(); + useLayoutEffect(() => { + if (forceScrollBackUp) { + // Valve would RE FOCUS THE ONE YOU LAST CLICKED ON after this ran, so i had to add a delay + setTimeout(() => { + firstCardRef?.current && firstCardRef.current?.focus(); + setGlobalState("forceScrollBackUp", false); + }, 100); + } + }, []); + const [indexToSnapTo, setSnapIndex] = useState(-1); useEffect(() => { if (endOfPageRef?.current) { @@ -77,10 +90,11 @@ export const ThemeBrowserPage: FC = () => { .filter((e) => e.manifestVersion <= backendVersion) .map((e, i) => ( diff --git a/src/theme-manager/ThemeManagerRouter.tsx b/src/pages/theme-manager/ThemeManagerRouter.tsx similarity index 76% rename from src/theme-manager/ThemeManagerRouter.tsx rename to src/pages/theme-manager/ThemeManagerRouter.tsx index 96f2950..add161d 100644 --- a/src/theme-manager/ThemeManagerRouter.tsx +++ b/src/pages/theme-manager/ThemeManagerRouter.tsx @@ -1,22 +1,22 @@ import { Tabs } from "decky-frontend-lib"; -import { Permissions } from "../apiTypes"; -import { useCssLoaderState } from "../state"; +import { Permissions } from "../../apiTypes"; +import { useCssLoaderState } from "../../state"; import { LogInPage } from "./LogInPage"; import { StarredThemesPage } from "./StarredThemesPage"; import { SubmissionsPage } from "./SubmissionBrowserPage"; import { ThemeBrowserPage } from "./ThemeBrowserPage"; -import { UninstallThemePage } from "./UninstallThemePage"; - +import { ThemeBrowserCardStyles } from "../../components/Styles"; export function ThemeManagerRouter() { - const { apiMeData, currentTab, setGlobalState } = useCssLoaderState(); + const { apiMeData, currentTab, setGlobalState, browserCardSize } = useCssLoaderState(); return (
+ { @@ -47,12 +47,7 @@ export function ThemeManagerRouter() { ] : []), { - title: "Installed Themes", - content: , - id: "InstalledThemes", - }, - { - title: "Settings", + title: "DeckThemes Account", content: , id: "LogInPage", }, diff --git a/src/theme-manager/index.ts b/src/pages/theme-manager/index.ts similarity index 84% rename from src/theme-manager/index.ts rename to src/pages/theme-manager/index.ts index 58edafb..f16d7eb 100644 --- a/src/theme-manager/index.ts +++ b/src/pages/theme-manager/index.ts @@ -1,5 +1,4 @@ export * from "./ThemeBrowserPage"; -export * from "./UninstallThemePage"; export * from "./ExpandedView"; export * from "./LogInPage"; export * from "./StarredThemesPage"; diff --git a/src/python.ts b/src/python.ts index db8090b..8b4301b 100644 --- a/src/python.ts +++ b/src/python.ts @@ -1,10 +1,10 @@ // Code from https://github.com/NGnius/PowerTools/blob/dev/src/python.ts import { ServerAPI } from "decky-frontend-lib"; import { CssLoaderState } from "./state"; -import { Theme } from "./ThemeTypes"; +import { Theme, ThemeError } from "./ThemeTypes"; import { bulkThemeUpdateCheck } from "./logic/bulkThemeUpdateCheck"; -var server: ServerAPI | undefined = undefined; +export var server: ServerAPI | undefined = undefined; export var globalState: CssLoaderState | undefined = undefined; export function setServer(s: ServerAPI) { @@ -81,23 +81,31 @@ export async function changePreset(themeName: string, themeList: Theme[]) { // Disables all themes before enabling the preset await Promise.all(themeList.filter((e) => e.enabled).map((e) => setThemeState(e.name, false))); - await setThemeState(themeName, true); + if (themeName !== "None") { + await setThemeState(themeName, true); + } resolve(true); }); } -export function getInstalledThemes(): Promise { +export async function getInstalledThemes(): Promise { const setGlobalState = globalState!.setGlobalState.bind(globalState); - return server!.callPluginMethod<{}, Theme[]>("get_themes", {}).then((data) => { - if (data.success) { - setGlobalState("localThemeList", data.result); - } - return; - }); + const errorRes = await server!.callPluginMethod<{}, { fails: ThemeError[] }>( + "get_last_load_errors", + {} + ); + if (errorRes.success) { + setGlobalState("themeErrors", errorRes.result.fails); + } + const themeRes = await server!.callPluginMethod<{}, Theme[]>("get_themes", {}); + if (themeRes.success) { + setGlobalState("localThemeList", themeRes.result); + return themeRes.result; + } } -export function reloadBackend(): Promise { - return server!.callPluginMethod("reset", {}).then(() => { +export async function reloadBackend(): Promise { + return server!.callPluginMethod<{}, { fails: ThemeError[] }>("reset", {}).then((res) => { return getInstalledThemes(); }); } diff --git a/src/state/CssLoaderState.tsx b/src/state/CssLoaderState.tsx index d6038dc..702bf2f 100644 --- a/src/state/CssLoaderState.tsx +++ b/src/state/CssLoaderState.tsx @@ -1,4 +1,4 @@ -import { SingleDropdownOption } from "decky-frontend-lib"; +import { Patch, SingleDropdownOption } from "decky-frontend-lib"; import { createContext, FC, useContext, useEffect, useState } from "react"; import { AccountData, @@ -7,7 +7,7 @@ import { ThemeQueryRequest, ThemeQueryResponse, } from "../apiTypes"; -import { Theme, UpdateStatus } from "../ThemeTypes"; +import { Theme, ThemeError, UpdateStatus } from "../ThemeTypes"; interface PublicCssLoaderState { // Browse Page @@ -29,6 +29,7 @@ interface PublicCssLoaderState { submissionThemeList: ThemeQueryResponse; currentTab: string; + forceScrollBackUp: boolean; // Api selectedRepo: SingleDropdownOption; @@ -41,9 +42,11 @@ interface PublicCssLoaderState { nextUpdateCheckTime: number; updateCheckTimeout: NodeJS.Timeout | undefined; + navPatchInstance: Patch | undefined; updateStatuses: UpdateStatus[]; selectedPreset: Theme | undefined; localThemeList: Theme[]; + themeErrors: ThemeError[]; currentSettingsPageTheme: string | undefined; unpinnedThemes: string[]; isInstalling: boolean; @@ -60,8 +63,10 @@ interface PublicCssLoaderContext extends PublicCssLoaderState { // This class creates the getter and setter functions for all of the global state data. export class CssLoaderState { private currentTab: string = "ThemeBrowser"; + private forceScrollBackUp: boolean = false; private nextUpdateCheckTime: number = 0; private updateCheckTimeout: NodeJS.Timeout | undefined = undefined; + private navPatchInstance: Patch | undefined = undefined; private updateStatuses: UpdateStatus[] = []; private selectedPreset: Theme | undefined = undefined; @@ -71,6 +76,7 @@ export class CssLoaderState { private apiTokenExpireDate: Date | number | undefined = undefined; private apiMeData: AccountData | undefined = undefined; private localThemeList: Theme[] = []; + private themeErrors: ThemeError[] = []; private selectedRepo: SingleDropdownOption = { data: 1, label: "All", @@ -151,6 +157,7 @@ export class CssLoaderState { getPublicState() { return { currentTab: this.currentTab, + forceScrollBackUp: this.forceScrollBackUp, nextUpdateCheckTime: this.nextUpdateCheckTime, updateCheckTimeout: this.updateCheckTimeout, apiUrl: this.apiUrl, @@ -161,10 +168,12 @@ export class CssLoaderState { updateStatuses: this.updateStatuses, selectedPreset: this.selectedPreset, localThemeList: this.localThemeList, + themeErrors: this.themeErrors, currentSettingsPageTheme: this.currentSettingsPageTheme, unpinnedThemes: this.unpinnedThemes, isInstalling: this.isInstalling, + navPatchInstance: this.navPatchInstance, selectedRepo: this.selectedRepo, currentExpandedTheme: this.currentExpandedTheme, browserCardSize: this.browserCardSize, diff --git a/src/styles/themeSettingsModalStyles.css b/src/styles/themeSettingsModalStyles.css new file mode 100644 index 0000000..2cffd90 --- /dev/null +++ b/src/styles/themeSettingsModalStyles.css @@ -0,0 +1,70 @@ +.CSSLoader_ThemeSettingsModal_ToggleParent { + width: 90%; +} + +.CSSLoader_ThemeSettingsModal_Title { + font-weight: bold; + font-size: 2em; +} + +.CSSLoader_ThemeSettingsModal_Subtitle { + font-size: 0.75em; +} + +.CSSLoader_ThemeSettingsModal_Container { + display: flex; + flex-direction: column; + align-items: center; + gap: 1em; + width: 100%; +} + +.CSSLoader_ThemeSettingsModal_ButtonsContainer { + display: flex; + gap: 0.25em; +} + +.CSSLoader_ThemeSettingsModalHeader_DialogButton { + width: fit-content !important; + min-width: fit-content !important; + height: fit-content !important; + padding: 10px 12px !important; +} + +.CSSLoader_ThemeSettingsModal_IconTranslate { + transform: translate(0px, 2px); +} + +.CSSLoader_ThemeSettingsModal_Footer { + display: flex; + width: 100%; + justify-content: space-between; +} + +.CSSLoader_ThemeSettingsModal_TitleContainer { + display: flex; + max-width: 80%; + flex-direction: column; +} + +.CSSLoader_ThemeSettingsModal_Header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.CSSLoader_ThemeSettingsModal_PatchContainer { + display: flex; + width: 100%; + flex-direction: column; +} + +.CSSLoader_ThemeSettingsModal_UpdateButton { + display: flex !important; + gap: 0.25em; +} + +.CSSLoader_ThemeSettingsModal_UpdateText { + font-size: 0.75em; +} diff --git a/src/theme-manager/ExpandedView.tsx b/src/theme-manager/ExpandedView.tsx deleted file mode 100644 index f4d532a..0000000 --- a/src/theme-manager/ExpandedView.tsx +++ /dev/null @@ -1,410 +0,0 @@ -import { ButtonItem, Navigation, PanelSectionRow, showModal } from "decky-frontend-lib"; -import { useEffect, useMemo, useRef, useState, VFC } from "react"; -import { ImSpinner5 } from "react-icons/im"; -import { BsStar, BsStarFill } from "react-icons/bs"; -import { FiArrowLeft, FiArrowRight, FiDownload } from "react-icons/fi"; - -import * as python from "../python"; -import { genericGET, refreshToken, toggleStar as apiToggleStar, installTheme } from "../api"; - -import { useCssLoaderState } from "../state"; -import { Theme } from "../ThemeTypes"; -import { calcButtonColor } from "../logic"; -import { FullCSSThemeInfo, PartialCSSThemeInfo } from "../apiTypes"; -import { ThemeSettingsModalRoot } from "../components"; - -export const ExpandedViewPage: VFC = () => { - const { - localThemeList: installedThemes, - currentExpandedTheme, - isInstalling, - apiUrl, - apiFullToken, - setGlobalState, - } = useCssLoaderState(); - - const [fullThemeData, setFullData] = useState(); - const [loaded, setLoaded] = useState(false); - const [isStarred, setStarred] = useState(false); - const [blurStarButton, setBlurStar] = useState(false); - const [selectedImage, setSelected] = useState(0); - - const currentImg = useMemo(() => { - if ( - fullThemeData?.images[selectedImage]?.id && - fullThemeData.images[selectedImage].id !== "MISSING" - ) { - return `url(https://api.deckthemes.com/blobs/${fullThemeData?.images[selectedImage].id})`; - } else { - return `url(https://share.deckthemes.com/${fullThemeData?.type.toLowerCase()}placeholder.png)`; - } - }, [selectedImage, fullThemeData]); - - function incrementImg() { - if (selectedImage < fullThemeData!.images.length - 1) { - setSelected(selectedImage + 1); - return; - } - setSelected(0); - } - function decrementImg() { - if (selectedImage === 0) { - setSelected(fullThemeData!.images.length - 1); - return; - } - setSelected(selectedImage - 1); - } - async function getStarredStatus() { - if (fullThemeData) { - genericGET(`/users/me/stars/${fullThemeData.id}`, true).then((data) => { - if (data.starred) { - setStarred(data.starred); - } - if (data.starred && fullThemeData?.starCount === 0) { - setFullData({ - ...fullThemeData, - starCount: 1, - }); - } - }); - } - } - - async function toggleStar() { - if (apiFullToken) { - setBlurStar(true); - const newToken = await refreshToken(); - if (fullThemeData && newToken) { - apiToggleStar(fullThemeData.id, isStarred, newToken, apiUrl).then((bool) => { - if (bool) { - setFullData({ - ...fullThemeData, - starCount: isStarred - ? fullThemeData.starCount === 0 - ? // This stops it from going below 0 - fullThemeData.starCount - : fullThemeData.starCount - 1 - : fullThemeData.starCount + 1, - }); - setStarred((cur) => !cur); - setBlurStar(false); - } - }); - } - } else { - python.toast("Not Logged In!", "You can only star themes if logged in."); - } - } - - function checkIfThemeInstalled(themeObj: PartialCSSThemeInfo) { - const filteredArr: Theme[] = installedThemes.filter( - (e: Theme) => e.name === themeObj.name && e.author === themeObj.specifiedAuthor - ); - if (filteredArr.length > 0) { - if (filteredArr[0].version === themeObj.version) { - return "installed"; - } else { - return "outdated"; - } - } else { - return "uninstalled"; - } - } - // These are just switch statements I use to determine text/css for the buttons - // I put them up here just because I find it clearer to read when they aren't inline - function calcButtonText(installStatus: string) { - let buttonText = ""; - switch (installStatus) { - case "installed": - buttonText = "Configure"; - break; - case "outdated": - buttonText = "Update"; - break; - default: - buttonText = "Install"; - break; - } - return buttonText; - } - - const backButtonRef = useRef(null); - useEffect(() => { - if (backButtonRef?.current) { - backButtonRef.current.focus(); - } - }, []); - - useEffect(() => { - if (currentExpandedTheme?.id) { - setLoaded(false); - genericGET(`/themes/${currentExpandedTheme.id}`).then((data) => { - setFullData(data); - setLoaded(true); - }); - } - }, [currentExpandedTheme]); - - useEffect(() => { - if (apiFullToken && fullThemeData) { - getStarredStatus(); - } - }, [apiFullToken, fullThemeData]); - - if (!loaded) { - return ( - <> - -
- - Loading -
- - ); - } - - // if theres no theme in the detailed view - if (fullThemeData) { - // This returns 'installed', 'outdated', or 'uninstalled' - const installStatus = checkIfThemeInstalled(fullThemeData); - return ( - // The outermost div is to push the content down into the visible area -
-
-
-
- {fullThemeData.images.length > 1 && ( - <> - - - - )} -
-
-
- - {fullThemeData.name} - - {fullThemeData.specifiedAuthor} - {fullThemeData.target} - {fullThemeData.version} -
- {!apiFullToken && ( - <> -
- - {fullThemeData.starCount} -
- - )} - -
- - {fullThemeData.download.downloadCount} -
-
-
-
- {!!apiFullToken && ( - <> - -
- -
- {isStarred ? ( - - ) : ( - - )}{" "} - {fullThemeData.starCount} -
-
-
-
- - )} - -
- { - if ( - installStatus === "installed" && - installedThemes.find((e) => e.id === fullThemeData.id) - ) { - showModal( - // @ts-ignore - e.id === fullThemeData.id)!.id - } - /> - ); - return; - } - installTheme(fullThemeData.id); - }} - > - - {calcButtonText(installStatus)} - - -
-
- -
- { - setGlobalState("currentExpandedTheme", undefined); - setFullData(undefined); - setLoaded(false); - // Wow amazing navigation interface I wonder who coded it - Navigation.NavigateBack(); - }} - > - Back - -
-
-
-
-
-
- - {fullThemeData?.description || ( - - No description provided. - - )} - -
-
-
- ); - } - return ( - <> -
- Error fetching selected theme, please go back and retry. -
- - ); -}; diff --git a/src/theme-manager/UninstallThemePage.tsx b/src/theme-manager/UninstallThemePage.tsx deleted file mode 100644 index 94591ce..0000000 --- a/src/theme-manager/UninstallThemePage.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { PanelSectionRow, Focusable, DialogButton } from "decky-frontend-lib"; -import { useEffect, useState, VFC } from "react"; -import { FaTrash } from "react-icons/fa"; -import { AiOutlineDownload } from "react-icons/ai"; -import * as python from "../python"; - -import { useCssLoaderState } from "../state"; -import { Flags, Theme, UpdateStatus } from "../ThemeTypes"; -import { MinimalCSSThemeInfo } from "../apiTypes"; -import { bulkThemeUpdateCheck } from "../logic/bulkThemeUpdateCheck"; - -export const UninstallThemePage: VFC = () => { - const { localThemeList, unpinnedThemes } = useCssLoaderState(); - const [isUninstalling, setUninstalling] = useState(false); - - const [updateStatuses, setUpdateStatuses] = useState([]); - - function handleUninstall(listEntry: Theme) { - setUninstalling(true); - python.resolve(python.deleteTheme(listEntry.name), () => { - if (unpinnedThemes.includes(listEntry.id)) { - // This isn't really pinning it, it's just removing its name from the unpinned list. - python.pinTheme(listEntry.id); - } - python.reloadBackend().then(() => { - setUninstalling(false); - }); - }); - } - - function updateTheme(remoteEntry: MinimalCSSThemeInfo | false) { - if (remoteEntry && remoteEntry?.id) { - const id = remoteEntry.id; - setUninstalling(true); - python.resolve(python.downloadThemeFromUrl(id), () => { - python.reloadBackend(); - setUninstalling(false); - }); - } - } - - useEffect(() => { - bulkThemeUpdateCheck().then((value) => { - setUpdateStatuses(value); - }); - }, [localThemeList]); - - if (localThemeList.filter((e) => !e.bundled).length === 0) { - return ( - - No custom themes installed, find some in the 'Browse Themes' tab. - - ); - } - - return ( - <> -
-
- {localThemeList.map((e: Theme, i) => { - let [updateStatus, remoteEntry]: [string, false | MinimalCSSThemeInfo] = [ - "installed", - false, - ]; - const themeArrPlace = updateStatuses.find((f) => f[0] === e.id); - if (themeArrPlace) { - updateStatus = themeArrPlace[1]; - remoteEntry = themeArrPlace[2]; - } - return ( - -
- {e.name} - {/* Only show the version for themes that aren't presets */} - - {e.flags.includes(Flags.isPreset) ? "Profile" : e.version} - - - {/* Update Button */} - {updateStatus === "outdated" && ( - updateTheme(remoteEntry)} - disabled={isUninstalling} - > - - - )} - {/* This shows when a theme is local, but not a preset */} - {updateStatus === "local" && !e.flags.includes(Flags.isPreset) && ( - - Local Theme - - )} - handleUninstall(e)} - disabled={isUninstalling} - > - - - -
-
- ); - })} -
-
- - ); -};