Skip to content

Commit e642fda

Browse files
committed
add custom.css; fix uikit switcher hitch with caret movement in form fields;
1 parent 2845838 commit e642fda

8 files changed

Lines changed: 91 additions & 14 deletions

File tree

internal/server/routes.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package server
77
import (
88
"mime"
99
"net/http"
10+
"os"
1011

1112
"podnest/internal/auth"
1213
"podnest/internal/handlers/auditlog"
@@ -41,6 +42,20 @@ func (s *Server) routes() http.Handler {
4142
// serve embedded static assets under /static/
4243
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(web.Static))))
4344

45+
// serve an optional operator theme override from the on-disk app dir
46+
// (e.g. /opt/podnest/custom.css) — lets deployments retheme the panel by
47+
// mounting a stylesheet without rebuilding the embedded assets. Absent file
48+
// 404s quietly, so the <link> in the templates is a harmless no-op.
49+
mux.HandleFunc("/static/css/custom.css", func(w http.ResponseWriter, r *http.Request) {
50+
p := s.cfg.AppPath + "/custom.css"
51+
if _, err := os.Stat(p); err != nil {
52+
http.NotFound(w, r)
53+
return
54+
}
55+
w.Header().Set("Content-Type", "text/css; charset=utf-8")
56+
http.ServeFile(w, r, p)
57+
})
58+
4459
// serve the PWA manifest dynamically so its name reflects the host — an
4560
// exact path so it wins over the /static/ subtree handler above
4661
mux.HandleFunc("/static/manifest.json", s.handleManifest)

