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 0000000..e14e9e8 Binary files /dev/null and b/internal/http/static/icon-192.png differ diff --git a/internal/http/static/icon-512.png b/internal/http/static/icon-512.png new file mode 100644 index 0000000..cde02c9 Binary files /dev/null and b/internal/http/static/icon-512.png differ 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}}
-