From 92fb1aac24dafa5afb638a7a528b1c192d46ae94 Mon Sep 17 00:00:00 2001 From: Indra Gunawan Date: Thu, 15 Oct 2026 22:38:22 +0700 Subject: [PATCH] fix: theme toggle leaves system mode to the opposite of the effective theme Co-Authored-By: Claude Fable 5.1 --- docs/DESIGN-SYSTEM.md | 2 +- internal/http/router.go | 17 +++- internal/http/static/SOURCES.md | 4 + internal/http/static/app.css | 5 + internal/http/static/app.js | 83 ++++++++++++++++ internal/http/static/charts.js | 14 ++- internal/http/static/console.js | 9 +- .../http/static/htmx-ext-head-support.min.js | 1 + internal/http/static/htmx-ext-preload.min.js | 1 + internal/http/static/icon-192.png | Bin 0 -> 1964 bytes internal/http/static/icon-512.png | Bin 0 -> 5531 bytes internal/http/static/list.js | 12 ++- internal/http/static/manifest.webmanifest | 13 +++ internal/http/static/sw.js | 23 +++++ internal/http/templates/about.html | 7 +- internal/http/templates/apps.html | 5 +- internal/http/templates/apps_add.html | 7 +- internal/http/templates/apps_detail.html | 11 +-- internal/http/templates/apps_list.html | 2 +- internal/http/templates/dashboard.html | 5 +- internal/http/templates/error.html | 5 +- internal/http/templates/export.html | 7 +- internal/http/templates/import.html | 5 +- internal/http/templates/layout.html | 40 ++++---- internal/http/templates/servers.html | 5 +- internal/http/templates/servers_add.html | 5 +- internal/http/templates/servers_detail.html | 14 +-- internal/http/templates/servers_edit.html | 5 +- internal/http/templates/servers_list.html | 2 +- internal/http/templates/settings.html | 9 +- tests/e2e/ui.mjs | 89 +++++++++++++++++- 31 files changed, 318 insertions(+), 89 deletions(-) create mode 100644 internal/http/static/htmx-ext-head-support.min.js create mode 100644 internal/http/static/htmx-ext-preload.min.js create mode 100644 internal/http/static/icon-192.png create mode 100644 internal/http/static/icon-512.png create mode 100644 internal/http/static/manifest.webmanifest create mode 100644 internal/http/static/sw.js diff --git a/docs/DESIGN-SYSTEM.md b/docs/DESIGN-SYSTEM.md index 5916dcd..ac9e651 100644 --- a/docs/DESIGN-SYSTEM.md +++ b/docs/DESIGN-SYSTEM.md @@ -15,7 +15,7 @@ Tanpa framework CSS, tanpa Node saat build/runtime. Font: Plus Jakarta Sans 400/ - Preferensi di `localStorage`: `dockify_palette` (`indigo` default) dan `dockify_theme` (`light|dark|system`, **default `system`**). Kunci lama `dockify-theme` dibaca sekali lalu dihapus. - Skrip bootstrap **inline di ``** (sebelum CSS) menulis kedua atribut + ``; selama `system`, ikuti `matchMedia('(prefers-color-scheme: dark)')` dan perubahannya. Tanpa `@media (prefers-color-scheme)` di CSS. - CSS: lapis 2 didefinisikan per kombinasi: `:root[data-palette=indigo]`, `:root[data-palette=indigo][data-theme=dark]`, dst. (6 blok). Komponen hanya memakai token lapis 2 (`var(--ink)`, `var(--accent-fill)`...), tidak pernah hex. -- Kontrol: tombol ikon di header (siklus terang → gelap → sistem, ikon matahari/bulan/monitor, `aria-label` menyebut mode berikutnya) dan di **Settings → Appearance**: segmented "Palette" (Indigo / Navy / Teal) dan segmented "Theme" (Light / Dark / System). Halaman login ikut preferensi yang sama. +- Kontrol: tombol ikon di header (terang → gelap → sistem → lawan dari tema efektif saat itu, supaya setiap klik terlihat berubah; ikon matahari/bulan/monitor, `aria-label` menyebut mode berikutnya) dan di **Settings → Appearance**: segmented "Palette" (Indigo / Navy / Teal) dan segmented "Theme" (Light / Dark / System). Halaman login ikut preferensi yang sama. ## 3. Token lapis 2 (nama tetap, nilai per palet) Kegunaan tiap token sama dengan standar §3: `bg` latar halaman, `surface` kartu/header/sheet, `sunk` permukaan cekung (pill netral, wadah segmented), diff --git a/internal/http/router.go b/internal/http/router.go index 18c5497..7dfa977 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -260,7 +260,7 @@ func NewRouter(svc *server.Service, appSvc *app.Service, render RenderFunc, serv return r } -//go:embed static/*.js static/*.css +//go:embed static/*.js static/*.css static/*.png static/*.webmanifest var staticFS embed.FS // serveStatic serves the vendored browser assets (see static/SOURCES.md); no directory listing. @@ -270,8 +270,15 @@ func serveStatic(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - // ponytail: a week, not immutable — file names do not change when an asset is upgraded. - w.Header().Set("Cache-Control", "public, max-age=604800") + if name == "sw.js" { + // The worker script must always be revalidated; it controls the whole app path (scope "."). + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Service-Worker-Allowed", "../") + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + } else { + // ponytail: a week, not immutable — file names do not change when an asset is upgraded. + w.Header().Set("Cache-Control", "public, max-age=604800") + } http.ServeFileFS(w, r, staticFS, "static/"+name) } @@ -286,6 +293,10 @@ func securityHeaders(next http.Handler) http.Handler { h.Set("Content-Security-Policy", contentSecurityPolicy) h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "same-origin") + // htmx preload (hover/touch prefetch) relies on the browser cache: let the prefetched page live a few seconds. + if r.Method == http.MethodGet && r.Header.Get("HX-Preloaded") == "true" { + h.Set("Cache-Control", "private, max-age=5") + } next.ServeHTTP(w, r) }) } diff --git a/internal/http/static/SOURCES.md b/internal/http/static/SOURCES.md index a274d0a..f18f824 100644 --- a/internal/http/static/SOURCES.md +++ b/internal/http/static/SOURCES.md @@ -10,3 +10,7 @@ names in `templates/layout.html` if they change. | xterm.min.js | https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js | fc1dd31b221e3e5f929486e07a80b477a8aaf9dce2b4f9c3ffe7dd25f370655d | | xterm.min.css | https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css | 64ee6c4db69b4224d3362aced0fd4cdd620e0e60b3d01566450ae2d4b9e81849 | | xterm-addon-fit.min.js | https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js | c78e5795c6487acdf24ab436798d4a9cec3848101b281bc65b061f39db714be1 | +| htmx-ext-preload.min.js | https://unpkg.com/htmx-ext-preload@2.1.2/dist/preload.min.js | 2ac8ccf77deee4f8e87366d6b606085eed755b02513746ead7573395e717d26e | +| htmx-ext-head-support.min.js | https://unpkg.com/htmx-ext-head-support@2.0.5/dist/head-support.min.js | 86e59ae1048bf02fb31a5842b4b559a1058b4c3ff190c258530ecb94f9fa6d56 | + +`icon-192.png` / `icon-512.png`: the "D" logo on `accent-fill` indigo (#4f46e5), rendered from SVG with Playwright; `manifest.webmanifest` and `sw.js` are first-party. diff --git a/internal/http/static/app.css b/internal/http/static/app.css index 5013266..a438607 100644 --- a/internal/http/static/app.css +++ b/internal/http/static/app.css @@ -245,6 +245,11 @@ hgroup p { color: var(--muted); margin: 0; font-size: 12.8px; } background: var(--surface); border-bottom: 1px solid var(--line); } +/* S9: 3px request progress under the header (shown by app.js for requests slower than 150 ms). */ +.progress { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: var(--accent-fill); transform-origin: left; animation: progress 8s cubic-bezier(.1,.6,.2,1) forwards; } +.progress[hidden] { display: none; } +@keyframes progress { from { transform: scaleX(0.05); } to { transform: scaleX(0.95); } } +@media (prefers-reduced-motion: reduce) { .progress { animation: none; transform: none; } } .top-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; height: 56px; padding: 0 16px; } .top-actions { display: flex; align-items: center; gap: 8px; } .top-actions form { margin: 0; } diff --git a/internal/http/static/app.js b/internal/http/static/app.js index acdd58a..7a58a99 100644 --- a/internal/http/static/app.js +++ b/internal/http/static/app.js @@ -82,6 +82,7 @@ function loadXterm(cb) { var l = document.createElement('link'); l.rel = 'stylesheet'; l.href = 'static/xterm.min.css?v=' + assetV; + l.setAttribute('hx-preserve', 'true'); document.head.appendChild(l); function add(src, next) { var s = document.createElement('script'); @@ -95,3 +96,85 @@ function loadXterm(cb) { } xtermLoading.then(cb, function () { xtermLoading = null; }); } + +// ---- S9: boosted navigation (hx-boost on ; only #main is swapped, header/tab bar persist) ---- +// Page scripts must be idempotent: run on DOMContentLoaded and again on htmx:afterSettle, guarded by data-init. +function formatTimes(root) { + [].forEach.call((root || document).querySelectorAll('time[datetime]:not([data-init])'), function (el) { + el.setAttribute('data-init', '1'); + var d = new Date(el.getAttribute('datetime')); + if (isNaN(d)) return; + var p = function (n) { return String(n).padStart(2, '0'); }; + el.textContent = d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()); + }); +} +function syncNav() { + var m = document.getElementById('main'), cur = m && m.getAttribute('data-nav'); + [].forEach.call(document.querySelectorAll('.main-nav a[data-nav]'), function (a) { + if (a.getAttribute('data-nav') === cur) a.setAttribute('aria-current', 'page'); + else a.removeAttribute('aria-current'); + }); +} +document.addEventListener('DOMContentLoaded', function () { formatTimes(); }); +if (window.htmx) { + var progressTimer = null; + function progress(show) { + clearTimeout(progressTimer); + var bar = document.getElementById('progress'); + if (!bar) return; + if (!show) { bar.hidden = true; return; } + progressTimer = setTimeout(function () { bar.hidden = false; }, 150); + } + document.addEventListener('htmx:beforeRequest', function (e) { + var d = e.detail; + // Preload requests are intercepted by the preload extension and must not flash the bar. + if (d.boosted && !(d.requestConfig.headers || {})['HX-Preloaded']) progress(true); + }); + ['htmx:afterSettle', 'htmx:responseError', 'htmx:sendError', 'htmx:timeout', 'htmx:swapError'].forEach(function (n) { + document.addEventListener(n, function () { progress(false); }); + }); + document.addEventListener('htmx:beforeSwap', function (e) { + var d = e.detail; + if (d.boosted) { + // Login redirect, session expiry, anything without #main: fall back to a full page load. + if (d.xhr.responseText.indexOf('id="main"') < 0) { + d.shouldSwap = false; + progress(false); + location.assign(d.xhr.responseURL || location.href); + return; + } + // 4xx/5xx pages (error.html) are shown like any other page. + if (d.xhr.status >= 400) { d.shouldSwap = true; d.isError = false; } + } + if (d.target && d.target.id === 'main') { + var c = window.dockifyCleanups || []; + window.dockifyCleanups = []; + c.forEach(function (f) { try { f(); } catch (err) {} }); + } + }); + // head-support: never drop scripts/styles already loaded (fonts link mutates media=all, xterm is added at runtime). + document.addEventListener('htmx:removingHeadElement', function (e) { + var t = e.detail.headElement.tagName; + if (t === 'LINK' || t === 'SCRIPT') e.preventDefault(); + }); + document.addEventListener('htmx:addingHeadElement', function (e) { + var el = e.detail.headElement.firstElementChild; + if (!el) return; + var url = el.getAttribute('href') || el.getAttribute('src'); + if (url && document.head.querySelector((el.tagName === 'LINK' ? 'link[href="' : 'script[src="') + url + '"]')) e.preventDefault(); + }); + document.addEventListener('htmx:afterSettle', function (e) { + formatTimes(); + syncNav(); + if (window.dockifyApplyTheme) window.dockifyApplyTheme(); // head merge replaces + if (e.detail.boosted) window.scrollTo(0, 0); + }); + // Back/forward: do not restore stale DOM snapshots; fetch the page again. + htmx.config.historyCacheSize = 0; +} +// Light PWA: the service worker only caches /static/*. +if ('serviceWorker' in navigator) { + window.addEventListener('load', function () { + navigator.serviceWorker.register('static/sw.js?v=' + assetV, { scope: './' }).catch(function () {}); + }); +} diff --git a/internal/http/static/charts.js b/internal/http/static/charts.js index a454bd5..146d930 100644 --- a/internal/http/static/charts.js +++ b/internal/http/static/charts.js @@ -250,7 +250,7 @@ function initLiveCharts(opts) { readChartDataFromHTML(); updateCharts(); - document.body.addEventListener('htmx:afterSwap', function(evt) { + function onSwap(evt) { if (evt.target.id === opts.targetId) { var rangeEl = evt.target.getAttribute('data-range'); if (rangeEl && rangeDurations[rangeEl]) { @@ -270,8 +270,16 @@ function initLiveCharts(opts) { } } } - }); + } + document.body.addEventListener('htmx:afterSwap', onSwap); + + // Boosted navigation keeps this document alive: drop the socket and listener when #main is replaced. + function dispose() { + disconnect(); + document.body.removeEventListener('htmx:afterSwap', onSwap); + } + (window.dockifyCleanups = window.dockifyCleanups || []).push(dispose); connect(); - return { disconnect: disconnect }; + return { disconnect: dispose }; } diff --git a/internal/http/static/console.js b/internal/http/static/console.js index 2002040..8aeba4b 100644 --- a/internal/http/static/console.js +++ b/internal/http/static/console.js @@ -40,7 +40,8 @@ function dockifyConsole(cfg) { if (term) { term.dispose(); term = null; } setStatus('Disconnected', 'badge-offline'); } - return { + var api = { + disconnect: disconnect, toggle: function () { var box = el(cfg.container), btn = el(cfg.toggle); if (box.hidden) { @@ -54,6 +55,7 @@ function dockifyConsole(cfg) { } }, }; + return api; } var serverConsole = null, appConsole = null; function toggleConsole() { @@ -110,3 +112,8 @@ function toggleAppConsole() { } }); appConsole.toggle(); } +// A boosted navigation replaces #main; close any open console so its socket does not leak. +(window.dockifyCleanups = window.dockifyCleanups || []).push(function () { + if (serverConsole) { serverConsole.disconnect(); serverConsole = null; } + if (appConsole) { appConsole.disconnect(); appConsole = null; } +}); diff --git a/internal/http/static/htmx-ext-head-support.min.js b/internal/http/static/htmx-ext-head-support.min.js new file mode 100644 index 0000000..edf446b --- /dev/null +++ b/internal/http/static/htmx-ext-head-support.min.js @@ -0,0 +1 @@ +(function(){var H=null;function M(){}function a(e,t){if(e&&e.indexOf("-1){const g=document.createElement("html");var r=e.replace(/]*>|>)([\s\S]*?)<\/svg>/gim,"");var a=r.match(/(]*>|>)([\s\S]*?)<\/head>)/im);if(a){var n=[];var d=[];var o=[];var i=[];g.innerHTML=a;var h=g.querySelector("head");var m=document.head;if(h==null){return}else{var s=new Map;for(const p of h.children){s.set(p.outerHTML,p)}}var u=H.getAttributeValue(h,"hx-head")||t;for(const x of m.children){var l=s.has(x.outerHTML);var f=x.getAttribute("hx-head")==="re-eval";var v=H.getAttributeValue(x,"hx-preserve")==="true";if(l||v){if(f){d.push(x)}else{s.delete(x.outerHTML);o.push(x)}}else{if(u==="append"){if(f){d.push(x);i.push(x)}}else{if(H.triggerEvent(document.body,"htmx:removingHeadElement",{headElement:x})!==false){d.push(x)}}}}i.push(...s.values());M("to append: ",i);for(const E of i){M("adding: ",E);var c=document.createRange().createContextualFragment(E.outerHTML);M(c);if(H.triggerEvent(document.body,"htmx:addingHeadElement",{headElement:c})!==false){m.appendChild(c);n.push(c)}}for(const b of d){if(H.triggerEvent(document.body,"htmx:removingHeadElement",{headElement:b})!==false){m.removeChild(b)}}H.triggerEvent(document.body,"htmx:afterHeadMerge",{added:n,kept:o,removed:d})}}}htmx.defineExtension("head-support",{init:function(e){H=e;htmx.on("htmx:afterSwap",function(e){let t=e.detail.xhr;if(t){var r=t.response;if(H.triggerEvent(document.body,"htmx:beforeHeadMerge",e.detail)){a(r,e.detail.boosted?"merge":"append")}}});htmx.on("htmx:historyRestore",function(e){if(H.triggerEvent(document.body,"htmx:beforeHeadMerge",e.detail)){if(e.detail.cacheMiss){a(e.detail.serverResponse,"merge")}else{a(e.detail.item.head,"merge")}}});htmx.on("htmx:historyItemCreated",function(e){var t=e.detail.item;t.head=document.head.outerHTML})}})})(); \ No newline at end of file diff --git a/internal/http/static/htmx-ext-preload.min.js b/internal/http/static/htmx-ext-preload.min.js new file mode 100644 index 0000000..f805161 --- /dev/null +++ b/internal/http/static/htmx-ext-preload.min.js @@ -0,0 +1 @@ +(function(){htmx.defineExtension("preload",{onEvent:function(e,t){if(e==="htmx:afterProcessNode"){const n=t.target||t.detail.elt;const r=[...n.hasAttribute("preload")?[n]:[],...n.querySelectorAll("[preload]")];r.forEach(function(e){i(e);e.querySelectorAll("[href],[hx-get],[data-hx-get]").forEach(i)});return}if(e==="htmx:beforeRequest"){const o=t.detail.requestConfig.headers;if(!("HX-Preloaded"in o&&o["HX-Preloaded"]==="true")){return}t.preventDefault();const a=t.detail.xhr;a.onload=function(){s(t.detail.elt,a.responseText)};a.onerror=null;a.onabort=null;a.ontimeout=null;a.send()}}});function i(t){if(t.preloadState!==undefined){return}if(!l(t)){return}if(t instanceof HTMLFormElement){const o=t;if(!(o.hasAttribute("method")&&o.method==="get"||o.hasAttribute("hx-get")||o.hasAttribute("hx-data-get"))){return}for(let e=0;e{if(e.selected)return;o.set(u,e.value);const t=d(n.form,o);i(r,n.form,t)});return}const s=n.getAttribute("type")||n.control.getAttribute("type");const l=n.value||n.control?.value;if(s==="radio"){o.set(u,l)}else if(s==="checkbox"){const c=o.getAll(u);if(c.includes(l)){o[u]=c.filter(e=>e!==l)}else{o.append(u,l)}}const f=d(n.form,o);i(r,n.form,f);return}}function d(e,t){const n=e.elements;const r=new FormData;for(let e=0;en.some(e=>t.hasAttribute(e))||t.method==="get";const r=e.form instanceof HTMLFormElement&&t(e.form)&&g(e);if(!t(e)&&!r){return false}if(e instanceof HTMLInputElement&&e.closest("label")){return false}return true}function g(e){if(e instanceof HTMLInputElement||e instanceof HTMLButtonElement){const t=e.getAttribute("type");return["checkbox","radio","submit"].includes(t)}if(e instanceof HTMLLabelElement){return e.control&&g(e.control)}return e instanceof HTMLSelectElement}})(); \ No newline at end of file diff --git a/internal/http/static/icon-192.png b/internal/http/static/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..e14e9e8fc64c38104abc4db845f5fcf5ca62f942 GIT binary patch literal 1964 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 F{{Vsi2eAME literal 0 HcmV?d00001 diff --git a/internal/http/static/list.js b/internal/http/static/list.js index 2b57ff8..dab768c 100644 --- a/internal/http/static/list.js +++ b/internal/http/static/list.js @@ -50,7 +50,13 @@ }); apply(); } - document.addEventListener('DOMContentLoaded', function () { - [].forEach.call(document.querySelectorAll('[data-list]'), initList); - }); + // Idempotent: runs on load and after every htmx swap; data-init guards against double wiring. + function init() { + [].forEach.call(document.querySelectorAll('[data-list]:not([data-init])'), function (r) { + r.setAttribute('data-init', '1'); + initList(r); + }); + } + document.addEventListener('DOMContentLoaded', init); + document.addEventListener('htmx:afterSettle', init); })(); diff --git a/internal/http/static/manifest.webmanifest b/internal/http/static/manifest.webmanifest new file mode 100644 index 0000000..340ebd2 --- /dev/null +++ b/internal/http/static/manifest.webmanifest @@ -0,0 +1,13 @@ +{ + "name": "Dockify", + "short_name": "Dockify", + "start_url": ".", + "scope": ".", + "display": "standalone", + "theme_color": "#f5f7fb", + "background_color": "#f5f7fb", + "icons": [ + { "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" } + ] +} diff --git a/internal/http/static/sw.js b/internal/http/static/sw.js new file mode 100644 index 0000000..e1be429 --- /dev/null +++ b/internal/http/static/sw.js @@ -0,0 +1,23 @@ +// Light PWA service worker: caches /static/* only (cache-first, versioned by ?v=AssetVersion in the URL). +// HTML, API and WebSocket traffic always goes to the network. +var CACHE = 'dockify-static-' + (new URL(location.href).searchParams.get('v') || '0'); // registered as sw.js?v=AssetVersion +self.addEventListener('install', function () { self.skipWaiting(); }); +self.addEventListener('activate', function (e) { + e.waitUntil(caches.keys().then(function (keys) { + return Promise.all(keys.filter(function (k) { return k !== CACHE && k.indexOf('dockify-static-') === 0; }).map(function (k) { return caches.delete(k); })); + }).then(function () { return self.clients.claim(); })); +}); +self.addEventListener('fetch', function (e) { + var req = e.request, url = new URL(req.url); + if (req.method !== 'GET' || url.origin !== location.origin) return; + var dir = location.pathname.replace(/sw\.js$/, ''); // .../static/ + if (url.pathname.indexOf(dir) !== 0 || url.pathname === location.pathname) return; + e.respondWith(caches.open(CACHE).then(function (c) { + return c.match(req).then(function (hit) { + return hit || fetch(req).then(function (res) { + if (res.ok) c.put(req, res.clone()); + return res; + }); + }); + })); +}); diff --git a/internal/http/templates/about.html b/internal/http/templates/about.html index 9ed7898..a4186a2 100644 --- a/internal/http/templates/about.html +++ b/internal/http/templates/about.html @@ -1,9 +1,9 @@ {{template "head" .}} - + {{template "nav" .}} -
+
@@ -38,7 +38,6 @@