web/static/css/app.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/static/js/app.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2400,6 +2400,6 @@
24002400
</button>
24012401
</div>
24022402
</td>
2403-
</tr>`}function ia(t){t.addEventListener("click",async e=>{let a=e.target.closest('[data-action="delete-user"]');if(!(!a||!await E("Delete User","Delete this user? This cannot be undone.")))try{await m.delete(`/users/${a.dataset.uid}`),a.closest("tr").remove(),c.success("User deleted")}catch(n){c.error(n.message)}}),t.addEventListener("click",async e=>{let a=e.target.closest('[data-action="edit-user"]');a&&he(t,a.dataset.uid)})}h.register("dashboard",t=>Dt(t));h.register("sites",t=>qt(t));h.register("site-detail",(t,e)=>ge(t,e));h.register("users",t=>fe(t));h.register("settings",t=>Ht(t));h.register("security",t=>At(t));h.register("admin-logs",t=>Tt(t));h.register("audit-log",t=>It(t));document.addEventListener("click",t=>{let e=t.target.closest("[data-view]");e&&(t.preventDefault(),h.go(e.dataset.view))});document.addEventListener("click",async t=>{let e=t.target.closest("[data-action]");if(!e)return;t.stopPropagation();let{action:a,id:s}=e.dataset;switch(a){case"manage":h.go("site-detail",{id:s});break;case"start":await pt(s,"start","Starting Site","Starting all containers - please wait...");break;case"stop":await pt(s,"stop","Stopping Site","Gracefully stopping all containers - please wait...");break;case"restart":await pt(s,"restart","Restarting Site","Restarting all containers - please wait...");break;case"flush":await pt(s,"flush","Flushing Caches","Clearing container caches - please wait...");break;case"edit":{let n=await m.get(`/sites/${s}`);tt(n.site);break}case"clone":{let n=await Z(e.dataset.name??s);if(!n)break;S("Cloning Site","Copying files and database \u2014 this may take a few minutes...");try{let i=await m.post(`/sites/${s}/clone`,{name:n}),r=!1,o=0;for(;!r&&o<60;)await new Promise(u=>setTimeout(u,3e3)),r=(await m.get("/sites")).some(u=>u.ID===i.id&&u.SiteStatus===1),o++;y(),r?(c.success(`Site cloned as '${n}'`),h.go("sites")):c.error("Clone timed out \u2014 check container logs")}catch(i){y(),c.error(i.message)}break}case"delete":await oa(s);break;case"recreate":S("Recreating Pod","Recreating containers for this site - this may take a few minutes...");try{await m.post(`/sites/${s}/recreate`),y(),c.success("Pod recreated"),h.go("sites")}catch(n){y(),c.error(n.message)}break}});document.addEventListener("kp:bulk-action",async t=>{let{action:e,ids:a}=t.detail;if(!a.length)return;let s={start:"Starting",stop:"Stopping",restart:"Restarting",flush:"Flushing Caches",recreate:"Recreating"},n=e==="recreate"?"Please hold while we update your Pods":"Please wait...",i=e==="recreate"?{prune:!0}:void 0,r=e==="recreate"?1200*1e3:void 0;S(`${s[e]} ${a.length} Site${a.length!==1?"s":""}`,n);let o=await Promise.allSettled(a.map(u=>m.post(`/sites/${u}/${e}`,i,r)));y();let l=o.filter(u=>u.status==="rejected").length;l===0?c.success(`${e.charAt(0).toUpperCase()+e.slice(1)} complete for ${a.length} site${a.length!==1?"s":""}`):c.error(`${l} of ${a.length} sites failed \u2014 check logs`),["start","stop","restart","recreate"].includes(e)&&h.go("sites")});async function pt(t,e,a,s){S(a,s);try{await m.post(`/sites/${t}/${e}`),y(),c.success(a+" complete")}catch(n){y(),c.error(n.message)}}async function oa(t){if(!await E("Delete Site",`This will stop and permanently remove the pod and all its data. Are you sure?
2403+
</tr>`}function ia(t){t.addEventListener("click",async e=>{let a=e.target.closest('[data-action="delete-user"]');if(!(!a||!await E("Delete User","Delete this user? This cannot be undone.")))try{await m.delete(`/users/${a.dataset.uid}`),a.closest("tr").remove(),c.success("User deleted")}catch(n){c.error(n.message)}}),t.addEventListener("click",async e=>{let a=e.target.closest('[data-action="edit-user"]');a&&he(t,a.dataset.uid)})}h.register("dashboard",t=>Dt(t));h.register("sites",t=>qt(t));h.register("site-detail",(t,e)=>ge(t,e));h.register("users",t=>fe(t));h.register("settings",t=>Ht(t));h.register("security",t=>At(t));h.register("admin-logs",t=>Tt(t));h.register("audit-log",t=>It(t));document.addEventListener("keydown",t=>{let e=t.target;e?.matches?.("input, textarea, select, [contenteditable='true']")&&e.id!=="wpcli-input"&&["ArrowLeft","ArrowRight","ArrowUp","ArrowDown","Home","End"].includes(t.key)&&t.stopPropagation()},!0);document.addEventListener("click",t=>{let e=t.target.closest("[data-view]");e&&(t.preventDefault(),h.go(e.dataset.view))});document.addEventListener("click",async t=>{let e=t.target.closest("[data-action]");if(!e)return;t.stopPropagation();let{action:a,id:s}=e.dataset;switch(a){case"manage":h.go("site-detail",{id:s});break;case"start":await pt(s,"start","Starting Site","Starting all containers - please wait...");break;case"stop":await pt(s,"stop","Stopping Site","Gracefully stopping all containers - please wait...");break;case"restart":await pt(s,"restart","Restarting Site","Restarting all containers - please wait...");break;case"flush":await pt(s,"flush","Flushing Caches","Clearing container caches - please wait...");break;case"edit":{let n=await m.get(`/sites/${s}`);tt(n.site);break}case"clone":{let n=await Z(e.dataset.name??s);if(!n)break;S("Cloning Site","Copying files and database \u2014 this may take a few minutes...");try{let i=await m.post(`/sites/${s}/clone`,{name:n}),r=!1,o=0;for(;!r&&o<60;)await new Promise(u=>setTimeout(u,3e3)),r=(await m.get("/sites")).some(u=>u.ID===i.id&&u.SiteStatus===1),o++;y(),r?(c.success(`Site cloned as '${n}'`),h.go("sites")):c.error("Clone timed out \u2014 check container logs")}catch(i){y(),c.error(i.message)}break}case"delete":await oa(s);break;case"recreate":S("Recreating Pod","Recreating containers for this site - this may take a few minutes...");try{await m.post(`/sites/${s}/recreate`),y(),c.success("Pod recreated"),h.go("sites")}catch(n){y(),c.error(n.message)}break}});document.addEventListener("kp:bulk-action",async t=>{let{action:e,ids:a}=t.detail;if(!a.length)return;let s={start:"Starting",stop:"Stopping",restart:"Restarting",flush:"Flushing Caches",recreate:"Recreating"},n=e==="recreate"?"Please hold while we update your Pods":"Please wait...",i=e==="recreate"?{prune:!0}:void 0,r=e==="recreate"?1200*1e3:void 0;S(`${s[e]} ${a.length} Site${a.length!==1?"s":""}`,n);let o=await Promise.allSettled(a.map(u=>m.post(`/sites/${u}/${e}`,i,r)));y();let l=o.filter(u=>u.status==="rejected").length;l===0?c.success(`${e.charAt(0).toUpperCase()+e.slice(1)} complete for ${a.length} site${a.length!==1?"s":""}`):c.error(`${l} of ${a.length} sites failed \u2014 check logs`),["start","stop","restart","recreate"].includes(e)&&h.go("sites")});async function pt(t,e,a,s){S(a,s);try{await m.post(`/sites/${t}/${e}`),y(),c.success(a+" complete")}catch(n){y(),c.error(n.message)}}async function oa(t){if(!await E("Delete Site",`This will stop and permanently remove the pod and all its data. Are you sure?
24042404
2405-
A final backup will be created before deletion. This may take a moment.`))return;S("Deleting Site","Creating final backup and removing the pod \u2014 please wait...");let a;try{a=await fetch(`/api/sites/${t}`,{method:"DELETE"})}catch{}if(y(),a?.ok&&a.headers.get("Content-Type")?.includes("gzip")){let r=(a.headers.get("Content-Disposition")??"").match(/filename="([^"]+)"/)?.[1]??`${t}_final.tar.gz`,o=await a.blob(),l=document.createElement("a");l.href=URL.createObjectURL(o),l.download=r,l.click(),URL.revokeObjectURL(l.href),c.success("Site deleted. Final backup downloaded."),h.go("sites");return}let s=!1,n=0;for(;!s&&n<10;){try{await new Promise(r=>setTimeout(r,2e3)),s=!(await m.get("/sites")).find(r=>r.ID===parseInt(t))}catch{}n++}s?(c.success("Site deleted. Final backup saved to S3."),h.go("sites")):c.error("Delete failed - site still exists after 20s")}if(window.KP?.user?.role===99){let t=document.getElementById("kp-resource-warning"),e=document.getElementById("kp-resource-warning-msg"),a=async()=>{try{let s=await m.get("/settings/resource-warning");s?.active&&t&&e?(e.textContent=`${s.current_mb}MB used, threshold ${s.threshold_mb}MB \u2014 throttling ${s.offender}.`,t.style.display=""):t&&(t.style.display="none")}catch{}};a(),setInterval(a,3e4)}window.addEventListener("hashchange",()=>{if(h._ownHashChange)return;let{view:t,params:e}=kt();h.go(t,e)});var{view:la,params:ra}=kt();h.go(la,ra);})();
2405+
A final backup will be created before deletion. This may take a moment.`))return;S("Deleting Site","Creating final backup and removing the pod \u2014 please wait...");let a;try{a=await fetch(`/api/sites/${t}`,{method:"DELETE"})}catch{}if(y(),a?.ok&&a.headers.get("Content-Type")?.includes("gzip")){let r=(a.headers.get("Content-Disposition")??"").match(/filename="([^"]+)"/)?.[1]??`${t}_final.tar.gz`,o=await a.blob(),l=document.createElement("a");l.href=URL.createObjectURL(o),l.download=r,l.click(),URL.revokeObjectURL(l.href),c.success("Site deleted. Final backup downloaded."),h.go("sites");return}let s=!1,n=0;for(;!s&&n<10;){try{await new Promise(r=>setTimeout(r,2e3)),s=!(await m.get("/sites")).find(r=>r.ID===parseInt(t))}catch{}n++}s?(c.success("Site deleted. Final backup saved to S3."),h.go("sites")):c.error("Delete failed - site still exists after 20s")}if(window.KP?.user?.role===99){let t=document.getElementById("kp-resource-warning"),e=document.getElementById("kp-resource-warning-msg"),a=async()=>{try{let s=await m.get("/settings/resource-warning");s?.active&&t&&e?(e.textContent=`${s.current_mb}MB used, threshold ${s.threshold_mb}MB \u2014 throttling ${s.offender}.`,t.style.display=""):t&&(t.style.display="none")}catch{}};a(),setInterval(a,3e4)}window.addEventListener("hashchange",()=>{if(h._ownHashChange)return;let{view:t,params:e}=kt();h.go(t,e)});(()=>{let t=document.getElementById("kp-totop");if(!t)return;let e=()=>{t.classList.toggle("is-visible",window.scrollY>150)};window.addEventListener("scroll",e,{passive:!0}),e(),t.addEventListener("click",()=>{window.scrollTo({top:0,behavior:"smooth"})})})();var{view:la,params:ra}=kt();h.go(la,ra);})();

