Initial commit
No files matched your search
@@ -0,0 +1,5 @@
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
ecosystem.config.js
|
||||
README.md
|
||||
/data/*.json
|
||||
@@ -0,0 +1,9 @@
|
||||
// light follows the cursor across a card's glass
|
||||
document.querySelectorAll('.shard').forEach(el=>{
|
||||
el.addEventListener('pointermove',e=>{
|
||||
const r=el.getBoundingClientRect();
|
||||
el.style.setProperty('--mx',(e.clientX-r.left)+'px');
|
||||
el.style.setProperty('--my',(e.clientY-r.top)+'px');
|
||||
});
|
||||
el.addEventListener('pointerleave',()=>{el.style.removeProperty('--mx');el.style.removeProperty('--my')});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice">
|
||||
<style>
|
||||
.c{fill:none;stroke:#9fd8ff;stroke-width:1;stroke-linejoin:bevel;stroke-dasharray:1400;stroke-dashoffset:1400;animation:d 1.6s cubic-bezier(.2,.7,.2,1) forwards}
|
||||
.t{stroke-width:.6;opacity:.6;animation-delay:.35s}
|
||||
@keyframes d{to{stroke-dashoffset:0}}
|
||||
@media (prefers-reduced-motion:reduce){.c{animation:none;stroke-dashoffset:0}}
|
||||
</style>
|
||||
<g opacity=".16">
|
||||
<polygon points="1160,290 1600,0 1600,180 1380,330" fill="#8fd3ff" opacity=".35"/>
|
||||
<polygon points="1160,290 1380,330 1520,640 1210,560" fill="#ff4f8b" opacity=".18"/>
|
||||
<polygon points="1160,290 960,520 700,900 1210,900 1210,560" fill="#8fd3ff" opacity=".15"/>
|
||||
<polygon points="1160,290 760,60 900,0 1190,0" fill="#ff4f8b" opacity=".14"/>
|
||||
</g>
|
||||
<g opacity=".34">
|
||||
<polyline class="c" points="1160,290 1010,180 880,150 700,0"/>
|
||||
<polyline class="c" points="1160,290 1290,190 1370,90 1450,0"/>
|
||||
<polyline class="c" points="1160,290 1380,330 1500,300 1600,340"/>
|
||||
<polyline class="c" points="1160,290 1210,560 1190,700 1260,900"/>
|
||||
<polyline class="c" points="1160,290 960,520 820,560 520,900"/>
|
||||
<polyline class="c" points="1160,290 1100,380 900,350 640,420 0,380"/>
|
||||
<polyline class="c" points="1380,330 1520,640 1600,700"/>
|
||||
<polyline class="c t" points="1010,180 1100,380 1210,560"/>
|
||||
<polyline class="c t" points="1290,190 1380,330 1210,560"/>
|
||||
<polyline class="c t" points="880,150 900,350 960,520"/>
|
||||
<polyline class="c t" points="1160,290 1245,235 1290,190"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,92 @@
|
||||
:root{
|
||||
--bg:#0a1119;
|
||||
--glass:#121d29;
|
||||
--edge:#27384a;
|
||||
--ink:#dfe7ee;
|
||||
--dim:#8497a9;
|
||||
--ice:#8fd3ff;
|
||||
--hot:#ff4f8b;
|
||||
--display:"Bahnschrift","Oswald","Arial Narrow","Roboto Condensed",sans-serif;
|
||||
--body:"Segoe UI Variable Text","Segoe UI","Helvetica Neue",Arial,sans-serif;
|
||||
--mono:ui-monospace,"Cascadia Mono","JetBrains Mono",Consolas,monospace;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0}
|
||||
html{background:var(--bg);color-scheme:dark}
|
||||
body{
|
||||
min-height:100vh;font:400 1rem/1.55 var(--body);color:var(--ink);
|
||||
background:
|
||||
linear-gradient(115deg,transparent 0 62%,rgba(143,211,255,.045) 62% 63%,transparent 63%),
|
||||
url(/assets/shatter.svg) center/cover no-repeat fixed,
|
||||
radial-gradient(120% 80% at 85% 20%,#13263a 0,var(--bg) 60%);
|
||||
}
|
||||
a{color:inherit;text-decoration:none}
|
||||
:focus-visible{outline:2px solid var(--ice);outline-offset:3px}
|
||||
|
||||
/* top bar */
|
||||
.bar{
|
||||
position:sticky;top:0;z-index:5;display:flex;align-items:stretch;gap:2px;
|
||||
padding:0 clamp(1rem,4vw,3rem);height:3.4rem;
|
||||
background:rgba(10,17,25,.78);backdrop-filter:blur(10px);
|
||||
border-bottom:1px solid var(--edge);
|
||||
}
|
||||
.brand{
|
||||
display:flex;align-items:center;margin-right:auto;
|
||||
font:700 1.35rem/1 var(--display);letter-spacing:.04em;
|
||||
}
|
||||
.brand i{font-style:normal;color:var(--hot);margin:0 .08em}
|
||||
.bar nav{display:flex;gap:2px}
|
||||
.bar nav a{
|
||||
display:flex;align-items:center;padding:0 1.5rem;color:var(--dim);
|
||||
font:500 1rem var(--display);letter-spacing:.06em;
|
||||
clip-path:polygon(14px 0,100% 0,calc(100% - 14px) 100%,0 100%);
|
||||
transition:background .15s,color .15s;
|
||||
}
|
||||
.bar nav a:hover{color:var(--ink);background:rgba(143,211,255,.08)}
|
||||
.bar nav a[aria-current]{color:var(--bg);background:var(--ice)}
|
||||
@media(max-width:640px){.bar nav a{padding:0 .8rem}.brand{font-size:1.1rem}}
|
||||
|
||||
/* page frame */
|
||||
main{max-width:1080px;margin:0 auto;padding:clamp(2rem,6vw,4.5rem) clamp(1rem,4vw,2rem) 6rem}
|
||||
.title{font:700 clamp(2.6rem,7vw,4.6rem)/.95 var(--display);letter-spacing:.01em;margin-bottom:.9rem}
|
||||
.lede{max-width:46ch;color:var(--dim)}
|
||||
|
||||
/* home */
|
||||
.home{display:grid;place-items:center;min-height:calc(100vh - 3.4rem)}
|
||||
.home p{font:400 .95rem var(--mono);color:var(--dim)}
|
||||
.home p::after{content:"";display:inline-block;width:.6ch;height:1.1em;margin-left:.5ch;vertical-align:-.2em;background:var(--ice);animation:blink 1.1s steps(1) infinite}
|
||||
@keyframes blink{50%{opacity:0}}
|
||||
|
||||
/* services */
|
||||
.group{margin-top:3.5rem}
|
||||
.group h2{font:600 1.5rem var(--display);letter-spacing:.03em;display:flex;align-items:baseline;gap:1rem;margin-bottom:1.3rem}
|
||||
.group h2 small{font:400 .9rem var(--body);color:var(--dim);letter-spacing:0}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:1.1rem}
|
||||
@media(min-width:700px){.grid>:nth-child(3n+2){margin-top:1.4rem}.grid>:nth-child(3n){margin-top:.5rem}}
|
||||
|
||||
.shard{
|
||||
--a:16px;--b:30px;--c:22px;--line:var(--edge);--glint:rgba(143,211,255,.22);
|
||||
--cut:polygon(0 var(--a),var(--b) 0,100% 0,100% calc(100% - var(--c)),calc(100% - var(--b)) 100%,0 100%);
|
||||
position:relative;display:flex;flex-direction:column;gap:.35rem;min-height:9.5rem;
|
||||
padding:1.4rem 1.3rem 1.2rem;background:var(--line);clip-path:var(--cut);
|
||||
transition:transform .18s ease,background .18s;
|
||||
}
|
||||
.shard::before{
|
||||
content:"";position:absolute;inset:1px;z-index:0;clip-path:var(--cut);
|
||||
background:
|
||||
radial-gradient(220px circle at var(--mx,-50%) var(--my,-50%),var(--glint),transparent 70%),
|
||||
linear-gradient(160deg,rgba(255,255,255,.05),transparent 40%),var(--glass);
|
||||
}
|
||||
.shard>*{position:relative;z-index:1}
|
||||
.shard:nth-child(3n+2){--a:26px;--b:14px;--c:34px}
|
||||
.shard:nth-child(3n){--a:10px;--b:40px;--c:14px}
|
||||
.shard:nth-child(5n){--a:34px;--b:22px;--c:18px}
|
||||
.shard:hover,.shard:focus-visible{--line:var(--ice);transform:translate(-3px,-3px)}
|
||||
.shard h3{font:600 1.45rem/1.1 var(--display);letter-spacing:.02em}
|
||||
.shard p{color:var(--dim);font-size:.92rem;max-width:30ch}
|
||||
.host{margin-top:auto;padding-top:.8rem;font:400 .8rem var(--mono);color:var(--ice)}
|
||||
.admin{--glint:rgba(255,79,139,.22)}
|
||||
.admin:hover,.admin:focus-visible{--line:var(--hot)}
|
||||
.admin .host{color:var(--hot)}
|
||||
.badge{align-self:flex-start;margin-bottom:.3rem;padding:.1rem .6rem .1rem .5rem;font:500 .78rem var(--body);color:var(--bg);background:var(--hot);clip-path:polygon(0 0,100% 0,calc(100% - 7px) 100%,0 100%)}
|
||||
.heart{color:var(--hot)}
|
||||
@media(prefers-reduced-motion:reduce){.shard{transition:none}.home p::after{animation:none}}
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{ "name": "Glance", "desc": "Dashboard with feeds and status at a glance.", "url": "http://10.67.67.69:8405" },
|
||||
{ "name": "Dozzle", "desc": "Live container logs.", "url": "http://10.67.67.69:8407" },
|
||||
{ "name": "Scrutiny", "desc": "Drive health and SMART data.", "url": "http://10.67.67.69:8406" }
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>nkelabs</title>
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="brand" href="/">nke<i>/</i>labs</a>
|
||||
<nav aria-label="Main">
|
||||
<a href="/services/">Services</a>
|
||||
<a href="/socials/">Socials</a>
|
||||
<a href="/projects/">Projects</a>
|
||||
<a href="/contact/">Contact</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="home"><p>nothing here yet</p></main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Not found · nkelabs</title>
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="brand" href="/">nke<i>/</i>labs</a>
|
||||
<nav aria-label="Main">
|
||||
<a href="/services/">Services</a>
|
||||
<a href="/socials/">Socials</a>
|
||||
<a href="/contact/">Contact</a>
|
||||
<a class="admin-link" href="/admin/">Admin</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="home"><div><h1 class="title">404</h1><p class="lede">That page doesn't exist. Pick something from the bar above.</p></div></main>
|
||||
<script src="/assets/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Admin · nkelabs</title>
|
||||
<meta name="robots" content="noindex">
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="brand" href="/">nke<i>/</i>labs</a>
|
||||
<nav aria-label="Main">
|
||||
<a href="/services/">Services</a>
|
||||
<a href="/socials/">Socials</a>
|
||||
<a href="/contact/">Contact</a>
|
||||
<a class="admin-link" aria-current="page" href="/admin/">Admin</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<h1 class="title">Admin</h1>
|
||||
<p class="lede">Contact requests and admin tools.</p>
|
||||
<div id="admin"></div>
|
||||
</main>
|
||||
<script src="/assets/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="#000" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Discord</title><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="#000" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Gitea</title><path d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="#000" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Riot Games</title><path d="M13.458.86 0 7.093l3.353 12.761 2.552-.313-.701-8.024.838-.373 1.447 8.202 4.361-.535-.775-8.857.83-.37 1.591 9.025 4.412-.542-.849-9.708.84-.374 1.74 9.87L24 17.318V3.5Zm.316 19.356.222 1.256L24 23.14v-4.18l-10.22 1.256Z"/></svg>
|
||||
|
After Width: | Height: | Size: 347 B |
@@ -0,0 +1 @@
|
||||
<svg fill="#000" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Spotify</title><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>
|
||||
|
After Width: | Height: | Size: 709 B |
@@ -0,0 +1 @@
|
||||
<svg fill="#000" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Steam</title><path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.455-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="#000" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Twitch</title><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z"/></svg>
|
||||
|
After Width: | Height: | Size: 304 B |
@@ -0,0 +1,133 @@
|
||||
const $ = (s, r = document) => r.querySelector(s);
|
||||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const safe = u => /^(https?:|mailto:)/i.test(u || '') ? esc(u) : '';
|
||||
const host = u => { try { return new URL(u).host } catch { return '' } };
|
||||
|
||||
// light follows the cursor across a card's glass
|
||||
document.addEventListener('pointermove', e => {
|
||||
const el = e.target.closest?.('.shard'); if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
el.style.setProperty('--mx', e.clientX - r.left + 'px');
|
||||
el.style.setProperty('--my', e.clientY - r.top + 'px');
|
||||
});
|
||||
document.addEventListener('pointerout', e => {
|
||||
const el = e.target.closest?.('.shard');
|
||||
if (el && !el.contains(e.relatedTarget)) { el.style.removeProperty('--mx'); el.style.removeProperty('--my'); }
|
||||
});
|
||||
|
||||
const load = src => fetch(src).then(r => r.json()).catch(() => []);
|
||||
const empty = file => `<p class="empty">Nothing here yet. Add entries to <code>public/data/${file}.json</code>.</p>`;
|
||||
|
||||
// services: filter + reachability dots + admin section when signed in
|
||||
const q = $('#q');
|
||||
const watch = async card => {
|
||||
const url = card.dataset.check, dot = document.createElement('span');
|
||||
dot.className = 'dot'; card.prepend(dot);
|
||||
if (location.protocol === 'https:' && url.startsWith('http:')) { dot.title = 'Cannot check http services from an https page'; return; }
|
||||
const ctl = new AbortController(), timer = setTimeout(() => ctl.abort(), 4000);
|
||||
try { await fetch(url, { mode: 'no-cors', signal: ctl.signal, cache: 'no-store' }); dot.dataset.s = 'up'; dot.title = 'Reachable'; }
|
||||
catch { dot.dataset.s = 'down'; dot.title = 'Not reachable from this browser'; }
|
||||
clearTimeout(timer);
|
||||
};
|
||||
if (q) {
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === '/' && !/INPUT|TEXTAREA/.test(document.activeElement.tagName)) { e.preventDefault(); q.focus(); }
|
||||
if (e.key === 'Escape' && document.activeElement === q) { q.value = ''; q.dispatchEvent(new Event('input')); q.blur(); }
|
||||
});
|
||||
q.addEventListener('input', () => {
|
||||
const t = q.value.trim().toLowerCase();
|
||||
document.querySelectorAll('.shard').forEach(c => c.hidden = !c.textContent.toLowerCase().includes(t));
|
||||
document.querySelectorAll('.group').forEach(g => g.hidden = !g.querySelector('.shard:not([hidden])'));
|
||||
});
|
||||
document.querySelectorAll('[data-check]').forEach(watch);
|
||||
fetch('/api/session').then(r => r.json()).then(async s => {
|
||||
if (!s.admin) return;
|
||||
const r = await fetch('/api/admin/services'); if (!r.ok) return;
|
||||
const items = await r.json(); if (!items.length) return;
|
||||
const sec = document.createElement('section'); sec.className = 'group';
|
||||
sec.innerHTML = `<h2>Admin only <small>signed in</small></h2><div class="grid">` + items.map(i => `
|
||||
<a class="shard admin" data-check="${safe(i.url)}" href="${safe(i.url)}" target="_blank" rel="noopener">
|
||||
<span class="badge">Admin only</span><h3>${esc(i.name)}</h3><p>${esc(i.desc)}</p><span class="host">${esc(host(i.url))}</span></a>`).join('') + '</div>';
|
||||
$('main').append(sec); sec.querySelectorAll('[data-check]').forEach(watch);
|
||||
if (q.value) q.dispatchEvent(new Event('input'));
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// socials: rendered from JSON
|
||||
const list = $('#list[data-src]');
|
||||
if (list) load(list.dataset.src).then(items => {
|
||||
if (!items.length) { list.innerHTML = empty('socials'); return; }
|
||||
list.innerHTML = items.map(s => {
|
||||
const link = safe(s.url), t = link ? 'a' : 'button', c = esc(s.color || '');
|
||||
const attrs = link ? ` href="${link}" target="_blank" rel="noopener"` : ` type="button" data-copy="${esc(s.copy || s.handle)}"`;
|
||||
return `<${t} class="shard social"${attrs} style="--brand:${c}">
|
||||
<span class="ico" style="--i:url(/assets/icons/${esc(s.icon)}.svg)"></span>
|
||||
<h3>${esc(s.name)}</h3><p>${esc(s.handle)}</p>
|
||||
<span class="host">${link ? esc(host(s.url)) : 'Click to copy'}</span></${t}>`;
|
||||
}).join('');
|
||||
list.addEventListener('click', e => {
|
||||
const b = e.target.closest('[data-copy]'); if (!b) return;
|
||||
const h = $('.host', b), old = h.textContent;
|
||||
navigator.clipboard?.writeText(b.dataset.copy).then(() => { h.textContent = 'Copied'; setTimeout(() => h.textContent = old, 1500); });
|
||||
});
|
||||
});
|
||||
|
||||
// contact: info list with copy buttons + form
|
||||
const info = $('#info');
|
||||
if (info) load(info.dataset.src).then(items => {
|
||||
info.innerHTML = items.map(i => `<li><span>${esc(i.label)}</span>${safe(i.href) ? `<a href="${safe(i.href)}">${esc(i.value)}</a>` : esc(i.value)}
|
||||
<button class="chip" type="button" data-copy="${esc(i.value)}">Copy</button></li>`).join('');
|
||||
info.addEventListener('click', e => {
|
||||
const b = e.target.closest('[data-copy]'); if (!b) return;
|
||||
navigator.clipboard?.writeText(b.dataset.copy).then(() => { b.textContent = 'Copied'; setTimeout(() => b.textContent = 'Copy', 1400); });
|
||||
});
|
||||
});
|
||||
const form = $('#form');
|
||||
if (form) form.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const out = $('#out'), btn = $('.btn', form); out.removeAttribute('data-ok');
|
||||
if (!form.checkValidity()) { out.textContent = 'Fill in name, a valid email and a message of at least 5 characters.'; return; }
|
||||
btn.disabled = true; out.textContent = 'Sending...';
|
||||
try {
|
||||
const r = await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.fromEntries(new FormData(form))) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(j.error || 'Could not send. Try again later.');
|
||||
form.reset(); out.dataset.ok = 1; out.textContent = 'Message sent.';
|
||||
} catch (err) { out.textContent = err.message; }
|
||||
btn.disabled = false;
|
||||
});
|
||||
|
||||
// admin: sign in + contact requests
|
||||
const adm = $('#admin');
|
||||
if (adm) {
|
||||
const post = (u, d) => fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(d || {}) });
|
||||
const login = enabled => {
|
||||
adm.innerHTML = enabled === false
|
||||
? '<p class="empty">Admin is disabled. Set the <code>ADMIN_PASSWORD</code> environment variable and restart the server.</p>'
|
||||
: `<form class="form login" id="lf"><label>Password <input type="password" name="password" autocomplete="current-password" required autofocus></label>
|
||||
<button class="btn" type="submit">Sign in</button><p id="out" role="status"></p></form>`;
|
||||
$('#lf')?.addEventListener('submit', async e => {
|
||||
e.preventDefault(); const out = $('#out'); out.textContent = '';
|
||||
const r = await post('/api/login', { password: e.target.password.value });
|
||||
if (r.ok) return show();
|
||||
out.textContent = (await r.json().catch(() => ({}))).error || 'Sign in failed.';
|
||||
});
|
||||
};
|
||||
const show = async () => {
|
||||
const s = await fetch('/api/session').then(r => r.json()).catch(() => ({}));
|
||||
if (!s.admin) return login(s.enabled);
|
||||
const r = await fetch('/api/admin/messages'), msgs = r.ok ? await r.json() : [];
|
||||
adm.innerHTML = `<div class="adminbar"><h2>${msgs.length} message${msgs.length === 1 ? '' : 's'}</h2><button class="chip" id="lo" type="button">Sign out</button></div>` +
|
||||
(msgs.length ? msgs.map(m => `<article class="msg" data-id="${esc(m.id || m.at)}">
|
||||
<header><strong>${esc(m.name)}</strong><a href="mailto:${esc(m.email)}">${esc(m.email)}</a><time>${esc(new Date(m.at).toLocaleString())}</time></header>
|
||||
<p>${esc(m.message)}</p>
|
||||
<div class="tags"><a class="chip" href="mailto:${esc(m.email)}?subject=Re:%20your%20message%20to%20nkelabs">Reply</a><button class="chip" type="button" data-del>Delete</button></div>
|
||||
</article>`).join('') : '<p class="empty">No messages yet.</p>');
|
||||
};
|
||||
adm.addEventListener('click', async e => {
|
||||
if (e.target.closest('#lo')) { await post('/api/logout'); return login(true); }
|
||||
const d = e.target.closest('[data-del]'); if (!d || !confirm('Delete this message?')) return;
|
||||
await fetch('/api/admin/messages/' + encodeURIComponent(d.closest('.msg').dataset.id), { method: 'DELETE' }); show();
|
||||
});
|
||||
show();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice">
|
||||
<style>
|
||||
.c{fill:none;stroke:#9fd8ff;stroke-width:1;stroke-linejoin:bevel;stroke-dasharray:1400;stroke-dashoffset:1400;animation:d 1.6s cubic-bezier(.2,.7,.2,1) forwards}
|
||||
.t{stroke-width:.6;opacity:.6;animation-delay:.35s}
|
||||
@keyframes d{to{stroke-dashoffset:0}}
|
||||
@media (prefers-reduced-motion:reduce){.c{animation:none;stroke-dashoffset:0}}
|
||||
</style>
|
||||
<g opacity=".16">
|
||||
<polygon points="1160,290 1600,0 1600,180 1380,330" fill="#8fd3ff" opacity=".35"/>
|
||||
<polygon points="1160,290 1380,330 1520,640 1210,560" fill="#ff4f8b" opacity=".18"/>
|
||||
<polygon points="1160,290 960,520 700,900 1210,900 1210,560" fill="#8fd3ff" opacity=".15"/>
|
||||
<polygon points="1160,290 760,60 900,0 1190,0" fill="#ff4f8b" opacity=".14"/>
|
||||
</g>
|
||||
<g opacity=".34">
|
||||
<polyline class="c" points="1160,290 1010,180 880,150 700,0"/>
|
||||
<polyline class="c" points="1160,290 1290,190 1370,90 1450,0"/>
|
||||
<polyline class="c" points="1160,290 1380,330 1500,300 1600,340"/>
|
||||
<polyline class="c" points="1160,290 1210,560 1190,700 1260,900"/>
|
||||
<polyline class="c" points="1160,290 960,520 820,560 520,900"/>
|
||||
<polyline class="c" points="1160,290 1100,380 900,350 640,420 0,380"/>
|
||||
<polyline class="c" points="1380,330 1520,640 1600,700"/>
|
||||
<polyline class="c t" points="1010,180 1100,380 1210,560"/>
|
||||
<polyline class="c t" points="1290,190 1380,330 1210,560"/>
|
||||
<polyline class="c t" points="880,150 900,350 960,520"/>
|
||||
<polyline class="c t" points="1160,290 1245,235 1290,190"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,164 @@
|
||||
:root{
|
||||
--bg:#0a1119;
|
||||
--glass:#121d29;
|
||||
--edge:#27384a;
|
||||
--ink:#dfe7ee;
|
||||
--dim:#8497a9;
|
||||
--ice:#8fd3ff;
|
||||
--hot:#ff4f8b;
|
||||
--display:"Bahnschrift","Oswald","Arial Narrow","Roboto Condensed",sans-serif;
|
||||
--body:"Segoe UI Variable Text","Segoe UI","Helvetica Neue",Arial,sans-serif;
|
||||
--mono:ui-monospace,"Cascadia Mono","JetBrains Mono",Consolas,monospace;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0}
|
||||
html{background:var(--bg);color-scheme:dark}
|
||||
body{
|
||||
min-height:100vh;font:400 1rem/1.55 var(--body);color:var(--ink);
|
||||
background:
|
||||
linear-gradient(115deg,transparent 0 62%,rgba(143,211,255,.045) 62% 63%,transparent 63%),
|
||||
url(/assets/shatter.svg) center/cover no-repeat fixed,
|
||||
radial-gradient(120% 80% at 85% 20%,#13263a 0,var(--bg) 60%);
|
||||
}
|
||||
a{color:inherit;text-decoration:none}
|
||||
:focus-visible{outline:2px solid var(--ice);outline-offset:3px}
|
||||
|
||||
/* top bar */
|
||||
.bar{
|
||||
position:sticky;top:0;z-index:5;display:flex;align-items:stretch;gap:2px;
|
||||
padding:0 clamp(1rem,4vw,3rem);height:3.4rem;
|
||||
background:rgba(10,17,25,.78);backdrop-filter:blur(10px);
|
||||
border-bottom:1px solid var(--edge);
|
||||
}
|
||||
.brand{
|
||||
display:flex;align-items:center;margin-right:auto;
|
||||
font:700 1.35rem/1 var(--display);letter-spacing:.04em;
|
||||
}
|
||||
.brand i{font-style:normal;color:var(--hot);margin:0 .08em}
|
||||
.bar nav{display:flex;gap:2px}
|
||||
.bar nav a{
|
||||
display:flex;align-items:center;padding:0 1.5rem;color:var(--dim);
|
||||
font:500 1rem var(--display);letter-spacing:.06em;
|
||||
clip-path:polygon(14px 0,100% 0,calc(100% - 14px) 100%,0 100%);
|
||||
transition:background .15s,color .15s;
|
||||
}
|
||||
.bar nav a:hover{color:var(--ink);background:rgba(143,211,255,.08)}
|
||||
.bar nav a[aria-current]{color:var(--bg);background:var(--ice)}
|
||||
@media(max-width:640px){.bar nav a{padding:0 .8rem}.brand{font-size:1.1rem}}
|
||||
|
||||
/* page frame */
|
||||
main{max-width:1080px;margin:0 auto;padding:clamp(2rem,6vw,4.5rem) clamp(1rem,4vw,2rem) 6rem}
|
||||
.title{font:700 clamp(2.6rem,7vw,4.6rem)/.95 var(--display);letter-spacing:.01em;margin-bottom:.9rem}
|
||||
.lede{max-width:46ch;color:var(--dim)}
|
||||
|
||||
/* home */
|
||||
.home{display:flex;flex-direction:column;align-items:center;min-height:calc(100vh - 3.4rem)}
|
||||
.home p{margin:auto 0;font:400 .95rem var(--mono);color:var(--dim)}
|
||||
.logo{display:flex;align-items:baseline;font:700 clamp(3.5rem,13vw,8.5rem)/1 var(--display);letter-spacing:.04em}
|
||||
.logo span{
|
||||
background:linear-gradient(100deg,var(--ink) 42%,#fff 50%,var(--ink) 58%) 120% 0/300% 100%;
|
||||
-webkit-background-clip:text;background-clip:text;color:transparent;
|
||||
animation:split .8s .3s cubic-bezier(.2,.7,.2,1) both,glint 6s 1.4s ease-in-out infinite;
|
||||
}
|
||||
.logo .r{--dir:-1;animation-delay:.3s,1.6s}
|
||||
.logo i{
|
||||
display:inline-block;font-style:normal;color:var(--hot);margin:0 .08em;
|
||||
animation:strike .55s cubic-bezier(.2,.7,.2,1) both,pulse 3.2s 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes split{from{opacity:0;transform:translateX(calc(.35em * var(--dir,1)))}}
|
||||
@keyframes glint{0%,70%{background-position:120% 0}100%{background-position:-20% 0}}
|
||||
@keyframes strike{from{opacity:0;transform:translate(.15em,-.7em) skewX(-18deg) scaleY(1.8)}}
|
||||
@keyframes pulse{50%{text-shadow:0 0 .3em rgba(255,79,139,.65)}}
|
||||
.home p::after{content:"";display:inline-block;width:.6ch;height:1.1em;margin-left:.5ch;vertical-align:-.2em;background:var(--ice);animation:blink 1.1s steps(1) infinite}
|
||||
@keyframes blink{50%{opacity:0}}
|
||||
|
||||
/* services */
|
||||
.group{margin-top:3.5rem}
|
||||
.group h2{font:600 1.5rem var(--display);letter-spacing:.03em;display:flex;align-items:baseline;gap:1rem;margin-bottom:1.3rem}
|
||||
.group h2 small{font:400 .9rem var(--body);color:var(--dim);letter-spacing:0}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:1.1rem}
|
||||
@media(min-width:700px){.grid>:nth-child(3n+2){margin-top:1.4rem}.grid>:nth-child(3n){margin-top:.5rem}}
|
||||
|
||||
.shard{
|
||||
--a:16px;--b:30px;--c:22px;--line:var(--edge);--glint:rgba(143,211,255,.22);
|
||||
--cut:polygon(0 var(--a),var(--b) 0,100% 0,100% calc(100% - var(--c)),calc(100% - var(--b)) 100%,0 100%);
|
||||
position:relative;display:flex;flex-direction:column;gap:.35rem;min-height:9.5rem;
|
||||
padding:1.4rem 1.3rem 1.2rem;background:var(--line);clip-path:var(--cut);
|
||||
transition:transform .18s ease,background .18s;
|
||||
}
|
||||
.shard::before{
|
||||
content:"";position:absolute;inset:1px;z-index:0;clip-path:var(--cut);
|
||||
background:
|
||||
radial-gradient(220px circle at var(--mx,-50%) var(--my,-50%),var(--glint),transparent 70%),
|
||||
linear-gradient(160deg,rgba(255,255,255,.05),transparent 40%),var(--glass);
|
||||
}
|
||||
.shard>*{position:relative;z-index:1}
|
||||
.shard:nth-child(3n+2){--a:26px;--b:14px;--c:34px}
|
||||
.shard:nth-child(3n){--a:10px;--b:40px;--c:14px}
|
||||
.shard:nth-child(5n){--a:34px;--b:22px;--c:18px}
|
||||
.shard:hover,.shard:focus-visible{--line:var(--ice);transform:translate(-3px,-3px)}
|
||||
.shard h3{font:600 1.45rem/1.1 var(--display);letter-spacing:.02em}
|
||||
.shard p{color:var(--dim);font-size:.92rem;max-width:30ch}
|
||||
.host{margin-top:auto;padding-top:.8rem;font:400 .8rem var(--mono);color:var(--ice)}
|
||||
.admin{--glint:rgba(255,79,139,.22)}
|
||||
.admin:hover,.admin:focus-visible{--line:var(--hot)}
|
||||
.admin .host{color:var(--hot)}
|
||||
.badge{align-self:flex-start;margin-bottom:.3rem;padding:.1rem .6rem .1rem .5rem;font:500 .78rem var(--body);color:var(--bg);background:var(--hot);clip-path:polygon(0 0,100% 0,calc(100% - 7px) 100%,0 100%)}
|
||||
.heart{color:var(--hot)}
|
||||
@media(prefers-reduced-motion:reduce){.shard{transition:none}.home p::after,.logo span,.logo i{animation:none}}
|
||||
|
||||
/* tools, status, chips */
|
||||
[hidden]{display:none!important}
|
||||
.tools{display:flex;flex-wrap:wrap;align-items:center;gap:1rem;margin-top:2rem}
|
||||
input,textarea{font:inherit;color:var(--ink);background:var(--glass);border:1px solid var(--edge);border-radius:0;padding:.65rem .8rem;width:100%}
|
||||
input:focus,textarea:focus{outline:none;border-color:var(--ice);box-shadow:-4px 0 0 var(--ice)}
|
||||
.tools input{max-width:20rem}
|
||||
.hint{color:var(--dim);font-size:.85rem}
|
||||
kbd{font:.8rem var(--mono);border:1px solid var(--edge);padding:0 .35rem}
|
||||
.dot{position:absolute;top:1.1rem;right:1.7rem;z-index:2;width:9px;height:9px;rotate:45deg;background:var(--dim);opacity:.6}
|
||||
.dot[data-s=up]{background:#7dffc2;box-shadow:0 0 8px #7dffc2;opacity:1}
|
||||
.dot[data-s=down]{background:transparent;border:1px solid var(--dim)}
|
||||
.tags{display:flex;flex-wrap:wrap;gap:.4rem}
|
||||
.chip{font:.8rem var(--body);padding:.15rem .8rem .15rem .7rem;color:var(--ink);background:var(--edge);border:0;clip-path:polygon(0 0,100% 0,calc(100% - 8px) 100%,0 100%)}
|
||||
button.chip{cursor:pointer}
|
||||
.chip[aria-pressed=true]{background:var(--ice);color:var(--bg)}
|
||||
.shard .tags{margin-top:auto;padding-top:.8rem}
|
||||
.empty{grid-column:1/-1;max-width:48ch;color:var(--dim)}
|
||||
.empty code{font:.85rem var(--mono);color:var(--ice)}
|
||||
|
||||
/* contact */
|
||||
.split{display:grid;grid-template-columns:1fr 1.4fr;gap:3rem;margin-top:2.5rem}
|
||||
@media(max-width:800px){.split{grid-template-columns:1fr}}
|
||||
.info{list-style:none;padding:0;display:grid;gap:.9rem;align-content:start}
|
||||
.info li{display:flex;gap:1rem;align-items:baseline;padding-bottom:.7rem;border-bottom:1px solid var(--edge)}
|
||||
.info li>span{min-width:5rem;color:var(--dim)}
|
||||
.info button{margin-left:auto}
|
||||
.form{display:grid;gap:1rem}
|
||||
label{display:grid;gap:.35rem;font-size:.9rem;color:var(--dim)}
|
||||
textarea{min-height:10rem;resize:vertical}
|
||||
.hp{position:absolute;left:-9999px;width:1px}
|
||||
.btn{justify-self:start;padding:.75rem 2.2rem .75rem 1.6rem;border:0;cursor:pointer;color:var(--bg);background:var(--ice);font:600 1.05rem var(--display);letter-spacing:.04em;clip-path:polygon(0 0,100% 0,calc(100% - 16px) 100%,0 100%);transition:background .15s}
|
||||
.btn:hover{background:#fff}.btn:disabled{opacity:.5;cursor:wait}
|
||||
#out{min-height:1.5em;color:var(--hot)}#out[data-ok]{color:#7dffc2}
|
||||
|
||||
/* socials */
|
||||
.social{--brand:var(--ice);--glint:color-mix(in srgb,var(--brand) 28%,transparent);min-height:11.5rem;text-align:left;font:inherit;color:inherit;border:0;cursor:pointer;align-items:flex-start}
|
||||
.social:hover,.social:focus-visible{--line:var(--brand)}
|
||||
.social .ico{width:2.6rem;height:2.6rem;margin-bottom:.7rem;background:var(--brand);-webkit-mask:var(--i) center/contain no-repeat;mask:var(--i) center/contain no-repeat;transition:transform .2s}
|
||||
.social:hover .ico{transform:rotate(-6deg) scale(1.08)}
|
||||
.social .host{color:var(--dim)}
|
||||
.social:hover .host{color:var(--brand)}
|
||||
@media(prefers-reduced-motion:reduce){.social .ico{transition:none}}
|
||||
|
||||
/* admin */
|
||||
.bar nav a.admin-link{margin-left:1rem}
|
||||
#admin{margin-top:2.5rem}
|
||||
.login{max-width:22rem}
|
||||
.adminbar{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:1.2rem}
|
||||
.adminbar h2{font:600 1.5rem var(--display);letter-spacing:.03em}
|
||||
.msg{display:grid;gap:.6rem;margin-bottom:1rem;padding:1.1rem 1.3rem;background:var(--glass);border-left:3px solid var(--ice);clip-path:polygon(0 0,100% 0,100% calc(100% - 16px),calc(100% - 16px) 100%,0 100%)}
|
||||
.msg header{display:flex;flex-wrap:wrap;gap:.3rem 1.1rem;align-items:baseline}
|
||||
.msg header a{color:var(--ice)}
|
||||
.msg time{margin-left:auto;color:var(--dim);font-size:.85rem}
|
||||
.msg p{white-space:pre-wrap;overflow-wrap:anywhere;max-width:70ch}
|
||||
.msg .tags{margin:0}
|
||||
.msg .chip{display:inline-block;text-decoration:none}
|
||||
@@ -0,0 +1,36 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Contact · nkelabs</title>
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="brand" href="/">nke<i>/</i>labs</a>
|
||||
<nav aria-label="Main">
|
||||
<a href="/services/">Services</a>
|
||||
<a href="/socials/">Socials</a>
|
||||
<a href="/contact/" aria-current="page">Contact</a>
|
||||
<a class="admin-link" href="/admin/">Admin</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<h1 class="title">Contact</h1>
|
||||
<p class="lede">Because everyone has one of these lol</p>
|
||||
<div class="split">
|
||||
<ul class="info" id="info" data-src="/data/contact.json"></ul>
|
||||
<form class="form" id="form" novalidate>
|
||||
<label>Name <input name="name" required maxlength="80" autocomplete="name"></label>
|
||||
<label>Email <input name="email" type="email" required maxlength="120" autocomplete="email"></label>
|
||||
<label>Message <textarea name="message" required minlength="5" maxlength="4000"></textarea></label>
|
||||
<input class="hp" name="website" tabindex="-1" autocomplete="off" aria-hidden="true">
|
||||
<button class="btn" type="submit">Send message</button>
|
||||
<p id="out" role="status"></p>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
<script src="/assets/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{ "name": "Spotify", "handle": "Artist page", "url": "https://open.spotify.com/artist/6KrMvckCU666WvwZCOvn19", "icon": "spotify", "color": "#1ed760" },
|
||||
{ "name": "Twitch", "handle": "nke4", "url": "https://twitch.tv/nke4/", "icon": "twitch", "color": "#9146ff" },
|
||||
{ "name": "Steam", "handle": "nKe4", "url": "https://steamcommunity.com/id/nKe4/", "icon": "steam", "color": "#66c0f4" },
|
||||
{ "name": "Gitea", "handle": "nke4", "url": "https://gitea.nkelabs.xyz/nke4/", "icon": "gitea", "color": "#7cc13a" },
|
||||
{ "name": "Discord", "handle": "@pannick_dj", "copy": "pannick_dj", "icon": "discord", "color": "#7b86ff" },
|
||||
{ "name": "Riot Games", "handle": "6ft4nohoes#1337", "copy": "6ft4nohoes#1337", "icon": "riotgames", "color": "#ff3b57" }
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>nkelabs</title>
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="brand" href="/">nke<i>/</i>labs</a>
|
||||
<nav aria-label="Main">
|
||||
<a href="/services/">Services</a>
|
||||
<a href="/socials/">Socials</a>
|
||||
<a href="/contact/">Contact</a>
|
||||
<a class="admin-link" href="/admin/">Admin</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="home">
|
||||
<h1 class="logo" aria-label="nkelabs"><span class="l">nke</span><i>/</i><span class="r">labs</span></h1>
|
||||
<p>nothing here yet</p>
|
||||
</main>
|
||||
<script src="/assets/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Projects · nkelabs</title>
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="brand" href="/">nke<i>/</i>labs</a>
|
||||
<nav aria-label="Main">
|
||||
<a href="/services/">Services</a>
|
||||
<a href="/socials/">Socials</a>
|
||||
<a href="/projects/" aria-current="page">Projects</a>
|
||||
<a href="/contact/">Contact</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<h1 class="title">Projects</h1>
|
||||
<p class="lede"></p>
|
||||
<div class="tags tools" id="tags" aria-label="Filter by tag"></div>
|
||||
<div class="grid group" id="list" data-kind="projects" data-src="/data/projects.json"></div>
|
||||
</main>
|
||||
<script src="/assets/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Services · nkelabs</title>
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="brand" href="/">nke<i>/</i>labs</a>
|
||||
<nav aria-label="Main">
|
||||
<a href="/services/" aria-current="page">Services</a>
|
||||
<a href="/socials/">Socials</a>
|
||||
<a href="/contact/">Contact</a>
|
||||
<a class="admin-link" href="/admin/">Admin</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<h1 class="title">Services</h1>
|
||||
<p class="lede">Everything running in the lab. Admin tools appear here when you are signed in.</p>
|
||||
<div class="tools">
|
||||
<input id="q" type="search" placeholder="Filter services" aria-label="Filter services" autocomplete="off">
|
||||
<p class="hint">Press <kbd>/</kbd> to search. Dots show whether this browser can reach each service.</p>
|
||||
</div>
|
||||
|
||||
<section class="group">
|
||||
<h2>Open to everyone</h2>
|
||||
<div class="grid">
|
||||
<a class="shard" data-check="https://docs.nkelabs.xyz" href="https://docs.nkelabs.xyz" target="_blank" rel="noopener">
|
||||
<h3>Docs</h3>
|
||||
<p>Notes and write-ups for the lab.</p>
|
||||
<span class="host">docs.nkelabs.xyz</span>
|
||||
</a>
|
||||
<a class="shard" data-check="https://gitea.nkelabs.xyz" href="https://gitea.nkelabs.xyz" target="_blank" rel="noopener">
|
||||
<h3>Gitea</h3>
|
||||
<p>Self-hosted git repositories.</p>
|
||||
<span class="host">gitea.nkelabs.xyz</span>
|
||||
</a>
|
||||
<a class="shard" data-check="https://bytestash.nkelabs.xyz" href="https://bytestash.nkelabs.xyz" target="_blank" rel="noopener">
|
||||
<h3>ByteStash</h3>
|
||||
<p>A stash for code snippets.</p>
|
||||
<span class="host">bytestash.nkelabs.xyz</span>
|
||||
</a>
|
||||
<a class="shard" data-check="https://ittools.nkelabs.xyz" href="https://ittools.nkelabs.xyz" target="_blank" rel="noopener">
|
||||
<h3>IT-Tools</h3>
|
||||
<p>Converters, generators and other handy utilities.</p>
|
||||
<span class="host">ittools.nkelabs.xyz</span>
|
||||
</a>
|
||||
<a class="shard" data-check="https://friendbook.nkelabs.xyz" href="https://friendbook.nkelabs.xyz" target="_blank" rel="noopener">
|
||||
<h3>Valorant Friendbook <span class="heart"><3</span></h3>
|
||||
<p>Who we play with, and who we play against.</p>
|
||||
<span class="host">friendbook.nkelabs.xyz</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<script src="/assets/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Socials · nkelabs</title>
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="brand" href="/">nke<i>/</i>labs</a>
|
||||
<nav aria-label="Main">
|
||||
<a href="/services/">Services</a>
|
||||
<a href="/socials/" aria-current="page">Socials</a>
|
||||
<a href="/contact/">Contact</a>
|
||||
<a class="admin-link" href="/admin/">Admin</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<h1 class="title">Socials</h1>
|
||||
<p class="lede">Where to find me. Discord and Riot IDs copy to your clipboard.</p>
|
||||
<div class="grid group" id="list" data-kind="socials" data-src="/data/socials.json"></div>
|
||||
</main>
|
||||
<script src="/assets/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,108 @@
|
||||
// Zero-dependency static server + contact endpoint + admin area. Needs Node 18+.
|
||||
const http = require('http'), fs = require('fs'), path = require('path'), crypto = require('crypto');
|
||||
const PORT = process.env.PORT || 8080;
|
||||
const PUBLIC = path.join(__dirname, 'public');
|
||||
const STORE = path.join(__dirname, 'storage', 'messages.jsonl');
|
||||
const ADMIN_SERVICES = path.join(__dirname, 'config', 'admin-services.json');
|
||||
const WEBHOOK = process.env.WEBHOOK_URL || ''; // Discord webhook or ntfy topic URL
|
||||
const BEHIND_PROXY = process.env.TRUST_PROXY === '1'; // trust X-Forwarded-* headers
|
||||
const ADMIN_PW = process.env.ADMIN_PASSWORD || '';
|
||||
const SECRET = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex'); // set it to survive restarts
|
||||
if (!ADMIN_PW) console.warn('ADMIN_PASSWORD is not set: the admin area is disabled.');
|
||||
|
||||
const MIME = { '.html':'text/html; charset=utf-8', '.css':'text/css', '.js':'text/javascript', '.json':'application/json',
|
||||
'.svg':'image/svg+xml', '.png':'image/png', '.jpg':'image/jpeg', '.webp':'image/webp', '.ico':'image/x-icon', '.woff2':'font/woff2' };
|
||||
|
||||
// --- helpers
|
||||
const send = (res, code, body, type = 'application/json', extra = {}) => {
|
||||
res.writeHead(code, { 'Content-Type': type, 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', ...extra }); res.end(body);
|
||||
};
|
||||
const json = (res, code, obj, extra = {}) => send(res, code, JSON.stringify(obj), 'application/json', { 'Cache-Control': 'no-store', ...extra });
|
||||
const readJson = req => new Promise((ok, no) => {
|
||||
let r = ''; req.on('data', c => { r += c; if (r.length > 10000) { req.destroy(); no(new Error('too large')); } });
|
||||
req.on('end', () => { try { ok(JSON.parse(r || '{}')); } catch { ok(null); } }); req.on('error', no);
|
||||
});
|
||||
const clientIp = req => (BEHIND_PROXY && req.headers['x-forwarded-for']?.split(',')[0].trim()) || req.socket.remoteAddress;
|
||||
const recent = (map, ip, win) => { const n = Date.now(), a = (map.get(ip) || []).filter(t => n - t < win); map.set(ip, a); return a; };
|
||||
const push = (map, ip) => map.get(ip).push(Date.now());
|
||||
|
||||
// --- sessions: signed, expiring cookie
|
||||
const sha = s => crypto.createHash('sha256').update(s).digest();
|
||||
const sign = v => crypto.createHmac('sha256', SECRET).update(v).digest('hex');
|
||||
const makeToken = () => { const exp = Date.now() + 7 * 864e5; return exp + '.' + sign(String(exp)); };
|
||||
const validToken = t => {
|
||||
const [exp, sig] = (t || '').split('.'); if (!exp || !sig || Date.now() > +exp) return false;
|
||||
const good = sign(exp); return sig.length === good.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(good));
|
||||
};
|
||||
const cookie = (req, name) => (req.headers.cookie || '').split(';').map(c => c.trim()).find(c => c.startsWith(name + '='))?.slice(name.length + 1);
|
||||
const isAdmin = req => !!ADMIN_PW && validToken(cookie(req, 'nke_admin'));
|
||||
const setCookie = (req, val, age) => 'nke_admin=' + val + `; HttpOnly; SameSite=Strict; Path=/; Max-Age=${age}` +
|
||||
(BEHIND_PROXY && req.headers['x-forwarded-proto'] === 'https' ? '; Secure' : '');
|
||||
|
||||
// --- messages
|
||||
const readMsgs = () => { try { return fs.readFileSync(STORE, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)); } catch { return []; } };
|
||||
const writeMsgs = list => fs.writeFileSync(STORE, list.map(m => JSON.stringify(m)).join('\n') + (list.length ? '\n' : ''));
|
||||
|
||||
async function notify(m) {
|
||||
if (!WEBHOOK) return;
|
||||
const text = `New message from ${m.name} <${m.email}>\n\n${m.message}`;
|
||||
try {
|
||||
if (/discord(app)?\.com/.test(WEBHOOK)) await fetch(WEBHOOK, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: text.slice(0, 1900) }) });
|
||||
else await fetch(WEBHOOK, { method: 'POST', headers: { Title: 'nkelabs contact form' }, body: text });
|
||||
} catch (e) { console.error('webhook failed:', e.message); }
|
||||
}
|
||||
|
||||
const contactHits = new Map(), loginFails = new Map();
|
||||
|
||||
async function api(req, res, url) {
|
||||
const p = url.pathname, ip = clientIp(req);
|
||||
|
||||
if (p === '/api/contact' && req.method === 'POST') {
|
||||
const d = await readJson(req); if (!d) return json(res, 400, { error: 'Invalid request.' });
|
||||
if (d.website) return json(res, 200, { ok: true }); // honeypot
|
||||
if (recent(contactHits, ip, 36e5).length >= 5) return json(res, 429, { error: 'Too many messages. Try again in an hour.' });
|
||||
const m = { name: String(d.name || '').trim().slice(0, 80), email: String(d.email || '').trim().slice(0, 120), message: String(d.message || '').trim().slice(0, 4000) };
|
||||
if (!m.name || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(m.email) || m.message.length < 5) return json(res, 400, { error: 'Check your name, email and message.' });
|
||||
push(contactHits, ip);
|
||||
fs.mkdirSync(path.dirname(STORE), { recursive: true });
|
||||
fs.appendFileSync(STORE, JSON.stringify({ id: crypto.randomUUID(), ...m, ip, at: new Date().toISOString() }) + '\n');
|
||||
notify(m); return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
if (p === '/api/session' && req.method === 'GET') return json(res, 200, { admin: isAdmin(req), enabled: !!ADMIN_PW });
|
||||
|
||||
if (p === '/api/login' && req.method === 'POST') {
|
||||
if (!ADMIN_PW) return json(res, 503, { error: 'Admin is disabled. Set ADMIN_PASSWORD and restart.' });
|
||||
if (recent(loginFails, ip, 9e5).length >= 5) return json(res, 429, { error: 'Too many attempts. Wait 15 minutes.' });
|
||||
const d = await readJson(req);
|
||||
if (d && crypto.timingSafeEqual(sha(String(d.password || '')), sha(ADMIN_PW))) return json(res, 200, { ok: true }, { 'Set-Cookie': setCookie(req, makeToken(), 604800) });
|
||||
push(loginFails, ip); return json(res, 401, { error: 'Wrong password.' });
|
||||
}
|
||||
if (p === '/api/logout' && req.method === 'POST') return json(res, 200, { ok: true }, { 'Set-Cookie': setCookie(req, '', 0) });
|
||||
|
||||
if (p.startsWith('/api/admin/')) {
|
||||
if (!isAdmin(req)) return json(res, 401, { error: 'Not signed in.' });
|
||||
if (p === '/api/admin/services' && req.method === 'GET') {
|
||||
try { return json(res, 200, JSON.parse(fs.readFileSync(ADMIN_SERVICES, 'utf8'))); } catch { return json(res, 200, []); }
|
||||
}
|
||||
if (p === '/api/admin/messages' && req.method === 'GET') return json(res, 200, readMsgs().reverse());
|
||||
const del = p.match(/^\/api\/admin\/messages\/(.+)$/);
|
||||
if (del && req.method === 'DELETE') {
|
||||
const id = decodeURIComponent(del[1]); writeMsgs(readMsgs().filter(m => (m.id || m.at) !== id)); return json(res, 200, { ok: true });
|
||||
}
|
||||
}
|
||||
return json(res, 404, { error: 'Not found.' });
|
||||
}
|
||||
|
||||
http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://x');
|
||||
try {
|
||||
if (url.pathname.startsWith('/api/')) return await api(req, res, url);
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'Method not allowed.' });
|
||||
let file = path.join(PUBLIC, path.normalize(decodeURIComponent(url.pathname)));
|
||||
if (!file.startsWith(PUBLIC)) return json(res, 403, { error: 'Forbidden.' });
|
||||
if (fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, 'index.html');
|
||||
if (!fs.existsSync(file)) return send(res, 404, fs.readFileSync(path.join(PUBLIC, '404.html')), MIME['.html']);
|
||||
send(res, 200, fs.readFileSync(file), MIME[path.extname(file)] || 'application/octet-stream');
|
||||
} catch (e) { if (!res.headersSent) json(res, 500, { error: 'Server error.' }); }
|
||||
}).listen(PORT, () => console.log(`nkelabs listening on :${PORT}`));
|
||||
@@ -0,0 +1,80 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Services · nkelabs</title>
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="brand" href="/">nke<i>/</i>labs</a>
|
||||
<nav aria-label="Main">
|
||||
<a href="/services/" aria-current="page">Services</a>
|
||||
<a href="/socials/">Socials</a>
|
||||
<a href="/projects/">Projects</a>
|
||||
<a href="/contact/">Contact</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<h1 class="title">Services</h1>
|
||||
<p class="lede">Everything running in the lab. Admin tools only answer on the home network.</p>
|
||||
|
||||
<section class="group">
|
||||
<h2>Open to everyone</h2>
|
||||
<div class="grid">
|
||||
<a class="shard" href="https://docs.nkelabs.xyz" target="_blank" rel="noopener">
|
||||
<h3>Docs</h3>
|
||||
<p>Notes and write-ups for the lab.</p>
|
||||
<span class="host">docs.nkelabs.xyz</span>
|
||||
</a>
|
||||
<a class="shard" href="https://gitea.nkelabs.xyz" target="_blank" rel="noopener">
|
||||
<h3>Gitea</h3>
|
||||
<p>Self-hosted git repositories.</p>
|
||||
<span class="host">gitea.nkelabs.xyz</span>
|
||||
</a>
|
||||
<a class="shard" href="https://bytestash.nkelabs.xyz" target="_blank" rel="noopener">
|
||||
<h3>ByteStash</h3>
|
||||
<p>A stash for code snippets.</p>
|
||||
<span class="host">bytestash.nkelabs.xyz</span>
|
||||
</a>
|
||||
<a class="shard" href="https://ittools.nkelabs.xyz" target="_blank" rel="noopener">
|
||||
<h3>IT-Tools</h3>
|
||||
<p>Converters, generators and other handy utilities.</p>
|
||||
<span class="host">ittools.nkelabs.xyz</span>
|
||||
</a>
|
||||
<a class="shard" href="https://friendbook.nkelabs.xyz" target="_blank" rel="noopener">
|
||||
<h3>Valorant Friendbook <span class="heart"><3</span></h3>
|
||||
<p>Who we play with, and who we play against.</p>
|
||||
<span class="host">friendbook.nkelabs.xyz</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="group">
|
||||
<h2>Admin only <small>local network</small></h2>
|
||||
<div class="grid">
|
||||
<a class="shard admin" href="http://10.67.67.69:8405" target="_blank" rel="noopener">
|
||||
<span class="badge">Admin only</span>
|
||||
<h3>Glance</h3>
|
||||
<p>Dashboard with feeds and status at a glance.</p>
|
||||
<span class="host">10.67.67.69:8405</span>
|
||||
</a>
|
||||
<a class="shard admin" href="http://10.67.67.69:8407" target="_blank" rel="noopener">
|
||||
<span class="badge">Admin only</span>
|
||||
<h3>Dozzle</h3>
|
||||
<p>Live container logs.</p>
|
||||
<span class="host">10.67.67.69:8407</span>
|
||||
</a>
|
||||
<a class="shard admin" href="http://10.67.67.69:8406" target="_blank" rel="noopener">
|
||||
<span class="badge">Admin only</span>
|
||||
<h3>Scrutiny</h3>
|
||||
<p>Drive health and SMART data.</p>
|
||||
<span class="host">10.67.67.69:8406</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/assets/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||