Compare commits
28 Commits
33ca9213f2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fe3516b9e | |||
| bef8f8414b | |||
| f3e9cdf0bd | |||
| 7ecc0f68d7 | |||
| 00a26a9e49 | |||
| 163c68fcb8 | |||
| 3149eba196 | |||
| bc65789431 | |||
| 21f1f07a99 | |||
| 23f86681af | |||
| 8492821f75 | |||
| 17bb43851e | |||
| fd68efdb78 | |||
| 1ee61d8fe4 | |||
| 6c024342ba | |||
| 9860213760 | |||
| cb584f8b68 | |||
| 8c5fe25c2b | |||
| 22f40b2284 | |||
| b10a81068d | |||
| 6d3315c71f | |||
| 20944bf46e | |||
| a161c96b05 | |||
| 082e503d4f | |||
| 0031055315 | |||
| 9ae1b8a2e5 | |||
| e4a9be4899 | |||
| 898c891bd5 |
@@ -75,3 +75,11 @@ GITHUB_API_TOKEN="<rotate-in-github-settings>" # ghp_... / g
|
||||
# Source maps upload — create a token at: Settings > Projects > freno-dev > Client Keys (DSN) > Auth Token
|
||||
# or generate an internal auth token at: https://sentry.io/settings/account/api/keys/
|
||||
SENTRY_AUTH_TOKEN="sntrys_<generate-in-sentry-dashboard>"
|
||||
|
||||
# ── The Nook licensing ──
|
||||
NOOK_DB_URL="libsql://<nook-db>.turso.io"
|
||||
NOOK_DB_TOKEN="<rotate-in-turso-dashboard>" # eyJ...
|
||||
NOOK_LICENSE_PRIVATE_KEY="<generate-license-keypair>" # base64 PKCS8 Ed25519 private key (scripts/generate-license-keys.ts)
|
||||
NOOK_STRIPE_SK="sk_live_<rotate-in-stripe-dashboard>"
|
||||
NOOK_STRIPE_WEBHOOK_SECRET="whsec_<rotate-in-stripe-dashboard>"
|
||||
NOOK_STRIPE_PRICE_ID="price_<from-stripe-dashboard>"
|
||||
|
||||
4
.gitignore
vendored
@@ -31,3 +31,7 @@ perf-results-*.json
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
# pygienium run-state and check artifacts
|
||||
.pygienium/
|
||||
scripts/
|
||||
.cache
|
||||
|
||||
@@ -10,21 +10,7 @@ export default defineConfig({
|
||||
org: "mikefreno",
|
||||
project: "freno-dev",
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
telemetry: false,
|
||||
sourcemaps: {
|
||||
assets: [
|
||||
{
|
||||
type: "bundle",
|
||||
path: "dist/client/assets/",
|
||||
urlPrefix: "~/assets/"
|
||||
},
|
||||
{
|
||||
type: "sourcemap",
|
||||
path: "dist/client/assets/",
|
||||
urlPrefix: "~/assets/"
|
||||
}
|
||||
]
|
||||
}
|
||||
telemetry: false
|
||||
})
|
||||
],
|
||||
build: {
|
||||
|
||||
17
package.json
@@ -11,14 +11,14 @@
|
||||
"test:watch": "bun test --watch",
|
||||
"test:coverage": "bun test --coverage",
|
||||
"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": {
|
||||
"@aws-sdk/client-s3": "^3.953.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.953.0",
|
||||
"@clerk/backend": "^3.12.0",
|
||||
"@libsql/client": "^0.15.15",
|
||||
"@motionone/solid": "^10.16.4",
|
||||
"@sentry/solidstart": "^10.67.0",
|
||||
"@solidjs/meta": "^0.29.4",
|
||||
"@solidjs/router": "^0.15.0",
|
||||
@@ -26,13 +26,11 @@
|
||||
"@tailwindcss/vite": "^4.0.7",
|
||||
"@tiptap/core": "^3.14.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.14.0",
|
||||
"@tiptap/extension-color": "^3.14.0",
|
||||
"@tiptap/extension-details": "^3.14.0",
|
||||
"@tiptap/extension-details-content": "^2.26.2",
|
||||
"@tiptap/extension-details-summary": "^2.26.2",
|
||||
"@tiptap/extension-image": "^3.14.0",
|
||||
"@tiptap/extension-link": "^3.14.0",
|
||||
"@tiptap/extension-list-item": "^3.14.0",
|
||||
"@tiptap/extension-subscript": "^3.14.0",
|
||||
"@tiptap/extension-superscript": "^3.14.0",
|
||||
"@tiptap/extension-table": "^3.14.0",
|
||||
@@ -42,13 +40,11 @@
|
||||
"@tiptap/extension-task-item": "^3.14.0",
|
||||
"@tiptap/extension-task-list": "^3.14.0",
|
||||
"@tiptap/extension-text-align": "^3.14.0",
|
||||
"@tiptap/extension-text-style": "^3.14.0",
|
||||
"@tiptap/pm": "^3.14.0",
|
||||
"@tiptap/starter-kit": "^3.14.0",
|
||||
"@trpc/client": "^10.45.2",
|
||||
"@trpc/server": "^10.45.2",
|
||||
"@tursodatabase/api": "^1.9.2",
|
||||
"@typeschema/valibot": "^0.13.4",
|
||||
"bcrypt": "^6.0.0",
|
||||
"es-toolkit": "^1.43.0",
|
||||
"fast-diff": "^1.3.0",
|
||||
@@ -69,18 +65,11 @@
|
||||
"node": "24.x"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@sentry/vite-plugin": "^5.4.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/fast-diff": "^1.2.2",
|
||||
"chrome-launcher": "^1.2.1",
|
||||
"lighthouse": "^13.0.1",
|
||||
"playwright": "^1.57.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"trpc-panel": "^1.3.4",
|
||||
"vite-bundle-visualizer": "^1.2.1"
|
||||
"prettier-plugin-tailwindcss": "^0.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 8.7 KiB |
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"name": "MyWebSite",
|
||||
"short_name": "MySite",
|
||||
"name": "Gaze",
|
||||
"short_name": "Gaze",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"src": "/gaze/favicon/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"src": "/gaze/favicon/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#002cff",
|
||||
"background_color": "#002cff",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 6.5 KiB |
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"name": "MyWebSite",
|
||||
"short_name": "MySite",
|
||||
"name": "InputHalo",
|
||||
"short_name": "InputHalo",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"src": "/inputhalo/favicon/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"src": "/inputhalo/favicon/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#41a5ff",
|
||||
"background_color": "#41a5ff",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"name": "MyWebSite",
|
||||
"short_name": "MySite",
|
||||
"name": "Life and Lineage",
|
||||
"short_name": "Lineage",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"src": "/lineage/favicon/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"src": "/lineage/favicon/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#a13536",
|
||||
"background_color": "#a13536",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 845 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 1.9 KiB |
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"name": "MyWebSite",
|
||||
"short_name": "MySite",
|
||||
"name": "Nessa",
|
||||
"short_name": "Nessa",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"src": "/nessa/favicon/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"src": "/nessa/favicon/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#527640",
|
||||
"background_color": "#527640",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 8.3 KiB |
BIN
public/nook/cam-recording.mp4
Normal file
BIN
public/nook/demo-expansion.mp4
Normal file
BIN
public/nook/favicon/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/nook/favicon/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
public/nook/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
21
public/nook/favicon/site.webmanifest
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "The Nook",
|
||||
"short_name": "The Nook",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/nook/favicon/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/nook/favicon/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#4C9FBC",
|
||||
"background_color": "#4C9FBC",
|
||||
"display": "standalone"
|
||||
}
|
||||
BIN
public/nook/favicon/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/nook/favicon/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
public/nook/icon.png
Normal file
|
After Width: | Height: | Size: 392 KiB |
@@ -1,9 +1,21 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Allow: /blog
|
||||
Allow: /projects
|
||||
Disallow: /login
|
||||
Disallow: /debug/
|
||||
|
||||
# Private / dynamic paths — not for indexing.
|
||||
# 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: /debug/
|
||||
Disallow: /error-test
|
||||
Disallow: /login
|
||||
Disallow: /success
|
||||
Disallow: /test
|
||||
|
||||
Sitemap: https://www.freno.me/sitemap.xml
|
||||
|
||||
45
scripts/generate-license-keys.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* One-shot The Nook license Ed25519 keypair generator.
|
||||
*
|
||||
* Run with: bun scripts/generate-license-keys.ts
|
||||
*
|
||||
* Prints:
|
||||
* - The Ed25519 PRIVATE key as base64 PKCS8 DER → NOOK_LICENSE_PRIVATE_KEY
|
||||
* (freno-dev env).
|
||||
* - The raw 32-byte Ed25519 public key (the X coordinate) as base64 SPKI DER
|
||||
* suffix → compiled into the Swift `LicenseVerifier.PublicKeyConstant`.
|
||||
*
|
||||
* The private key lives ONLY in env — never in git.
|
||||
* Paste the public key into Sources/NookCore/Licensing/LicenseVerifier.swift
|
||||
* (step 4 of the distribution plan) after running this once.
|
||||
*/
|
||||
import { generateKeyPairSync, createPrivateKey } from "node:crypto";
|
||||
|
||||
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
||||
|
||||
const privateKeyDer = privateKey.export({ format: "der", type: "pkcs8" });
|
||||
const privateKeyPem = privateKey.export({ format: "pem", type: "pkcs8" });
|
||||
|
||||
// Raw 32-byte X coordinate: tail of the SPKI DER public key.
|
||||
const publicKeySpki = publicKey.export({ format: "der", type: "spki" });
|
||||
const rawPublic = publicKeySpki.subarray(-32);
|
||||
|
||||
console.log("── The Nook license keypair ──────────────────────────────");
|
||||
console.log("NOOK_LICENSE_PRIVATE_KEY (base64 PKCS8 DER):");
|
||||
console.log(privateKeyDer.toString("base64"));
|
||||
console.log("");
|
||||
console.log("Private key PEM (reference, for license signing only):");
|
||||
console.log(privateKeyPem);
|
||||
console.log("");
|
||||
console.log("LicenseVerifier public key base64 (raw 32-byte X, step 4):");
|
||||
console.log(rawPublic.toString("base64"));
|
||||
console.log("──────────────────────────────────────────────────────────");
|
||||
|
||||
// Sanity check: sign + verify round trip with the exported artifacts.
|
||||
const publicKeyFromPem = createPrivateKey(privateKeyPem)
|
||||
.export({ format: "der", type: "pkcs8" });
|
||||
if (Buffer.compare(Buffer.from(privateKeyDer), Buffer.from(publicKeyFromPem)) !== 0) {
|
||||
console.error("Keypair export sanity check failed.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("Sanity check passed.");
|
||||
28
scripts/grant-nook-license.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { nookSchemaBootstrap, grantLicense } from "~/server/nook";
|
||||
|
||||
// Mint a free The Nook license (gifting / comps). Defaults to 1 device.
|
||||
//
|
||||
// bun --env-file=.env scripts/grant-nook-license.ts \
|
||||
// --email friend@example.com [--devices 1]
|
||||
|
||||
const arg = (name: string) => {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
return i === -1 ? undefined : process.argv[i + 1];
|
||||
};
|
||||
|
||||
const email = arg("email");
|
||||
const devices = Number(arg("devices") ?? "1");
|
||||
|
||||
if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
||||
console.error(
|
||||
"Usage: bun --env-file=.env scripts/grant-nook-license.ts --email you@example.com [--devices 1]"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await nookSchemaBootstrap;
|
||||
const { key } = await grantLicense(email, devices);
|
||||
|
||||
console.log(`Granted The Nook license for ${email} (${devices} device(s)):`);
|
||||
console.log(key);
|
||||
console.log("Send it to them; they enter it in Settings > License.");
|
||||
@@ -96,7 +96,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
||||
"───────────────────────────────────────────────────────────────────\n"
|
||||
);
|
||||
|
||||
// Compare each page
|
||||
for (const baseResult of baseline.results) {
|
||||
const optResult = optimized.results.find((r) => r.page === baseResult.page);
|
||||
if (!optResult) continue;
|
||||
@@ -107,7 +106,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
||||
console.log(`\n📄 ${baseResult.page}`);
|
||||
console.log("─".repeat(70));
|
||||
|
||||
// Core Web Vitals
|
||||
console.log("\n Core Web Vitals:");
|
||||
|
||||
const fcpDiff = opt.fcp - base.fcp;
|
||||
@@ -121,7 +119,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
||||
` CLS: ${base.cls.toFixed(3)} → ${opt.cls.toFixed(3)} (${formatDiff(clsDiff * 1000, "ms")})`
|
||||
);
|
||||
|
||||
// Loading Metrics
|
||||
console.log("\n Loading Metrics:");
|
||||
|
||||
const ttfbDiff = opt.ttfb - base.ttfb;
|
||||
@@ -148,7 +145,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
||||
` Load: ${formatTime(base.loadComplete)} → ${formatTime(opt.loadComplete)} (${formatDiff(loadDiff, "ms")}, ${loadPercent.toFixed(1)}%)${getImpact(loadPercent)}`
|
||||
);
|
||||
|
||||
// Resource Loading
|
||||
console.log("\n Resources:");
|
||||
|
||||
const reqDiff = opt.totalRequests - base.totalRequests;
|
||||
@@ -185,7 +181,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
||||
);
|
||||
}
|
||||
|
||||
// Overall Summary
|
||||
console.log(
|
||||
"\n\n═══════════════════════════════════════════════════════════════════"
|
||||
);
|
||||
@@ -338,7 +333,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
||||
);
|
||||
}
|
||||
|
||||
// Specific findings
|
||||
const reqPercent = calculatePercentChange(baseAvg.requests, optAvg.requests);
|
||||
if (reqPercent < -5) {
|
||||
console.log(
|
||||
|
||||
@@ -61,7 +61,6 @@ const BASE_URL = process.env.TEST_URL || "http://localhost:3000";
|
||||
const RUNS_PER_PAGE = parseInt(process.env.RUNS || "5", 10);
|
||||
const WARMUP_RUNS = 1;
|
||||
|
||||
// Pages to test
|
||||
const TEST_PAGES: PageTestConfig[] = [
|
||||
{ name: "Home", path: "/" },
|
||||
{ name: "Blog Index", path: "/blog" },
|
||||
@@ -78,7 +77,6 @@ const TEST_PAGES: PageTestConfig[] = [
|
||||
{ name: "404", path: "/404" }
|
||||
];
|
||||
|
||||
// Add additional blog post path if provided
|
||||
if (process.env.TEST_BLOG_POST) {
|
||||
TEST_PAGES.push({
|
||||
name: "Custom Blog Post",
|
||||
@@ -102,7 +100,6 @@ async function setupPerformanceObservers(page: Page) {
|
||||
interactions: [] as number[]
|
||||
};
|
||||
|
||||
// Observe LCP
|
||||
if ("PerformanceObserver" in window) {
|
||||
try {
|
||||
const lcpObserver = new PerformanceObserver((entryList) => {
|
||||
@@ -118,10 +115,8 @@ async function setupPerformanceObservers(page: Page) {
|
||||
buffered: true
|
||||
});
|
||||
} catch (e) {
|
||||
// LCP not supported
|
||||
}
|
||||
|
||||
// Observe CLS
|
||||
try {
|
||||
const clsObserver = new PerformanceObserver((entryList) => {
|
||||
for (const entry of entryList.getEntries()) {
|
||||
@@ -138,10 +133,8 @@ async function setupPerformanceObservers(page: Page) {
|
||||
});
|
||||
clsObserver.observe({ type: "layout-shift", buffered: true });
|
||||
} catch (e) {
|
||||
// CLS not supported
|
||||
}
|
||||
|
||||
// Observe FID (first input)
|
||||
try {
|
||||
const fidObserver = new PerformanceObserver((entryList) => {
|
||||
const firstInput = entryList.getEntries()[0] as any;
|
||||
@@ -154,10 +147,8 @@ async function setupPerformanceObservers(page: Page) {
|
||||
});
|
||||
fidObserver.observe({ type: "first-input", buffered: true });
|
||||
} catch (e) {
|
||||
// FID not supported
|
||||
}
|
||||
|
||||
// Observe long tasks
|
||||
try {
|
||||
const longTaskObserver = new PerformanceObserver((entryList) => {
|
||||
for (const entry of entryList.getEntries()) {
|
||||
@@ -166,10 +157,8 @@ async function setupPerformanceObservers(page: Page) {
|
||||
});
|
||||
longTaskObserver.observe({ type: "longtask", buffered: true });
|
||||
} catch (e) {
|
||||
// Long tasks not supported
|
||||
}
|
||||
|
||||
// Observe INP (event timing for interactions)
|
||||
try {
|
||||
const inpObserver = new PerformanceObserver((entryList) => {
|
||||
for (const entry of entryList.getEntries()) {
|
||||
@@ -194,7 +183,6 @@ async function setupPerformanceObservers(page: Page) {
|
||||
});
|
||||
inpObserver.observe({ type: "event", buffered: true });
|
||||
} catch (e) {
|
||||
// Event timing not supported
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -203,18 +191,14 @@ async function setupPerformanceObservers(page: Page) {
|
||||
async function collectPerformanceMetrics(
|
||||
page: Page
|
||||
): Promise<PerformanceMetrics> {
|
||||
// Wait for page to be loaded
|
||||
await page.waitForLoadState("load");
|
||||
|
||||
// Wait a bit longer for LCP to settle (it can change as content loads)
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Additional wait for any remaining network activity
|
||||
await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {
|
||||
// Ignore timeout - networkidle may never happen for some pages
|
||||
});
|
||||
|
||||
// Collect comprehensive performance metrics
|
||||
const metrics = await page.evaluate(() => {
|
||||
const perf = performance.getEntriesByType(
|
||||
"navigation"
|
||||
@@ -222,7 +206,6 @@ async function collectPerformanceMetrics(
|
||||
const paint = performance.getEntriesByType("paint");
|
||||
const fcp = paint.find((entry) => entry.name === "first-contentful-paint");
|
||||
|
||||
// Get metrics from our observers
|
||||
const observedMetrics = (window as any).__perfMetrics || {
|
||||
lcp: 0,
|
||||
cls: 0,
|
||||
@@ -232,7 +215,6 @@ async function collectPerformanceMetrics(
|
||||
interactions: []
|
||||
};
|
||||
|
||||
// Fallback to direct API if observers didn't capture anything
|
||||
let lcp = observedMetrics.lcp;
|
||||
let cls = observedMetrics.cls;
|
||||
let fid = observedMetrics.fid;
|
||||
@@ -258,7 +240,6 @@ async function collectPerformanceMetrics(
|
||||
.reduce((sum: number, entry: any) => sum + entry.value, 0);
|
||||
}
|
||||
|
||||
// Calculate INP from event timing entries if not already captured
|
||||
if (inp === 0) {
|
||||
const eventEntries = performance.getEntriesByType("event") as any[];
|
||||
const interactionLatencies = eventEntries
|
||||
@@ -275,7 +256,6 @@ async function collectPerformanceMetrics(
|
||||
}
|
||||
}
|
||||
|
||||
// Get resource timing
|
||||
const resources = performance.getEntriesByType(
|
||||
"resource"
|
||||
) as PerformanceResourceTiming[];
|
||||
@@ -318,7 +298,6 @@ async function collectPerformanceMetrics(
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate long task duration
|
||||
let taskDuration = 0;
|
||||
if (observedMetrics.longTasks && observedMetrics.longTasks.length > 0) {
|
||||
taskDuration = observedMetrics.longTasks.reduce(
|
||||
@@ -327,7 +306,6 @@ async function collectPerformanceMetrics(
|
||||
);
|
||||
}
|
||||
|
||||
// Get more granular performance entries
|
||||
let jsExecutionTime = 0;
|
||||
let layoutDuration = 0;
|
||||
let paintDuration = 0;
|
||||
@@ -339,7 +317,6 @@ async function collectPerformanceMetrics(
|
||||
}
|
||||
});
|
||||
|
||||
// Check for script evaluation entries
|
||||
const entries = performance.getEntries();
|
||||
entries.forEach((entry: any) => {
|
||||
if (entry.entryType === "measure") {
|
||||
@@ -399,7 +376,6 @@ async function testPagePerformance(
|
||||
` Running ${WARMUP_RUNS} warmup + ${RUNS_PER_PAGE} measured runs...\n`
|
||||
);
|
||||
|
||||
// Warmup runs (not counted)
|
||||
for (let i = 0; i < WARMUP_RUNS; i++) {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
@@ -410,20 +386,16 @@ async function testPagePerformance(
|
||||
console.log(` ✓ Warmup run ${i + 1}/${WARMUP_RUNS}`);
|
||||
}
|
||||
|
||||
// Measured runs
|
||||
for (let i = 0; i < RUNS_PER_PAGE; i++) {
|
||||
console.log(` → Run ${i + 1}/${RUNS_PER_PAGE}...`);
|
||||
|
||||
// Create new context for each run to ensure clean state
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1920, height: 1080 }
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// Setup performance observers before navigation
|
||||
await setupPerformanceObservers(page);
|
||||
|
||||
// Navigate and collect metrics
|
||||
await page.goto(url, { waitUntil: "load" });
|
||||
const metrics = await collectPerformanceMetrics(page);
|
||||
|
||||
@@ -436,7 +408,6 @@ async function testPagePerformance(
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
const average = calculateAverage(runs);
|
||||
const median = calculateMedian(runs);
|
||||
const p95 = calculatePercentile(runs, 95);
|
||||
@@ -648,7 +619,6 @@ function printResults(results: TestResult[]) {
|
||||
"═══════════════════════════════════════════════════════════════════\n"
|
||||
);
|
||||
|
||||
// Overall averages
|
||||
const overallAverage = {
|
||||
lcp: results.reduce((sum, r) => sum + r.median.lcp, 0) / results.length,
|
||||
fcp: results.reduce((sum, r) => sum + r.median.fcp, 0) / results.length,
|
||||
@@ -696,7 +666,6 @@ function printResults(results: TestResult[]) {
|
||||
|
||||
console.log("\n Optimization Opportunities:");
|
||||
|
||||
// Find pages with highest JS bytes
|
||||
const highestJS = [...results].sort(
|
||||
(a, b) => b.median.jsBytes - a.median.jsBytes
|
||||
)[0];
|
||||
@@ -707,7 +676,6 @@ function printResults(results: TestResult[]) {
|
||||
);
|
||||
}
|
||||
|
||||
// Find pages with slow LCP
|
||||
const slowLCP = results.filter((r) => r.median.lcp > 2500);
|
||||
if (slowLCP.length > 0) {
|
||||
console.log(
|
||||
@@ -715,7 +683,6 @@ function printResults(results: TestResult[]) {
|
||||
);
|
||||
}
|
||||
|
||||
// Find pages with high CLS
|
||||
const highCLS = results.filter((r) => r.median.cls > 0.1);
|
||||
if (highCLS.length > 0) {
|
||||
console.log(
|
||||
@@ -723,7 +690,6 @@ function printResults(results: TestResult[]) {
|
||||
);
|
||||
}
|
||||
|
||||
// Find pages with high INP
|
||||
const highINP = results.filter((r) => r.median.inp > 200);
|
||||
if (highINP.length > 0) {
|
||||
console.log(
|
||||
@@ -740,7 +706,6 @@ async function main() {
|
||||
console.log(`Pages to test: ${TEST_PAGES.length}`);
|
||||
console.log(`Runs per page: ${RUNS_PER_PAGE} (+ ${WARMUP_RUNS} warmup)\n`);
|
||||
|
||||
// Check if server is running
|
||||
try {
|
||||
const response = await fetch(BASE_URL);
|
||||
if (!response.ok) {
|
||||
@@ -770,10 +735,8 @@ async function main() {
|
||||
|
||||
await browser.close();
|
||||
|
||||
// Print results
|
||||
printResults(results);
|
||||
|
||||
// Save results to JSON file
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, "-")
|
||||
|
||||
181
src/app.css
@@ -574,3 +574,184 @@ a.hover-underline-animation:hover::after {
|
||||
.shaker:hover {
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -35,10 +35,8 @@ function AppLayout(props: { children: any }) {
|
||||
let lastScrollY = 0;
|
||||
|
||||
onMount(() => {
|
||||
// Initialize performance tracking
|
||||
initPerformanceTracking();
|
||||
|
||||
// Start monitoring for new deployments
|
||||
startDeploymentMonitoring();
|
||||
|
||||
const windowWidth = createWindowWidth();
|
||||
|
||||
@@ -366,6 +366,11 @@ function MainRightBarContent() {
|
||||
Contact Me
|
||||
</a>
|
||||
</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>
|
||||
<a
|
||||
href="https://github.com/MikeFreno/"
|
||||
@@ -460,7 +465,7 @@ function MainRightBarContent() {
|
||||
);
|
||||
}
|
||||
|
||||
export function RightBarContent() {
|
||||
function RightBarContent() {
|
||||
const site = useSite();
|
||||
return (
|
||||
<Show when={site().id === "main"} fallback={<SubdomainRightBarContent />}>
|
||||
|
||||
@@ -144,7 +144,6 @@ const sendContactEmail = action(async (formData: FormData) => {
|
||||
|
||||
const { env } = await import("~/env/server");
|
||||
|
||||
// Verify Cloudflare Turnstile token
|
||||
const turnstileValid = await verifyTurnstileToken(
|
||||
turnstileToken,
|
||||
env.TURNSTILE_SECRET_KEY,
|
||||
@@ -252,7 +251,6 @@ export function ContactForm(props: ContactFormProps) {
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Load server data using createAsync
|
||||
const contactData = createAsync(() => getContactData(), {
|
||||
deferStream: true
|
||||
});
|
||||
@@ -350,7 +348,6 @@ export function ContactForm(props: ContactFormProps) {
|
||||
const message = formData.get("message") as string;
|
||||
|
||||
if (name && email && message) {
|
||||
// Get fresh Turnstile token
|
||||
let currentToken = turnstileToken();
|
||||
if (
|
||||
!currentToken &&
|
||||
@@ -388,7 +385,6 @@ export function ContactForm(props: ContactFormProps) {
|
||||
setError("");
|
||||
form.reset();
|
||||
|
||||
// Reset Turnstile widget
|
||||
if (typeof window !== "undefined" && (window as any).turnstile) {
|
||||
const widgetEl = document.getElementById("turnstile-widget-1");
|
||||
if (widgetEl) {
|
||||
|
||||
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() {
|
||||
return (
|
||||
<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
|
||||
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>
|
||||
</A>
|
||||
<A
|
||||
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
|
||||
</A>
|
||||
|
||||
@@ -138,9 +138,6 @@ export function Typewriter(props: {
|
||||
entries.forEach((entry) => {
|
||||
// If component leaves viewport while animating, we could pause
|
||||
// For now, we just ensure it starts when visible
|
||||
if (!entry.isIntersecting && cleanupAnimation) {
|
||||
// Component is off-screen - could add pause logic here if needed
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
|
||||
@@ -63,7 +63,6 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Refresh the S3 file list
|
||||
await loadAttachments();
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -81,7 +80,6 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
|
||||
body: JSON.stringify({ key })
|
||||
});
|
||||
|
||||
// Refresh the S3 file list
|
||||
await loadAttachments();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete file:", err);
|
||||
|
||||
@@ -259,7 +259,6 @@ export default function CommentSectionWrapper(
|
||||
const newComment = async (commentBody: string, parentCommentID?: number) => {
|
||||
setCommentSubmitLoading(true);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (commentSubmitTimeoutId) {
|
||||
clearTimeout(commentSubmitTimeoutId);
|
||||
}
|
||||
@@ -428,7 +427,6 @@ export default function CommentSectionWrapper(
|
||||
const editComment = async (body: string, comment_id: number) => {
|
||||
setCommentEditLoading(true);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (editCommentTimeoutId) {
|
||||
clearTimeout(editCommentTimeoutId);
|
||||
}
|
||||
@@ -527,7 +525,6 @@ export default function CommentSectionWrapper(
|
||||
|
||||
setCommentDeletionLoading(true);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (deleteCommentTimeoutId) {
|
||||
clearTimeout(deleteCommentTimeoutId);
|
||||
}
|
||||
@@ -623,7 +620,6 @@ export default function CommentSectionWrapper(
|
||||
setOperationError("");
|
||||
|
||||
if (data.commentBody) {
|
||||
// Soft delete (replace body with deletion message)
|
||||
setAllComments((prev) =>
|
||||
prev.map((comment) => {
|
||||
if (comment.id === data.commentID) {
|
||||
@@ -652,7 +648,6 @@ export default function CommentSectionWrapper(
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Hard delete (remove from list)
|
||||
setAllComments((prev) =>
|
||||
prev.filter((comment) => comment.id !== data.commentID)
|
||||
);
|
||||
@@ -667,7 +662,6 @@ export default function CommentSectionWrapper(
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Deletion/edit prompt toggle
|
||||
const toggleModification = (
|
||||
commentID: number,
|
||||
commenterID: string,
|
||||
@@ -708,7 +702,6 @@ export default function CommentSectionWrapper(
|
||||
setCommentBodyForModification("");
|
||||
};
|
||||
|
||||
// Reaction handling
|
||||
const commentReaction = (reactionType: ReactionType, commentID: number) => {
|
||||
if (!props.currentUserID) {
|
||||
console.warn("Cannot react to comment: user not authenticated");
|
||||
@@ -800,7 +793,6 @@ export default function CommentSectionWrapper(
|
||||
}
|
||||
};
|
||||
|
||||
// Click outside handlers (SolidJS version)
|
||||
createEffect(() => {
|
||||
const handleClickOutsideDelete = (e: MouseEvent) => {
|
||||
if (
|
||||
|
||||
@@ -8,12 +8,10 @@ function sanitizeMermaidSvg(svgString: string): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(svgString, "text/html");
|
||||
|
||||
// Remove dangerous elements
|
||||
doc.querySelectorAll("script, iframe, object, embed, form, link, meta, base").forEach((el) => {
|
||||
el.remove();
|
||||
});
|
||||
|
||||
// Remove event handlers and dangerous attributes from all elements
|
||||
doc.querySelectorAll("[on*], [href*='javascript:'], [style*='expression(']").forEach((el) => {
|
||||
const attrs = Array.from(el.attributes);
|
||||
attrs.forEach((attr) => {
|
||||
|
||||
@@ -11,7 +11,6 @@ function sanitizeHtml(html: string): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
|
||||
// Remove dangerous elements
|
||||
doc
|
||||
.querySelectorAll(
|
||||
"script, iframe, object, embed, form, link, meta, base, svg script"
|
||||
@@ -131,7 +130,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
||||
const processVideos = () => {
|
||||
if (!contentRef) return;
|
||||
|
||||
// Handle direct video elements
|
||||
const videoElements = contentRef.querySelectorAll("video");
|
||||
|
||||
videoElements.forEach((video) => {
|
||||
@@ -139,18 +137,14 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
||||
video.setAttribute("playsinline", "");
|
||||
video.setAttribute("controls", "");
|
||||
|
||||
// Remove download attribute if present
|
||||
video.removeAttribute("download");
|
||||
|
||||
// Ensure proper MIME types on source elements
|
||||
const sources = video.querySelectorAll("source");
|
||||
sources.forEach((source) => {
|
||||
const src = source.getAttribute("src");
|
||||
if (src) {
|
||||
// Remove download attribute from sources
|
||||
source.removeAttribute("download");
|
||||
|
||||
// Set correct type attribute if missing
|
||||
if (!source.hasAttribute("type")) {
|
||||
if (src.endsWith(".mp4")) {
|
||||
source.setAttribute("type", "video/mp4");
|
||||
@@ -163,7 +157,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
||||
}
|
||||
});
|
||||
|
||||
// If video has direct src attribute, ensure type is set
|
||||
const videoSrc = video.getAttribute("src");
|
||||
if (videoSrc && !video.hasAttribute("type")) {
|
||||
if (videoSrc.endsWith(".mp4")) {
|
||||
@@ -176,7 +169,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
||||
}
|
||||
});
|
||||
|
||||
// Handle iframes with video sources - replace with proper video tags
|
||||
const iframes = contentRef.querySelectorAll("iframe");
|
||||
iframes.forEach((iframe) => {
|
||||
const src = iframe.getAttribute("src");
|
||||
@@ -187,7 +179,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
||||
src.endsWith(".webm") ||
|
||||
src.endsWith(".ogg"))
|
||||
) {
|
||||
// Create a proper video element
|
||||
const video = document.createElement("video");
|
||||
video.setAttribute("controls", "");
|
||||
video.setAttribute("playsinline", "");
|
||||
@@ -195,7 +186,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
||||
video.style.maxWidth = "100%";
|
||||
video.style.height = "auto";
|
||||
|
||||
// Set appropriate type based on file extension
|
||||
let videoType = "video/mp4";
|
||||
if (src.endsWith(".mov")) {
|
||||
videoType = "video/mp4"; // MOV files are typically H.264 which plays as mp4
|
||||
@@ -208,7 +198,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
||||
video.setAttribute("type", videoType);
|
||||
video.src = src;
|
||||
|
||||
// Replace the iframe with the video element
|
||||
const parent = iframe.parentElement;
|
||||
if (parent) {
|
||||
parent.replaceChild(video, iframe);
|
||||
@@ -216,7 +205,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
||||
}
|
||||
});
|
||||
|
||||
// Also check for any anchor tags wrapping videos that might have download attribute
|
||||
const videoLinks = contentRef.querySelectorAll("a");
|
||||
videoLinks.forEach((link) => {
|
||||
const hasVideo = link.querySelector("video");
|
||||
|
||||
@@ -58,7 +58,6 @@ export default function PostForm(props: PostFormProps) {
|
||||
props.postId
|
||||
);
|
||||
|
||||
// Mark initial load as complete after data is loaded (for edit mode)
|
||||
// Use setTimeout to ensure this runs after all signals are initialized
|
||||
createEffect(() => {
|
||||
if (props.mode === "edit" && props.initialData) {
|
||||
@@ -73,12 +72,10 @@ export default function PostForm(props: PostFormProps) {
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
// Helper to ensure post exists (create if needed)
|
||||
const ensurePostExists = async (): Promise<number> => {
|
||||
const existingId = createdPostId() || props.postId;
|
||||
if (existingId) return existingId;
|
||||
|
||||
// Create minimal post if it doesn't exist yet
|
||||
const result = await api.database.createPost.mutate({
|
||||
category: "blog",
|
||||
title: title().replaceAll(" ", "_") || "Untitled",
|
||||
@@ -95,7 +92,6 @@ export default function PostForm(props: PostFormProps) {
|
||||
return newId;
|
||||
};
|
||||
|
||||
// Individual autosave functions for each field
|
||||
const autoSaveTitle = async () => {
|
||||
const currentTitle = title();
|
||||
if (!currentTitle || currentTitle === props.initialData?.title) return;
|
||||
@@ -248,7 +244,6 @@ export default function PostForm(props: PostFormProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// Debounced versions
|
||||
const debouncedAutoSaveTitle = debounce(autoSaveTitle, 2500);
|
||||
const debouncedAutoSaveSubtitle = debounce(autoSaveSubtitle, 2500);
|
||||
const debouncedAutoSaveBody = debounce(autoSaveBody, 2500);
|
||||
@@ -256,7 +251,6 @@ export default function PostForm(props: PostFormProps) {
|
||||
const debouncedAutoSavePublished = debounce(autoSavePublished, 1000);
|
||||
const debouncedAutoSaveBanner = debounce(autoSaveBanner, 2500);
|
||||
|
||||
// Individual effects for each field
|
||||
createEffect(() => {
|
||||
const titleVal = title();
|
||||
if (isInitialLoad()) return;
|
||||
@@ -405,7 +399,6 @@ export default function PostForm(props: PostFormProps) {
|
||||
author_id: props.userID
|
||||
});
|
||||
} else {
|
||||
// Create new post
|
||||
const result = await api.database.createPost.mutate({
|
||||
category: "blog",
|
||||
title: title().replaceAll(" ", "_"),
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function PostSorting(props: PostSortingProps) {
|
||||
case "newest":
|
||||
break; // Posts already come newest first from DB (DESC order)
|
||||
case "oldest":
|
||||
sorted.reverse(); // Reverse to get oldest first
|
||||
sorted.reverse();
|
||||
break;
|
||||
case "most_liked":
|
||||
sorted.sort((a, b) => (b.total_likes || 0) - (a.total_likes || 0));
|
||||
|
||||
@@ -1547,7 +1547,6 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
},
|
||||
handleDOMEvents: {
|
||||
touchstart: (view, event) => {
|
||||
// Only handle touch events on mobile in fullscreen with active suggestion
|
||||
if (
|
||||
!hasSuggestion() ||
|
||||
!isFullscreen() ||
|
||||
@@ -1562,7 +1561,6 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
return false;
|
||||
},
|
||||
touchend: (view, event) => {
|
||||
// Only handle touch events on mobile in fullscreen with active suggestion
|
||||
if (
|
||||
!hasSuggestion() ||
|
||||
!isFullscreen() ||
|
||||
@@ -1860,7 +1858,6 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
const node = allSuperscriptNodes[i];
|
||||
const text = node.text;
|
||||
|
||||
// Check if this is a complete reference (with optional whitespace)
|
||||
const completeMatch = text.match(/^\s*\[(\d+)\]\s*$/);
|
||||
if (completeMatch) {
|
||||
const hasOtherMarks = node.marks.some(
|
||||
@@ -1877,7 +1874,6 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this might be the start of a split reference
|
||||
if (text === "[" && i + 2 < allSuperscriptNodes.length) {
|
||||
const nextNode = allSuperscriptNodes[i + 1];
|
||||
const afterNode = allSuperscriptNodes[i + 2];
|
||||
@@ -1958,7 +1954,6 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
|
||||
allRefs.sort((a, b) => a.pos - b.pos);
|
||||
|
||||
// Check if renumbering is needed (if any ref doesn't match its expected number)
|
||||
let needsRenumbering = false;
|
||||
for (let i = 0; i < allRefs.length; i++) {
|
||||
if (allRefs[i].refNum !== i + 1) {
|
||||
|
||||
@@ -69,7 +69,6 @@ export const Mermaid = Node.create({
|
||||
getAttrs: (element) => {
|
||||
if (typeof element === "string") return false;
|
||||
|
||||
// Skip if already has data-type or data-mermaid-diagram attribute
|
||||
if (
|
||||
element.hasAttribute("data-type") ||
|
||||
element.hasAttribute("data-mermaid-diagram")
|
||||
@@ -83,7 +82,6 @@ export const Mermaid = Node.create({
|
||||
const content = code.textContent || "";
|
||||
const trimmedContent = content.trim();
|
||||
|
||||
// Check if this looks like a mermaid diagram
|
||||
const mermaidKeywords = [
|
||||
"graph ",
|
||||
"sequenceDiagram",
|
||||
@@ -174,12 +172,10 @@ export const Mermaid = Node.create({
|
||||
code.textContent = node.attrs.content || "";
|
||||
pre.appendChild(code);
|
||||
|
||||
// Validation status indicator
|
||||
const statusIndicator = document.createElement("div");
|
||||
statusIndicator.className =
|
||||
"absolute top-2 left-2 w-3 h-3 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-200";
|
||||
|
||||
// Validate syntax asynchronously
|
||||
const validateSyntax = async () => {
|
||||
const content = node.attrs.content || "";
|
||||
if (!content.trim()) {
|
||||
@@ -250,7 +246,6 @@ export const Mermaid = Node.create({
|
||||
(p: any) => p.spec?.key === "mermaidSelection"
|
||||
);
|
||||
|
||||
// Use intersection observer to trigger update when visible
|
||||
let updateInterval: ReturnType<typeof setInterval> | null = null;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
const BackArrow = (props: {
|
||||
height: number;
|
||||
width: number;
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
class?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div class={props.class}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={props.strokeWidth}
|
||||
stroke={props.stroke}
|
||||
height={props.height}
|
||||
width={props.width}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackArrow;
|
||||
@@ -1,39 +0,0 @@
|
||||
function MenuBars() {
|
||||
return (
|
||||
<svg
|
||||
width="36"
|
||||
height="30"
|
||||
viewBox="0 0 120 100"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g id="Mask group">
|
||||
<g id="Frame 1">
|
||||
<rect width="120" height="100" />
|
||||
<line
|
||||
id="LineA"
|
||||
x1="11.5"
|
||||
y1="31.5"
|
||||
x2="108.5"
|
||||
y2="31.5"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
class="stroke-black dark:stroke-white"
|
||||
/>
|
||||
<line
|
||||
id="LineB"
|
||||
x1="11.5"
|
||||
y1="64.5"
|
||||
x2="108.5"
|
||||
y2="64.5"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
class="stroke-black dark:stroke-white"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default MenuBars;
|
||||
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
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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -128,7 +128,6 @@ export default function Button(props: ButtonProps) {
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
// Measure content dimensions when not loading
|
||||
createEffect(() => {
|
||||
if (!local.loading && contentRef) {
|
||||
const rect = contentRef.getBoundingClientRect();
|
||||
|
||||
@@ -23,7 +23,6 @@ export const AUTH_CONFIG = {
|
||||
ACCESS_TOKEN_EXPIRY_DEV: "2m" as const, // 2 minutes for faster testing
|
||||
ACCESS_TOKEN_EXPIRY_LONG: "30d" as const, // rememberMe cookie lifetime
|
||||
|
||||
// Other Auth Settings
|
||||
CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14,
|
||||
EMAIL_LOGIN_LINK_EXPIRY: "15m" as const,
|
||||
EMAIL_VERIFICATION_LINK_EXPIRY: "15m" as const,
|
||||
@@ -74,9 +73,6 @@ export const RATE_LIMITS = {
|
||||
EMAIL_VERIFICATION_IP: { maxAttempts: 5, windowMs: 15 * 60 * 1000 }
|
||||
} as const;
|
||||
|
||||
/** Rate limit store cleanup interval (5 minutes) */
|
||||
export const RATE_LIMIT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
// ============================================================
|
||||
// ACCOUNT SECURITY
|
||||
// ============================================================
|
||||
@@ -136,22 +132,6 @@ export const NETWORK_CONFIG = {
|
||||
RETRY_DELAY_MS: 1000
|
||||
} as const;
|
||||
|
||||
// ============================================================
|
||||
// UI/UX - TYPEWRITER COMPONENT
|
||||
// ============================================================
|
||||
|
||||
export const TYPEWRITER_CONFIG = {
|
||||
DEFAULT_SPEED: 30,
|
||||
FAST_SPEED: 80,
|
||||
SLOW_SPEED: 10,
|
||||
VERY_SLOW_SPEED: 100,
|
||||
EXTRA_SLOW_SPEED: 120,
|
||||
DEFAULT_KEEP_ALIVE_MS: 2000,
|
||||
LONG_KEEP_ALIVE_MS: 10000,
|
||||
DEFAULT_DELAY_MS: 500,
|
||||
CURSOR_FADE_DELAY_MS: 1000
|
||||
} as const;
|
||||
|
||||
// ============================================================
|
||||
// UI/UX - COUNTDOWN TIMER COMPONENT
|
||||
// ============================================================
|
||||
@@ -177,41 +157,6 @@ export const BREAKPOINTS = {
|
||||
DESKTOP_MIN_WIDTH: 1025
|
||||
} as const;
|
||||
|
||||
// ============================================================
|
||||
// UI/UX - ANIMATIONS & TRANSITIONS
|
||||
// ============================================================
|
||||
|
||||
export const ANIMATION_CONFIG = {
|
||||
TRANSITION_DURATION_MS: 300,
|
||||
FAST_TRANSITION_MS: 200,
|
||||
SLOW_TRANSITION_MS: 500,
|
||||
EXTRA_SLOW_TRANSITION_MS: 600,
|
||||
SIDEBAR_DURATION_MS: 500,
|
||||
MENU_TYPING_DELAY_MS: 140,
|
||||
MENU_INITIAL_DELAY_MS: 500,
|
||||
SUCCESS_MESSAGE_DURATION_MS: 3000,
|
||||
ERROR_MESSAGE_DURATION_MS: 5000,
|
||||
REDIRECT_DELAY_MS: 500
|
||||
} as const;
|
||||
|
||||
// ============================================================
|
||||
// UI/UX - PDF VIEWER
|
||||
// ============================================================
|
||||
|
||||
export const PDF_CONFIG = {
|
||||
RENDER_SCALE: 1.5
|
||||
} as const;
|
||||
|
||||
// ============================================================
|
||||
// UI/UX - 401 ERROR PAGE
|
||||
// ============================================================
|
||||
|
||||
export const ERROR_PAGE_CONFIG = {
|
||||
GLITCH_INTERVAL_MS: 300,
|
||||
GLITCH_DURATION_MS: 100,
|
||||
PARTICLE_COUNT: 45
|
||||
} as const;
|
||||
|
||||
// ============================================================
|
||||
// UI/UX - MOBILE CONFIG
|
||||
// ============================================================
|
||||
@@ -284,22 +229,4 @@ export const LINEAGE_CONFIG = {
|
||||
JWT_AUDIENCE: "lineage-app" as const
|
||||
} as const;
|
||||
|
||||
// ============================================================
|
||||
// AUDIT & LOGGING
|
||||
// ============================================================
|
||||
|
||||
export const AUDIT_CONFIG = {
|
||||
DEFAULT_QUERY_LIMIT: 100,
|
||||
MAX_RETENTION_DAYS: 90
|
||||
} as const;
|
||||
|
||||
// ============================================================
|
||||
// SESSION CLEANUP
|
||||
// ============================================================
|
||||
|
||||
export const SESSION_CLEANUP_CONFIG = {
|
||||
ENABLED: true,
|
||||
INTERVAL_HOURS: 24,
|
||||
RETENTION_DAYS: 90,
|
||||
RUN_ON_STARTUP: true
|
||||
} as const;
|
||||
|
||||
@@ -58,7 +58,7 @@ declare global {
|
||||
}
|
||||
|
||||
/** Resolve the client-side active site, preferring the SSR-injected id. */
|
||||
export function resolveClientSite(): Site {
|
||||
function resolveClientSite(): Site {
|
||||
if (typeof window === "undefined") return MAIN_SITE;
|
||||
const injected = window.__SITE__;
|
||||
if (injected && SITE_CONFIG[injected]) return SITE_CONFIG[injected];
|
||||
|
||||
@@ -53,9 +53,7 @@ export const AuthProvider: ParentComponent = (props) => {
|
||||
// Get server state using createAsync which works with cache()
|
||||
const serverAuth = createAsync(() => getUserState(), { deferStream: true });
|
||||
|
||||
// Refresh callback that forces re-fetch
|
||||
const refreshAuth = () => {
|
||||
// Manually trigger a re-fetch by calling the revalidate function
|
||||
revalidate(["user-auth-state"]);
|
||||
};
|
||||
|
||||
@@ -70,7 +68,6 @@ export const AuthProvider: ParentComponent = (props) => {
|
||||
// Server handles all token refresh logic
|
||||
// Client just displays the current auth state from server
|
||||
|
||||
// Listen for auth refresh events from external sources (token refresh, etc.)
|
||||
onMount(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
|
||||
160
src/db/create.ts
@@ -1,160 +0,0 @@
|
||||
export const model: { [key: string]: string } = {
|
||||
User: `
|
||||
CREATE TABLE User
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
email TEXT UNIQUE,
|
||||
email_verified INTEGER DEFAULT 0,
|
||||
password_hash TEXT,
|
||||
display_name TEXT,
|
||||
provider TEXT,
|
||||
image TEXT,
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
registered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
failed_attempts INTEGER DEFAULT 0,
|
||||
locked_until TEXT
|
||||
);
|
||||
`,
|
||||
UserProvider: `
|
||||
CREATE TABLE UserProvider
|
||||
(
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL CHECK(provider IN ('email', 'google', 'github', 'apple')),
|
||||
provider_user_id TEXT,
|
||||
email TEXT,
|
||||
display_name TEXT,
|
||||
image TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_used_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_user ON UserProvider (provider, provider_user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_email ON UserProvider (provider, email);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_provider_user_id ON UserProvider (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_provider_provider ON UserProvider (provider);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_provider_email ON UserProvider (email);
|
||||
`,
|
||||
PasswordResetToken: `
|
||||
CREATE TABLE PasswordResetToken
|
||||
(
|
||||
id TEXT PRIMARY KEY,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_password_reset_token ON PasswordResetToken (token);
|
||||
CREATE INDEX IF NOT EXISTS idx_password_reset_user_id ON PasswordResetToken (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_password_reset_expires_at ON PasswordResetToken (expires_at);
|
||||
`,
|
||||
Post: `
|
||||
CREATE TABLE Post
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL UNIQUE,
|
||||
subtitle TEXT,
|
||||
body TEXT NOT NULL,
|
||||
banner_photo TEXT,
|
||||
date TEXT,
|
||||
published INTEGER NOT NULL,
|
||||
category TEXT,
|
||||
author_id TEXT NOT NULL,
|
||||
reads INTEGER NOT NULL DEFAULT 0,
|
||||
attachments TEXT,
|
||||
last_edited_date TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_category ON Post (category);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_published ON Post (published);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_date ON Post (date);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_published_date ON Post (published, date);
|
||||
`,
|
||||
PostLike: `
|
||||
CREATE TABLE PostLike
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
post_id INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_likes_user_post ON PostLike (user_id, post_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_likes_post_id ON PostLike (post_id);
|
||||
`,
|
||||
Comment: `
|
||||
CREATE TABLE Comment
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
body TEXT NOT NULL,
|
||||
post_id INTEGER,
|
||||
parent_comment_id INTEGER,
|
||||
date TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
edited INTEGER NOT NULL DEFAULT 0,
|
||||
commenter_id TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_comment_commenter_id ON Comment (commenter_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_comment_parent_comment_id ON Comment (parent_comment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_comment_post_id ON Comment (post_id);
|
||||
`,
|
||||
CommentReaction: `
|
||||
CREATE TABLE CommentReaction
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
comment_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_reaction_user_type_comment ON CommentReaction (user_id, type, comment_id);
|
||||
`,
|
||||
Connection: `
|
||||
CREATE TABLE Connection
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
connection_id TEXT NOT NULL,
|
||||
post_id INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_connection_post_id ON Connection (post_id);
|
||||
`,
|
||||
Tag: `
|
||||
CREATE TABLE Tag
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
value TEXT NOT NULL,
|
||||
post_id INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tag_post_id ON Tag (post_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tag_value ON Tag (value);
|
||||
CREATE INDEX IF NOT EXISTS idx_tag_post_value ON Tag (post_id, value);
|
||||
`,
|
||||
PostHistory: `
|
||||
CREATE TABLE PostHistory
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER NOT NULL,
|
||||
parent_id INTEGER,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
is_saved INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (post_id) REFERENCES Post(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_history_post_id ON PostHistory (post_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_history_parent_id ON PostHistory (parent_id);
|
||||
`,
|
||||
RateLimit: `
|
||||
CREATE TABLE RateLimit
|
||||
(
|
||||
id TEXT PRIMARY KEY,
|
||||
identifier TEXT NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 1,
|
||||
reset_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
-- Unique constraint on identifier so ON CONFLICT(identifier) atomic upserts
|
||||
-- (see src/server/security.ts checkRateLimit) are well-defined. This makes
|
||||
-- the rate-limit state shared across all instances (p8-010).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_ratelimit_identifier_unique ON RateLimit (identifier);
|
||||
CREATE INDEX IF NOT EXISTS idx_ratelimit_reset_at ON RateLimit (reset_at);
|
||||
`
|
||||
};
|
||||
@@ -34,14 +34,12 @@ function shouldAttemptReload(): boolean {
|
||||
10
|
||||
);
|
||||
|
||||
// Reset counter if outside the time window
|
||||
if (now - lastReloadTime > RELOAD_WINDOW_MS) {
|
||||
sessionStorage.setItem(RELOAD_STORAGE_KEY, "0");
|
||||
sessionStorage.setItem(RELOAD_TIMESTAMP_KEY, now.toString());
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if we've exceeded max reloads
|
||||
if (reloadCount >= MAX_RELOADS) {
|
||||
console.error(
|
||||
`Exceeded ${MAX_RELOADS} reload attempts in ${RELOAD_WINDOW_MS}ms. Stopping to prevent infinite loop.`
|
||||
@@ -49,12 +47,10 @@ function shouldAttemptReload(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Increment counter and allow reload
|
||||
sessionStorage.setItem(RELOAD_STORAGE_KEY, (reloadCount + 1).toString());
|
||||
sessionStorage.setItem(RELOAD_TIMESTAMP_KEY, now.toString());
|
||||
return true;
|
||||
} catch (e) {
|
||||
// If sessionStorage fails, allow reload but log error
|
||||
console.warn("Failed to access sessionStorage:", e);
|
||||
return true;
|
||||
}
|
||||
@@ -102,33 +98,29 @@ function handleChunkError(source: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle runtime chunk loading errors
|
||||
window.addEventListener("error", (event) => {
|
||||
if (
|
||||
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();
|
||||
handleChunkError("error event");
|
||||
}
|
||||
});
|
||||
|
||||
// Handle promise-based chunk loading errors
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
if (
|
||||
event.reason?.message?.includes("Importing a module script failed") ||
|
||||
event.reason?.message?.includes(
|
||||
"Failed to fetch dynamically imported module"
|
||||
)
|
||||
event.reason?.message?.includes("Failed to fetch dynamically imported module") ||
|
||||
event.reason?.message?.includes("error loading dynamically imported module")
|
||||
) {
|
||||
event.preventDefault();
|
||||
handleChunkError("unhandled rejection");
|
||||
}
|
||||
});
|
||||
|
||||
// Clear reload counter on successful page load
|
||||
window.addEventListener("load", () => {
|
||||
// Only clear if we successfully loaded (we're past the critical chunk loading phase)
|
||||
setTimeout(() => {
|
||||
sessionStorage.removeItem(RELOAD_STORAGE_KEY);
|
||||
sessionStorage.removeItem(RELOAD_TIMESTAMP_KEY);
|
||||
|
||||
11
src/env/client.ts
vendored
@@ -38,16 +38,7 @@ export const validateClientEnv = (
|
||||
return envVars as unknown as ClientEnv;
|
||||
};
|
||||
|
||||
const validateAndExportEnv = (): ClientEnv => {
|
||||
try {
|
||||
const validated = validateClientEnv(import.meta.env);
|
||||
return validated;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const env = validateAndExportEnv();
|
||||
export const env = validateClientEnv(import.meta.env);
|
||||
|
||||
export const isMissingEnvVar = (varName: string): boolean => {
|
||||
return !import.meta.env[varName] || import.meta.env[varName]?.trim() === "";
|
||||
|
||||
8
src/env/server.ts
vendored
@@ -67,7 +67,13 @@ const serverEnvSchema = z.object({
|
||||
APPLE_CLIENT_ID_NESSA: z.string().min(1).optional(),
|
||||
APPLE_CLIENT_ID_LINEAGE: z.string().min(1).optional(),
|
||||
VITE_TURNSTILE_SITE_KEY: z.string().min(1),
|
||||
TURNSTILE_SECRET_KEY: z.string().min(1)
|
||||
TURNSTILE_SECRET_KEY: z.string().min(1),
|
||||
NOOK_DB_URL: z.string().min(1),
|
||||
NOOK_DB_TOKEN: z.string().min(1),
|
||||
NOOK_LICENSE_PRIVATE_KEY: z.string().min(1),
|
||||
NOOK_STRIPE_SK: z.string().min(1),
|
||||
NOOK_STRIPE_WEBHOOK_SECRET: z.string().min(1),
|
||||
NOOK_STRIPE_PRICE_ID: z.string().min(1)
|
||||
});
|
||||
|
||||
export type ServerEnv = z.infer<typeof serverEnvSchema>;
|
||||
|
||||
@@ -80,7 +80,6 @@ export const getUserState = query(async (): Promise<UserState> => {
|
||||
* Call this after login, logout, token refresh, email verification
|
||||
*/
|
||||
export function revalidateAuth() {
|
||||
// Revalidate the cache
|
||||
revalidateKey("user-auth-state");
|
||||
|
||||
// Dispatch event to trigger UI updates (client-side only)
|
||||
|
||||
@@ -3,21 +3,6 @@
|
||||
* Note: These utilities should only run in the browser
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetch wrapper for auth checks where 401s are expected and should not trigger console errors
|
||||
*/
|
||||
export async function safeFetch(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const response = await fetch(input, init);
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JWT payload without verification (client-side only)
|
||||
* @param token - JWT token string
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* Cookie utilities for SolidStart
|
||||
* Provides client and server-side cookie management
|
||||
*/
|
||||
|
||||
import { getCookie as getServerCookie, setCookie as setServerCookie } from "vinxi/http";
|
||||
import type { H3Event } from "vinxi/http";
|
||||
|
||||
/**
|
||||
* Get cookie value on the server
|
||||
*/
|
||||
export function getCookie(event: H3Event, name: string): string | undefined {
|
||||
return getServerCookie(event, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cookie on the server
|
||||
*/
|
||||
export function setCookie(
|
||||
event: H3Event,
|
||||
name: string,
|
||||
value: string,
|
||||
options?: {
|
||||
maxAge?: number;
|
||||
expires?: Date;
|
||||
httpOnly?: boolean;
|
||||
secure?: boolean;
|
||||
sameSite?: "strict" | "lax" | "none";
|
||||
path?: string;
|
||||
}
|
||||
) {
|
||||
setServerCookie(event, name, value, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cookie on the server
|
||||
*/
|
||||
export function deleteCookie(event: H3Event, name: string) {
|
||||
setServerCookie(event, name, "", {
|
||||
maxAge: 0,
|
||||
expires: new Date("2016-10-05"),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cookie value on the client (browser)
|
||||
*/
|
||||
export function getClientCookie(name: string): string | undefined {
|
||||
if (typeof document === "undefined") return undefined;
|
||||
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
|
||||
if (parts.length === 2) {
|
||||
return parts.pop()?.split(";").shift();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cookie on the client (browser)
|
||||
*/
|
||||
export function setClientCookie(
|
||||
name: string,
|
||||
value: string,
|
||||
options?: {
|
||||
maxAge?: number;
|
||||
expires?: Date;
|
||||
path?: string;
|
||||
secure?: boolean;
|
||||
sameSite?: "strict" | "lax" | "none";
|
||||
}
|
||||
) {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
let cookieString = `${name}=${value}`;
|
||||
|
||||
if (options?.maxAge) {
|
||||
cookieString += `; max-age=${options.maxAge}`;
|
||||
}
|
||||
|
||||
if (options?.expires) {
|
||||
cookieString += `; expires=${options.expires.toUTCString()}`;
|
||||
}
|
||||
|
||||
if (options?.path) {
|
||||
cookieString += `; path=${options.path}`;
|
||||
} else {
|
||||
cookieString += "; path=/";
|
||||
}
|
||||
|
||||
if (options?.secure) {
|
||||
cookieString += "; secure";
|
||||
}
|
||||
|
||||
if (options?.sameSite) {
|
||||
cookieString += `; samesite=${options.sameSite}`;
|
||||
}
|
||||
|
||||
document.cookie = cookieString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cookie on the client (browser)
|
||||
*/
|
||||
export function deleteClientCookie(name: string) {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
document.cookie = `${name}=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
||||
}
|
||||
@@ -81,7 +81,6 @@ export function formatRelativeTime(
|
||||
return `${diffDay}d ago`;
|
||||
}
|
||||
} else {
|
||||
// style === "long"
|
||||
if (includeSeconds && diffSec < 60) {
|
||||
return `${diffSec} second${diffSec === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
|
||||
@@ -12,14 +12,12 @@ const VERSION_STORAGE_KEY = "app-version-hash";
|
||||
*/
|
||||
function getCurrentVersionHash(): string {
|
||||
try {
|
||||
// Use a combination of script tags to detect version
|
||||
const scripts = Array.from(document.querySelectorAll("script[src]"))
|
||||
.map((s) => (s as HTMLScriptElement).src)
|
||||
.filter((src) => src.includes("/_build/"))
|
||||
.sort()
|
||||
.join(",");
|
||||
|
||||
// Simple hash function
|
||||
let hash = 0;
|
||||
for (let i = 0; i < scripts.length; i++) {
|
||||
const char = scripts.charCodeAt(i);
|
||||
@@ -39,7 +37,6 @@ function getCurrentVersionHash(): string {
|
||||
*/
|
||||
async function checkForNewVersion(): Promise<boolean> {
|
||||
try {
|
||||
// Fetch current page HTML
|
||||
const response = await fetch(window.location.pathname, {
|
||||
method: "HEAD",
|
||||
cache: "no-cache"
|
||||
@@ -64,7 +61,6 @@ async function checkForNewVersion(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Store current ETag for future checks
|
||||
if (newEtag) {
|
||||
sessionStorage.setItem("app-etag", newEtag);
|
||||
}
|
||||
@@ -80,7 +76,6 @@ async function checkForNewVersion(): Promise<boolean> {
|
||||
* Show update notification to user
|
||||
*/
|
||||
function showUpdateNotification(): void {
|
||||
// Only show once per session
|
||||
if (sessionStorage.getItem("update-notification-shown")) {
|
||||
return;
|
||||
}
|
||||
@@ -147,7 +142,6 @@ function showUpdateNotification(): void {
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Auto-remove after 30 seconds
|
||||
setTimeout(() => {
|
||||
if (notification.parentElement) {
|
||||
notification.style.animation = "slideIn 0.3s ease-out reverse";
|
||||
@@ -162,11 +156,9 @@ function showUpdateNotification(): void {
|
||||
export function startDeploymentMonitoring(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// Store initial version
|
||||
const initialVersion = getCurrentVersionHash();
|
||||
sessionStorage.setItem(VERSION_STORAGE_KEY, initialVersion);
|
||||
|
||||
// Periodic version check
|
||||
const intervalId = setInterval(async () => {
|
||||
const hasNewVersion = await checkForNewVersion();
|
||||
if (hasNewVersion) {
|
||||
@@ -174,7 +166,6 @@ export function startDeploymentMonitoring(): void {
|
||||
}
|
||||
}, VERSION_CHECK_INTERVAL);
|
||||
|
||||
// Check on visibility change (user returns to tab)
|
||||
const handleVisibilityChange = async () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
const hasNewVersion = await checkForNewVersion();
|
||||
@@ -186,7 +177,6 @@ export function startDeploymentMonitoring(): void {
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
// Cleanup function
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).__cleanupDeploymentMonitoring = () => {
|
||||
clearInterval(intervalId);
|
||||
|
||||
@@ -16,16 +16,17 @@ import {
|
||||
} from "./nav-config";
|
||||
import { SITE_CONFIG, type SiteId } from "./site-context";
|
||||
|
||||
const ALL_SITES: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo"];
|
||||
const ALL_SITES: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo", "nook"];
|
||||
|
||||
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([
|
||||
"Home",
|
||||
"Blog",
|
||||
"Downloads",
|
||||
"Resume",
|
||||
"Contact",
|
||||
"Privacy",
|
||||
"GitHub",
|
||||
"LinkedIn"
|
||||
]);
|
||||
@@ -80,7 +81,7 @@ describe("NAV_CONFIG — href correctness", () => {
|
||||
});
|
||||
|
||||
it("subdomain nav hrefs are public browser paths, never the internal rewritten prefix", () => {
|
||||
for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) {
|
||||
for (const id of ["nessa", "lineage", "gaze", "inputhalo", "nook"] as SiteId[]) {
|
||||
for (const item of NAV_CONFIG[id]) {
|
||||
// No subdomain-prefixed paths leak into the public nav.
|
||||
expect(item.href.startsWith(`/${id}/`)).toBe(false);
|
||||
@@ -111,7 +112,7 @@ describe("NAV_CONFIG — href correctness", () => {
|
||||
|
||||
describe("NAV_CONFIG — auth-scoping by construction", () => {
|
||||
it("no subdomain nav item sets showLoggedIn / showLoggedOut", () => {
|
||||
for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) {
|
||||
for (const id of ["nessa", "lineage", "gaze", "inputhalo", "nook"] as SiteId[]) {
|
||||
for (const item of NAV_CONFIG[id]) {
|
||||
expect(item.showLoggedIn).toBeUndefined();
|
||||
expect(item.showLoggedOut).toBeUndefined();
|
||||
|
||||
@@ -80,6 +80,7 @@ export const NAV_CONFIG: Record<SiteId, NavItem[]> = {
|
||||
{ label: "Downloads", href: "/downloads", icon: "downloads" },
|
||||
{ label: "Resume", href: "/resume", icon: "resume" },
|
||||
{ label: "Contact", href: "/contact", icon: "contact" },
|
||||
{ label: "Privacy", href: "/privacy-policy", icon: "privacy" },
|
||||
{
|
||||
label: "GitHub",
|
||||
href: "https://github.com/MikeFreno/",
|
||||
@@ -116,6 +117,10 @@ export const NAV_CONFIG: Record<SiteId, NavItem[]> = {
|
||||
{ label: "Contact", href: "/contact", icon: "contact" },
|
||||
{ label: "Privacy", href: "/privacy", icon: "privacy" },
|
||||
{ label: "Downloads", href: "/downloads", icon: "downloads" }
|
||||
],
|
||||
nook: [
|
||||
{ label: "Home", href: "/", icon: "home" },
|
||||
{ label: "Privacy", href: "/privacy", icon: "privacy" }
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ export function initPerformanceTracking() {
|
||||
|
||||
const supported = new Set(PerformanceObserver.supportedEntryTypes ?? []);
|
||||
|
||||
// Observe LCP
|
||||
if (supported.has("largest-contentful-paint")) {
|
||||
try {
|
||||
const lcpObserver = new PerformanceObserver((entryList) => {
|
||||
@@ -40,7 +39,6 @@ export function initPerformanceTracking() {
|
||||
}
|
||||
}
|
||||
|
||||
// Observe CLS
|
||||
if (supported.has("layout-shift")) {
|
||||
try {
|
||||
const clsObserver = new PerformanceObserver((entryList) => {
|
||||
@@ -59,7 +57,6 @@ export function initPerformanceTracking() {
|
||||
}
|
||||
}
|
||||
|
||||
// Observe FID
|
||||
if (supported.has("first-input")) {
|
||||
try {
|
||||
const fidObserver = new PerformanceObserver((entryList) => {
|
||||
@@ -74,7 +71,6 @@ export function initPerformanceTracking() {
|
||||
}
|
||||
}
|
||||
|
||||
// Observe INP (event timing)
|
||||
if (supported.has("event")) {
|
||||
try {
|
||||
const interactions: number[] = [];
|
||||
@@ -96,7 +92,6 @@ export function initPerformanceTracking() {
|
||||
}
|
||||
}
|
||||
|
||||
// Get navigation timing metrics
|
||||
window.addEventListener("load", () => {
|
||||
setTimeout(() => {
|
||||
const navTiming = performance.getEntriesByType(
|
||||
@@ -110,7 +105,6 @@ export function initPerformanceTracking() {
|
||||
metrics.loadComplete = navTiming.loadEventEnd - navTiming.fetchStart;
|
||||
}
|
||||
|
||||
// Get FCP
|
||||
const paintEntries = performance.getEntriesByType("paint");
|
||||
const fcpEntry = paintEntries.find(
|
||||
(entry) => entry.name === "first-contentful-paint"
|
||||
@@ -135,7 +129,6 @@ export function initPerformanceTracking() {
|
||||
}
|
||||
|
||||
function sendMetrics() {
|
||||
// Only send if we have at least one metric
|
||||
if (Object.keys(metrics).length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -157,7 +150,6 @@ function sendMetrics() {
|
||||
const blob = new Blob([payload], { type: "application/json" });
|
||||
navigator.sendBeacon(apiUrl, blob);
|
||||
} else {
|
||||
// Fallback to fetch with keepalive
|
||||
fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -168,6 +160,5 @@ function sendMetrics() {
|
||||
);
|
||||
}
|
||||
|
||||
// Clear metrics after sending
|
||||
metrics = {};
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ export default async function AddImageToS3(
|
||||
throw new Error("Failed to upload file to S3");
|
||||
}
|
||||
|
||||
// Create thumbnails for images (blog posts only)
|
||||
if (type === "blog" && isImage) {
|
||||
try {
|
||||
const thumbnail = await resizeImage(file, 200, 200, 0.8);
|
||||
|
||||
@@ -81,7 +81,7 @@ describe("resolveSiteFromHost", () => {
|
||||
});
|
||||
|
||||
it("every SITE_CONFIG entry has a non-empty baseRoutePrefix for subdomains", () => {
|
||||
for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) {
|
||||
for (const id of ["nessa", "lineage", "gaze", "inputhalo", "nook"] as SiteId[]) {
|
||||
expect(SITE_CONFIG[id].baseRoutePrefix).toBe(`/${id}`);
|
||||
expect(SITE_CONFIG[id].subdomain).toBe(id);
|
||||
expect(SITE_CONFIG[id].titleSuffix).toBe(
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* `src/server/site-context-server.ts` builds on `resolveSiteFromHost`.
|
||||
*/
|
||||
|
||||
export type SiteId = "main" | "nessa" | "lineage" | "gaze" | "inputhalo";
|
||||
export type SiteId = "main" | "nessa" | "lineage" | "gaze" | "inputhalo" | "nook";
|
||||
|
||||
export interface Site {
|
||||
/** Canonical id, also serialized into `<html data-site>` and `window.__SITE__`. */
|
||||
@@ -122,6 +122,18 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
|
||||
brandColor: "#41a5ff",
|
||||
ogDefaultImage: "/inputhalo/og-default.png",
|
||||
faviconPath: "/inputhalo/favicon/favicon.ico"
|
||||
},
|
||||
nook: {
|
||||
id: "nook",
|
||||
subdomain: "nook",
|
||||
domain: `nook.${BASE_DOMAIN}`,
|
||||
baseRoutePrefix: "/nook",
|
||||
displayName: "The Nook",
|
||||
titleSuffix: " | The Nook",
|
||||
brandColor: "#4C9FBC",
|
||||
brandColorDark: "#4C9FBC",
|
||||
ogDefaultImage: "/nook/og-default.png",
|
||||
faviconPath: "/nook/favicon/favicon.ico"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -130,7 +142,8 @@ const SUBDOMAIN_SITES: ReadonlyArray<Site> = [
|
||||
SITE_CONFIG.nessa,
|
||||
SITE_CONFIG.lineage,
|
||||
SITE_CONFIG.gaze,
|
||||
SITE_CONFIG.inputhalo
|
||||
SITE_CONFIG.inputhalo,
|
||||
SITE_CONFIG.nook
|
||||
];
|
||||
|
||||
/** Matches `<sub>.localhost` and `<sub>.localhost:<port>` (dev only). */
|
||||
|
||||
@@ -22,7 +22,7 @@ function xmlEscape(s: string): string {
|
||||
/**
|
||||
* Generate a single `<url>` element for a given entry on a site.
|
||||
*/
|
||||
export function urlElement(site: Site, entry: SitemapEntry): string {
|
||||
function urlElement(site: Site, entry: SitemapEntry): string {
|
||||
const loc = `https://${site.domain}${entry.path}`;
|
||||
return ` <url>
|
||||
<loc>${xmlEscape(loc)}</loc>
|
||||
|
||||
@@ -28,13 +28,11 @@ describe("generateSitemap", () => {
|
||||
it("generates valid XML for main site with all expected routes", () => {
|
||||
const xml = generateSitemap(SITE_CONFIG.main, SITEMAP_ROUTES.main);
|
||||
|
||||
// Basic structure
|
||||
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
|
||||
expect(xml).toContain(
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
|
||||
);
|
||||
|
||||
// All main site paths present with freno.me domain
|
||||
const locs = extractLocs(xml);
|
||||
expect(locs).toContain("https://freno.me/");
|
||||
expect(locs).toContain("https://freno.me/blog");
|
||||
@@ -42,21 +40,19 @@ describe("generateSitemap", () => {
|
||||
expect(locs).toContain("https://freno.me/login");
|
||||
expect(locs).toContain("https://freno.me/resume");
|
||||
expect(locs).toContain("https://freno.me/downloads");
|
||||
expect(locs).toContain("https://freno.me/privacy-policy");
|
||||
|
||||
// Exactly 6 entries
|
||||
expect(locs.length).toBe(6);
|
||||
expect(locs.length).toBe(7);
|
||||
|
||||
// Verify well-formedness by checking balanced tags
|
||||
expect(xml).toContain("</urlset>");
|
||||
const urlOpens = (xml.match(/<url>/g) || []).length;
|
||||
const urlCloses = (xml.match(/<\/url>/g) || []).length;
|
||||
expect(urlOpens).toBe(urlCloses);
|
||||
expect(urlOpens).toBe(6);
|
||||
expect(urlOpens).toBe(7);
|
||||
});
|
||||
|
||||
it("generates valid parseable XML for lineage site", () => {
|
||||
const xml = generateSitemap(SITE_CONFIG.lineage, SITEMAP_ROUTES.lineage);
|
||||
// Verify balanced tags
|
||||
expect(xml).toContain("</urlset>");
|
||||
const urlOpens = (xml.match(/<url>/g) || []).length;
|
||||
const urlCloses = (xml.match(/<\/url>/g) || []).length;
|
||||
@@ -73,7 +69,6 @@ describe("generateSitemap", () => {
|
||||
expect(locs).toContain("https://nessa.freno.me/privacy");
|
||||
expect(locs.length).toBe(3);
|
||||
|
||||
// No leakage from main site
|
||||
for (const loc of locs) {
|
||||
expect(loc).not.toContain("://freno.me/");
|
||||
expect(loc).not.toContain("://freno.me/blog");
|
||||
@@ -152,7 +147,7 @@ describe("SITEMAP_ROUTES validation", () => {
|
||||
});
|
||||
|
||||
it("each site has at least the home page entry", () => {
|
||||
const siteIds: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo"];
|
||||
const siteIds: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo", "nook"];
|
||||
for (const id of siteIds) {
|
||||
expect(SITEMAP_ROUTES[id].some((e) => e.path === "/")).toBe(true);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
|
||||
{ path: "/contact", changefreq: "monthly", priority: 0.7 },
|
||||
{ path: "/login", changefreq: "monthly", priority: 0.5 },
|
||||
{ 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 ──────────────────────────────────────────────────
|
||||
@@ -77,5 +78,11 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
|
||||
{ path: "/", changefreq: "weekly", priority: 1.0 },
|
||||
{ path: "/contact", changefreq: "monthly", priority: 0.6 },
|
||||
{ path: "/privacy", changefreq: "yearly", priority: 0.4 }
|
||||
],
|
||||
|
||||
nook: [
|
||||
{ path: "/", changefreq: "weekly", priority: 1.0 },
|
||||
{ path: "/checkout", changefreq: "monthly", priority: 0.5 },
|
||||
{ path: "/privacy", changefreq: "yearly", priority: 0.4 }
|
||||
]
|
||||
};
|
||||
|
||||
@@ -45,15 +45,12 @@ export function useCountdown(options: UseCountdownOptions = {}) {
|
||||
};
|
||||
|
||||
const startCountdown = (expiresAt: string | Date) => {
|
||||
// Clear any existing interval
|
||||
if (intervalId !== null) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
|
||||
// Calculate immediately
|
||||
calculateRemaining(expiresAt);
|
||||
|
||||
// Then update every second
|
||||
intervalId = setInterval(() => calculateRemaining(expiresAt), 1000);
|
||||
};
|
||||
|
||||
@@ -64,7 +61,6 @@ export function useCountdown(options: UseCountdownOptions = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
onCleanup(() => {
|
||||
stopCountdown();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { HttpStatusCode } from "@solidjs/start";
|
||||
import { HttpHeader, HttpStatusCode } from "@solidjs/start";
|
||||
import { useLocation, useNavigate } from "@solidjs/router";
|
||||
import { createSignal, onCleanup, onMount, Show } from "solid-js";
|
||||
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."
|
||||
/>
|
||||
<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
|
||||
errorContent={errorContent}
|
||||
quickActions={quickActions}
|
||||
|
||||
@@ -39,7 +39,6 @@ export async function GET(_event: APIEvent) {
|
||||
});
|
||||
}
|
||||
|
||||
// Stream the XML content from S3
|
||||
const body = await response.Body.transformToString();
|
||||
|
||||
return new Response(body, {
|
||||
|
||||
@@ -39,7 +39,6 @@ export async function GET(_event: APIEvent) {
|
||||
});
|
||||
}
|
||||
|
||||
// Stream the XML content from S3
|
||||
const body = await response.Body.transformToString();
|
||||
|
||||
return new Response(body, {
|
||||
|
||||
61
src/routes/api/TheNook/appcast.xml.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { env } from "~/env/server";
|
||||
|
||||
/**
|
||||
* Serves the TheNook appcast.xml file from S3
|
||||
* This endpoint is used by Sparkle updater to check for new versions
|
||||
*
|
||||
* URL: https://freno.me/api/TheNook/appcast.xml
|
||||
*/
|
||||
export async function GET(_event: APIEvent) {
|
||||
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
|
||||
const key = "api/TheNook/appcast.xml";
|
||||
|
||||
const credentials = {
|
||||
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||
};
|
||||
|
||||
try {
|
||||
const client = new S3Client({
|
||||
region: env.AWS_REGION,
|
||||
credentials: credentials
|
||||
});
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key
|
||||
});
|
||||
|
||||
const response = await client.send(command);
|
||||
|
||||
if (!response.Body) {
|
||||
return new Response("Appcast not found", {
|
||||
status: 404,
|
||||
headers: {
|
||||
"Content-Type": "text/plain"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const body = await response.Body.transformToString();
|
||||
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=300", // Cache for 5 minutes
|
||||
"Access-Control-Allow-Origin": "*" // Allow CORS for Sparkle appcast
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch appcast:", error);
|
||||
return new Response("Internal Server Error", {
|
||||
status: 500,
|
||||
headers: {
|
||||
"Content-Type": "text/plain"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ export async function GET(event: APIEvent) {
|
||||
"emailLogin",
|
||||
(caller, params) => caller.auth.emailLogin(params),
|
||||
(error) => {
|
||||
// Check for token expiration
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
const isTokenError =
|
||||
message.includes("expired") || message.includes("invalid");
|
||||
|
||||
@@ -62,17 +62,14 @@ export async function GET(event: APIEvent) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Create tRPC caller to invoke the emailVerification procedure
|
||||
const caller = await createServerCaller(event);
|
||||
|
||||
// Call the email verification handler
|
||||
const result = await caller.auth.emailVerification({
|
||||
email,
|
||||
token
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// Show success page
|
||||
return new Response(
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
@@ -136,7 +133,6 @@ export async function GET(event: APIEvent) {
|
||||
} catch (error) {
|
||||
console.error("Email verification callback error:", error);
|
||||
|
||||
// Check if it's a token expiration error
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "server_error";
|
||||
const isTokenError =
|
||||
|
||||
@@ -24,12 +24,13 @@ export async function GET(event: APIEvent) {
|
||||
});
|
||||
}
|
||||
|
||||
// Validate filename format (only allow Gaze or InputHalo files)
|
||||
const validPrefixes = ["Gaze", "InputHalo"];
|
||||
const validPrefixes = ["Gaze", "InputHalo", "TheNook"];
|
||||
const isValidPrefix = validPrefixes.some((prefix) => filename.startsWith(prefix));
|
||||
if (
|
||||
!isValidPrefix ||
|
||||
(!filename.endsWith(".dmg") && !filename.endsWith(".delta"))
|
||||
(!filename.endsWith(".dmg") &&
|
||||
!filename.endsWith(".delta") &&
|
||||
!filename.endsWith(".zip"))
|
||||
) {
|
||||
return new Response("Invalid file format", {
|
||||
status: 400,
|
||||
@@ -70,12 +71,12 @@ export async function GET(event: APIEvent) {
|
||||
});
|
||||
}
|
||||
|
||||
// Get content type based on file extension
|
||||
const contentType = filename.endsWith(".dmg")
|
||||
? "application/x-apple-diskimage"
|
||||
: "application/octet-stream";
|
||||
const contentType = filename.endsWith(".zip")
|
||||
? "application/zip"
|
||||
: filename.endsWith(".dmg")
|
||||
? "application/x-apple-diskimage"
|
||||
: "application/octet-stream";
|
||||
|
||||
// Stream the file content from S3
|
||||
const body = await response.Body.transformToByteArray();
|
||||
|
||||
console.log(`✓ Serving ${filename} (${body.length} bytes)`);
|
||||
@@ -93,7 +94,6 @@ export async function GET(event: APIEvent) {
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch ${filename} from S3:`, error);
|
||||
|
||||
// Check if it's a not found error
|
||||
if (error instanceof Error && error.name === "NoSuchKey") {
|
||||
return new Response("File not found in storage", {
|
||||
status: 404,
|
||||
|
||||
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"
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -58,8 +58,3 @@ export function bearerToken(event: APIEvent): string | null {
|
||||
const m = auth.match(/^Bearer\s+(.+)$/i);
|
||||
return m?.[1]?.trim() ?? null;
|
||||
}
|
||||
|
||||
/** Parse the JSON request body. */
|
||||
export async function jsonBody<T = any>(event: APIEvent): Promise<T> {
|
||||
return await event.request.json();
|
||||
}
|
||||
|
||||
21
src/routes/api/the-nook/_lib.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Shared JSON response helpers for the The Nook license API routes.
|
||||
* Underscore-prefixed file — not a route.
|
||||
*/
|
||||
|
||||
export function json(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
export function error(message: string, status: number): Response {
|
||||
return json({ error: message }, status);
|
||||
}
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function isUuid(value: unknown): value is string {
|
||||
return typeof value === "string" && UUID_RE.test(value);
|
||||
}
|
||||
103
src/routes/api/the-nook/activate.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { Client } from "@libsql/client/web";
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { NookConnectionFactory } from "~/server/db-connections";
|
||||
import { nookSchemaBootstrap, verifyLicenseKey } from "~/server/nook";
|
||||
import { json, error, isUuid } from "./_lib";
|
||||
|
||||
/**
|
||||
* POST /api/the-nook/activate
|
||||
* Body: { key, deviceFingerprint, deviceName }
|
||||
*
|
||||
* Re-verifies the Ed25519 license signature server-side (defense in depth —
|
||||
* the app already verified it offline), enforces the 3-device activation cap,
|
||||
* and records/refreshes an activation row. Re-activating an already-activated
|
||||
* fingerprint is idempotent and does NOT consume an extra seat.
|
||||
*/
|
||||
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 key = typeof b.key === "string" ? b.key : "";
|
||||
const fingerprint = b.deviceFingerprint;
|
||||
const deviceName =
|
||||
typeof b.deviceName === "string" && b.deviceName.length > 0
|
||||
? b.deviceName
|
||||
: "Mac";
|
||||
|
||||
if (!key || !isUuid(fingerprint)) {
|
||||
return error("Invalid request", 400);
|
||||
}
|
||||
if (!verifyLicenseKey(key)) {
|
||||
return error("Invalid license key", 400);
|
||||
}
|
||||
|
||||
await nookSchemaBootstrap;
|
||||
const conn = NookConnectionFactory();
|
||||
|
||||
const licenseRes = await conn.execute({
|
||||
sql: "SELECT id, email, revoked, max_devices FROM licenses WHERE key = ?",
|
||||
args: [key]
|
||||
});
|
||||
if (licenseRes.rows.length === 0) {
|
||||
return error("License not found", 404);
|
||||
}
|
||||
const license = licenseRes.rows[0] as {
|
||||
id: string;
|
||||
email: string;
|
||||
revoked: number;
|
||||
maxDevices: number;
|
||||
};
|
||||
if (license.revoked === 1) {
|
||||
return error("License revoked", 403);
|
||||
}
|
||||
|
||||
// Idempotent re-activation: already-active fingerprint does not consume a seat.
|
||||
const existingRes = await conn.execute({
|
||||
sql: `SELECT id FROM activations
|
||||
WHERE license_id = ? AND device_fingerprint = ? AND deactivated_at IS NULL`,
|
||||
args: [license.id, fingerprint]
|
||||
});
|
||||
if (existingRes.rows.length > 0) {
|
||||
const existing = existingRes.rows[0] as { id: string }; // row we inserted as an activation
|
||||
await conn.execute({
|
||||
sql: "UPDATE activations SET activated_at = ? WHERE id = ?",
|
||||
args: [new Date().toISOString(), existing.id]
|
||||
});
|
||||
const count = await activeCount(conn, license.id);
|
||||
return json({ ok: true, email: license.email, activatedCount: count, maxDevices: license.maxDevices });
|
||||
}
|
||||
|
||||
const count = await activeCount(conn, license.id);
|
||||
if (count >= license.maxDevices) {
|
||||
return error(`Activation limit reached (${license.maxDevices} devices)`, 409);
|
||||
}
|
||||
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO activations
|
||||
(id, license_id, device_fingerprint, device_name, activated_at, deactivated_at)
|
||||
VALUES (?, ?, ?, ?, ?, NULL)`,
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
license.id,
|
||||
fingerprint,
|
||||
deviceName,
|
||||
new Date().toISOString()
|
||||
]
|
||||
});
|
||||
return json({ ok: true, email: license.email, activatedCount: count + 1, maxDevices: license.maxDevices });
|
||||
}
|
||||
|
||||
async function activeCount(
|
||||
conn: Client,
|
||||
licenseId: string
|
||||
): Promise<number> {
|
||||
const res = await conn.execute({
|
||||
sql: "SELECT COUNT(*) AS n FROM activations WHERE license_id = ? AND deactivated_at IS NULL",
|
||||
args: [licenseId]
|
||||
});
|
||||
return Number((res.rows[0] as { n: number | bigint }).n);
|
||||
}
|
||||
32
src/routes/api/the-nook/by-session.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { NookConnectionFactory } from "~/server/db-connections";
|
||||
import { nookSchemaBootstrap } from "~/server/nook";
|
||||
import { json, error } from "./_lib";
|
||||
|
||||
/**
|
||||
* GET /api/the-nook/by-session?session_id=cs_...
|
||||
*
|
||||
* Used by the success page (nook.freno.me/success) to retrieve the license
|
||||
* key once the `checkout.session.completed` webhook has landed. The page
|
||||
* polls this endpoint until the license row exists.
|
||||
*/
|
||||
export async function GET(event: APIEvent) {
|
||||
const url = new URL(event.request.url);
|
||||
const sessionId = url.searchParams.get("session_id");
|
||||
if (!sessionId || !sessionId.startsWith("cs_")) {
|
||||
return error("Invalid session id", 400);
|
||||
}
|
||||
|
||||
await nookSchemaBootstrap;
|
||||
const conn = NookConnectionFactory();
|
||||
|
||||
const res = await conn.execute({
|
||||
sql: "SELECT key, email FROM licenses WHERE stripe_session_id = ?",
|
||||
args: [sessionId]
|
||||
});
|
||||
if (res.rows.length === 0) {
|
||||
return error("Not found", 404);
|
||||
}
|
||||
const license = res.rows[0] as { key: string; email: string };
|
||||
return json({ key: license.key, email: license.email });
|
||||
}
|
||||