web/static/src/css/misc.css

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,4 +232,25 @@ pre code {
232232
.kp-filter-bar input.uk-input { width: 150px; }
233233
.kp-filter-bar input[type="date"].uk-input { width: 155px; }
234234
.kp-filter-bar .uk-select { width: 155px; }
235-
}
235+
}
236+
237+
/* -- scroll to top --------------------------------------------------------- */
238+
.kp-totop {
239+
position: fixed;
240+
right: 20px;
241+
bottom: 24px;
242+
z-index: 190;
243+
opacity: 0;
244+
visibility: hidden;
245+
transform: translateY(8px);
246+
transition: opacity var(--kp-transition), transform var(--kp-transition), visibility var(--kp-transition);
247+
}
248+
.kp-totop.is-visible {
249+
opacity: 1;
250+
visibility: visible;
251+
transform: translateY(0);
252+
}
253+
/* lift above the mobile bottom nav (64px) so it isn't hidden behind it */
254+
@media (max-width: 959px) {
255+
.kp-totop { bottom: 80px; }
256+
}

web/static/src/js/app.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ router.register("security", (root) => viewSecurity(root));
2828
router.register("admin-logs", (root) => viewAdminLogs(root));
2929
router.register("audit-log", (root) => viewAuditLog(root));
3030