Sponsor & Donation

Sponsor on GitHub
-
{{template "ui_confirm" (dict "ID" "update-confirm" "Title" "Update Dockify now?" "Body" "The server restarts to apply the update and is unavailable for a short time." "Label" "Update")}} -{{template "scripts" .}} + diff --git a/internal/http/templates/apps.html b/internal/http/templates/apps.html index 668823a..bb6452b 100644 --- a/internal/http/templates/apps.html +++ b/internal/http/templates/apps.html @@ -1,9 +1,9 @@ {{template "head" .}} - + {{template "nav" .}} -
+
{{if .Flash}}
{{.Flash}}
{{end}}

Apps

@@ -16,6 +16,5 @@

Apps

{{template "apps_list" .}} {{template "ui_confirm" (dict "ID" "redeploy-all" "Title" "Redeploy all apps?" "Body" "This rebuilds and restarts every running app. Each app is briefly unavailable while it restarts." "Label" "Redeploy all" "Action" "apps/redeploy-all")}}
-{{template "scripts" .}} diff --git a/internal/http/templates/apps_add.html b/internal/http/templates/apps_add.html index 4324500..343b810 100644 --- a/internal/http/templates/apps_add.html +++ b/internal/http/templates/apps_add.html @@ -1,9 +1,9 @@ {{template "head" .}} - + {{template "nav" .}} -
+

