A focused Docker Compose management web application.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: search navigation popup

Brooke 73f7203a c01b8a8f

+218 -29
+2
packages/panel/src/routes/(authenticated)/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import Navbar from "./Navbar.svelte"; 3 3 import { api } from "$lib"; 4 + import Search from "./Search.svelte"; 4 5 5 6 let { children } = $props(); 6 7 ··· 8 9 </script> 9 10 10 11 <Navbar {children} /> 12 + <Search />
+18 -5
packages/panel/src/routes/(authenticated)/Navbar.svelte
··· 7 7 8 8 <script lang="ts"> 9 9 import Crane from "$lib/component/Crane.svelte"; 10 + import { onMount, type Snippet } from "svelte"; 10 11 import { onNavigate } from "$app/navigation"; 12 + import { openSearch } from "./Search.svelte"; 11 13 import { slide } from "svelte/transition"; 12 14 import { isMobile, roving } from "$lib"; 13 - import type { Snippet } from "svelte"; 14 15 import { page } from "$app/state"; 16 + import hotkeys from "hotkeys-js"; 15 17 import Fa from "svelte-fa"; 16 18 import { 17 19 faMagnifyingGlass, ··· 21 23 faBars, 22 24 faGear, 23 25 faPlusCircle, 24 - faLock, 26 + faServer, 25 27 } from "@fortawesome/free-solid-svg-icons"; 26 28 27 29 const EXPANDED_KEY = "luminary-navbar-expanded"; ··· 30 32 { icon: faLayerGroup, label: "Projects", href: "/projects" }, 31 33 { icon: faPlusCircle, label: "Create", href: "/create" }, 32 34 "search", 33 - { icon: faLock, label: "Admin", href: "/admin" }, 35 + { icon: faServer, label: "Server", href: "/server" }, 34 36 { icon: faGear, label: "Settings", href: "/settings" }, 35 37 ] satisfies ({ icon: any; label: string; href: string } | "search")[]; 36 38 ··· 55 57 onNavigate(() => { 56 58 open = false; 57 59 }); 60 + 61 + onMount(() => { 62 + hotkeys("ctrl+/", () => { 63 + openSearch(); 64 + return false; 65 + }); 66 + 67 + return () => { 68 + hotkeys.unbind("ctrl+/"); 69 + }; 70 + }); 58 71 </script> 59 72 60 73 {#snippet links()} 61 74 {#each PAGES as entry} 62 75 {#if entry === "search"} 63 - <button class="a entry" style="margin-bottom: auto"> 76 + <button class="a entry" style="margin-bottom: auto" aria-label="search" onclick={openSearch}> 64 77 <div class="icon"> 65 78 <Fa icon={faMagnifyingGlass} /> 66 79 </div> 67 - <div class="label">Search</div> 80 + <div class="label"><span class="keybind">ctrl + /</span></div> 68 81 </button> 69 82 {:else} 70 83 {@const { icon, label, href } = entry}
+172
packages/panel/src/routes/(authenticated)/Search.svelte
··· 1 + <script lang="ts" module> 2 + import { faCirclePlus, faGear, faLayerGroup, faListUl, faServer } from "@fortawesome/free-solid-svg-icons"; 3 + import type { IconDefinition } from "@fortawesome/fontawesome-common-types"; 4 + import { getProjects } from "$lib/api"; 5 + import { Dialog } from "melt/builders"; 6 + 7 + // svelte-ignore non_reactive_update 8 + let dialog: Dialog; 9 + let input: HTMLInputElement; 10 + 11 + export function openSearch() { 12 + dialog.open = true; 13 + input.value = ""; 14 + input.focus(); 15 + } 16 + 17 + interface SearchOption { 18 + icon: IconDefinition; 19 + label: string; 20 + href: string; 21 + aliases?: string; 22 + } 23 + 24 + let options: Record<string, SearchOption> = $derived({ 25 + projects: { 26 + icon: faListUl, 27 + label: "Projects", 28 + href: "/projects", 29 + }, 30 + create: { 31 + icon: faCirclePlus, 32 + label: "Create Project", 33 + href: "/create", 34 + aliases: "new project", 35 + }, 36 + server: { 37 + icon: faServer, 38 + label: "Server Settings", 39 + href: "/server", 40 + }, 41 + settings: { 42 + icon: faGear, 43 + label: "User Settings", 44 + href: "/settings", 45 + }, 46 + ...Object.fromEntries( 47 + Object.values(getProjects()).map((project) => [ 48 + `project ${project.name}`, 49 + { 50 + icon: faLayerGroup, 51 + label: project.name, 52 + href: `/projects/${project.name}`, 53 + aliases: `project ${project.name}`, 54 + }, 55 + ]), 56 + ), 57 + }); 58 + </script> 59 + 60 + <script lang="ts"> 61 + import { Combobox } from "melt/builders"; 62 + import { goto } from "$app/navigation"; 63 + import Fa from "svelte-fa"; 64 + 65 + dialog = new Dialog(); 66 + const combobox = new Combobox<keyof typeof options>({ 67 + onValueChange(value) { 68 + if (!value) return; 69 + 70 + combobox.value = undefined; 71 + goto(options[value].href); 72 + dialog.open = false; 73 + }, 74 + }); 75 + 76 + function normalise(str: string) { 77 + return str.trim().toLowerCase().replaceAll(/\s/g, ""); 78 + } 79 + 80 + const filtered = $derived.by(() => { 81 + if (!combobox.touched) return Object.entries(options); 82 + 83 + const search = normalise(combobox.inputValue); 84 + return Object.entries(options).filter( 85 + ([_, o]) => 86 + normalise(o.label).includes(search) || 87 + (o.aliases === undefined ? false : normalise(o.aliases).includes(search)), 88 + ); 89 + }); 90 + 91 + function onkeydown(event: KeyboardEvent) { 92 + if (event.key === "Escape") dialog.open = false; 93 + else combobox.input.onkeydown(event); 94 + } 95 + </script> 96 + 97 + <dialog {...dialog.content}> 98 + <h1><label {...combobox.label}>Navigate</label></h1> 99 + <input bind:this={input} type="text" {...combobox.input} autocomplete="off" {onkeydown} /> 100 + 101 + <div class="options" {...combobox.content} popover={undefined} inert={undefined}> 102 + {#each filtered as [key, option] (option)} 103 + <div class="option" {...combobox.getOption(key)}> 104 + <Fa icon={option.icon} /> 105 + {option.label} 106 + </div> 107 + {:else} 108 + <span>No results found</span> 109 + {/each} 110 + </div> 111 + </dialog> 112 + 113 + <style lang="scss"> 114 + dialog { 115 + background: none; 116 + border: none; 117 + 118 + color: inherit; 119 + } 120 + 121 + .options { 122 + // Override popover styles 123 + transform-origin: unset !important; 124 + min-width: unset !important; 125 + position: unset !important; 126 + width: unset !important; 127 + left: unset !important; 128 + top: unset !important; 129 + 130 + margin-top: 10px; 131 + 132 + overflow-y: auto; 133 + 134 + display: flex; 135 + flex-direction: column; 136 + gap: 5px; 137 + } 138 + 139 + .option { 140 + background: var(--surface0); 141 + padding: 5px; 142 + 143 + border-radius: 10px; 144 + border: 2px solid transparent; 145 + 146 + font-size: 18px; 147 + cursor: pointer; 148 + 149 + display: flex; 150 + align-items: center; 151 + gap: 5px; 152 + 153 + &[data-highlighted] { 154 + border-color: var(--flamingo); 155 + } 156 + } 157 + 158 + dialog::backdrop { 159 + background: rgba(0, 0, 0, 0.2); 160 + backdrop-filter: blur(2px); 161 + } 162 + 163 + dialog[data-open] { 164 + pointer-events: all; 165 + opacity: 1; 166 + scale: 1; 167 + } 168 + 169 + [data-melt-dialog-overlay][data-open] { 170 + opacity: 1; 171 + } 172 + </style>
packages/panel/src/routes/(authenticated)/admin/+page.svelte packages/panel/src/routes/(authenticated)/server/+page.svelte
packages/panel/src/routes/(authenticated)/admin/UserList.svelte packages/panel/src/routes/(authenticated)/server/UserList.svelte
+1 -1
packages/panel/src/routes/(authenticated)/projects/+page.svelte
··· 6 6 import { getProjects } from "$lib/api"; 7 7 import { Debounced } from "runed"; 8 8 import hotkeys from "hotkeys-js"; 9 - import Fa from "svelte-fa"; 10 9 import { roving } from "$lib"; 10 + import Fa from "svelte-fa"; 11 11 12 12 const ORDER = ["healthy", "running", "exited", "paused", "down", "paused"] as LuminaryStatus[]; 13 13
+25 -23
packages/panel/src/routes/(authenticated)/projects/[project]/+page.svelte
··· 2 2 import { faCircleInfo, faClockRotateLeft, faLayerGroup } from "@fortawesome/free-solid-svg-icons"; 3 3 import PromiseButton from "$lib/component/PromiseButton.svelte"; 4 4 import { faSave } from "@fortawesome/free-regular-svg-icons"; 5 + import { getProjects, type LuminaryProject } from "$lib/api"; 5 6 import LogTerminal from "$lib/component/LogTerminal.svelte"; 6 7 import StatusIcon from "$lib/component/StatusIcon.svelte"; 7 8 import { beforeNavigate, goto } from "$app/navigation"; 8 9 import StatusTab from "./ProjectStatus.svelte"; 9 10 import EditTabs from "../EditTabs.svelte"; 10 - import { getProjects } from "$lib/api"; 11 11 import { api, isMobile } from "$lib"; 12 12 import { page } from "$app/state"; 13 13 import { onMount } from "svelte"; 14 + import { watch } from "runed"; 14 15 import Fa from "svelte-fa"; 15 16 16 17 let project = $derived(getProjects()[page.params.project!]); ··· 19 20 let format = $state(async () => {}); 20 21 21 22 // svelte-ignore state_referenced_locally 22 - let copy = $state({ 23 - compose: data.compose ?? "", 24 - name: project?.name ?? "", 25 - }); 26 - 27 - // Watch for changes to set unsaved state 28 - let unsaved = $state(false); 29 - $effect(() => { 30 - if (!project) return; 31 - 32 - unsaved = copy.name !== project.name || copy.compose !== data.compose; 33 - }); 23 + // @ts-ignore 24 + let changes: { compose: string; name: string } = $state({}); 34 25 35 26 function revert() { 36 - copy.compose = data.compose ?? ""; 37 - copy.name = project.name; 27 + changes.compose = data.compose ?? ""; 28 + changes.name = project.name; 38 29 } 39 30 31 + revert(); 32 + 33 + // If the project changes (eg. due to navigation), reset working data to the new project's data 34 + watch(() => page.params.project, revert); 35 + 36 + // Unsaved check 37 + let unsaved = $derived.by(() => { 38 + if (!project) return false; 39 + return changes.name !== project.name || changes.compose !== data.compose; 40 + }); 41 + 40 42 async function save() { 41 43 if (localStorage.getItem("luminary-format-on-save") == "true") await format(); 42 44 43 - const rename = copy.name !== project.name; 45 + const rename = changes.name !== project.name; 44 46 const response = await api.client.PATCH(`/api/project/{project}`, { 45 47 params: { path: { project: project.name } }, 46 48 body: { 47 - compose: copy.compose === data.compose ? null : copy.compose, 48 - to: rename ? copy.name : null, 49 + compose: changes.compose === data.compose ? null : changes.compose, 50 + to: rename ? changes.name : null, 49 51 creating: false, 50 52 }, 51 53 }); ··· 54 56 unsaved = false; 55 57 56 58 if (rename) { 57 - await goto(`/projects/${copy.name}${location.hash}`); 59 + await goto(`/projects/${changes.name}${location.hash}`); 58 60 return; 59 61 } 60 62 61 - data.compose = copy.compose; 63 + data.compose = changes.compose; 62 64 } 63 65 64 66 onMount(() => { ··· 77 79 }); 78 80 79 81 beforeNavigate(({ cancel }) => { 80 - if (unsaved && !confirm("You have unsaved changes. Are you sure you want to leave?")) cancel(); 82 + if (unsaved && !confirm("You have unsaved changes. Are you sure you want to leave?")) return cancel(); 81 83 }); 82 84 </script> 83 85 ··· 99 101 </div> 100 102 </h1> 101 103 102 - <EditTabs bind:format bind:data={copy} tabs={[{ label: "status", icon: faCircleInfo, content: status }]} /> 104 + <EditTabs bind:format bind:data={changes} tabs={[{ label: "status", icon: faCircleInfo, content: status }]} /> 103 105 </div> 104 106 105 107 {#snippet status()} ··· 114 116 <div style="color: var(--peach); margin-bottom: 10px;">* Unsaved changes</div> 115 117 <div class="flexr gap-10"> 116 118 <div> 117 - <PromiseButton onclick={save} disabled={copy.name.trim() === ""}> 119 + <PromiseButton onclick={save} disabled={changes.name.trim() === ""}> 118 120 <div class="flexr gap-5 center"> 119 121 <Fa icon={faSave} /> Save 120 122 </div>