31+
/* -- protect native caret keys in form fields ------------------------------ */
32+
// UIkit's tab/switcher keyboard nav intercepts arrow keys and preventDefaults
33+
// them; since the panel's forms live inside switcher-connected tab panels, that
34+
// kills caret movement in every field. Stop these keys in the capture phase so
35+
// UIkit never receives them — the browser's default caret behavior then runs.
36+
// The WP-CLI terminal deliberately uses Up/Down for history, so it's exempt.
37+
document.addEventListener("keydown", (e) => {
38+
const t = e.target;
39+
if (!t?.matches?.("input, textarea, select, [contenteditable='true']")) return;
40+
if (t.id === "wpcli-input") return; // keep its Up/Down history handler
41+
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) {
42+
e.stopPropagation();
43+
}
44+
}, true); // capture — runs before UIkit's handler
45+
3146
/* -- nav wiring ------------------------------------------------------------ */
3247
document.addEventListener("click", (e) => {
3348
const link = e.target.closest("[data-view]");
@@ -222,6 +237,24 @@ window.addEventListener("hashchange", () => {
222237
router.go(view, params);
223238
});
224239

240+
/* -- scroll to top --------------------------------------------------------- */
241+
(() => {
242+
const btn = document.getElementById("kp-totop");
243+
if (!btn) return;
244+
245+
// reveal the button once the page is scrolled past a threshold
246+
const onScroll = () => {
247+
btn.classList.toggle("is-visible", window.scrollY > 150);
248+
};
249+
window.addEventListener("scroll", onScroll, { passive: true });
250+
onScroll();
251+
252+
// smooth-scroll back to the top on click
253+
btn.addEventListener("click", () => {
254+
window.scrollTo({ top: 0, behavior: "smooth" });
255+
});
256+
})();
257+
225258
/* -- boot ------------------------------------------------------------------ */
226259
const { view: initialView, params: initialParams } = parseHash();
227260
router.go(initialView, initialParams);

web/templates/app.html

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
<meta charset="UTF-8">
66
<meta name="viewport" content="width=device-width, initial-scale=1.0">
77
<title>PodNest ~ Secure. Manage. Deploy.</title>
8-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@latest/dist/css/uikit.min.css">
8+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.25.19/dist/css/uikit.min.css">
99
<link rel="stylesheet" href="/static/css/app.css">
10+
<link rel="stylesheet" href="/static/css/custom.css">
1011
<link rel="icon" type="image/svg+xml" href="https://c.pdn.st/logos/podnest.svg">
1112
<link rel="alternate icon" type="image/svg+xml" href="https://c.pdn.st/logos/podnest.svg">
1213
<link rel="apple-touch-icon" href="https://c.pdn.st/logos/podnest.svg">
@@ -161,6 +162,11 @@ <h3 class="uk-modal-title" id="kp-confirm-title">Confirm</h3>
161162
<!-- -- toast container ----------------------------------------------------- -->
162163
<div id="kp-toasts" class="kp-toasts"></div>
163164

165+
<!-- -- scroll to top ------------------------------------------------------- -->
166+
<button id="kp-totop" class="kp-btn-icon kp-totop" type="button" aria-label="Scroll to top" title="Scroll to top">
167+
<span uk-icon="icon: chevron-up"></span>
168+
</button>
169+
164170
<!-- -- user data for JS ---------------------------------------------------- -->
165171
<script nonce="{{.Nonce}}">
166172
window.KP = {
@@ -176,9 +182,9 @@ <h3 class="uk-modal-title" id="kp-confirm-title">Confirm</h3>
176182
};
177183
</script>
178184

179-
<script src="https://cdn.jsdelivr.net/npm/uikit@latest/dist/js/uikit.min.js" defer></script>
180-
<script src="https://cdn.jsdelivr.net/npm/uikit@latest/dist/js/uikit-icons.min.js" defer></script>
181-
<script src="https://cdn.jsdelivr.net/npm/qrcode@latest/build/qrcode.min.js" defer></script>
185+
<script src="https://cdn.jsdelivr.net/npm/uikit@3.25.19/dist/js/uikit.min.js" defer></script>
186+
<script src="https://cdn.jsdelivr.net/npm/uikit@3.25.19/dist/js/uikit-icons.min.js" defer></script>
187+
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.1/build/qrcode.min.js" defer></script>
182188
<script src="/static/js/app.js" defer></script>
183189
</body>
184190
</html>

web/templates/login.html

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
<meta charset="UTF-8">
66
<meta name="viewport" content="width=device-width, initial-scale=1.0">
77
<title>PodNest ~ Secure. Manage. Deploy.</title>
8-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@latest/dist/css/uikit.min.css">
8+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.25.19/dist/css/uikit.min.css">
99
<link rel="stylesheet" href="/static/css/app.css">
10+
<link rel="stylesheet" href="/static/css/custom.css">
1011
<link rel="icon" type="image/svg+xml" href="https://c.pdn.st/logos/podnest.svg">
1112
<link rel="alternate icon" type="image/svg+xml" href="https://c.pdn.st/logos/podnest.svg">
1213
<link rel="apple-touch-icon" href="https://c.pdn.st/logos/podnest.svg">
@@ -131,7 +132,7 @@ <h1 class="kp-brand-heading uk-margin-small-top">
131132
</div>
132133
</div>
133134

134-
<script src="https://cdn.jsdelivr.net/npm/uikit@latest/dist/js/uikit.min.js" defer></script>
135-
<script src="https://cdn.jsdelivr.net/npm/uikit@latest/dist/js/uikit-icons.min.js" defer></script>
135+
<script src="https://cdn.jsdelivr.net/npm/uikit@3.25.19/dist/js/uikit.min.js" defer></script>
136+
<script src="https://cdn.jsdelivr.net/npm/uikit@3.25.19/dist/js/uikit-icons.min.js" defer></script>
136137
</body>
137138
</html>

web/templates/totp.html

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
<meta charset="UTF-8">
66
<meta name="viewport" content="width=device-width, initial-scale=1.0">
77
<title>PodNest ~ Two-Factor Authentication</title>
8-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@latest/dist/css/uikit.min.css">
8+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.25.19/dist/css/uikit.min.css">
99
<link rel="stylesheet" href="/static/css/app.css">
10+
<link rel="stylesheet" href="/static/css/custom.css">
1011
<link rel="icon" type="image/svg+xml" href="https://c.pdn.st/logos/podnest.svg">
1112
<link rel="alternate icon" type="image/svg+xml" href="https://c.pdn.st/logos/podnest.svg">
1213
<link rel="apple-touch-icon" href="https://c.pdn.st/logos/podnest.svg">
@@ -86,7 +87,7 @@ <h1 class="kp-brand-heading uk-margin-small-top">
8687
</div>
8788
</div>
8889

89-
<script src="https://cdn.jsdelivr.net/npm/uikit@latest/dist/js/uikit.min.js" defer></script>
90-
<script src="https://cdn.jsdelivr.net/npm/uikit@latest/dist/js/uikit-icons.min.js" defer></script>
90+
<script src="https://cdn.jsdelivr.net/npm/uikit@3.25.19/dist/js/uikit.min.js" defer></script>
91+
<script src="https://cdn.jsdelivr.net/npm/uikit@3.25.19/dist/js/uikit-icons.min.js" defer></script>
9192
</body>
9293
</html>

0 commit comments

Comments
 (0)