{{if .IsEdit}}Edit App{{else}}Deploy App{{end}}

{{if .IsEdit}}Update configuration and redeploy{{else}}Deploy a Docker container to a worker VM{{end}}

@@ -185,7 +185,6 @@

Basic auth

{{end}}
-
{{if and .IsEdit (ne .App.Status "draft")}}{{template "ui_confirm" (dict "ID" "save-redeploy" "Title" (printf "Save and redeploy %s?" .App.Name) "Body" "This restarts the app with the new configuration." "Label" "Save & Redeploy")}}{{end}} @@ -480,6 +479,6 @@

Basic auth

if (ok) ok.addEventListener('click', function () { closeSheet('save-redeploy'); var f = document.getElementById('app-form'); if (f.requestSubmit) f.requestSubmit(); else if (validateServer()) f.submit(); }); })(); -{{template "scripts" .}} +
diff --git a/internal/http/templates/apps_detail.html b/internal/http/templates/apps_detail.html index 4f53ccc..c52954e 100644 --- a/internal/http/templates/apps_detail.html +++ b/internal/http/templates/apps_detail.html @@ -1,9 +1,9 @@ -{{template "head" .}}{{if eq .App.Status "deploying"}}{{end}} - +{{template "head" .}}{{if eq .App.Status "deploying"}}{{end}} + {{template "nav" .}} -
+
{{if .Flash}}
{{.Flash}}
{{end}}

