Compare commits
18 Commits
22f40b2284
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fe3516b9e | |||
| bef8f8414b | |||
| f3e9cdf0bd | |||
| 7ecc0f68d7 | |||
| 00a26a9e49 | |||
| 163c68fcb8 | |||
| 3149eba196 | |||
| bc65789431 | |||
| 21f1f07a99 | |||
| 23f86681af | |||
| 8492821f75 | |||
| 17bb43851e | |||
| fd68efdb78 | |||
| 1ee61d8fe4 | |||
| 6c024342ba | |||
| 9860213760 | |||
| cb584f8b68 | |||
| 8c5fe25c2b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,3 +34,4 @@ Thumbs.db
|
|||||||
# pygienium run-state and check artifacts
|
# pygienium run-state and check artifacts
|
||||||
.pygienium/
|
.pygienium/
|
||||||
scripts/
|
scripts/
|
||||||
|
.cache
|
||||||
|
|||||||
@@ -10,21 +10,7 @@ export default defineConfig({
|
|||||||
org: "mikefreno",
|
org: "mikefreno",
|
||||||
project: "freno-dev",
|
project: "freno-dev",
|
||||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||||
telemetry: false,
|
telemetry: false
|
||||||
sourcemaps: {
|
|
||||||
assets: [
|
|
||||||
{
|
|
||||||
type: "bundle",
|
|
||||||
path: "dist/client/assets/",
|
|
||||||
urlPrefix: "~/assets/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "sourcemap",
|
|
||||||
path: "dist/client/assets/",
|
|
||||||
urlPrefix: "~/assets/"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
"test:watch": "bun test --watch",
|
"test:watch": "bun test --watch",
|
||||||
"test:coverage": "bun test --coverage",
|
"test:coverage": "bun test --coverage",
|
||||||
"perf": "bun run scripts/perf-test.ts",
|
"perf": "bun run scripts/perf-test.ts",
|
||||||
"perf:compare": "bun run scripts/perf-compare.ts"
|
"perf:compare": "bun run scripts/perf-compare.ts",
|
||||||
|
"nook:grant": "NODE_ENV=production bun --env-file=.env scripts/grant-nook-license.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.953.0",
|
"@aws-sdk/client-s3": "^3.953.0",
|
||||||
|
|||||||
BIN
public/nook/cam-recording.mp4
Normal file
BIN
public/nook/cam-recording.mp4
Normal file
Binary file not shown.
BIN
public/nook/demo-expansion.mp4
Normal file
BIN
public/nook/demo-expansion.mp4
Normal file
Binary file not shown.
@@ -1,9 +1,21 @@
|
|||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
Allow: /blog
|
Allow: /blog
|
||||||
Allow: /projects
|
|
||||||
Disallow: /login
|
# Private / dynamic paths — not for indexing.
|
||||||
Disallow: /debug/
|
# Keeps crawlers off auth, admin, and API pages so they don't burn
|
||||||
|
# uncached SSR function invocations.
|
||||||
|
Disallow: /account
|
||||||
|
Disallow: /analytics
|
||||||
|
Disallow: /api/
|
||||||
|
Disallow: /blog/create
|
||||||
|
Disallow: /blog/edit
|
||||||
|
Disallow: /checkout
|
||||||
Disallow: /databaseMGMT
|
Disallow: /databaseMGMT
|
||||||
|
Disallow: /debug/
|
||||||
|
Disallow: /error-test
|
||||||
|
Disallow: /login
|
||||||
|
Disallow: /success
|
||||||
|
Disallow: /test
|
||||||
|
|
||||||
Sitemap: https://www.freno.me/sitemap.xml
|
Sitemap: https://www.freno.me/sitemap.xml
|
||||||
|
|||||||
181
src/app.css
181
src/app.css
@@ -574,3 +574,184 @@ a.hover-underline-animation:hover::after {
|
|||||||
.shaker:hover {
|
.shaker:hover {
|
||||||
animation: shaker 0.5s ease;
|
animation: shaker 0.5s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Nook landing: campfire sprite frame cycling ──────────────────── */
|
||||||
|
/* Each frame layer is stacked; its animation holds opacity 1 during its
|
||||||
|
slot and 0 otherwise, so the stack reads as stop-motion at the app's
|
||||||
|
sprite cadence (frame time = total duration / frame count). The phase
|
||||||
|
lives inside the keyframes and every layer animates delay-free from the
|
||||||
|
same clock WebKit quantizes animation starts per cycle wrap, so
|
||||||
|
delay-offset layers can drop a wrap frame and show BOTH transparent
|
||||||
|
for a frame (the "blank frame" flicker). Complementary holds mean any
|
||||||
|
moment sums to exactly one opaque layer. Linear timing: steps()
|
||||||
|
holds mis-swap at boundary frames under WebKit. */
|
||||||
|
/* 2-frame sprites: layer i uses campfire-slot-2-{a,b}. */
|
||||||
|
@keyframes campfire-slot-2-a {
|
||||||
|
0%,
|
||||||
|
49.99% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes campfire-slot-2-b {
|
||||||
|
0%,
|
||||||
|
49.99% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 6-frame sprites (question): six slot phases. */
|
||||||
|
@keyframes campfire-slot-6-a {
|
||||||
|
0%,
|
||||||
|
16.66% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
16.67%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes campfire-slot-6-b {
|
||||||
|
0%,
|
||||||
|
16.66% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
16.67%,
|
||||||
|
33.32% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
33.33%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes campfire-slot-6-c {
|
||||||
|
0%,
|
||||||
|
33.32% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
33.33%,
|
||||||
|
49.99% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes campfire-slot-6-d {
|
||||||
|
0%,
|
||||||
|
49.99% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50%,
|
||||||
|
66.66% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
66.67%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes campfire-slot-6-e {
|
||||||
|
0%,
|
||||||
|
66.66% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
66.67%,
|
||||||
|
83.32% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
83.33%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes campfire-slot-6-f {
|
||||||
|
0%,
|
||||||
|
83.32% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
83.33%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.campfire-frame {
|
||||||
|
animation: var(--slot-kf, campfire-slot-2-a) var(--campfire-duration, 1s)
|
||||||
|
linear infinite;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Nook landing: breakdown section reveal ───────────────────────── */
|
||||||
|
.reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(24px);
|
||||||
|
transition:
|
||||||
|
opacity 0.7s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal.in {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.reveal {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campfire-frame {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campfire-frame + .campfire-frame {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* ── Nook landing: fan card shake, builds with fan speed ───────── */
|
||||||
|
.fan-card-shake {
|
||||||
|
transform-origin: left center;
|
||||||
|
animation-name: fan-shake;
|
||||||
|
animation-duration: var(--fan-speed, 0.4s);
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
animation-timing-function: ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fan-shake {
|
||||||
|
0%, 100% { transform: rotate(0deg); }
|
||||||
|
50% { transform: rotate(calc(var(--fan-amp, 0deg) * -1)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sputtering sparks off the fan card at max speed */
|
||||||
|
.fan-spark {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #ffd23f;
|
||||||
|
box-shadow: 0 0 4px 1px rgba(255, 140, 0, 0.9);
|
||||||
|
animation: fan-spark-fly var(--sd, 0.5s) ease-out var(--sdel, 0s) infinite;
|
||||||
|
}
|
||||||
|
@keyframes fan-spark-fly {
|
||||||
|
0% { transform: translate(0, 0) scale(1); opacity: 0; }
|
||||||
|
10% { opacity: 1; }
|
||||||
|
100% { transform: translate(var(--sx, 20px), var(--sy, 30px)) scale(0.4); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Demo pulse: battery bolt */
|
||||||
|
@keyframes nook-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -366,6 +366,11 @@ function MainRightBarContent() {
|
|||||||
Contact Me
|
Contact Me
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
|
||||||
|
<a href="/privacy-policy" onClick={handleLinkClick}>
|
||||||
|
Privacy
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/MikeFreno/"
|
href="https://github.com/MikeFreno/"
|
||||||
|
|||||||
29
src/components/EdgeCacheHeaders.tsx
Normal file
29
src/components/EdgeCacheHeaders.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { HttpHeader } from "@solidjs/start";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders edge-cache response headers for a page route.
|
||||||
|
*
|
||||||
|
* Sets `CDN-Cache-Control` so Vercel serves the rendered HTML from the edge
|
||||||
|
* (no origin re-render) for `maxAge` seconds, then stale-while-revalidate up
|
||||||
|
* to `staleSeconds`. `Cache-Control: public, max-age=0` keeps browsers
|
||||||
|
* revalidating, so visitors always get the latest version — only the CDN
|
||||||
|
* holds a cached copy. Function/CND-Cache-Control overrides the Vercel
|
||||||
|
* default, so repeated visits and bot crawls stop re-rendering at origin.
|
||||||
|
*/
|
||||||
|
export function EdgeCacheHeaders(props: {
|
||||||
|
/** Seconds the CDN may serve the response fresh. */
|
||||||
|
maxAge: number;
|
||||||
|
/** Seconds the CDN serves stale while revalidating (default: 1 day). */
|
||||||
|
staleSeconds?: number;
|
||||||
|
}) {
|
||||||
|
const stale = props.staleSeconds ?? 86400;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HttpHeader name="Cache-Control" value="public, max-age=0" />
|
||||||
|
<HttpHeader
|
||||||
|
name="CDN-Cache-Control"
|
||||||
|
value={`public, s-maxage=${props.maxAge}, stale-while-revalidate=${stale}`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,16 +4,16 @@ import { buildMainSiteUrl } from "~/lib/site-context";
|
|||||||
export default function SubdomainFooter() {
|
export default function SubdomainFooter() {
|
||||||
return (
|
return (
|
||||||
<footer class="border-surface0 bg-surface0 relative z-10 border-t py-8">
|
<footer class="border-surface0 bg-surface0 relative z-10 border-t py-8">
|
||||||
<div class="relative flex items-center text-sm">
|
<div class="relative flex flex-col items-center gap-2 text-sm sm:flex-row sm:justify-center">
|
||||||
<A
|
<A
|
||||||
href={buildMainSiteUrl()}
|
href={buildMainSiteUrl()}
|
||||||
class="text-text/60 hover:text-text/80 mx-auto text-center underline underline-offset-4 transition-colors"
|
class="text-text/60 hover:text-text/80 text-center underline underline-offset-4 transition-colors"
|
||||||
>
|
>
|
||||||
made with <span class="text-red-400"><3</span>
|
made with <span class="text-red-400"><3</span>
|
||||||
</A>
|
</A>
|
||||||
<A
|
<A
|
||||||
href={buildMainSiteUrl("/downloads")}
|
href={buildMainSiteUrl("/downloads")}
|
||||||
class="text-text/80 hover:text-text absolute right-4 underline underline-offset-4 transition-colors"
|
class="text-text/80 hover:text-text underline underline-offset-4 transition-colors sm:absolute sm:right-4"
|
||||||
>
|
>
|
||||||
see more products
|
see more products
|
||||||
</A>
|
</A>
|
||||||
|
|||||||
367
src/components/nook/Campfire.tsx
Normal file
367
src/components/nook/Campfire.tsx
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import { For, type JSX } from "solid-js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pixel-art campfire sprite, faithful to the app's CampfireFrames: a 14×14
|
||||||
|
* grid where the log base never moves and only the flame and sparks animate.
|
||||||
|
* `state` picks the frame set and cadence — idle simmers, running dances,
|
||||||
|
* ready exhales, error pulses dead embers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FIRE_PALETTE: Record<string, string> = {
|
||||||
|
o: "#e7873a",
|
||||||
|
y: "#ffce70",
|
||||||
|
w: "#ffffff",
|
||||||
|
b: "#8b5a2b",
|
||||||
|
d: "#5a3a1b",
|
||||||
|
s: "#ffd166",
|
||||||
|
r: "#c43a30",
|
||||||
|
R: "#f25240",
|
||||||
|
x: "#ff7864",
|
||||||
|
m: "rgba(255,255,255,0.5)",
|
||||||
|
k: "rgba(255,255,255,0.26)",
|
||||||
|
e: "#a8582a",
|
||||||
|
E: "#d87636",
|
||||||
|
B: "#4890fc",
|
||||||
|
L: "#92c7ff",
|
||||||
|
C: "#e9f8ff",
|
||||||
|
S: "#bfe0ff"
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CampfireFrame {
|
||||||
|
rows: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDLE_FRAMES: CampfireFrame[] = [
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".....oyo......",
|
||||||
|
"....oyyyo.....",
|
||||||
|
"..dddeeeeeEd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".........k....",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"......oyo.....",
|
||||||
|
"....oyyyo.....",
|
||||||
|
"..dddEEEEEEd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const RUN_FRAMES: CampfireFrame[] = [
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".....s.yy.....",
|
||||||
|
".....oyyo.....",
|
||||||
|
"....oywwyo....",
|
||||||
|
"...oyywwyyo...",
|
||||||
|
"...oyywwyyo...",
|
||||||
|
"...oyywwyyo...",
|
||||||
|
"....oywwyo....",
|
||||||
|
"..dddddddddd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
".....s........",
|
||||||
|
".....yy.......",
|
||||||
|
".....oyyo.....",
|
||||||
|
"....oywwyo....",
|
||||||
|
"...oyywwyyo...",
|
||||||
|
"...oyywwyyo...",
|
||||||
|
"...oyywwyyo...",
|
||||||
|
"....oywwyo....",
|
||||||
|
"..dddddddddd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const READY_FRAMES: CampfireFrame[] = [
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"......yy......",
|
||||||
|
".....oyyo.....",
|
||||||
|
"....oyyyyo....",
|
||||||
|
"....oyyyyo....",
|
||||||
|
"..dddyyyyyyd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"........m.....",
|
||||||
|
".....yy.......",
|
||||||
|
".....oyyo.....",
|
||||||
|
"....oyyyyo....",
|
||||||
|
"....oyyyyo....",
|
||||||
|
"..dddyyyyyyd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/* App CampfireFrames.ques0-5: cool blue flame, tip dips, sparks spit. */
|
||||||
|
const QUES_FRAMES: CampfireFrame[] = [
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".....S.LL.....",
|
||||||
|
".....BLLB.....",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"..dddccccccd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
".....S........",
|
||||||
|
".....LL.......",
|
||||||
|
".....BLLB.....",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"..dddccccccd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
".....S........",
|
||||||
|
"......LL......",
|
||||||
|
".....BLLB.....",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"..dddccccccd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
".....S........",
|
||||||
|
"..............",
|
||||||
|
"......LL......",
|
||||||
|
".....BLLB.....",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"..dddccccccd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
".....S........",
|
||||||
|
"..............",
|
||||||
|
".......LL.....",
|
||||||
|
".....BLLB.....",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"..dddccccccd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"........S.....",
|
||||||
|
"..............",
|
||||||
|
".......LL.....",
|
||||||
|
".....BLLB.....",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"..dddccccccd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const ERROR_FRAMES: CampfireFrame[] = [
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..dddrrrrrrd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"........x.....",
|
||||||
|
"..............",
|
||||||
|
".....x........",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..dddRRRRRRd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export type CampfireState = "idle" | "running" | "ready" | "question" | "error";
|
||||||
|
|
||||||
|
const STATE_FRAMES: Record<CampfireState, CampfireFrame[]> = {
|
||||||
|
idle: IDLE_FRAMES,
|
||||||
|
running: RUN_FRAMES,
|
||||||
|
ready: READY_FRAMES,
|
||||||
|
question: QUES_FRAMES,
|
||||||
|
error: ERROR_FRAMES
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATE_FPS: Record<CampfireState, number> = {
|
||||||
|
idle: 2,
|
||||||
|
running: 9,
|
||||||
|
ready: 4,
|
||||||
|
question: 4.5,
|
||||||
|
error: 4
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Campfire(props: { state: CampfireState; pixel?: number }) {
|
||||||
|
const pixel = () => props.pixel ?? 3;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="animate-campfire grid"
|
||||||
|
style={{
|
||||||
|
"grid-template-columns": `repeat(14, ${pixel()}px)`,
|
||||||
|
/* One full cycle: every frame exactly one frame-interval long. */
|
||||||
|
"--campfire-duration": `${(1000 * STATE_FRAMES[props.state].length) / STATE_FPS[props.state] / 1000}s`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Slot phase lives in the keyframes per layer (campfire-slot-N-x);
|
||||||
|
delay-offset layers blank a wrap frame under WebKit. */}
|
||||||
|
<For each={STATE_FRAMES[props.state]}>
|
||||||
|
{(frame, i) => {
|
||||||
|
const n = STATE_FRAMES[props.state].length;
|
||||||
|
const phase = String.fromCharCode(97 + (i() % 26));
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="campfire-frame col-span-full row-start-1"
|
||||||
|
style={{ "--slot-kf": `campfire-slot-${n}-${phase}` }}
|
||||||
|
>
|
||||||
|
<For each={frame.rows}>
|
||||||
|
{(row) => (
|
||||||
|
<div class="flex" style={{ height: `${pixel()}px` }}>
|
||||||
|
<For each={row.split("")}>
|
||||||
|
{(ch) => (
|
||||||
|
<span
|
||||||
|
class="inline-block"
|
||||||
|
style={{
|
||||||
|
width: `${pixel()}px`,
|
||||||
|
height: `${pixel()}px`,
|
||||||
|
background:
|
||||||
|
ch === "." ? "transparent" : FIRE_PALETTE[ch]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1128
src/components/nook/FeatureBreakdowns.tsx
Normal file
1128
src/components/nook/FeatureBreakdowns.tsx
Normal file
File diff suppressed because it is too large
Load Diff
370
src/components/nook/FeatureCollage.tsx
Normal file
370
src/components/nook/FeatureCollage.tsx
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
import { For, type JSX } from "solid-js";
|
||||||
|
import {
|
||||||
|
IslandPill,
|
||||||
|
AgentDotGrid,
|
||||||
|
STATUS,
|
||||||
|
CALM,
|
||||||
|
CRITICAL,
|
||||||
|
ACCENT,
|
||||||
|
ModuleCard
|
||||||
|
} from "./IslandMock";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature collage: five cells sold as the app's actual UI. The hero cell
|
||||||
|
* shows the real collapsed pill (campfire slot, camera housing, live data)
|
||||||
|
* dressed in the island's black surface; supporting cells reuse the same
|
||||||
|
* module chrome, status palette, and metric language.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function CollageCell(props: {
|
||||||
|
class?: string;
|
||||||
|
kicker: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
children?: JSX.Element;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`border-overlay1 bg-surface1 relative flex flex-col overflow-hidden rounded-2xl border p-6 ${props.class ?? ""}`}
|
||||||
|
>
|
||||||
|
<p class="text-subtext1 mb-3 text-[11px] font-semibold tracking-[0.14em] uppercase">
|
||||||
|
{props.kicker}
|
||||||
|
</p>
|
||||||
|
<h3 class="text-text mb-2 text-lg font-bold tracking-tight">
|
||||||
|
{props.title}
|
||||||
|
</h3>
|
||||||
|
<p class="text-subtext0 text-sm leading-relaxed">{props.body}</p>
|
||||||
|
<div class="mt-5 flex-1">{props.children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage for app-UI mocks: the island's black ink ground. The real island
|
||||||
|
* is always dark (preferredColorScheme .dark), so the mocks read correctly
|
||||||
|
* in both site themes.
|
||||||
|
*/
|
||||||
|
function InkStage(props: { children: JSX.Element; class?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`flex justify-center rounded-xl py-5 ${props.class ?? ""}`}
|
||||||
|
style={{ background: "#0d0d0f" }}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The approvals mock rebuilt on the app's PermissionCard anatomy. */
|
||||||
|
function ApprovalCardMock() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="mx-auto w-full max-w-sm rounded-[10px] p-2.5 text-left"
|
||||||
|
style={{ background: "rgba(234,179,8,0.08)" }}
|
||||||
|
>
|
||||||
|
<div class="mb-1.5 flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
class="inline-block h-[13px] w-[13px] rounded-[3px]"
|
||||||
|
style={{ background: "#D97742" }}
|
||||||
|
/>
|
||||||
|
<span class="text-[12px] font-semibold text-white">
|
||||||
|
the-nook · bridge
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||||
|
style={{ background: "rgba(234,179,8,0.25)" }}
|
||||||
|
>
|
||||||
|
approval
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-[13px] font-medium text-white">Run Edit</p>
|
||||||
|
<p class="mt-0.5 font-mono text-[11px] text-white/50">
|
||||||
|
server/routes.ts +12 −4
|
||||||
|
</p>
|
||||||
|
<div class="mt-2.5 flex gap-2">
|
||||||
|
<span
|
||||||
|
class="rounded-md px-3 py-1 text-[11px] font-semibold"
|
||||||
|
style={{ background: ACCENT }}
|
||||||
|
>
|
||||||
|
Allow
|
||||||
|
</span>
|
||||||
|
<span class="rounded-md border border-white/20 px-3 py-1 text-[11px] font-semibold text-white/80">
|
||||||
|
Deny
|
||||||
|
</span>
|
||||||
|
<span class="px-1 py-1 text-[11px] text-white/50">
|
||||||
|
Deny with reason…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fans panel mock: RPM gauge rows in the app's card chrome. */
|
||||||
|
function FansPanelMock() {
|
||||||
|
const fans = [
|
||||||
|
{ rpm: 1270, frac: 0.26 },
|
||||||
|
{ rpm: 1219, frac: 0.25 }
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<ModuleCard>
|
||||||
|
<div class="space-y-2.5">
|
||||||
|
<For each={fans}>
|
||||||
|
{(f) => (
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="h-1 flex-1 overflow-hidden rounded-full"
|
||||||
|
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${f.frac * 100}%`,
|
||||||
|
background: CALM
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="font-mono text-white"
|
||||||
|
style={{ "font-size": "13px", "font-weight": 500 }}
|
||||||
|
>
|
||||||
|
{f.rpm}
|
||||||
|
</span>
|
||||||
|
<span class="text-[9px] font-semibold text-white/50">RPM</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<span class="rounded-md border border-white/15 px-2 py-0.5 text-[11px] text-white/70">
|
||||||
|
Auto
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="rounded-md px-2 py-0.5 text-[11px] font-bold"
|
||||||
|
style={{ color: CRITICAL }}
|
||||||
|
>
|
||||||
|
MAX
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModuleCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remote agents: two session rows like the expanded agents panel. */
|
||||||
|
function RemoteMock() {
|
||||||
|
return (
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<div
|
||||||
|
class="rounded-[10px] p-2.5"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.055)",
|
||||||
|
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-block h-[7px] w-[7px] rounded-full"
|
||||||
|
style={{ background: STATUS.running }}
|
||||||
|
/>
|
||||||
|
<span class="text-[13px] font-semibold text-white">build-box</span>
|
||||||
|
<span
|
||||||
|
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white/75"
|
||||||
|
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||||
|
>
|
||||||
|
ssh
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-[11px] text-white/50">You: fix flaky test</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-[10px] p-2.5"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.055)",
|
||||||
|
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-block h-[7px] w-[7px] rounded-full"
|
||||||
|
style={{ background: STATUS.ready }}
|
||||||
|
/>
|
||||||
|
<span class="text-[13px] font-semibold text-white">gpu-rig</span>
|
||||||
|
<span
|
||||||
|
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white/75"
|
||||||
|
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||||
|
>
|
||||||
|
ssh
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-[11px] text-white/50">Ready</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** System gauges: temp die blocks + memory, in module cards. */
|
||||||
|
function GaugesMock() {
|
||||||
|
return (
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<ModuleCard>
|
||||||
|
<div class="flex items-baseline justify-between">
|
||||||
|
<span class="text-[9px] font-semibold text-white/50">CPU</span>
|
||||||
|
<span class="font-mono text-white" style={{ "font-size": "15px" }}>
|
||||||
|
72°
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-1.5 h-1 overflow-hidden rounded-full"
|
||||||
|
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full"
|
||||||
|
style={{ width: "64%", background: CALM }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ModuleCard>
|
||||||
|
<ModuleCard>
|
||||||
|
<div class="flex items-baseline justify-between">
|
||||||
|
<span class="text-[9px] font-semibold text-white/50">GPU</span>
|
||||||
|
<span class="font-mono text-white" style={{ "font-size": "15px" }}>
|
||||||
|
58°
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-1.5 h-1 overflow-hidden rounded-full"
|
||||||
|
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full"
|
||||||
|
style={{ width: "31%", background: CALM }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ModuleCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default function FeatureCollage() {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
class="bg-base relative z-20 px-4 pt-40 pb-16 md:px-8"
|
||||||
|
id="feature-collage"
|
||||||
|
>
|
||||||
|
<div class="mx-auto max-w-5xl">
|
||||||
|
<p class="text-subtext1 mb-2 text-center text-xs font-semibold tracking-[0.18em] uppercase">
|
||||||
|
Why the Nook
|
||||||
|
</p>
|
||||||
|
<h2 class="text-text mb-12 text-center text-3xl font-bold">
|
||||||
|
One glance. Everything your agents are doing.
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-5 md:grid-cols-3">
|
||||||
|
{/* Hero: real collapsed pill on the island surface */}
|
||||||
|
<div class="border-overlay1 bg-surface1 relative col-span-1 flex flex-col items-center overflow-hidden rounded-2xl border p-6 md:col-span-2">
|
||||||
|
<div class="mb-5 self-start">
|
||||||
|
<p class="text-subtext1 mb-3 text-[11px] font-semibold tracking-[0.14em] uppercase">
|
||||||
|
The island
|
||||||
|
</p>
|
||||||
|
<h3 class="text-text mb-2 text-2xl font-bold tracking-tight">
|
||||||
|
Every agent, one glance
|
||||||
|
</h3>
|
||||||
|
<p class="text-subtext0 max-w-sm text-sm leading-relaxed">
|
||||||
|
A pill tucked into your notch — a campfire for your whole fleet
|
||||||
|
on the left, live data on the right. Blue running, green ready,
|
||||||
|
amber needs you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="my-10 flex w-full justify-center">
|
||||||
|
<IslandPill
|
||||||
|
campfire="running"
|
||||||
|
right={
|
||||||
|
<div class="flex flex-col items-center gap-1 px-1">
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<span
|
||||||
|
class="inline-block h-2.5 w-2.5 rounded-[3px]"
|
||||||
|
style={{ background: STATUS.running }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="inline-block h-2.5 w-2.5 rounded-[3px]"
|
||||||
|
style={{ background: STATUS.ready }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<span
|
||||||
|
class="inline-block h-2.5 w-2.5 rounded-[3px]"
|
||||||
|
style={{ background: STATUS.attention }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="inline-block h-2.5 w-2.5 rounded-[3px]"
|
||||||
|
style={{ background: STATUS.idle }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-subtext0 -mb-1 text-center text-xs">
|
||||||
|
expand for the full panel ↓
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Approvals */}
|
||||||
|
<CollageCell
|
||||||
|
kicker="Approvals"
|
||||||
|
title="Unblock agents, anywhere"
|
||||||
|
body="Permission prompts surface as cards in the panel — Allow once, always, or deny. Away from the desk, push them to your phone and answer from there (coming soon)."
|
||||||
|
>
|
||||||
|
<InkStage>
|
||||||
|
<div class="w-full max-w-sm px-3">
|
||||||
|
<ApprovalCardMock />
|
||||||
|
</div>
|
||||||
|
</InkStage>
|
||||||
|
</CollageCell>
|
||||||
|
|
||||||
|
{/* Fans */}
|
||||||
|
<CollageCell
|
||||||
|
kicker="Cooling"
|
||||||
|
title="Silence it or max it"
|
||||||
|
body="Read both fans live, then take over the curve completely. Flip to MAX before a compile, whisper-quiet when the fleet idles."
|
||||||
|
>
|
||||||
|
<InkStage class="w-full">
|
||||||
|
<div class="w-full max-w-[260px] text-left">
|
||||||
|
<FansPanelMock />
|
||||||
|
</div>
|
||||||
|
</InkStage>
|
||||||
|
</CollageCell>
|
||||||
|
|
||||||
|
{/* Remote agents */}
|
||||||
|
<CollageCell
|
||||||
|
kicker="Remote"
|
||||||
|
title="Agents on other machines"
|
||||||
|
body="Run agents on Linux boxes and SSH servers — one click installs the nook-hook, and their sessions join the same island."
|
||||||
|
>
|
||||||
|
<InkStage class="w-full">
|
||||||
|
<div class="w-full max-w-[280px] text-left">
|
||||||
|
<RemoteMock />
|
||||||
|
</div>
|
||||||
|
</InkStage>
|
||||||
|
</CollageCell>
|
||||||
|
|
||||||
|
{/* Gauges */}
|
||||||
|
<CollageCell
|
||||||
|
kicker="Your Mac"
|
||||||
|
title="The gauges that matter"
|
||||||
|
body="CPU and GPU die temps, memory pressure, live network throughput — the same tier palette the island uses, warning before a compile cooks your lap."
|
||||||
|
>
|
||||||
|
<InkStage class="w-full">
|
||||||
|
<div class="w-full max-w-[300px] text-left">
|
||||||
|
<GaugesMock />
|
||||||
|
</div>
|
||||||
|
</InkStage>
|
||||||
|
</CollageCell>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-subtext1 mt-6 text-center text-xs">
|
||||||
|
Plus calendar, reminders, calls, and Now Playing — all in the same
|
||||||
|
island, all one-time purchase.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
src/components/nook/IslandMock.tsx
Normal file
263
src/components/nook/IslandMock.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { For, createMemo, onMount, onCleanup, type JSX } from "solid-js";
|
||||||
|
import { Campfire, type CampfireState } from "./Campfire";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Faithful mock of the island's collapsed pill and expanded panel.
|
||||||
|
* Geometry follows the app's IslandSurfaceShape: the top edge spans the
|
||||||
|
* full width, the notch housing sits centered, and each corner is a
|
||||||
|
* concave scoop (quadratic curve with the control ON the top edge).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* The app's island indicator palette (AgentsModuleView.statusColor). */
|
||||||
|
export const STATUS = {
|
||||||
|
running: "#6EA7FF",
|
||||||
|
ready: "#6FB982",
|
||||||
|
attention: "#E7A762",
|
||||||
|
error: "#E5484D",
|
||||||
|
idle: "rgba(255,255,255,0.35)"
|
||||||
|
};
|
||||||
|
|
||||||
|
/** MetricPalette.calm — the desaturated sky of every metric bar. */
|
||||||
|
export const CALM = "#6EA7FF";
|
||||||
|
export const WARM = "#E7A762";
|
||||||
|
export const CRITICAL = "rgba(229,72,77,0.9)";
|
||||||
|
|
||||||
|
/* NookPalette.accent — brand + live interactive state, never status. */
|
||||||
|
export const ACCENT = "#4897b2";
|
||||||
|
|
||||||
|
/** The expanded panel's card chrome: white 5.5% fill, white 7% stroke. */
|
||||||
|
export const CARD_FILL = "rgba(255,255,255,0.055)";
|
||||||
|
export const CARD_STROKE = "rgba(255,255,255,0.07)";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One module-card surface shared by every mock panel — the app's
|
||||||
|
* cardChrome(): rounded 12, hairline stroke, white-on-black fill.
|
||||||
|
*/
|
||||||
|
export function ModuleCard(props: {
|
||||||
|
children: JSX.Element;
|
||||||
|
class?: string;
|
||||||
|
style?: Record<string, string>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`rounded-xl ${props.class ?? ""}`}
|
||||||
|
style={{
|
||||||
|
background: CARD_FILL,
|
||||||
|
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`,
|
||||||
|
...props.style
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="p-3">{props.children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Island surface, faithful to the app's IslandSurfaceShape: the top edge
|
||||||
|
* spans the full outer width while the body is inset, each corner joining
|
||||||
|
* them with a concave quad flare (control point ON the top edge), the
|
||||||
|
* bottom a convex 14px round. Rendered as an inline SVG with
|
||||||
|
* foreignObject-clip via CSS `clip-path: path(...)`.
|
||||||
|
*/
|
||||||
|
const FLARE = 22; // the app's topRadius — how far the wings taper inward
|
||||||
|
const BOTTOM_R = 14;
|
||||||
|
|
||||||
|
function islandPath(w: number, h: number): string {
|
||||||
|
const tr = Math.min(FLARE, w / 2);
|
||||||
|
const br = Math.min(BOTTOM_R, tr);
|
||||||
|
// Mirrors IslandSurfaceShape.path exactly (same node order, same controls).
|
||||||
|
return (
|
||||||
|
`M0,0 L${w},0 ` +
|
||||||
|
`Q${w - tr},0 ${w - tr},${tr} ` +
|
||||||
|
`L${w - tr},${h - br} ` +
|
||||||
|
`Q${w - tr},${h} ${w - tr - br},${h} ` +
|
||||||
|
`L${tr + br},${h} ` +
|
||||||
|
`Q${tr},${h} ${tr},${h - br} ` +
|
||||||
|
`L${tr},${tr} ` +
|
||||||
|
`Q${tr},0 0,0 Z`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IslandSurface(props: { children: JSX.Element; class?: string }) {
|
||||||
|
let el: HTMLDivElement | undefined;
|
||||||
|
// The path must track the element's real size; a fixed viewBox would
|
||||||
|
// stretch the flare. Measure on mount + resize.
|
||||||
|
onMount(() => {
|
||||||
|
const node = el;
|
||||||
|
if (!node) return;
|
||||||
|
const apply = () => {
|
||||||
|
const r = node.getBoundingClientRect();
|
||||||
|
node.style.clipPath = `path("${islandPath(r.width, r.height)}")`;
|
||||||
|
};
|
||||||
|
apply();
|
||||||
|
const observer = new ResizeObserver(apply);
|
||||||
|
observer.observe(node);
|
||||||
|
onCleanup(() => observer.disconnect());
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={el}
|
||||||
|
class={`bg-black ${props.class ?? ""}`}
|
||||||
|
style={{ "border-radius": "0 0 14px 14px" }}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISLAND … */
|
||||||
|
const NOTCH_NAME = "The Nook";
|
||||||
|
|
||||||
|
/* ── Collapsed pill ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The collapsed pill: campfire slot left, camera housing center, live
|
||||||
|
* data slot right. Slot squares are native (no menu-bar stub — the wings
|
||||||
|
* hang from nothing, like a floating notch).
|
||||||
|
*/
|
||||||
|
export function IslandPill(props: {
|
||||||
|
campfire?: CampfireState;
|
||||||
|
right?: JSX.Element;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<IslandSurface class="mx-auto w-fit">
|
||||||
|
<div class="flex items-end" style={{ height: "60px" }}>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center"
|
||||||
|
style={{ width: "86px", height: "56px" }}
|
||||||
|
>
|
||||||
|
<Campfire state={props.campfire ?? "running"} pixel={4.2} />
|
||||||
|
</div>
|
||||||
|
{/* camera housing: lens dot centered like the real notch */}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center"
|
||||||
|
style={{ width: "150px", height: "56px" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="rounded-full"
|
||||||
|
style={{
|
||||||
|
width: "12px",
|
||||||
|
height: "12px",
|
||||||
|
background: "radial-gradient(circle at 40% 35%, #2a3a4a 0%, #0a0c10 70%)",
|
||||||
|
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.06)"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center"
|
||||||
|
style={{ width: "86px", height: "56px" }}
|
||||||
|
>
|
||||||
|
{props.right}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</IslandSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The agents dot-grid slot indicator: balanced rows of status squares. */
|
||||||
|
export function AgentDotGrid(props: { states: (keyof typeof STATUS)[] }) {
|
||||||
|
// Balanced rows: 3+3+2 for 8, 2x2 for 4, 3 for 3 … app: 1,2,3,4=2x2,
|
||||||
|
// 5=[3,2], 6=[3,3], 7=[4,3], 8=[4,4], 9=[3,3,3]
|
||||||
|
const rows = (n: number): number[] => {
|
||||||
|
switch (n) {
|
||||||
|
case 1:
|
||||||
|
return [1];
|
||||||
|
case 2:
|
||||||
|
return [2];
|
||||||
|
case 3:
|
||||||
|
return [3];
|
||||||
|
case 4:
|
||||||
|
return [2, 2];
|
||||||
|
case 5:
|
||||||
|
return [3, 2];
|
||||||
|
case 6:
|
||||||
|
return [3, 3];
|
||||||
|
case 7:
|
||||||
|
return [4, 3];
|
||||||
|
case 8:
|
||||||
|
return [4, 4];
|
||||||
|
default:
|
||||||
|
return [4, 4];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const sizes = rows(props.states.length);
|
||||||
|
const groups = createMemo(() => {
|
||||||
|
let cursor = 0;
|
||||||
|
return sizes.map((count) => props.states.slice(cursor, (cursor += count)));
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col items-center gap-[1.5px]">
|
||||||
|
<For each={groups()}>
|
||||||
|
{(group) => (
|
||||||
|
<div class="flex gap-[1.5px]">
|
||||||
|
<For each={group}>
|
||||||
|
{(s) => (
|
||||||
|
<span
|
||||||
|
class="rounded-[1.5px]"
|
||||||
|
style={{
|
||||||
|
width: "7px",
|
||||||
|
height: "7px",
|
||||||
|
background: STATUS[s]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Expanded panel ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expanded island: header ("The Nook" + "N active" + controls),
|
||||||
|
* tab strip, divider, then one panel page. Width ~ the app's expanded
|
||||||
|
* footprint on a 14" display.
|
||||||
|
*/
|
||||||
|
export function IslandExpanded(props: {
|
||||||
|
activeCount: number;
|
||||||
|
tabs: { icon: string; label: string; active?: boolean }[];
|
||||||
|
children: JSX.Element;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div class="w-[560px] max-w-full">
|
||||||
|
<IslandSurface>
|
||||||
|
{/* header */}
|
||||||
|
<div class="flex items-center px-[18px] py-2.5">
|
||||||
|
<span class="text-[14px] font-semibold text-white">{NOTCH_NAME}</span>
|
||||||
|
<div class="flex-1" />
|
||||||
|
<span class="text-[11px] text-white/50">
|
||||||
|
{props.activeCount} active
|
||||||
|
</span>
|
||||||
|
<span class="ml-3 text-[11px] font-semibold text-white/60">⚙</span>
|
||||||
|
<span class="ml-3 text-[11px] font-semibold text-white/60">✕</span>
|
||||||
|
</div>
|
||||||
|
{/* tab strip: one icon button per panel; active wears accent 22% */}
|
||||||
|
<div class="flex gap-2 px-[18px] pb-1.5">
|
||||||
|
<For each={props.tabs}>
|
||||||
|
{(tab) => (
|
||||||
|
<span
|
||||||
|
title={tab.label}
|
||||||
|
class="flex items-center justify-center rounded-[5px]"
|
||||||
|
style={{
|
||||||
|
width: "22px",
|
||||||
|
height: "22px",
|
||||||
|
"font-size": "13px",
|
||||||
|
background: tab.active ? `${ACCENT}38` : "transparent",
|
||||||
|
color: tab.active ? "rgba(255,255,255,0.95)" : "rgba(255,255,255,0.5)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<div class="h-px bg-white/10" />
|
||||||
|
<div class="px-[18px] py-4">{props.children}</div>
|
||||||
|
</IslandSurface>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -101,7 +101,8 @@ function handleChunkError(source: string): void {
|
|||||||
window.addEventListener("error", (event) => {
|
window.addEventListener("error", (event) => {
|
||||||
if (
|
if (
|
||||||
event.message?.includes("Importing a module script failed") ||
|
event.message?.includes("Importing a module script failed") ||
|
||||||
event.message?.includes("Failed to fetch dynamically imported module")
|
event.message?.includes("Failed to fetch dynamically imported module") ||
|
||||||
|
event.message?.includes("error loading dynamically imported module")
|
||||||
) {
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleChunkError("error event");
|
handleChunkError("error event");
|
||||||
@@ -111,9 +112,8 @@ window.addEventListener("error", (event) => {
|
|||||||
window.addEventListener("unhandledrejection", (event) => {
|
window.addEventListener("unhandledrejection", (event) => {
|
||||||
if (
|
if (
|
||||||
event.reason?.message?.includes("Importing a module script failed") ||
|
event.reason?.message?.includes("Importing a module script failed") ||
|
||||||
event.reason?.message?.includes(
|
event.reason?.message?.includes("Failed to fetch dynamically imported module") ||
|
||||||
"Failed to fetch dynamically imported module"
|
event.reason?.message?.includes("error loading dynamically imported module")
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleChunkError("unhandled rejection");
|
handleChunkError("unhandled rejection");
|
||||||
|
|||||||
@@ -19,13 +19,14 @@ import { SITE_CONFIG, type SiteId } from "./site-context";
|
|||||||
const ALL_SITES: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo", "nook"];
|
const ALL_SITES: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo", "nook"];
|
||||||
|
|
||||||
describe("NAV_CONFIG — per-site link sets", () => {
|
describe("NAV_CONFIG — per-site link sets", () => {
|
||||||
it("main → Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn", () => {
|
it("main → Home, Blog, Downloads, Resume, Contact, Privacy, GitHub, LinkedIn", () => {
|
||||||
expect(navLabelsFor("main")).toEqual([
|
expect(navLabelsFor("main")).toEqual([
|
||||||
"Home",
|
"Home",
|
||||||
"Blog",
|
"Blog",
|
||||||
"Downloads",
|
"Downloads",
|
||||||
"Resume",
|
"Resume",
|
||||||
"Contact",
|
"Contact",
|
||||||
|
"Privacy",
|
||||||
"GitHub",
|
"GitHub",
|
||||||
"LinkedIn"
|
"LinkedIn"
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export const NAV_CONFIG: Record<SiteId, NavItem[]> = {
|
|||||||
{ label: "Downloads", href: "/downloads", icon: "downloads" },
|
{ label: "Downloads", href: "/downloads", icon: "downloads" },
|
||||||
{ label: "Resume", href: "/resume", icon: "resume" },
|
{ label: "Resume", href: "/resume", icon: "resume" },
|
||||||
{ label: "Contact", href: "/contact", icon: "contact" },
|
{ label: "Contact", href: "/contact", icon: "contact" },
|
||||||
|
{ label: "Privacy", href: "/privacy-policy", icon: "privacy" },
|
||||||
{
|
{
|
||||||
label: "GitHub",
|
label: "GitHub",
|
||||||
href: "https://github.com/MikeFreno/",
|
href: "https://github.com/MikeFreno/",
|
||||||
|
|||||||
@@ -40,14 +40,15 @@ describe("generateSitemap", () => {
|
|||||||
expect(locs).toContain("https://freno.me/login");
|
expect(locs).toContain("https://freno.me/login");
|
||||||
expect(locs).toContain("https://freno.me/resume");
|
expect(locs).toContain("https://freno.me/resume");
|
||||||
expect(locs).toContain("https://freno.me/downloads");
|
expect(locs).toContain("https://freno.me/downloads");
|
||||||
|
expect(locs).toContain("https://freno.me/privacy-policy");
|
||||||
|
|
||||||
expect(locs.length).toBe(6);
|
expect(locs.length).toBe(7);
|
||||||
|
|
||||||
expect(xml).toContain("</urlset>");
|
expect(xml).toContain("</urlset>");
|
||||||
const urlOpens = (xml.match(/<url>/g) || []).length;
|
const urlOpens = (xml.match(/<url>/g) || []).length;
|
||||||
const urlCloses = (xml.match(/<\/url>/g) || []).length;
|
const urlCloses = (xml.match(/<\/url>/g) || []).length;
|
||||||
expect(urlOpens).toBe(urlCloses);
|
expect(urlOpens).toBe(urlCloses);
|
||||||
expect(urlOpens).toBe(6);
|
expect(urlOpens).toBe(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("generates valid parseable XML for lineage site", () => {
|
it("generates valid parseable XML for lineage site", () => {
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
|
|||||||
{ path: "/contact", changefreq: "monthly", priority: 0.7 },
|
{ path: "/contact", changefreq: "monthly", priority: 0.7 },
|
||||||
{ path: "/login", changefreq: "monthly", priority: 0.5 },
|
{ path: "/login", changefreq: "monthly", priority: 0.5 },
|
||||||
{ path: "/resume", changefreq: "yearly", priority: 0.6 },
|
{ path: "/resume", changefreq: "yearly", priority: 0.6 },
|
||||||
{ path: "/downloads", changefreq: "weekly", priority: 0.8 }
|
{ path: "/downloads", changefreq: "weekly", priority: 0.8 },
|
||||||
|
{ path: "/privacy-policy", changefreq: "yearly", priority: 0.4 }
|
||||||
],
|
],
|
||||||
|
|
||||||
// ── Subdomain sites ──────────────────────────────────────────────────
|
// ── Subdomain sites ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import { HttpStatusCode } from "@solidjs/start";
|
import { HttpHeader, HttpStatusCode } from "@solidjs/start";
|
||||||
import { useLocation, useNavigate } from "@solidjs/router";
|
import { useLocation, useNavigate } from "@solidjs/router";
|
||||||
import { createSignal, onCleanup, onMount, Show } from "solid-js";
|
import { createSignal, onCleanup, onMount, Show } from "solid-js";
|
||||||
import { TerminalErrorPage } from "~/components/TerminalErrorPage";
|
import { TerminalErrorPage } from "~/components/TerminalErrorPage";
|
||||||
@@ -90,6 +90,14 @@ export default function NotFound() {
|
|||||||
description="404 - Page not found. The page you're looking for doesn't exist."
|
description="404 - Page not found. The page you're looking for doesn't exist."
|
||||||
/>
|
/>
|
||||||
<HttpStatusCode code={404} />
|
<HttpStatusCode code={404} />
|
||||||
|
{/* Cache 404/fallback responses at the edge (Vercel caches 404s) so
|
||||||
|
bots/monitors that probe dead paths don't re-render the full page
|
||||||
|
on every request. Function headers override vercel.json here. */}
|
||||||
|
<HttpHeader name="Cache-Control" value="public, max-age=0" />
|
||||||
|
<HttpHeader
|
||||||
|
name="CDN-Cache-Control"
|
||||||
|
value="public, s-maxage=300, stale-while-revalidate=86400"
|
||||||
|
/>
|
||||||
<TerminalErrorPage
|
<TerminalErrorPage
|
||||||
errorContent={errorContent}
|
errorContent={errorContent}
|
||||||
quickActions={quickActions}
|
quickActions={quickActions}
|
||||||
|
|||||||
23
src/routes/api/health.ts
Normal file
23
src/routes/api/health.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight uptime/monitoring probe.
|
||||||
|
*
|
||||||
|
* Returns a tiny 200 that the edge CDN caches (`CDN-Cache-Control`), so
|
||||||
|
* health checks (Sentry uptime monitors, external monitors, `curl`) cost a
|
||||||
|
* fraction of a rendered page instead of a full SSR response. Point monitors
|
||||||
|
* at `https://freno.me/api/health`.
|
||||||
|
*/
|
||||||
|
export async function GET(_event: APIEvent) {
|
||||||
|
return new Response(JSON.stringify({ status: "ok" }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
// Browser: always revalidate (tiny body, no reason to cache).
|
||||||
|
"Cache-Control": "public, max-age=0",
|
||||||
|
// Edge CDN: serve from cache for 1 min, then stale-while-revalidate
|
||||||
|
// for a day so repeated probe hits never touch the origin.
|
||||||
|
"CDN-Cache-Control": "public, s-maxage=60, stale-while-revalidate=86400"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
137
src/routes/api/the-nook/report.test.ts
Normal file
137
src/routes/api/the-nook/report.test.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
||||||
|
|
||||||
|
// The route's core contracts:
|
||||||
|
// 1. A valid report is emailed to the owner with the machine snapshot
|
||||||
|
// and description, HTML-escaped.
|
||||||
|
// 2. Invalid payloads never reach the mailer.
|
||||||
|
// 3. The rate limiter halts floods per fingerprint (falling back to IP).
|
||||||
|
// sendEmail and the limiter are mocked so the test sends no real mail.
|
||||||
|
|
||||||
|
const sentEmails: { to: string; subject: string; html: string }[] = [];
|
||||||
|
const tokenAnswers: { allowed: boolean; retryAfterSec?: number }[] = [];
|
||||||
|
|
||||||
|
mock.module("~/server/email", () => ({
|
||||||
|
default: async (to: string, subject: string, html: string) => {
|
||||||
|
sentEmails.push({ to, subject, html });
|
||||||
|
return { success: true, messageId: "test" };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("~/server/bug-report-rate-limit", () => ({
|
||||||
|
takeBugReportToken: () => tokenAnswers.shift() ?? { allowed: true }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { POST } = await import("./report");
|
||||||
|
|
||||||
|
/** A minimal APIEvent double: JSON body plus optional headers. */
|
||||||
|
function request(body: unknown, headers: Record<string, string> = {}) {
|
||||||
|
return {
|
||||||
|
request: {
|
||||||
|
json: async () => body,
|
||||||
|
headers: new Headers(headers)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPayload = {
|
||||||
|
appVersion: "The Nook 0.2.0 (12)",
|
||||||
|
title: "Island flickers",
|
||||||
|
description: "It happened <twice> & stayed",
|
||||||
|
contact: "",
|
||||||
|
machine: {
|
||||||
|
macOS: "macOS 15.5",
|
||||||
|
model: "Mac15,6",
|
||||||
|
cpu: "Apple M3 Pro",
|
||||||
|
memoryGB: 36,
|
||||||
|
freeDiskGB: 100,
|
||||||
|
locale: "en_US"
|
||||||
|
},
|
||||||
|
displays: [
|
||||||
|
{
|
||||||
|
name: "Built-in",
|
||||||
|
boundsPx: "3456×2234 pt",
|
||||||
|
scale: "2.0x",
|
||||||
|
hz: "120",
|
||||||
|
builtin: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DELL U2723QE",
|
||||||
|
boundsPx: "3008×1692 pt",
|
||||||
|
scale: "1.0x",
|
||||||
|
hz: "120",
|
||||||
|
builtin: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("POST /api/the-nook/report", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sentEmails.length = 0;
|
||||||
|
tokenAnswers.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emails the owner a report containing the snapshot, escaped", async () => {
|
||||||
|
const res = await POST(request(validPayload));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ success: true });
|
||||||
|
|
||||||
|
expect(sentEmails).toHaveLength(1);
|
||||||
|
const email = sentEmails[0]!;
|
||||||
|
expect(email.subject).toBe("The Nook bug report: Island flickers");
|
||||||
|
expect(email.to).toBe("michael@freno.me");
|
||||||
|
for (const fact of ["Mac15,6", "Apple M3 Pro", "macOS 15.5", "36"]) {
|
||||||
|
expect(email.html).toContain(fact);
|
||||||
|
}
|
||||||
|
expect(email.html).toContain("Built-in");
|
||||||
|
expect(email.html).toContain("3456×2234 pt");
|
||||||
|
expect(email.html).toContain("DELL U2723QE");
|
||||||
|
expect(email.html).toContain("3008×1692 pt");
|
||||||
|
expect(email.html).toContain("<b>builtin:</b> false");
|
||||||
|
expect(email.html).toContain("It happened <twice> & stayed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing, empty, or oversized title", async () => {
|
||||||
|
const empty = await POST(request({ ...validPayload, title: " " }));
|
||||||
|
expect(empty.status).toBe(400);
|
||||||
|
const missing = await POST(
|
||||||
|
request({ ...validPayload, title: undefined })
|
||||||
|
);
|
||||||
|
expect(missing.status).toBe(400);
|
||||||
|
const tooLong = await POST(
|
||||||
|
request({ ...validPayload, title: "x".repeat(201) })
|
||||||
|
);
|
||||||
|
expect(tooLong.status).toBe(400);
|
||||||
|
expect(sentEmails).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed reply-to contact", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
request({ ...validPayload, contact: "not-an-email" })
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(sentEmails).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects more than eight displays", async () => {
|
||||||
|
const displays = Array.from({ length: 9 }, () => validPayload.displays[0]!);
|
||||||
|
const res = await POST(request({ ...validPayload, displays }));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(sentEmails).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 429 with Retry-After when the limiter denies the token", async () => {
|
||||||
|
tokenAnswers.push({ allowed: false, retryAfterSec: 3600 });
|
||||||
|
const res = await POST(request(validPayload));
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
expect(res.headers.get("Retry-After")).toBe("3600");
|
||||||
|
expect(sentEmails).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still succeeds when no fingerprint header exists", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
request(validPayload, { "x-forwarded-for": "203.0.113.9, 10.0.0.1" })
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(sentEmails).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
131
src/routes/api/the-nook/report.ts
Normal file
131
src/routes/api/the-nook/report.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import sendEmail from "~/server/email";
|
||||||
|
import { CONTACT_RECIPIENT_EMAIL } from "~/lib/contact-config";
|
||||||
|
import { takeBugReportToken } from "~/server/bug-report-rate-limit";
|
||||||
|
import { json, error } from "./_lib";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/the-nook/report
|
||||||
|
* Body: { appVersion, title, description, contact?, machine, displays }
|
||||||
|
*
|
||||||
|
* Emails the developer a Nook bug report in plain prose — each field on
|
||||||
|
* its own line. The Mac client collects the machine facts itself (model,
|
||||||
|
* CPU, memory, every display); styling is irrelevant, information is the
|
||||||
|
* product here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const displaySchema = z.object({
|
||||||
|
name: z.string().max(200).default(""),
|
||||||
|
boundsPx: z.string().max(100).default(""),
|
||||||
|
scale: z.string().max(20).default(""),
|
||||||
|
hz: z.string().max(20).default(""),
|
||||||
|
builtin: z.boolean().default(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
const reportSchema = z.object({
|
||||||
|
title: z.string().trim().min(1).max(200),
|
||||||
|
description: z.string().trim().max(20_000).default(""),
|
||||||
|
contact: z.string().trim().max(254).default(""),
|
||||||
|
appVersion: z.string().trim().max(100).default(""),
|
||||||
|
machine: z
|
||||||
|
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
|
||||||
|
.default({}),
|
||||||
|
displays: z.array(displaySchema).max(8).default([])
|
||||||
|
});
|
||||||
|
|
||||||
|
type Report = z.infer<typeof reportSchema>;
|
||||||
|
|
||||||
|
const contactRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
// One escaping contract for every interpolated string (many call sites).
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBody(report: Report): string {
|
||||||
|
const machineLines = Object.entries(report.machine).map(
|
||||||
|
([key, value]) => `<b>${escapeHtml(key)}:</b> ${escapeHtml(String(value))}`
|
||||||
|
);
|
||||||
|
const displayLines = report.displays.map((display) =>
|
||||||
|
[
|
||||||
|
`<b>name:</b> ${escapeHtml(display.name)}`,
|
||||||
|
`<b>boundsPx:</b> ${escapeHtml(display.boundsPx)}`,
|
||||||
|
`<b>scale:</b> ${escapeHtml(display.scale)}`,
|
||||||
|
`<b>hz:</b> ${escapeHtml(display.hz)}`,
|
||||||
|
`<b>builtin:</b> ${escapeHtml(String(display.builtin))}`
|
||||||
|
].join(" · ")
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
`<h2>The Nook bug report</h2>`,
|
||||||
|
`<h3>“${escapeHtml(report.title)}”</h3>`,
|
||||||
|
report.description
|
||||||
|
? `<h3>What happened</h3><div>${escapeHtml(report.description).replace(/\r\n|\n|\r/g, "<br>")}</div>`
|
||||||
|
: "",
|
||||||
|
report.contact && contactRe.test(report.contact)
|
||||||
|
? `<p><b>Reply-to:</b> ${escapeHtml(report.contact)}</p>`
|
||||||
|
: "",
|
||||||
|
`<h3>Snapshot</h3>`,
|
||||||
|
report.appVersion ? `<p><b>${escapeHtml(report.appVersion)}</b></p>` : "",
|
||||||
|
machineLines.length > 0 ? `<p>${machineLines.join("<br>")}</p>` : "",
|
||||||
|
displayLines.length > 0
|
||||||
|
? `<h3>Displays</h3><div>${displayLines.map((line) => `<div>${line}</div>`).join("")}</div>`
|
||||||
|
: ""
|
||||||
|
]
|
||||||
|
.filter((chunk) => chunk !== "")
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(event: APIEvent): Promise<Response> {
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = await event.request.json();
|
||||||
|
} catch {
|
||||||
|
return error("Invalid JSON", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = reportSchema.safeParse(raw);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return error("Invalid report", 400);
|
||||||
|
}
|
||||||
|
const report = parsed.data;
|
||||||
|
if (report.contact && !contactRe.test(report.contact)) {
|
||||||
|
return error("Invalid reply-to email", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fingerprint = (
|
||||||
|
event.request.headers.get("x-nook-fingerprint") ?? ""
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const forwarded = event.request.headers.get("x-forwarded-for");
|
||||||
|
const rateKey =
|
||||||
|
fingerprint ||
|
||||||
|
(forwarded ? forwarded.split(",")[0]!.trim() : "unknown");
|
||||||
|
const token = takeBugReportToken(rateKey);
|
||||||
|
if (!token.allowed) {
|
||||||
|
return new Response(JSON.stringify({ error: "Too many reports" }), {
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Retry-After": String(token.retryAfterSec ?? 3600)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = `The Nook bug report: ${report.title.slice(0, 100)}`;
|
||||||
|
const result = await sendEmail(
|
||||||
|
CONTACT_RECIPIENT_EMAIL,
|
||||||
|
subject,
|
||||||
|
renderBody(report)
|
||||||
|
);
|
||||||
|
if (!result.success) {
|
||||||
|
return error("Failed to send report", 500);
|
||||||
|
}
|
||||||
|
return json({ success: true });
|
||||||
|
}
|
||||||
79
src/routes/api/the-nook/resend-license.test.ts
Normal file
79
src/routes/api/the-nook/resend-license.test.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
||||||
|
|
||||||
|
// The route's two core contracts:
|
||||||
|
// 1. A license key is emailed only when the requested email owns one.
|
||||||
|
// 2. The HTTP response is identical whether or not a license exists —
|
||||||
|
// callers can't learn whether an email is registered.
|
||||||
|
// NookConnectionFactory and the nook helper module are mocked so the test
|
||||||
|
// needs no real DB, env, or outbound email.
|
||||||
|
|
||||||
|
let licenseRows: { key: string }[] = [];
|
||||||
|
const sentEmails: { to: string; key: string }[] = [];
|
||||||
|
const queries: string[] = [];
|
||||||
|
|
||||||
|
const fakeConn = {
|
||||||
|
execute: async (q: { sql: string; args?: unknown[] }) => {
|
||||||
|
queries.push(q.sql);
|
||||||
|
if (q.sql.includes("FROM licenses")) return { rows: licenseRows };
|
||||||
|
return { rows: [] };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module("~/server/db-connections", () => ({
|
||||||
|
NookConnectionFactory: () => fakeConn
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("~/server/nook", () => ({
|
||||||
|
nookSchemaBootstrap: Promise.resolve(),
|
||||||
|
emailLicenseKey: async (to: string, key: string) => {
|
||||||
|
sentEmails.push({ to, key });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { POST } = await import("./resend-license");
|
||||||
|
|
||||||
|
function request(body: unknown): any {
|
||||||
|
return { request: { json: async () => body } };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("POST /api/the-nook/resend-license", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
licenseRows = [];
|
||||||
|
sentEmails.length = 0;
|
||||||
|
queries.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid email", async () => {
|
||||||
|
const res = await POST(request({ email: "not-an-email" }));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(await res.json()).toEqual({ error: "Invalid email" });
|
||||||
|
expect(sentEmails).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emails the stored key when a license exists for the email", async () => {
|
||||||
|
licenseRows = [{ key: "NOOK-ABC123" }];
|
||||||
|
const res = await POST(request({ email: "USER@Example.com " }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ success: true });
|
||||||
|
expect(sentEmails).toEqual([{ to: "user@example.com", key: "NOOK-ABC123" }]);
|
||||||
|
expect(queries.some((q) => q.toLowerCase().includes("lower(email)"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends nothing when no license exists, but still succeeds", async () => {
|
||||||
|
const res = await POST(request({ email: "nobody@example.com" }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ success: true });
|
||||||
|
expect(sentEmails).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an identical response whether or not a license exists", async () => {
|
||||||
|
const withLicense = await POST(request({ email: "buyer@example.com" }));
|
||||||
|
licenseRows = [{ key: "NOOK-XYZ" }];
|
||||||
|
const withoutLicense = await POST(request({ email: "buyer@example.com" }));
|
||||||
|
|
||||||
|
expect(withLicense.status).toBe(withoutLicense.status);
|
||||||
|
expect(await withLicense.text()).toBe(await withoutLicense.text());
|
||||||
|
});
|
||||||
|
});
|
||||||
42
src/routes/api/the-nook/resend-license.ts
Normal file
42
src/routes/api/the-nook/resend-license.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
|
import { NookConnectionFactory } from "~/server/db-connections";
|
||||||
|
import { nookSchemaBootstrap, emailLicenseKey } from "~/server/nook";
|
||||||
|
import { json, error } from "./_lib";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/the-nook/resend-license
|
||||||
|
* Body: { email }
|
||||||
|
*
|
||||||
|
* Re-sends the most recent license key to a buyer who lost it. If the email
|
||||||
|
* owns a license, the key is emailed; otherwise nothing is sent. The response
|
||||||
|
* is identical either way so callers can't learn whether an email has a
|
||||||
|
* license — only validation failures differ.
|
||||||
|
*/
|
||||||
|
export async function POST(event: APIEvent) {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await event.request.json();
|
||||||
|
} catch {
|
||||||
|
return error("Invalid JSON", 400);
|
||||||
|
}
|
||||||
|
const b = (body ?? {}) as Record<string, unknown>;
|
||||||
|
const email = typeof b.email === "string" ? b.email.trim().toLowerCase() : "";
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
return error("Invalid email", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await nookSchemaBootstrap;
|
||||||
|
const conn = NookConnectionFactory();
|
||||||
|
|
||||||
|
const res = await conn.execute({
|
||||||
|
sql: "SELECT key FROM licenses WHERE lower(email) = lower(?) ORDER BY created_at DESC LIMIT 1",
|
||||||
|
args: [email]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.rows.length > 0) {
|
||||||
|
const { key } = res.rows[0] as { key: string };
|
||||||
|
await emailLicenseKey(email, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ success: true });
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { APIEvent } from "@solidjs/start/server";
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
import { env } from "~/env/server";
|
import { env } from "~/env/server";
|
||||||
import { NookConnectionFactory } from "~/server/db-connections";
|
import { NookConnectionFactory } from "~/server/db-connections";
|
||||||
import { nookSchemaBootstrap, issueLicense } from "~/server/nook";
|
import { nookSchemaBootstrap, issueLicense, emailLicenseKey } from "~/server/nook";
|
||||||
import { json } from "../_lib";
|
import { json } from "../_lib";
|
||||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ import { createHmac, timingSafeEqual } from "node:crypto";
|
|||||||
interface StripeSessionCompleted {
|
interface StripeSessionCompleted {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
customer_details?: { email?: string };
|
data?: { object?: { id?: string; customer_details?: { email?: string } } };
|
||||||
}
|
}
|
||||||
|
|
||||||
function verifyStripeSignature(rawBody: string, signatureHeader: string): boolean {
|
function verifyStripeSignature(rawBody: string, signatureHeader: string): boolean {
|
||||||
@@ -46,31 +46,6 @@ function verifyStripeSignature(rawBody: string, signatureHeader: string): boolea
|
|||||||
return timingSafeEqual(provided, expectedBuffer);
|
return timingSafeEqual(provided, expectedBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function emailLicenseKey(to: string, licenseKey: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await fetch("https://api.brevo.com/v3/smtp/email", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"api-key": env.SENDINBLUE_KEY,
|
|
||||||
"content-type": "application/json",
|
|
||||||
accept: "application/json"
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
sender: { email: env.EMAIL_FROM },
|
|
||||||
to: [{ email: to }],
|
|
||||||
subject: "Your The Nook license key",
|
|
||||||
textContent:
|
|
||||||
`Your The Nook license key is:\n\n${licenseKey}\n\n` +
|
|
||||||
`Open The Nook, go to Settings, and enter this key to activate.\n` +
|
|
||||||
`You can activate up to 3 devices.\n\n— The Nook`
|
|
||||||
})
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Purchase must not fail because email delivery did.
|
|
||||||
console.error("Failed to email The Nook license key:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(event: APIEvent) {
|
export async function POST(event: APIEvent) {
|
||||||
const rawBody = await event.request.text();
|
const rawBody = await event.request.text();
|
||||||
const signatureHeader = event.request.headers.get("Stripe-Signature");
|
const signatureHeader = event.request.headers.get("Stripe-Signature");
|
||||||
@@ -94,8 +69,8 @@ export async function POST(event: APIEvent) {
|
|||||||
return json({ received: true });
|
return json({ received: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = body.id;
|
const sessionId = body.data?.object?.id;
|
||||||
const email = body.customer_details?.email;
|
const email = body.data?.object?.customer_details?.email;
|
||||||
if (!sessionId || !email) {
|
if (!sessionId || !email) {
|
||||||
console.error("checkout.session.completed missing id or email:", rawBody);
|
console.error("checkout.session.completed missing id or email:", rawBody);
|
||||||
return json({ received: true });
|
return json({ received: true });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useSearchParams
|
useSearchParams
|
||||||
} from "@solidjs/router";
|
} from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import { createAsync } from "@solidjs/router";
|
import { createAsync } from "@solidjs/router";
|
||||||
import { getRequestEvent } from "solid-js/web";
|
import { getRequestEvent } from "solid-js/web";
|
||||||
import AuthenticatedLike from "~/components/blog/AuthenticatedLike";
|
import AuthenticatedLike from "~/components/blog/AuthenticatedLike";
|
||||||
@@ -327,6 +328,7 @@ export default function PostPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title={p().title.replaceAll("_", " ")}
|
title={p().title.replaceAll("_", " ")}
|
||||||
description={
|
description={
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Show } from "solid-js";
|
import { Show } from "solid-js";
|
||||||
import { useSearchParams, A, query } from "@solidjs/router";
|
import { useSearchParams, A, query } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import { createAsync } from "@solidjs/router";
|
import { createAsync } from "@solidjs/router";
|
||||||
import PostSortingSelect from "~/components/blog/PostSortingSelect";
|
import PostSortingSelect from "~/components/blog/PostSortingSelect";
|
||||||
import TagSelector from "~/components/blog/TagSelector";
|
import TagSelector from "~/components/blog/TagSelector";
|
||||||
@@ -90,6 +91,7 @@ export default function BlogIndex() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Blog"
|
title="Blog"
|
||||||
description="Technical blog posts about web development, programming, and software engineering."
|
description="Technical blog posts about web development, programming, and software engineering."
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useSearchParams } from "@solidjs/router";
|
|||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import RevealDropDown from "~/components/RevealDropDown";
|
import RevealDropDown from "~/components/RevealDropDown";
|
||||||
import { ContactForm } from "~/components/ContactForm";
|
import { ContactForm } from "~/components/ContactForm";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import { buildSubdomainUrl } from "~/lib/site-context";
|
import { buildSubdomainUrl } from "~/lib/site-context";
|
||||||
import { useSite } from "~/context/SiteContext";
|
import { useSite } from "~/context/SiteContext";
|
||||||
import NessaContactPage from "./nessa/contact";
|
import NessaContactPage from "./nessa/contact";
|
||||||
@@ -125,7 +126,9 @@ function MainContactPage() {
|
|||||||
const viewer = () => searchParams.viewer ?? "default";
|
const viewer = () => searchParams.viewer ?? "default";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContactForm
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
||||||
|
<ContactForm
|
||||||
subline={
|
subline={
|
||||||
<Show when={viewer() !== "lineage"}>
|
<Show when={viewer() !== "lineage"}>
|
||||||
(for this website or any of my apps...)
|
(for this website or any of my apps...)
|
||||||
@@ -134,6 +137,7 @@ function MainContactPage() {
|
|||||||
>
|
>
|
||||||
<LineageContactQuestions />
|
<LineageContactQuestions />
|
||||||
</ContactForm>
|
</ContactForm>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { createSignal, onMount, onCleanup, Switch, Match } from "solid-js";
|
import { createSignal, onMount, onCleanup, Switch, Match } from "solid-js";
|
||||||
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
|
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
|
||||||
@@ -71,6 +72,7 @@ function MainDownloadsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Downloads"
|
title="Downloads"
|
||||||
description="Download The Nook, InputHalo, Gaze, Life and Lineage, Shapes with Abigail, and Cork. Available on macOS, iOS, and Android."
|
description="Download The Nook, InputHalo, Gaze, Life and Lineage, Shapes with Abigail, and Cork. Available on macOS, iOS, and Android."
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ContactForm } from "~/components/ContactForm";
|
import { ContactForm } from "~/components/ContactForm";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +20,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
|
|||||||
export default function GazeContactPage() {
|
export default function GazeContactPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
<ContactForm />
|
<ContactForm />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
||||||
import Button from "~/components/ui/Button";
|
import Button from "~/components/ui/Button";
|
||||||
@@ -48,6 +49,7 @@ export default function GazeDownloadsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Download Gaze"
|
title="Download Gaze"
|
||||||
description="Download Gaze for macOS — menu bar app for eye and posture health."
|
description="Download Gaze for macOS — menu bar app for eye and posture health."
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createSignal, For } from "solid-js";
|
import { createSignal, For } from "solid-js";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
||||||
import Button from "~/components/ui/Button";
|
import Button from "~/components/ui/Button";
|
||||||
@@ -60,6 +61,7 @@ export default function GazeLanding() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Home"
|
title="Home"
|
||||||
description="Gaze is a macOS menu bar app for eye and posture health — blink reminders, 20-20-20 breaks, posture check-ins, and customizable reminder intervals."
|
description="Gaze is a macOS menu bar app for eye and posture health — blink reminders, 20-20-20 breaks, posture check-ins, and customizable reminder intervals."
|
||||||
|
|||||||
@@ -15,11 +15,13 @@
|
|||||||
*/
|
*/
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
export default function GazePrivacyPolicy() {
|
export default function GazePrivacyPolicy() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Privacy Policy"
|
title="Privacy Policy"
|
||||||
description="Privacy policy for Gaze, a macOS eye health reminder app."
|
description="Privacy policy for Gaze, a macOS eye health reminder app."
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Switch, Match, type JSX } from "solid-js";
|
import { Switch, Match, type JSX } from "solid-js";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import { DarkModeToggle } from "~/components/DarkModeToggle";
|
import { DarkModeToggle } from "~/components/DarkModeToggle";
|
||||||
import { Typewriter } from "~/components/Typewriter";
|
import { Typewriter } from "~/components/Typewriter";
|
||||||
import { useSite } from "~/context/SiteContext";
|
import { useSite } from "~/context/SiteContext";
|
||||||
|
import { buildSubdomainUrl } from "~/lib/site-context";
|
||||||
import NessaLanding from "./nessa";
|
import NessaLanding from "./nessa";
|
||||||
import LineageLanding from "./lineage";
|
import LineageLanding from "./lineage";
|
||||||
import GazeLanding from "./gaze";
|
import GazeLanding from "./gaze";
|
||||||
@@ -56,6 +58,7 @@ export default function Home(): JSX.Element {
|
|||||||
function MainHome(): JSX.Element {
|
function MainHome(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Home"
|
title="Home"
|
||||||
description="Michael Freno - Software Engineer based in Brooklyn, NY"
|
description="Michael Freno - Software Engineer based in Brooklyn, NY"
|
||||||
@@ -101,6 +104,32 @@ function MainHome(): JSX.Element {
|
|||||||
</Typewriter>
|
</Typewriter>
|
||||||
<div class="pt-8 text-center">
|
<div class="pt-8 text-center">
|
||||||
<div class="pb-4">Some of my recent projects:</div>
|
<div class="pb-4">Some of my recent projects:</div>
|
||||||
|
{/* The Nook */}
|
||||||
|
<div class="border-surface0 mb-2 flex w-full flex-col gap-2 rounded-md border-2 p-4 text-center">
|
||||||
|
<div>My macOS notch utility:</div>
|
||||||
|
<a
|
||||||
|
href={buildSubdomainUrl("nook")}
|
||||||
|
class="text-blue hover-underline-animation mx-auto w-fit"
|
||||||
|
>
|
||||||
|
The Nook
|
||||||
|
</a>
|
||||||
|
<div class="mx-auto w-full max-w-4xl overflow-hidden rounded-lg">
|
||||||
|
<video
|
||||||
|
src="/nook/demo-expansion.mp4"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="pt-2 text-left text-sm">
|
||||||
|
A native macOS island that lives in the notch: orchestration for
|
||||||
|
a fleet of coding agents, fan curve control, thermal gauges,
|
||||||
|
calendar and camera modules. One-time purchase - no
|
||||||
|
subscription, ever.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex flex-col items-center gap-2 xl:flex-row xl:items-start xl:justify-center">
|
<div class="flex flex-col items-center gap-2 xl:flex-row xl:items-start xl:justify-center">
|
||||||
{/* FlexLöve */}
|
{/* FlexLöve */}
|
||||||
<div class="border-surface0 flex w-full flex-col rounded-md border-2 p-4 text-center">
|
<div class="border-surface0 flex w-full flex-col rounded-md border-2 p-4 text-center">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ContactForm } from "~/components/ContactForm";
|
import { ContactForm } from "~/components/ContactForm";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +20,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
|
|||||||
export default function InputHaloContactPage() {
|
export default function InputHaloContactPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
<ContactForm />
|
<ContactForm />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
||||||
import Button from "~/components/ui/Button";
|
import Button from "~/components/ui/Button";
|
||||||
@@ -51,6 +52,7 @@ export default function InputHaloDownloadsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Download InputHalo"
|
title="Download InputHalo"
|
||||||
description="Download InputHalo for macOS — menu bar app for keyboard, mouse, and scroll visualization."
|
description="Download InputHalo for macOS — menu bar app for keyboard, mouse, and scroll visualization."
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import { For, createSignal } from "solid-js";
|
import { For, createSignal } from "solid-js";
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import Button from "~/components/ui/Button";
|
import Button from "~/components/ui/Button";
|
||||||
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
||||||
@@ -98,6 +99,7 @@ export default function InputHaloLanding() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Home"
|
title="Home"
|
||||||
description="A polished macOS menu bar app that visualizes keyboard presses, mouse clicks, cursor halos, and scroll events on screen — for streamers, presenters, and developers."
|
description="A polished macOS menu bar app that visualizes keyboard presses, mouse clicks, cursor halos, and scroll events on screen — for streamers, presenters, and developers."
|
||||||
|
|||||||
@@ -23,11 +23,13 @@
|
|||||||
*/
|
*/
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
export default function InputHaloPrivacyPolicy() {
|
export default function InputHaloPrivacyPolicy() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Privacy Policy"
|
title="Privacy Policy"
|
||||||
description="Privacy policy for InputHalo, a macOS menu bar productivity app."
|
description="Privacy policy for InputHalo, a macOS menu bar productivity app."
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ContactForm } from "~/components/ContactForm";
|
import { ContactForm } from "~/components/ContactForm";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import { LineageContactQuestions } from "../contact";
|
import { LineageContactQuestions } from "../contact";
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ import { LineageContactQuestions } from "../contact";
|
|||||||
export default function LineageContactPage() {
|
export default function LineageContactPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
<ContactForm>
|
<ContactForm>
|
||||||
<LineageContactQuestions />
|
<LineageContactQuestions />
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
* the form posts to the correct tRPC mutation (Lineage-branded email).
|
* the form posts to the correct tRPC mutation (Lineage-branded email).
|
||||||
*/
|
*/
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import DeletionForm from "~/components/DeletionForm";
|
import DeletionForm from "~/components/DeletionForm";
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +46,7 @@ import {
|
|||||||
export default function LineageDeletionPage() {
|
export default function LineageDeletionPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
<div class="pt-20">
|
<div class="pt-20">
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { createSignal, onMount, onCleanup } from "solid-js";
|
import { createSignal, onMount, onCleanup } from "solid-js";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
|
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
|
||||||
import Button from "~/components/ui/Button";
|
import Button from "~/components/ui/Button";
|
||||||
@@ -71,6 +72,7 @@ export default function LineageDownloadsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
||||||
|
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import SimpleParallax from "~/components/SimpleParallax";
|
import SimpleParallax from "~/components/SimpleParallax";
|
||||||
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
export default function LineageLandingPage() {
|
export default function LineageLandingPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
<SimpleParallax>
|
<SimpleParallax>
|
||||||
|
|||||||
@@ -17,11 +17,13 @@
|
|||||||
*/
|
*/
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
export default function LineagePrivacyPolicy() {
|
export default function LineagePrivacyPolicy() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Privacy Policy"
|
title="Privacy Policy"
|
||||||
description="Privacy policy for Life and Lineage mobile game, outlining data collection, usage, and user rights."
|
description="Privacy policy for Life and Lineage mobile game, outlining data collection, usage, and user rights."
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ContactForm } from "~/components/ContactForm";
|
import { ContactForm } from "~/components/ContactForm";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,6 +21,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
|
|||||||
export default function NessaContactPage() {
|
export default function NessaContactPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
<ContactForm />
|
<ContactForm />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
* Acceptance: `nessa.localhost:3000/deletion` renders the deletion form.
|
* Acceptance: `nessa.localhost:3000/deletion` renders the deletion form.
|
||||||
*/
|
*/
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import DeletionForm from "~/components/DeletionForm";
|
import DeletionForm from "~/components/DeletionForm";
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
export default function NessaDeletionPage() {
|
export default function NessaDeletionPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
<div class="pt-20">
|
<div class="pt-20">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
import { For, Show } from "solid-js";
|
import { For, Show } from "solid-js";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import { useDarkMode } from "~/context/darkMode";
|
import { useDarkMode } from "~/context/darkMode";
|
||||||
import { useSite } from "~/context/SiteContext";
|
import { useSite } from "~/context/SiteContext";
|
||||||
@@ -59,6 +60,7 @@ export default function NessaLanding() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead {...NESSA_LANDING_META} />
|
<PageHead {...NESSA_LANDING_META} />
|
||||||
|
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
|
|||||||
@@ -29,11 +29,13 @@
|
|||||||
*/
|
*/
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
export default function NessaPrivacyPolicy() {
|
export default function NessaPrivacyPolicy() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Privacy Policy"
|
title="Privacy Policy"
|
||||||
description="Privacy policy for Nessa, a community platform for clubs, challenges, and social features."
|
description="Privacy policy for Nessa, a community platform for clubs, challenges, and social features."
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ export default function NookCheckout() {
|
|||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ turnstileToken: turnstileToken() })
|
body: JSON.stringify({ turnstileToken: turnstileToken() })
|
||||||
});
|
});
|
||||||
const data = (await res.json()) as { checkoutUrl?: string; error?: string };
|
const data = (await res.json()) as {
|
||||||
|
checkoutUrl?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
if (!res.ok || !data.checkoutUrl) {
|
if (!res.ok || !data.checkoutUrl) {
|
||||||
setError(data.error ?? "Unable to start checkout. Please try again.");
|
setError(data.error ?? "Unable to start checkout. Please try again.");
|
||||||
return;
|
return;
|
||||||
@@ -66,20 +69,28 @@ export default function NookCheckout() {
|
|||||||
<>
|
<>
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Checkout"
|
title="Checkout"
|
||||||
description="Buy a The Nook license — $10 one-time for up to 3 devices."
|
description="Buy a The Nook license — $10 beta price (33% off) for up to 3 devices."
|
||||||
/>
|
/>
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
|
|
||||||
<div class="bg-base mx-auto max-w-lg px-4 py-16">
|
<div class="bg-base mx-auto max-w-lg px-4 py-16">
|
||||||
<div class="border-overlay0 bg-surface0 rounded-xl border p-8 shadow-2xl">
|
<div class="border-overlay0 bg-surface0 rounded-xl border p-8 shadow-2xl">
|
||||||
<h1 class="text-text mb-1 text-3xl font-bold">The Nook</h1>
|
<div class="mb-2 flex items-end gap-3">
|
||||||
<p class="text-subtext0 mb-6">Lifetime License — 3 devices</p>
|
<div class="text-text text-4xl font-extrabold">
|
||||||
|
$10
|
||||||
<div class="text-text mb-6 text-4xl font-extrabold">
|
<span class="text-subtext0 text-base font-normal"> one-time</span>
|
||||||
$10<span class="text-subtext0 text-base font-normal"> one-time</span>
|
</div>
|
||||||
|
<div class="text-subtext0 mb-1 text-2xl font-medium line-through">
|
||||||
|
$15
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mb-6 text-sm font-medium">
|
||||||
|
<span class="border-overlay0 bg-surface0 text-subtext0 inline-block rounded-full border px-3 py-1 text-xs font-semibold tracking-wide">
|
||||||
|
Beta pricing — 33% off
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
<ul class="text-subtext0 mb-6 space-y-2 text-sm">
|
<ul class="text-subtext0 mb-6 space-y-2 text-sm">
|
||||||
<li>• Activate on up to 3 of your own Macs</li>
|
<li>• Activate on up to 3 devices</li>
|
||||||
<li>• No subscription, ever</li>
|
<li>• No subscription, ever</li>
|
||||||
<li>• Key delivered by email + on this page</li>
|
<li>• Key delivered by email + on this page</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -97,11 +108,10 @@ export default function NookCheckout() {
|
|||||||
Pay $10
|
Pay $10
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{error() && (
|
{error() && <p class="text-red mt-4 text-sm">{error()}</p>}
|
||||||
<p class="text-red mt-4 text-sm">{error()}</p>
|
|
||||||
)}
|
|
||||||
<p class="text-subtext1 mt-4 text-xs">
|
<p class="text-subtext1 mt-4 text-xs">
|
||||||
Billed once through Stripe. License delivered immediately after payment.
|
Billed once through Stripe. License delivered immediately and by
|
||||||
|
email after payment.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,42 +1,64 @@
|
|||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
import FeatureCollage from "~/components/nook/FeatureCollage";
|
||||||
|
import FeatureBreakdowns from "~/components/nook/FeatureBreakdowns";
|
||||||
import Button from "~/components/ui/Button";
|
import Button from "~/components/ui/Button";
|
||||||
|
import Input from "~/components/ui/Input";
|
||||||
|
import { createSignal, Show } from "solid-js";
|
||||||
import { useDarkMode } from "~/context/darkMode";
|
import { useDarkMode } from "~/context/darkMode";
|
||||||
import { useSite } from "~/context/SiteContext";
|
import { useSite } from "~/context/SiteContext";
|
||||||
|
|
||||||
const NOOK_DOWNLOAD_URL = "https://freno.me/api/downloads/TheNook-0.2.0.zip";
|
/* The appcast's first enclosure always names the latest release dmg. */
|
||||||
|
async function downloadNook() {
|
||||||
const FEATURES = [
|
try {
|
||||||
{
|
const res = await fetch("/api/TheNook/appcast.xml");
|
||||||
title: "Agent orchestration",
|
const xml = await res.text();
|
||||||
body: "Orchestrate your coding agents in one native panel, with sessions that keep working while you do."
|
const url =
|
||||||
},
|
xml.match(/<enclosure url="([^"]+\.dmg)"/)?.[1] ??
|
||||||
{
|
xml.match(/<enclosure url="([^"]+\.zip)"/)?.[1];
|
||||||
title: "A beautiful native UI",
|
if (!url) throw new Error("no enclosure");
|
||||||
body: "Animated panels and buttery SwiftUI transitions, tuned to feel right at home on your Mac."
|
window.location.href = url;
|
||||||
},
|
} catch {
|
||||||
{
|
console.error("Could not resolve latest Nook download");
|
||||||
title: "Fan & thermal insight",
|
|
||||||
body: "Read and control system fans, watch temperatures, and keep performance predictable under load."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Private by design",
|
|
||||||
body: "Runs fully on your machine with no mandatory accounts. Your hardware data never leaves the device."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "One-time license",
|
|
||||||
body: "Pay once, activate on up to three of your own Macs. No subscriptions, no forced renewals."
|
|
||||||
}
|
}
|
||||||
] as const;
|
}
|
||||||
|
|
||||||
export default function NookLanding() {
|
export default function NookLanding() {
|
||||||
const site = useSite();
|
const site = useSite();
|
||||||
const { isDark } = useDarkMode();
|
const { isDark } = useDarkMode();
|
||||||
const brandColor = () =>
|
const brandColor = () =>
|
||||||
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
|
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
|
||||||
|
const [resendEmail, setResendEmail] = createSignal("");
|
||||||
|
const [resendSending, setResendSending] = createSignal(false);
|
||||||
|
const [resendSent, setResendSent] = createSignal(false);
|
||||||
|
const [resendError, setResendError] = createSignal(false);
|
||||||
|
|
||||||
|
const resendLicense = async () => {
|
||||||
|
if (resendSending()) return;
|
||||||
|
const email = resendEmail().trim();
|
||||||
|
if (!email) return;
|
||||||
|
setResendSending(true);
|
||||||
|
setResendSent(false);
|
||||||
|
setResendError(false);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/the-nook/resend-license", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ email })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("resend failed");
|
||||||
|
setResendSent(true);
|
||||||
|
} catch {
|
||||||
|
setResendError(true);
|
||||||
|
} finally {
|
||||||
|
setResendSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Home"
|
title="Home"
|
||||||
description="The Nook — a native macOS utility for coding-agent orchestration, fan and thermal control."
|
description="The Nook — a native macOS utility for coding-agent orchestration, fan and thermal control."
|
||||||
@@ -48,7 +70,9 @@ export default function NookLanding() {
|
|||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
|
|
||||||
{/* ── Hero ─────────────────────────────────────────────────────── */}
|
{/* ── Hero ─────────────────────────────────────────────────────── */}
|
||||||
<div class="relative flex min-h-screen flex-col overflow-hidden">
|
{/* -mt-14 pulls the hero under the sticky header; pt-14 re-clears
|
||||||
|
content, so the gradient fills the full viewport incl. the bar */}
|
||||||
|
<div class="relative -mt-14 flex min-h-svh flex-col overflow-hidden pt-14">
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-0"
|
class="fixed inset-0 z-0"
|
||||||
style={{
|
style={{
|
||||||
@@ -57,13 +81,13 @@ export default function NookLanding() {
|
|||||||
: "radial-gradient(ellipse at top, #d7eef5 0%, #f5f5f5 70%)"
|
: "radial-gradient(ellipse at top, #d7eef5 0%, #f5f5f5 70%)"
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div class="relative z-10 flex min-h-screen flex-col items-center justify-center px-4 py-24 text-center">
|
<div class="relative z-10 flex flex-1 flex-col items-center justify-center px-4 py-12 text-center sm:py-24">
|
||||||
<img
|
<img
|
||||||
src="/nook/icon.png"
|
src="/nook/icon.png"
|
||||||
alt="The Nook App Icon"
|
alt="The Nook App Icon"
|
||||||
height={128}
|
height={128}
|
||||||
width={128}
|
width={128}
|
||||||
class="mb-6 h-32 w-32 rounded-[22%] object-cover object-center shadow-2xl"
|
class="mb-4 h-24 w-24 rounded-[22%] object-cover object-center shadow-2xl sm:mb-6 sm:h-32 sm:w-32"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="text-text/90 mb-6 rounded-2xl px-5 py-3 text-sm font-semibold tracking-wide backdrop-blur-sm"
|
class="text-text/90 mb-6 rounded-2xl px-5 py-3 text-sm font-semibold tracking-wide backdrop-blur-sm"
|
||||||
@@ -74,15 +98,15 @@ export default function NookLanding() {
|
|||||||
>
|
>
|
||||||
The Nook
|
The Nook
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-text mb-4 text-5xl font-bold tracking-tight">
|
<h1 class="text-text mb-4 text-4xl font-bold tracking-tight sm:text-5xl">
|
||||||
Your Mac, under your control
|
Your Mac and Agents, under control
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-subtext0 mb-2 max-w-xl text-xl">
|
<p class="text-subtext0 mb-2 max-w-xl text-xl">
|
||||||
Agent orchestration, fan and thermal control — native macOS,
|
Agent orchestration, fan and thermal control in a beautiful native
|
||||||
one-time license.
|
UI.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-subtext1 mb-8 text-sm">
|
<p class="text-subtext1 mb-8 text-sm">
|
||||||
macOS 14+ · 14-day free trial · 3 devices
|
macOS 14+ · 14-day free trial · up to 3 devices
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-col items-center gap-4 sm:flex-row sm:space-x-4">
|
<div class="flex flex-col items-center gap-4 sm:flex-row sm:space-x-4">
|
||||||
@@ -90,7 +114,7 @@ export default function NookLanding() {
|
|||||||
variant="download"
|
variant="download"
|
||||||
size="lg"
|
size="lg"
|
||||||
color={brandColor()}
|
color={brandColor()}
|
||||||
onClick={() => (window.location.href = NOOK_DOWNLOAD_URL)}
|
onClick={downloadNook}
|
||||||
>
|
>
|
||||||
Download trial
|
Download trial
|
||||||
</Button>
|
</Button>
|
||||||
@@ -102,28 +126,31 @@ export default function NookLanding() {
|
|||||||
Buy
|
Buy
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-subtext1 mt-3 text-xs">
|
<div class="mt-3 flex flex-col items-center gap-1.5">
|
||||||
$10 one-time · 3 devices · 14-day free trial
|
<span class="border-overlay0 bg-surface0 text-subtext0 rounded-full border px-2.5 py-0.5 text-[11px] font-semibold tracking-wide">
|
||||||
</p>
|
Beta pricing · 33% off
|
||||||
|
</span>
|
||||||
|
<p class="text-subtext1 text-xs">
|
||||||
|
<span class="text-subtext0 line-through">$15</span>{" "}
|
||||||
|
<span class="text-text font-bold">$10</span> one-time
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Feature highlights ───────────────────────────────────────── */}
|
{/* ── Feature collage ─────────────────────────────────────────── */}
|
||||||
<section class="bg-base relative z-20 px-4 py-20 md:px-8">
|
<FeatureCollage />
|
||||||
<div class="mx-auto max-w-4xl">
|
|
||||||
<h2 class="text-text mb-12 text-center text-3xl font-bold">
|
{/* ── Feature breakdowns: the features in action ─────────────── */}
|
||||||
One panel for your agents and your Mac
|
<section class="bg-base relative z-20 px-4 py-16 md:px-8">
|
||||||
|
<div class="mx-auto max-w-5xl">
|
||||||
|
<p class="text-subtext1 mb-2 text-center text-xs font-semibold tracking-[0.18em] uppercase">
|
||||||
|
In action
|
||||||
|
</p>
|
||||||
|
<h2 class="text-text mb-16 text-center text-3xl font-bold">
|
||||||
|
Built for the way you actually run agents
|
||||||
</h2>
|
</h2>
|
||||||
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-2">
|
<FeatureBreakdowns />
|
||||||
{FEATURES.map((feature) => (
|
|
||||||
<div class="border-overlay0 bg-surface0 rounded-lg border p-6">
|
|
||||||
<h3 class="text-text mb-2 text-xl font-semibold">
|
|
||||||
{feature.title}
|
|
||||||
</h3>
|
|
||||||
<p class="text-subtext0 leading-relaxed">{feature.body}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -144,7 +171,7 @@ export default function NookLanding() {
|
|||||||
variant="download"
|
variant="download"
|
||||||
size="lg"
|
size="lg"
|
||||||
color={brandColor()}
|
color={brandColor()}
|
||||||
onClick={() => (window.location.href = NOOK_DOWNLOAD_URL)}
|
onClick={downloadNook}
|
||||||
>
|
>
|
||||||
Download trial
|
Download trial
|
||||||
</Button>
|
</Button>
|
||||||
@@ -152,14 +179,64 @@ export default function NookLanding() {
|
|||||||
class="text-subtext1 my-auto text-sm underline decoration-dotted hover:opacity-80"
|
class="text-subtext1 my-auto text-sm underline decoration-dotted hover:opacity-80"
|
||||||
href="/checkout"
|
href="/checkout"
|
||||||
>
|
>
|
||||||
Buy a license → $10
|
Buy a license → $10{" "}
|
||||||
|
<span class="text-subtext0 line-through">$15</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-subtext1 mt-6 text-xs">
|
<p class="text-subtext1 mt-6 text-xs">
|
||||||
One license covers up to 3 of your own Macs.
|
Beta pricing — full price will be $15. One license covers up to 3 of
|
||||||
|
your own Macs.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* ── Resend license ─────────────────────────────────────────── */}
|
||||||
|
<section class="bg-base relative z-20 px-4 py-16 md:px-8">
|
||||||
|
<div class="border-overlay0 bg-surface0 mx-auto max-w-md rounded-xl border p-8">
|
||||||
|
<h2 class="text-text mb-2 text-center text-2xl font-bold">
|
||||||
|
Lost your license?
|
||||||
|
</h2>
|
||||||
|
<p class="text-subtext0 mb-6 text-center text-sm">
|
||||||
|
Enter the email you purchased with and we'll resend your key.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
class="flex flex-col gap-3"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
resendLicense();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
label="Email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
disabled={resendSending()}
|
||||||
|
value={resendEmail()}
|
||||||
|
onInput={(e) => setResendEmail(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
color={brandColor()}
|
||||||
|
loading={resendSending()}
|
||||||
|
>
|
||||||
|
Resend license
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<Show when={resendSent()}>
|
||||||
|
<p class="text-subtext1 mt-4 text-center text-sm">
|
||||||
|
Check your inbox — if a license is registered to that email, your
|
||||||
|
key is on its way.
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
<Show when={resendError()}>
|
||||||
|
<p class="mt-4 text-center text-sm text-red-500">
|
||||||
|
Something went wrong. Please try again.
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,15 @@
|
|||||||
* UUID + device name for trial/activation limits, the email used for purchase,
|
* UUID + device name for trial/activation limits, the email used for purchase,
|
||||||
* and Stripe payment processing.
|
* and Stripe payment processing.
|
||||||
*/
|
*/
|
||||||
import { A } from "@solidjs/router";
|
import { buildMainSiteUrl } from "~/lib/subdomain-url";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
export default function NookPrivacyPolicy() {
|
export default function NookPrivacyPolicy() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Privacy Policy"
|
title="Privacy Policy"
|
||||||
description="Privacy policy for The Nook, a coding-agent orchestration and hardware control app."
|
description="Privacy policy for The Nook, a coding-agent orchestration and hardware control app."
|
||||||
@@ -94,9 +96,14 @@ export default function NookPrivacyPolicy() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="pb-2 pl-4">
|
<div class="pb-2 pl-4">
|
||||||
If you have any questions about this policy, you can contact us{" "}
|
If you have any questions about this policy, you can contact us{" "}
|
||||||
<A href="/contact" class="text-blue hover-underline-animation">
|
<a
|
||||||
|
href={buildMainSiteUrl("/contact")}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-blue hover-underline-animation"
|
||||||
|
>
|
||||||
here
|
here
|
||||||
</A>
|
</a>
|
||||||
.
|
.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,10 +55,7 @@ export default function NookSuccess() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead
|
<PageHead title="Order complete" description="Your license key." />
|
||||||
title="Order complete"
|
|
||||||
description="Your The Nook license key."
|
|
||||||
/>
|
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
|
|
||||||
<div class="bg-base mx-auto max-w-xl px-4 py-16">
|
<div class="bg-base mx-auto max-w-xl px-4 py-16">
|
||||||
@@ -88,16 +85,24 @@ export default function NookSuccess() {
|
|||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<h1 class="text-text mb-2 text-2xl font-bold">Thanks for buying The Nook</h1>
|
<h1 class="text-text mb-2 text-2xl font-bold">
|
||||||
|
Thanks for buying The Nook
|
||||||
|
</h1>
|
||||||
<p class="text-subtext0 mb-6 text-sm">
|
<p class="text-subtext0 mb-6 text-sm">
|
||||||
Here is your license key. Open The Nook, go to Settings, and enter it to activate.
|
Here is your license key. Open The Nook, go to Settings, and enter
|
||||||
|
it to activate.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="border-overlay0 bg-base mb-4 flex items-center justify-between gap-3 rounded-lg border p-4">
|
<div class="border-overlay0 bg-base mb-4 flex items-center justify-between gap-3 rounded-lg border p-4">
|
||||||
<code class="text-text break-all text-sm">{key()}</code>
|
<code class="text-text text-sm break-all">{key()}</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button variant="download" size="md" color={brandColor()} onClick={copyKey}>
|
<Button
|
||||||
|
variant="download"
|
||||||
|
size="md"
|
||||||
|
color={brandColor()}
|
||||||
|
onClick={copyKey}
|
||||||
|
>
|
||||||
{copied() ? "Copied" : "Copy key"}
|
{copied() ? "Copied" : "Copy key"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
158
src/routes/privacy-policy/index.tsx
Normal file
158
src/routes/privacy-policy/index.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { A } from "@solidjs/router";
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main-site privacy policy — `freno.me/privacy-policy`.
|
||||||
|
*
|
||||||
|
* Covers the personal site (freno.me): accounts, blog comments & likes, the
|
||||||
|
* contact form, transactional email, and error monitoring. Product
|
||||||
|
* subdomains (Nessa, Lineage, Gaze, InputHalo, The Nook) each publish their
|
||||||
|
* own policy at `<product>.freno.me/privacy`.
|
||||||
|
*
|
||||||
|
* The legacy `/privacy-policy/shapes-with-abigail` 308-redirects here.
|
||||||
|
*/
|
||||||
|
export default function PrivacyPolicy() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
|
<PageHead
|
||||||
|
title="Privacy Policy"
|
||||||
|
description="Privacy policy for the freno.me blog and personal site, covering accounts, comments, and contact forms."
|
||||||
|
/>
|
||||||
|
<div class="bg-base">
|
||||||
|
<div class="min-h-screen px-[8vw] py-[8vh]">
|
||||||
|
<div class="py-4 text-xl">
|
||||||
|
freno.me's Privacy Policy
|
||||||
|
</div>
|
||||||
|
<div class="py-2">Last Updated: August 28, 2026</div>
|
||||||
|
<div class="py-2">
|
||||||
|
Welcome to freno.me ('We', 'Us', 'Our').
|
||||||
|
This site is a personal site and blog. This policy explains what
|
||||||
|
we collect, why, and how it is handled.
|
||||||
|
</div>
|
||||||
|
<ol>
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">1.</span> Data We Collect
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) Account:</div> If you create an
|
||||||
|
account with an email and password, we store your email, a
|
||||||
|
hash of your password, and your profile name. If you sign in
|
||||||
|
with Google or GitHub, we store your name, email, and avatar
|
||||||
|
as provided by the provider.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) Blog:</div> If you comment on a post,
|
||||||
|
we store your username, your comment, and its date. If you
|
||||||
|
like a post, we store that you liked it, so the like can be
|
||||||
|
removed.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) Contact:</div> If you submit the
|
||||||
|
contact form, we store your name, email, and message so we
|
||||||
|
can reply.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">2.</span> How We Use It
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
We use your data only to operate the site: to authenticate
|
||||||
|
you, to publish your comments, and to reply to your messages.
|
||||||
|
We do not use it for advertising and we do not sell it to
|
||||||
|
anyone.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">3.</span> Cookies
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
We use a session cookie to keep you signed in and short-lived
|
||||||
|
cookies to rate-limit the login and contact forms against
|
||||||
|
abuse. No tracking or advertising cookies are set.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">4.</span> Third-Party Services
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) Email:</div> Transactional email
|
||||||
|
(login links, password resets, contact replies) is sent by
|
||||||
|
Sendinblue. We share only the email address needed to
|
||||||
|
deliver the message.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) Error monitoring:</div> We use Sentry
|
||||||
|
to collect crash reports, which include your browser type
|
||||||
|
and page URL. Your account details are never sent.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">5.</span> Data Retention &
|
||||||
|
Deletion
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
You can delete your account and your comments or messages any
|
||||||
|
time by contacting us. We delete the data when we receive your
|
||||||
|
request.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">6.</span> Security
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
All connections use TLS. Passwords are stored as salted
|
||||||
|
hashes. Access to the database is restricted to the site
|
||||||
|
operator.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">7.</span> Changes to the Privacy
|
||||||
|
Policy
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
We may update this policy periodically. Any changes are posted
|
||||||
|
on this page, so please review it from time to time.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">8.</span> Contact Us
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
If you have any questions about this policy, you can contact
|
||||||
|
us{" "}
|
||||||
|
<A
|
||||||
|
href="/contact"
|
||||||
|
class="text-blue hover-underline-animation"
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</A>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
|
|
||||||
export default function PrivacyPolicy() {
|
export default function PrivacyPolicy() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Privacy Policy - Shapes with Abigail"
|
title="Privacy Policy - Shapes with Abigail"
|
||||||
description="Privacy policy for Shapes with Abigail app, explaining our commitment to child safety and non-collection of personal data."
|
description="Privacy policy for Shapes with Abigail app, explaining our commitment to child safety and non-collection of personal data."
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import NotFound from "./[...404]";
|
|||||||
import NessaPrivacyPolicy from "./nessa/privacy";
|
import NessaPrivacyPolicy from "./nessa/privacy";
|
||||||
import LineagePrivacyPolicy from "./lineage/privacy";
|
import LineagePrivacyPolicy from "./lineage/privacy";
|
||||||
import GazePrivacyPolicy from "./gaze/privacy";
|
import GazePrivacyPolicy from "./gaze/privacy";
|
||||||
|
import InputHaloPrivacyPolicy from "./inputhalo/privacy";
|
||||||
import NookPrivacyPolicy from "./nook/privacy";
|
import NookPrivacyPolicy from "./nook/privacy";
|
||||||
|
import MainPrivacyPolicy from "./privacy-policy/index";
|
||||||
/**
|
/**
|
||||||
* Host-aware privacy policy route — `/privacy`.
|
* Host-aware privacy policy route — `/privacy`.
|
||||||
*
|
*
|
||||||
@@ -18,16 +20,20 @@ import NookPrivacyPolicy from "./nook/privacy";
|
|||||||
* (SolidStart's client `Router` matches on `window.location.pathname`, not the
|
* (SolidStart's client `Router` matches on `window.location.pathname`, not the
|
||||||
* rewritten server path).
|
* rewritten server path).
|
||||||
*
|
*
|
||||||
* The main site has no `/privacy` page (it uses `/privacy-policy/*`), so
|
* The main site's policy lives at `/privacy-policy` (a legacy path, so the
|
||||||
* `main` falls back to the 404 handler — preserving its pre-rewrite behavior.
|
* page stays there for SEO); `main` renders it here at the public path
|
||||||
|
* `/privacy`, so both URLs serve the same content on `freno.me`.
|
||||||
*
|
*
|
||||||
* Legacy `/privacy-policy/<product>` routes 308-redirect to the corresponding
|
* Legacy `/privacy-policy/<product>` routes 308-redirect to the
|
||||||
* subdomain `/privacy` (see `src/routes/privacy-policy/*.tsx`).
|
* corresponding subdomain `/privacy` (see `src/routes/privacy-policy/*.tsx`).
|
||||||
*/
|
*/
|
||||||
export default function PrivacyPage() {
|
export default function PrivacyPage() {
|
||||||
const site = useSite();
|
const site = useSite();
|
||||||
return (
|
return (
|
||||||
<Switch fallback={<NotFound />}>
|
<Switch fallback={<NotFound />}>
|
||||||
|
<Match when={site().id === "main"}>
|
||||||
|
<MainPrivacyPolicy />
|
||||||
|
</Match>
|
||||||
<Match when={site().id === "nessa"}>
|
<Match when={site().id === "nessa"}>
|
||||||
<NessaPrivacyPolicy />
|
<NessaPrivacyPolicy />
|
||||||
</Match>
|
</Match>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { onCleanup, onMount } from "solid-js";
|
import { onCleanup, onMount } from "solid-js";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
|
|
||||||
export default function Resume() {
|
export default function Resume() {
|
||||||
let iframeRef: HTMLIFrameElement | undefined;
|
let iframeRef: HTMLIFrameElement | undefined;
|
||||||
@@ -25,6 +26,7 @@ export default function Resume() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Resume"
|
title="Resume"
|
||||||
description="View Michael Freno's resume - Software Engineer."
|
description="View Michael Freno's resume - Software Engineer."
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const latestAssets: Record<
|
|||||||
> = {
|
> = {
|
||||||
gaze: { prefix: "downloads/Gaze-", ext: ".dmg" },
|
gaze: { prefix: "downloads/Gaze-", ext: ".dmg" },
|
||||||
inputhalo: { prefix: "downloads/InputHalo-", ext: ".dmg" },
|
inputhalo: { prefix: "downloads/InputHalo-", ext: ".dmg" },
|
||||||
thenook: { prefix: "downloads/TheNook-", ext: ".zip" }
|
thenook: { prefix: "downloads/TheNook-", ext: ".dmg" }
|
||||||
};
|
};
|
||||||
|
|
||||||
export const downloadsRouter = createTRPCRouter({
|
export const downloadsRouter = createTRPCRouter({
|
||||||
|
|||||||
57
src/server/bug-report-rate-limit.ts
Normal file
57
src/server/bug-report-rate-limit.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Bug-report rate limiting.
|
||||||
|
*
|
||||||
|
* Unauthenticated endpoint → in-memory limiter. Vercel lambdas are
|
||||||
|
* per-instance, so this is best-effort abuse resistance, not a hard
|
||||||
|
* guarantee; the goal is "one scripted spam loop per IP/fingerprint
|
||||||
|
* doesn't mail-bomb the owner," which it achieves everywhere but
|
||||||
|
* under a distributed attacker.
|
||||||
|
*
|
||||||
|
* NB: `report.ts` (and only it) mutates the map. Tests reset it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface RateWindow {
|
||||||
|
count: number;
|
||||||
|
expires: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buckets = new Map<string, RateWindow>();
|
||||||
|
|
||||||
|
/** Keep the map tiny even under foreign-key pressure. */
|
||||||
|
const MAX_BUCKETS = 10_000;
|
||||||
|
|
||||||
|
export const BUG_REPORT_WINDOW_MS = 60 * 60 * 1000;
|
||||||
|
export const BUG_REPORT_LIMIT = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increments the client's counter and reports whether it may submit.
|
||||||
|
* `expiresIn`/`now` are injectable for tests.
|
||||||
|
*/
|
||||||
|
export function takeBugReportToken(
|
||||||
|
key: string,
|
||||||
|
expiresIn: number = BUG_REPORT_WINDOW_MS,
|
||||||
|
now: number = Date.now()
|
||||||
|
): { allowed: boolean; retryAfterSec?: number } {
|
||||||
|
const existing = buckets.get(key);
|
||||||
|
if (existing && existing.expires > now) {
|
||||||
|
if (existing.count >= BUG_REPORT_LIMIT) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
retryAfterSec: Math.ceil(existing.expires - now) / 1000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
existing.count += 1;
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buckets.size >= MAX_BUCKETS) {
|
||||||
|
buckets.clear();
|
||||||
|
}
|
||||||
|
buckets.set(key, { count: 1, expires: now + expiresIn });
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: wipes all buckets. */
|
||||||
|
export function resetBugReportLimiter(): void {
|
||||||
|
buckets.clear();
|
||||||
|
}
|
||||||
@@ -10,15 +10,11 @@ import { createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
|
|||||||
* is bootstrapped idempotently (CREATE TABLE IF NOT EXISTS) so no migration
|
* is bootstrapped idempotently (CREATE TABLE IF NOT EXISTS) so no migration
|
||||||
* tool is needed.
|
* tool is needed.
|
||||||
*
|
*
|
||||||
* The license key is a compact printable string:
|
* key = "NOOK-" + hyphen-grouped base32(payload || ed25519-signature)
|
||||||
*
|
*
|
||||||
* key = payloadJson + "." + base64url(ed25519-signature(payloadJson))
|
* Payload layout (v1) is defined below in `buildPayload`. The Swift client
|
||||||
*
|
* decodes the base32 and verifies the EXACT payload bytes against the
|
||||||
* Canonicalization is load-bearing. The Swift client verifies the EXACT
|
* Ed25519 signature, so the layout and signing must stay stable.
|
||||||
* payload bytes (the substring before the last "."), never a re-serialization
|
|
||||||
* of the decoded JSON — key order must stay stable. Without a compact,
|
|
||||||
* deterministic payload this breaks, so `issueLicense` builds the payload as
|
|
||||||
* a hand-ordered literal and `JSON.stringify`s it in place.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface IssueLicenseResult {
|
interface IssueLicenseResult {
|
||||||
@@ -76,24 +72,90 @@ function privateKeyObject() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function signPayload(payload: string): string {
|
const KEY_PREFIX = "NOOK-";
|
||||||
const signature = sign(null, Buffer.from(payload, "utf8"), privateKeyObject());
|
const SIGNATURE_LENGTH = 64;
|
||||||
return Buffer.from(signature).toString("base64url");
|
const B32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
|
|
||||||
|
// Binary license payload (v1), signed with Ed25519:
|
||||||
|
// [0] version (1 byte)
|
||||||
|
// [1..17) lid (16 bytes, UUID decoded)
|
||||||
|
// [17..25) iat (8 bytes, big-endian uint64)
|
||||||
|
// [25] emailLen (1 byte)
|
||||||
|
// [26..) email (UTF-8)
|
||||||
|
// A key is a "NOOK-" prefixed, hyphen-grouped base32 of payload || 64-byte sig.
|
||||||
|
|
||||||
|
function uuidToBytes(uuid: string): Buffer | null {
|
||||||
|
const hex = uuid.replace(/-/g, "");
|
||||||
|
return /^[0-9a-fA-F]{32}$/.test(hex) ? Buffer.from(hex, "hex") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(lid: string, email: string, iat: number): Buffer {
|
||||||
|
const lidBytes = uuidToBytes(lid);
|
||||||
|
if (!lidBytes) throw new Error("Invalid license id");
|
||||||
|
const emailBytes = Buffer.from(email, "utf8");
|
||||||
|
const header = Buffer.allocUnsafe(26);
|
||||||
|
header[0] = PAYLOAD_VERSION;
|
||||||
|
lidBytes.copy(header, 1);
|
||||||
|
header.writeBigUInt64BE(BigInt(iat), 17);
|
||||||
|
header[25] = emailBytes.length;
|
||||||
|
return Buffer.concat([header, emailBytes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function base32Encode(data: Buffer): string {
|
||||||
|
let bits = 0;
|
||||||
|
let value = 0;
|
||||||
|
let out = "";
|
||||||
|
for (const byte of data) {
|
||||||
|
value = (value << 8) | byte;
|
||||||
|
bits += 8;
|
||||||
|
while (bits >= 5) {
|
||||||
|
out += B32_ALPHABET[(value >>> (bits - 5)) & 31];
|
||||||
|
bits -= 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bits > 0) out += B32_ALPHABET[(value << (5 - bits)) & 31];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base32Decode(input: string): Buffer | null {
|
||||||
|
const cleaned = input
|
||||||
|
.trim()
|
||||||
|
.replace(/^NOOK-/i, "")
|
||||||
|
.replace(/[^A-Z2-7]/g, "");
|
||||||
|
if (cleaned.length === 0) return null;
|
||||||
|
let bits = 0;
|
||||||
|
let value = 0;
|
||||||
|
const bytes: number[] = [];
|
||||||
|
for (const char of cleaned) {
|
||||||
|
const idx = B32_ALPHABET.indexOf(char);
|
||||||
|
if (idx < 0) return null;
|
||||||
|
value = (value << 5) | idx;
|
||||||
|
bits += 5;
|
||||||
|
if (bits >= 8) {
|
||||||
|
bytes.push((value >>> (bits - 8)) & 0xff);
|
||||||
|
bits -= 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Buffer.from(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeKey(data: Buffer): string {
|
||||||
|
const encoded = base32Encode(data);
|
||||||
|
const groups: string[] = [];
|
||||||
|
for (let i = 0; i < encoded.length; i += 5)
|
||||||
|
groups.push(encoded.slice(i, i + 5));
|
||||||
|
return `${KEY_PREFIX}${groups.join("-")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-verifies a license key's Ed25519 signature server-side (defense in depth). */
|
/** Re-verifies a license key's Ed25519 signature server-side (defense in depth). */
|
||||||
export function verifyLicenseKey(key: string): boolean {
|
export function verifyLicenseKey(key: string): boolean {
|
||||||
const token = key.match(/^([^]*?)\.([A-Za-z0-9_-]+)$/);
|
const token = base32Decode(key);
|
||||||
if (!token) return false;
|
if (!token || token.length <= SIGNATURE_LENGTH) return false;
|
||||||
const [, payload, sigB64] = token;
|
const payload = token.subarray(0, token.length - SIGNATURE_LENGTH);
|
||||||
|
if (payload[0] !== PAYLOAD_VERSION || payload.length < 27) return false;
|
||||||
|
const signature = token.subarray(token.length - SIGNATURE_LENGTH);
|
||||||
const publicKey = createPublicKey(privateKeyObject());
|
const publicKey = createPublicKey(privateKeyObject());
|
||||||
let signature: Buffer;
|
return verify(null, payload as Buffer, publicKey, signature);
|
||||||
try {
|
|
||||||
signature = Buffer.from(sigB64!, "base64url");
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return verify(null, Buffer.from(payload!, "utf8"), publicKey, signature);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,17 +168,21 @@ async function insertLicense(
|
|||||||
maxDevices: number
|
maxDevices: number
|
||||||
): Promise<IssueLicenseResult> {
|
): Promise<IssueLicenseResult> {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const payload = JSON.stringify({
|
const iat = Math.floor(Date.now() / 1000);
|
||||||
v: PAYLOAD_VERSION,
|
const payload = buildPayload(id, email, iat);
|
||||||
lid: id,
|
const signature = sign(null, payload, privateKeyObject());
|
||||||
email: email,
|
const key = encodeKey(Buffer.concat([payload, signature]));
|
||||||
iat: Math.floor(Date.now() / 1000)
|
|
||||||
});
|
|
||||||
const key = `${payload}.${signPayload(payload)}`;
|
|
||||||
await NookConnectionFactory().execute({
|
await NookConnectionFactory().execute({
|
||||||
sql: `INSERT INTO licenses (id, key, email, stripe_session_id, created_at, revoked, max_devices)
|
sql: `INSERT INTO licenses (id, key, email, stripe_session_id, created_at, revoked, max_devices)
|
||||||
VALUES (?, ?, ?, ?, ?, 0, ?)`,
|
VALUES (?, ?, ?, ?, ?, 0, ?)`,
|
||||||
args: [id, key, email, stripeSessionId, new Date().toISOString(), maxDevices]
|
args: [
|
||||||
|
id,
|
||||||
|
key,
|
||||||
|
email,
|
||||||
|
stripeSessionId,
|
||||||
|
new Date().toISOString(),
|
||||||
|
maxDevices
|
||||||
|
]
|
||||||
});
|
});
|
||||||
return { key, id };
|
return { key, id };
|
||||||
}
|
}
|
||||||
@@ -147,3 +213,34 @@ export async function grantLicense(
|
|||||||
): Promise<IssueLicenseResult> {
|
): Promise<IssueLicenseResult> {
|
||||||
return insertLicense(email, `gift:${crypto.randomUUID()}`, maxDevices);
|
return insertLicense(email, `gift:${crypto.randomUUID()}`, maxDevices);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a license key to the buyer. Best-effort — failures are logged, never
|
||||||
|
* thrown, so the caller (checkout webhook / resend) isn't interrupted.
|
||||||
|
*/
|
||||||
|
export async function emailLicenseKey(
|
||||||
|
to: string,
|
||||||
|
licenseKey: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetch("https://api.brevo.com/v3/smtp/email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"api-key": env.SENDINBLUE_KEY,
|
||||||
|
"content-type": "application/json",
|
||||||
|
accept: "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sender: { name: "The Nook", email: "support@freno.me" },
|
||||||
|
to: [{ email: to }],
|
||||||
|
subject: "Your license key",
|
||||||
|
textContent:
|
||||||
|
`Your license key is:\n\n${licenseKey}\n\n` +
|
||||||
|
`Open The Nook, go to Settings, and enter this key to activate.\n` +
|
||||||
|
`You can activate up to 3 devices.`
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to email The Nook license key:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user