-
+

Loading stats…

@@ -190,13 +190,12 @@

Danger zone

- {{template "ui_confirm" (dict "ID" "app-redeploy" "Title" (printf "Redeploy %s?" .App.Name) "Body" "This rebuilds the app and restarts its containers. It is briefly unavailable while it restarts." "Label" "Redeploy" "Action" (printf "apps/%d/redeploy" .App.ID))}} {{template "ui_confirm" (dict "ID" "app-rollback" "Title" (printf "Roll back %s?" .App.Name) "Body" "This restores the last successful deployment and replaces the running version." "Label" "Roll back" "Action" (printf "apps/%d/rollback" .App.ID))}} {{if eq .App.Status "draft"}}{{template "ui_confirm" (dict "ID" "app-undeploy" "Title" (printf "Delete draft %s?" .App.Name) "Body" "This permanently deletes the draft." "Label" "Delete draft" "Action" (printf "apps/%d/undeploy" .App.ID))}} {{else}}{{template "ui_confirm" (dict "ID" "app-undeploy" "Title" (printf "Undeploy %s?" .App.Name) "Body" (printf "This stops and removes the app from %s." .ServerName) "Label" "Undeploy" "Action" (printf "apps/%d/undeploy" .App.ID))}}{{end}} {{template "ui_confirm" (dict "ID" "app-clear-history" "Title" "Clear deployment history?" "Body" "This permanently removes all deployment records for this app and cannot be undone." "Label" "Clear history" "Action" (printf "apps/%d/clear-deployments" .App.ID))}} -{{template "scripts" .}} + diff --git a/internal/http/templates/apps_list.html b/internal/http/templates/apps_list.html index 0e4ab6f..0f451c8 100644 --- a/internal/http/templates/apps_list.html +++ b/internal/http/templates/apps_list.html @@ -29,7 +29,7 @@

{{if .ServerID}}{{.ServerName}}{{else}}{ data-memory="{{with $st := index $.AppStats .ID}}{{$st.MemUsageBytes}}{{end}}" data-disk="{{with $st := index $.AppStats .ID}}{{$st.DiskUsageBytes}}{{end}}" data-port="{{.Port}}"> - {{.Name}}{{if .Domain}}{{.Domain}}{{end}} + {{.Name}}{{if .Domain}}{{.Domain}}{{end}} {{if eq .Status "running"}}{{end}}{{.Status}} {{if .Domain}}{{.Domain}}{{else}}-{{end}} {{with $st := index $.AppStats .ID}}{{printf "%.1f" (clamp100 $st.CPUPercent)}}%{{else}}-{{end}} diff --git a/internal/http/templates/dashboard.html b/internal/http/templates/dashboard.html index a923d51..db4003d 100644 --- a/internal/http/templates/dashboard.html +++ b/internal/http/templates/dashboard.html @@ -1,9 +1,9 @@ {{template "head" .}} - + {{template "nav" .}} -
+

Dashboard

VM pool and app overview

@@ -24,6 +24,5 @@

Dashboard

Apps

{{len .Apps}}View all
{{template "apps_list" .}}
-{{template "scripts" .}} diff --git a/internal/http/templates/error.html b/internal/http/templates/error.html index b14a8a7..bcd02cd 100644 --- a/internal/http/templates/error.html +++ b/internal/http/templates/error.html @@ -1,15 +1,14 @@ {{template "head" .}} - + {{template "nav" .}} -
+
{{template "ui_alert" (dict "Kind" "danger" "Icon" "alert" "Title" "Something went wrong" "Sub" .Message)}}
Go Home
-{{template "scripts" .}} diff --git a/internal/http/templates/export.html b/internal/http/templates/export.html index 7beb4d5..b1cd441 100644 --- a/internal/http/templates/export.html +++ b/internal/http/templates/export.html @@ -1,9 +1,9 @@ {{template "head" .}} - + {{template "nav" .}} -
+
-
-{{template "scripts" .}} +
diff --git a/internal/http/templates/import.html b/internal/http/templates/import.html index ee62a6c..9213c03 100644 --- a/internal/http/templates/import.html +++ b/internal/http/templates/import.html @@ -1,9 +1,9 @@ {{template "head" .}} - + {{template "nav" .}} -
+
{{end}}
-{{template "scripts" .}} diff --git a/internal/http/templates/layout.html b/internal/http/templates/layout.html index 8cf5d94..9a2cf93 100644 --- a/internal/http/templates/layout.html +++ b/internal/http/templates/layout.html @@ -9,6 +9,8 @@ if (old) { if (!localStorage.getItem('dockify_theme')) localStorage.setItem('dockify_theme', old); localStorage.removeItem('dockify-theme'); } } catch (e) {} var MODES = ['light', 'dark', 'system']; + // light -> dark -> system -> (lawan dari tema efektif), supaya klik dari system selalu terlihat berubah + function nextMode(mode) { if (mode === 'system') return mq.matches ? 'light' : 'dark'; return mode === 'light' ? 'dark' : 'system'; } function apply() { var mode = get('dockify_theme', 'system', MODES); d.setAttribute('data-palette', get('dockify_palette', 'indigo', ['indigo', 'navy', 'teal'])); @@ -19,7 +21,7 @@ if (window.syncAppearance) window.syncAppearance(); var b = document.getElementById('theme-toggle'); if (b) { - var next = MODES[(MODES.indexOf(mode) + 1) % 3]; + var next = nextMode(mode); var ics = b.querySelectorAll('[data-theme-icon]'); for (var i = 0; i < ics.length; i++) ics[i].hidden = ics[i].getAttribute('data-theme-icon') !== mode; b.setAttribute('aria-label', 'Theme: ' + mode + '. Switch to ' + next); @@ -29,7 +31,7 @@ window.dockifyApplyTheme = apply; window.dockifyCycleTheme = function () { var mode = get('dockify_theme', 'system', MODES); - try { localStorage.setItem('dockify_theme', MODES[(MODES.indexOf(mode) + 1) % 3]); } catch (e) {} + try { localStorage.setItem('dockify_theme', nextMode(mode)); } catch (e) {} apply(); }; mq.addEventListener('change', function () { if (get('dockify_theme', 'system', MODES) === 'system') apply(); }); @@ -51,8 +53,15 @@ {{.Title}} - Dockify {{template "theme_head" .}} + + + + + - + + + {{end}} {{define "nav"}} @@ -62,27 +71,16 @@
- {{if not .NoAuth}}
{{end}} + {{if not .NoAuth}}
{{end}}
-