Compare commits
2 Commits
master
...
641d3ed0ed
| Author | SHA1 | Date | |
|---|---|---|---|
| 641d3ed0ed | |||
| b6b2bc8ef6 |
@@ -75,11 +75,3 @@ 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,7 +31,3 @@ perf-results-*.json
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
# pygienium run-state and check artifacts
|
||||
.pygienium/
|
||||
scripts/
|
||||
.cache
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
|
||||
This project serves four product subdomains (`nessa.freno.me`, `lineage.freno.me`, `gaze.freno.me`, `inputhalo.freno.me`) plus the personal site on `freno.me`. See `docs/subdomain-setup.md` for DNS/Vercel configuration.
|
||||
|
||||
- **Route placement:** Subdomain pages live under `src/routes/<prefix>/*` (e.g. `src/routes/nessa/...`) as the source-of-truth content components. The public browser path on each subdomain (e.g. `lineage.freno.me/privacy` → `/privacy`) is served by **host-aware root route dispatch**: the root route file (`src/routes/privacy.tsx`, `deletion.tsx`, `contact.tsx`, `downloads.tsx`, `index.tsx`) resolves on the public path on BOTH server and client, then selects the matching subdomain component via `useSite()`. Do **not** rewrite the request path server-side — SolidStart's client `Router` matches on `window.location.pathname`, so a server-only rewrite diverges SSR from hydration and causes hydration mismatches. (The `vercel.json` host `rewrites` are declared but **not applied** by Vercel — the Nitro `vercel` preset emits a Build Output API `config.json` whose `routes` array fully replaces `vercel.json` rewrites/redirects/headers — so the host-aware root dispatch is what actually serves subdomain pages.)
|
||||
- **Route placement:** Subdomain pages live under `src/routes/<prefix>/*` (e.g. `src/routes/nessa/...`). The `vercel.json` host-based rewrites map each subdomain to its prefix.
|
||||
- **Site context:** Use `useSite()` (SolidJS) or `getSiteFromEvent`/`getSiteFromRequest` (server) from `src/lib/site-context.ts` to detect the current site. Never host-snoop in route files — SolidStart's router can't match on host.
|
||||
- **API routes:** `/api/*` is a shared pool — subdomain API requests pass through to existing routes via vercel.json pass-through rewrites (ordering matters).
|
||||
- **Auth:** Host-scoped only — no cookie domain broadening.
|
||||
|
||||
@@ -3,6 +3,7 @@ import tailwindcss from "@tailwindcss/vite";
|
||||
import { sentryVitePlugin as sentryPlugin } from "@sentry/vite-plugin";
|
||||
|
||||
export default defineConfig({
|
||||
middleware: "./src/middleware.ts",
|
||||
vite: {
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
@@ -10,7 +11,21 @@ export default defineConfig({
|
||||
org: "mikefreno",
|
||||
project: "freno-dev",
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
telemetry: false
|
||||
telemetry: false,
|
||||
sourcemaps: {
|
||||
assets: [
|
||||
{
|
||||
type: "bundle",
|
||||
path: "dist/client/assets/",
|
||||
urlPrefix: "~/assets/"
|
||||
},
|
||||
{
|
||||
type: "sourcemap",
|
||||
path: "dist/client/assets/",
|
||||
urlPrefix: "~/assets/"
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
],
|
||||
build: {
|
||||
@@ -38,6 +53,6 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
server: {
|
||||
preset: "vercel"
|
||||
preset: "node-server"
|
||||
}
|
||||
});
|
||||
|
||||
18
package.json
@@ -5,20 +5,21 @@
|
||||
"dev": "vinxi dev",
|
||||
"dev-flush": "vinxi dev --env-file=.env",
|
||||
"build": "vinxi build",
|
||||
"vercel-build": "rm -rf .vinxi .output node_modules/.vite && vinxi build",
|
||||
"start": "NODE_OPTIONS='--import ./public/instrument.server.mjs' vinxi start",
|
||||
"test": "bun test",
|
||||
"test:security": "bun test src/server/security/",
|
||||
"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",
|
||||
"nook:grant": "NODE_ENV=production bun --env-file=.env scripts/grant-nook-license.ts"
|
||||
"perf:compare": "bun run scripts/perf-compare.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,11 +27,13 @@
|
||||
"@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",
|
||||
@@ -40,11 +43,13 @@
|
||||
"@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",
|
||||
@@ -65,11 +70,18 @@
|
||||
"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"
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"trpc-panel": "^1.3.4",
|
||||
"vite-bundle-visualizer": "^1.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 5.9 MiB |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "Gaze",
|
||||
"short_name": "Gaze",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/gaze/favicon/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/gaze/favicon/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#002cff",
|
||||
"background_color": "#002cff",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 5.0 MiB |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "InputHalo",
|
||||
"short_name": "InputHalo",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/inputhalo/favicon/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/inputhalo/favicon/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#41a5ff",
|
||||
"background_color": "#41a5ff",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 860 KiB |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "Life and Lineage",
|
||||
"short_name": "Lineage",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/lineage/favicon/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/lineage/favicon/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#a13536",
|
||||
"background_color": "#a13536",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 64 KiB |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "Nessa",
|
||||
"short_name": "Nessa",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/nessa/favicon/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/nessa/favicon/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#527640",
|
||||
"background_color": "#527640",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 392 KiB |
@@ -1,21 +1,9 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Allow: /blog
|
||||
|
||||
# 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
|
||||
Allow: /projects
|
||||
Disallow: /login
|
||||
Disallow: /success
|
||||
Disallow: /test
|
||||
Disallow: /debug/
|
||||
Disallow: /databaseMGMT
|
||||
|
||||
Sitemap: https://www.freno.me/sitemap.xml
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* 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.");
|
||||
@@ -1,28 +0,0 @@
|
||||
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,6 +96,7 @@ 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;
|
||||
@@ -106,6 +107,7 @@ 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;
|
||||
@@ -119,6 +121,7 @@ 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;
|
||||
@@ -145,6 +148,7 @@ 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;
|
||||
@@ -181,6 +185,7 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
||||
);
|
||||
}
|
||||
|
||||
// Overall Summary
|
||||
console.log(
|
||||
"\n\n═══════════════════════════════════════════════════════════════════"
|
||||
);
|
||||
@@ -333,6 +338,7 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
||||
);
|
||||
}
|
||||
|
||||
// Specific findings
|
||||
const reqPercent = calculatePercentChange(baseAvg.requests, optAvg.requests);
|
||||
if (reqPercent < -5) {
|
||||
console.log(
|
||||
|
||||
@@ -61,6 +61,7 @@ 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" },
|
||||
@@ -77,6 +78,7 @@ 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",
|
||||
@@ -100,6 +102,7 @@ async function setupPerformanceObservers(page: Page) {
|
||||
interactions: [] as number[]
|
||||
};
|
||||
|
||||
// Observe LCP
|
||||
if ("PerformanceObserver" in window) {
|
||||
try {
|
||||
const lcpObserver = new PerformanceObserver((entryList) => {
|
||||
@@ -115,8 +118,10 @@ 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()) {
|
||||
@@ -133,8 +138,10 @@ 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;
|
||||
@@ -147,8 +154,10 @@ 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()) {
|
||||
@@ -157,8 +166,10 @@ 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()) {
|
||||
@@ -183,6 +194,7 @@ async function setupPerformanceObservers(page: Page) {
|
||||
});
|
||||
inpObserver.observe({ type: "event", buffered: true });
|
||||
} catch (e) {
|
||||
// Event timing not supported
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -191,14 +203,18 @@ 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"
|
||||
@@ -206,6 +222,7 @@ 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,
|
||||
@@ -215,6 +232,7 @@ 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;
|
||||
@@ -240,6 +258,7 @@ 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
|
||||
@@ -256,6 +275,7 @@ async function collectPerformanceMetrics(
|
||||
}
|
||||
}
|
||||
|
||||
// Get resource timing
|
||||
const resources = performance.getEntriesByType(
|
||||
"resource"
|
||||
) as PerformanceResourceTiming[];
|
||||
@@ -298,6 +318,7 @@ async function collectPerformanceMetrics(
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate long task duration
|
||||
let taskDuration = 0;
|
||||
if (observedMetrics.longTasks && observedMetrics.longTasks.length > 0) {
|
||||
taskDuration = observedMetrics.longTasks.reduce(
|
||||
@@ -306,6 +327,7 @@ async function collectPerformanceMetrics(
|
||||
);
|
||||
}
|
||||
|
||||
// Get more granular performance entries
|
||||
let jsExecutionTime = 0;
|
||||
let layoutDuration = 0;
|
||||
let paintDuration = 0;
|
||||
@@ -317,6 +339,7 @@ async function collectPerformanceMetrics(
|
||||
}
|
||||
});
|
||||
|
||||
// Check for script evaluation entries
|
||||
const entries = performance.getEntries();
|
||||
entries.forEach((entry: any) => {
|
||||
if (entry.entryType === "measure") {
|
||||
@@ -376,6 +399,7 @@ 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();
|
||||
@@ -386,16 +410,20 @@ 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);
|
||||
|
||||
@@ -408,6 +436,7 @@ async function testPagePerformance(
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
const average = calculateAverage(runs);
|
||||
const median = calculateMedian(runs);
|
||||
const p95 = calculatePercentile(runs, 95);
|
||||
@@ -619,6 +648,7 @@ 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,
|
||||
@@ -666,6 +696,7 @@ 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];
|
||||
@@ -676,6 +707,7 @@ function printResults(results: TestResult[]) {
|
||||
);
|
||||
}
|
||||
|
||||
// Find pages with slow LCP
|
||||
const slowLCP = results.filter((r) => r.median.lcp > 2500);
|
||||
if (slowLCP.length > 0) {
|
||||
console.log(
|
||||
@@ -683,6 +715,7 @@ 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(
|
||||
@@ -690,6 +723,7 @@ function printResults(results: TestResult[]) {
|
||||
);
|
||||
}
|
||||
|
||||
// Find pages with high INP
|
||||
const highINP = results.filter((r) => r.median.inp > 200);
|
||||
if (highINP.length > 0) {
|
||||
console.log(
|
||||
@@ -706,6 +740,7 @@ 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) {
|
||||
@@ -735,8 +770,10 @@ async function main() {
|
||||
|
||||
await browser.close();
|
||||
|
||||
// Print results
|
||||
printResults(results);
|
||||
|
||||
// Save results to JSON file
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, "-")
|
||||
|
||||
195
src/app.css
@@ -74,11 +74,6 @@
|
||||
--color-base: #fbf1c7;
|
||||
--color-mantle: #f3eac1;
|
||||
--color-crust: #e7deb7;
|
||||
|
||||
/* Button text colors: white on dark bg variants (primary/danger),
|
||||
theme text on light bg variants (secondary). */
|
||||
--color-button-text: #ffffff;
|
||||
--color-button-text-alt: var(--color-text);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -109,11 +104,6 @@
|
||||
--color-base: #1e1e2e;
|
||||
--color-mantle: #141620;
|
||||
--color-crust: #0e0f16;
|
||||
|
||||
/* Dark mode: dark text on lighter bg variants (primary/danger),
|
||||
white text on dark bg variants (secondary). */
|
||||
--color-button-text: var(--color-crust);
|
||||
--color-button-text-alt: #ffffff;
|
||||
}
|
||||
@theme {
|
||||
--color-rosewater: #efc9c2;
|
||||
@@ -173,8 +163,6 @@
|
||||
--color-base: #fbf1c7;
|
||||
--color-mantle: #f3eac1;
|
||||
--color-crust: #e7deb7;
|
||||
--color-button-text: #ffffff;
|
||||
--color-button-text-alt: var(--color-text);
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
@@ -204,8 +192,6 @@
|
||||
--color-base: #1e1e2e;
|
||||
--color-mantle: #141620;
|
||||
--color-crust: #0e0f16;
|
||||
--color-button-text: var(--color-crust);
|
||||
--color-button-text-alt: #ffffff;
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -574,184 +560,3 @@ 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; }
|
||||
}
|
||||
|
||||
26
src/app.tsx
@@ -11,7 +11,6 @@ import {
|
||||
import "./app.css";
|
||||
import { LeftBar, RightBar } from "./components/Bars";
|
||||
import { TerminalSplash } from "./components/TerminalSplash";
|
||||
import SubdomainFooter from "./components/SubdomainFooter";
|
||||
import { MetaProvider } from "@solidjs/meta";
|
||||
import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback";
|
||||
import { BarsProvider, useBars } from "./context/bars";
|
||||
@@ -35,8 +34,10 @@ function AppLayout(props: { children: any }) {
|
||||
let lastScrollY = 0;
|
||||
|
||||
onMount(() => {
|
||||
// Initialize performance tracking
|
||||
initPerformanceTracking();
|
||||
|
||||
// Start monitoring for new deployments
|
||||
startDeploymentMonitoring();
|
||||
|
||||
const windowWidth = createWindowWidth();
|
||||
@@ -198,24 +199,21 @@ function AppLayout(props: { children: any }) {
|
||||
</Show>
|
||||
|
||||
<Show when={!isMainSite()}>
|
||||
<div class="bg-base flex min-h-screen w-full flex-col overflow-x-hidden">
|
||||
<div class="bg-base min-h-screen w-full overflow-x-hidden">
|
||||
<noscript>
|
||||
<div class="bg-yellow text-crust border-text fixed top-0 z-150 w-full border-b-2 p-4 text-center font-semibold">
|
||||
JavaScript is disabled. Features will be limited.
|
||||
</div>
|
||||
</noscript>
|
||||
<div class="flex-1">
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<ErrorBoundaryFallback error={error} reset={reset} />
|
||||
)}
|
||||
>
|
||||
<Suspense fallback={<TerminalSplash inverse />}>
|
||||
{props.children}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<SubdomainFooter />
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<ErrorBoundaryFallback error={error} reset={reset} />
|
||||
)}
|
||||
>
|
||||
<Suspense fallback={<TerminalSplash inverse />}>
|
||||
{props.children}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
|
||||
@@ -366,11 +366,6 @@ 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/"
|
||||
@@ -465,7 +460,7 @@ function MainRightBarContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function RightBarContent() {
|
||||
export function RightBarContent() {
|
||||
const site = useSite();
|
||||
return (
|
||||
<Show when={site().id === "main"} fallback={<SubdomainRightBarContent />}>
|
||||
|
||||
@@ -144,6 +144,7 @@ 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,
|
||||
@@ -251,6 +252,7 @@ export function ContactForm(props: ContactFormProps) {
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Load server data using createAsync
|
||||
const contactData = createAsync(() => getContactData(), {
|
||||
deferStream: true
|
||||
});
|
||||
@@ -348,6 +350,7 @@ 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 &&
|
||||
@@ -385,6 +388,7 @@ 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) {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -60,42 +60,6 @@ describe("resolvePageHeadMeta — canonical URL derivation", () => {
|
||||
expect(meta.canonical).toBe("https://nessa.freno.me/contact");
|
||||
});
|
||||
|
||||
// Subdomain routing is host-aware (root routes dispatch by useSite()), so
|
||||
// useLocation() normally reports the public path. resolvePageHeadMeta also
|
||||
// defensively strips a subdomain prefix from a prefixed pathname, so the
|
||||
// canonical stays public even if the prefixed form ever appears.
|
||||
it("nessa prefixed /nessa/contact → https://nessa.freno.me/contact", () => {
|
||||
const meta = resolvePageHeadMeta(
|
||||
BASE_PROPS,
|
||||
SITE_CONFIG.nessa,
|
||||
"/nessa/contact"
|
||||
);
|
||||
expect(meta.canonical).toBe("https://nessa.freno.me/contact");
|
||||
});
|
||||
|
||||
it("lineage prefixed /lineage/privacy → https://lineage.freno.me/privacy", () => {
|
||||
const meta = resolvePageHeadMeta(
|
||||
BASE_PROPS,
|
||||
SITE_CONFIG.lineage,
|
||||
"/lineage/privacy"
|
||||
);
|
||||
expect(meta.canonical).toBe("https://lineage.freno.me/privacy");
|
||||
});
|
||||
|
||||
it("nessa prefixed root /nessa/ → https://nessa.freno.me/", () => {
|
||||
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nessa, "/nessa/");
|
||||
expect(meta.canonical).toBe("https://nessa.freno.me/");
|
||||
});
|
||||
|
||||
it("main path that happens to start with /nessa is NOT stripped (main site has no prefix)", () => {
|
||||
const meta = resolvePageHeadMeta(
|
||||
BASE_PROPS,
|
||||
SITE_CONFIG.main,
|
||||
"/nessa/contact"
|
||||
);
|
||||
expect(meta.canonical).toBe("https://freno.me/nessa/contact");
|
||||
});
|
||||
|
||||
it("lineage → canonical starts with https://lineage.freno.me", () => {
|
||||
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.lineage, "/");
|
||||
expect(meta.canonical.startsWith("https://lineage.freno.me")).toBe(true);
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { A } from "@solidjs/router";
|
||||
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 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 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 underline underline-offset-4 transition-colors sm:absolute sm:right-4"
|
||||
>
|
||||
see more products
|
||||
</A>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -12,13 +12,13 @@ import { For, Show } from "solid-js";
|
||||
import { A, useLocation } from "@solidjs/router";
|
||||
import { useSite } from "~/context/SiteContext";
|
||||
import { useDarkMode } from "~/context/darkMode";
|
||||
import { NAV_CONFIG } from "~/lib/nav-config";
|
||||
import { NAV_CONFIG, BACK_TO_FRENO } from "~/lib/nav-config";
|
||||
import { DarkModeToggle } from "~/components/DarkModeToggle";
|
||||
|
||||
export default function SubdomainHeader() {
|
||||
const site = useSite();
|
||||
const location = useLocation();
|
||||
const { isDark } = useDarkMode();
|
||||
const { isDark, toggleDarkMode } = useDarkMode();
|
||||
|
||||
const brandName = () => site().displayName;
|
||||
const brandColor = () =>
|
||||
@@ -32,7 +32,10 @@ export default function SubdomainHeader() {
|
||||
};
|
||||
|
||||
return (
|
||||
<header class="bg-base/80 border-surface0 sticky top-0 z-50 w-full border-b backdrop-blur-md">
|
||||
<header
|
||||
class="sticky top-0 z-50 w-full border-b backdrop-blur-md"
|
||||
classList={{ "bg-base/80 border-surface0": site().headerOpaque }}
|
||||
>
|
||||
<div class="mx-auto flex h-14 max-w-7xl items-center justify-between px-4">
|
||||
<A
|
||||
href="/"
|
||||
@@ -75,7 +78,7 @@ export default function SubdomainHeader() {
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
<DarkModeToggle shouldScale={false} />
|
||||
<DarkModeToggle />
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -138,6 +138,9 @@ 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,6 +63,7 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Refresh the S3 file list
|
||||
await loadAttachments();
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -80,6 +81,7 @@ 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,6 +259,7 @@ export default function CommentSectionWrapper(
|
||||
const newComment = async (commentBody: string, parentCommentID?: number) => {
|
||||
setCommentSubmitLoading(true);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (commentSubmitTimeoutId) {
|
||||
clearTimeout(commentSubmitTimeoutId);
|
||||
}
|
||||
@@ -427,6 +428,7 @@ export default function CommentSectionWrapper(
|
||||
const editComment = async (body: string, comment_id: number) => {
|
||||
setCommentEditLoading(true);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (editCommentTimeoutId) {
|
||||
clearTimeout(editCommentTimeoutId);
|
||||
}
|
||||
@@ -525,6 +527,7 @@ export default function CommentSectionWrapper(
|
||||
|
||||
setCommentDeletionLoading(true);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (deleteCommentTimeoutId) {
|
||||
clearTimeout(deleteCommentTimeoutId);
|
||||
}
|
||||
@@ -620,6 +623,7 @@ 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) {
|
||||
@@ -648,6 +652,7 @@ export default function CommentSectionWrapper(
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Hard delete (remove from list)
|
||||
setAllComments((prev) =>
|
||||
prev.filter((comment) => comment.id !== data.commentID)
|
||||
);
|
||||
@@ -662,6 +667,7 @@ export default function CommentSectionWrapper(
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Deletion/edit prompt toggle
|
||||
const toggleModification = (
|
||||
commentID: number,
|
||||
commenterID: string,
|
||||
@@ -702,6 +708,7 @@ 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");
|
||||
@@ -793,6 +800,7 @@ export default function CommentSectionWrapper(
|
||||
}
|
||||
};
|
||||
|
||||
// Click outside handlers (SolidJS version)
|
||||
createEffect(() => {
|
||||
const handleClickOutsideDelete = (e: MouseEvent) => {
|
||||
if (
|
||||
|
||||
@@ -8,10 +8,12 @@ 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,17 +11,15 @@ 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"
|
||||
)
|
||||
.forEach((el) => el.remove());
|
||||
|
||||
// Remove event handler attributes and dangerous URLs from all elements.
|
||||
// NOTE: attribute-name wildcards (e.g. [on*]) are not valid CSS selectors and
|
||||
// throw a SyntaxError on querySelectorAll, so we match all elements here and
|
||||
// the per-attribute checks below handle on*/href/style/action filtering.
|
||||
doc.querySelectorAll("*").forEach((el) => {
|
||||
// Remove event handler attributes and dangerous URLs from all elements
|
||||
doc.querySelectorAll("[on*], [href], [style], [action]").forEach((el) => {
|
||||
const attrs = Array.from(el.attributes);
|
||||
attrs.forEach((attr) => {
|
||||
const name = attr.name;
|
||||
@@ -130,6 +128,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
||||
const processVideos = () => {
|
||||
if (!contentRef) return;
|
||||
|
||||
// Handle direct video elements
|
||||
const videoElements = contentRef.querySelectorAll("video");
|
||||
|
||||
videoElements.forEach((video) => {
|
||||
@@ -137,14 +136,18 @@ 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");
|
||||
@@ -157,6 +160,7 @@ 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")) {
|
||||
@@ -169,6 +173,7 @@ 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");
|
||||
@@ -179,6 +184,7 @@ 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", "");
|
||||
@@ -186,6 +192,7 @@ 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
|
||||
@@ -198,6 +205,7 @@ 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);
|
||||
@@ -205,6 +213,7 @@ 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,6 +58,7 @@ 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) {
|
||||
@@ -72,10 +73,12 @@ 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",
|
||||
@@ -92,6 +95,7 @@ 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;
|
||||
@@ -244,6 +248,7 @@ export default function PostForm(props: PostFormProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// Debounced versions
|
||||
const debouncedAutoSaveTitle = debounce(autoSaveTitle, 2500);
|
||||
const debouncedAutoSaveSubtitle = debounce(autoSaveSubtitle, 2500);
|
||||
const debouncedAutoSaveBody = debounce(autoSaveBody, 2500);
|
||||
@@ -251,6 +256,7 @@ 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;
|
||||
@@ -399,6 +405,7 @@ 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();
|
||||
sorted.reverse(); // Reverse to get oldest first
|
||||
break;
|
||||
case "most_liked":
|
||||
sorted.sort((a, b) => (b.total_likes || 0) - (a.total_likes || 0));
|
||||
|
||||
@@ -1547,6 +1547,7 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
},
|
||||
handleDOMEvents: {
|
||||
touchstart: (view, event) => {
|
||||
// Only handle touch events on mobile in fullscreen with active suggestion
|
||||
if (
|
||||
!hasSuggestion() ||
|
||||
!isFullscreen() ||
|
||||
@@ -1561,6 +1562,7 @@ 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() ||
|
||||
@@ -1858,6 +1860,7 @@ 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(
|
||||
@@ -1874,6 +1877,7 @@ 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];
|
||||
@@ -1954,6 +1958,7 @@ 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,6 +69,7 @@ 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")
|
||||
@@ -82,6 +83,7 @@ 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",
|
||||
@@ -172,10 +174,12 @@ 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()) {
|
||||
@@ -246,6 +250,7 @@ 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) => {
|
||||
|
||||
29
src/components/icons/BackArrow.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
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;
|
||||
39
src/components/icons/MenuBars.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
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;
|
||||
@@ -1,367 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -10,11 +10,11 @@
|
||||
* - `title` → `props.title + site.titleSuffix`
|
||||
* - `canonical` → explicit `props.canonical` override wins; otherwise
|
||||
* `https://${site.domain}${pathname}` where `pathname` is the *public*
|
||||
* browser path. Subdomain routing is host-aware (root routes dispatch by
|
||||
* `useSite()`, see `src/routes/index.tsx`/`privacy.tsx`/`deletion.tsx`),
|
||||
* so `useLocation()` already reports the public path. The defensive
|
||||
* prefix-strip below also handles any legacy prefixed form, so the canonical
|
||||
* is always the public URL.
|
||||
* browser path. Because the subdomain prefix (`/lineage`, `/nessa`, …) is
|
||||
* an internal-only rewrite (applied by `vercel.json` host rewrites or, in
|
||||
* their absence, by `src/middleware.ts`), `useLocation()` reports the
|
||||
* prefixed path (`/lineage/privacy`) and we strip the prefix back off so
|
||||
* the canonical reflects the public URL (`https://lineage.freno.me/privacy`).
|
||||
* - `ogImage` → explicit `props.ogImage` wins; otherwise `site.ogDefaultImage`.
|
||||
* - `ogTitle` / `ogDescription` → explicit override wins; otherwise fall
|
||||
* back to the base title (no suffix) / description (existing behavior).
|
||||
@@ -56,11 +56,13 @@ export function resolvePageHeadMeta(
|
||||
): ResolvedPageHeadMeta {
|
||||
const title = `${props.title}${site.titleSuffix}`;
|
||||
/**
|
||||
* The canonical URL is the *public* browser URL, never any internal route
|
||||
* prefix. Subdomain routing is host-aware (root routes dispatch by
|
||||
* `useSite()`), so `useLocation()` returns the public path. The strip below
|
||||
* is a defensive no-op for the public path and still yields the canonical
|
||||
* `https://lineage.freno.me/privacy` if a prefixed form ever appears.
|
||||
* The canonical URL is the *public* browser URL, never the internal route
|
||||
* prefix. SolidStart's file router is host-blind, so subdomain routes live
|
||||
* under `src/routes/<prefix>/*` and are served either by `vercel.json` host
|
||||
* rewrites OR by `src/middleware.ts` (the in-app host rewrite). Both append
|
||||
* the prefix to the internal request path (`/privacy` → `/lineage/privacy`),
|
||||
* so `useLocation()` reports the prefixed path — which we strip back off so
|
||||
* the canonical stays `https://lineage.freno.me/privacy`.
|
||||
*/
|
||||
const publicPath =
|
||||
site.baseRoutePrefix &&
|
||||
|
||||
@@ -1,113 +1,11 @@
|
||||
import {
|
||||
type JSX,
|
||||
splitProps,
|
||||
Show,
|
||||
createSignal,
|
||||
createEffect
|
||||
} from "solid-js";
|
||||
import { JSX, splitProps, Show, createSignal, createEffect } from "solid-js";
|
||||
import { Spinner } from "~/components/Spinner";
|
||||
|
||||
/** Parse a hex color string like `#f9e2af` into `[r, g, b]` in 0..255. */
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
const h = hex.replace(/^#/, "");
|
||||
return [
|
||||
parseInt(h.substring(0, 2), 16),
|
||||
parseInt(h.substring(2, 4), 16),
|
||||
parseInt(h.substring(4, 6), 16)
|
||||
];
|
||||
}
|
||||
|
||||
/** Convert `[r, g, b]` 0..255 to `[h, s, l]` — h in 0..360, s and l in 0..1. */
|
||||
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||
const rn = r / 255;
|
||||
const gn = g / 255;
|
||||
const bn = b / 255;
|
||||
const max = Math.max(rn, gn, bn);
|
||||
const min = Math.min(rn, gn, bn);
|
||||
const l = (max + min) / 2;
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case rn:
|
||||
h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
|
||||
break;
|
||||
case gn:
|
||||
h = ((bn - rn) / d + 2) / 6;
|
||||
break;
|
||||
case bn:
|
||||
h = ((rn - gn) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return [h * 360, s, l];
|
||||
}
|
||||
|
||||
/** Convert `[h, s, l]` (h in 0..360, s and l in 0..1) to `[r, g, b]` 0..255. */
|
||||
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||
const a = s * Math.min(l, 1 - l);
|
||||
const f = (n: number) => {
|
||||
const k = (n + h / 30) % 12;
|
||||
return Math.round(255 * (l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)));
|
||||
};
|
||||
return [f(0), f(8), f(4)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the relative luminance (WCAG) of an sRGB color, given linear
|
||||
* channel values in 0..1.
|
||||
*/
|
||||
function luminance(r: number, g: number, b: number): number {
|
||||
return (
|
||||
0.2126 * (r > 0.04045 ? ((r + 0.055) / 1.055) ** 2.4 : r / 12.92) +
|
||||
0.7152 * (g > 0.04045 ? ((g + 0.055) / 1.055) ** 2.4 : g / 12.92) +
|
||||
0.0722 * (b > 0.04045 ? ((b + 0.055) / 1.055) ** 2.4 : b / 12.92)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a background color, return a pair of `{ bg, text }` CSS color
|
||||
* strings that guarantee WCAG AA contrast.
|
||||
*
|
||||
* Light backgrounds (luminance > 0.4) are darkened in HSL space so the
|
||||
* result is dark enough for white text. Dark backgrounds are kept as-is
|
||||
* with white text. This is used by the download variant whose background
|
||||
* is a product brand color that can be arbitrarily light.
|
||||
*/
|
||||
function adjustForContrast(color: string): { bg: string; text: string } {
|
||||
const [r, g, b] = hexToRgb(color);
|
||||
const [h, s, l] = rgbToHsl(r, g, b);
|
||||
const lum = luminance(r / 255, g / 255, b / 255);
|
||||
|
||||
if (lum > 0.4) {
|
||||
// Darken: keep hue and saturation, clamp lightness so the result is
|
||||
// dark enough for white text (l ~ 0.35 → luminance ~ 0.17, contrast
|
||||
// with white ≈ 6:1).
|
||||
const dl = Math.min(l, 0.35);
|
||||
const [dr, dg, db] = hslToRgb(h, s, dl);
|
||||
return {
|
||||
bg: `rgb(${dr}, ${dg}, ${db})`,
|
||||
text: "#ffffff"
|
||||
};
|
||||
}
|
||||
return {
|
||||
bg: `rgb(${r}, ${g}, ${b})`,
|
||||
text: "#ffffff"
|
||||
};
|
||||
}
|
||||
|
||||
export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "danger" | "ghost" | "download";
|
||||
size?: "sm" | "md" | "lg";
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
/**
|
||||
* Override the variant's background color with an explicit CSS color.
|
||||
* Used by download pages to theme the button with the product brand color.
|
||||
*/
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export default function Button(props: ButtonProps) {
|
||||
@@ -118,8 +16,7 @@ export default function Button(props: ButtonProps) {
|
||||
"fullWidth",
|
||||
"class",
|
||||
"children",
|
||||
"disabled",
|
||||
"color"
|
||||
"disabled"
|
||||
]);
|
||||
|
||||
let contentRef: HTMLSpanElement | undefined;
|
||||
@@ -128,6 +25,7 @@ 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();
|
||||
@@ -155,8 +53,8 @@ export default function Button(props: ButtonProps) {
|
||||
: "bg-surface0 hover:brightness-125 active:scale-90";
|
||||
case "download":
|
||||
return isDisabledOrLoading
|
||||
? "cursor-not-allowed brightness-75"
|
||||
: "hover:brightness-125 active:scale-90";
|
||||
? "bg-green text-base cursor-not-allowed brightness-75"
|
||||
: "bg-green text-base hover:brightness-125 active:scale-90";
|
||||
case "danger":
|
||||
return isDisabledOrLoading
|
||||
? "bg-red cursor-not-allowed brightness-75"
|
||||
@@ -170,20 +68,6 @@ export default function Button(props: ButtonProps) {
|
||||
}
|
||||
};
|
||||
|
||||
/** Compute background + text color for the download variant. */
|
||||
const downloadColors = () => {
|
||||
const isDisabledOrLoading = local.disabled || local.loading;
|
||||
if (isDisabledOrLoading) {
|
||||
return { bg: "var(--color-base)", text: "#ffffff" };
|
||||
}
|
||||
// When no explicit color is given, fall back to the theme blue
|
||||
// (a dark color whose contrast with white is already fine). Only
|
||||
// pass a hex string to adjustForContrast — CSS variable values can't
|
||||
// be parsed numerically.
|
||||
if (!local.color) return { bg: "var(--color-blue)", text: "#ffffff" };
|
||||
return adjustForContrast(local.color);
|
||||
};
|
||||
|
||||
const sizeClasses = () => {
|
||||
switch (size()) {
|
||||
case "sm":
|
||||
@@ -199,25 +83,11 @@ export default function Button(props: ButtonProps) {
|
||||
|
||||
const widthClass = () => (local.fullWidth ? "w-full" : "");
|
||||
|
||||
const buttonStyle = (): JSX.CSSProperties => {
|
||||
if (variant() === "download") {
|
||||
const { bg, text } = downloadColors();
|
||||
return { background: bg, color: text };
|
||||
}
|
||||
// Theme-aware text: white on dark bg variants (primary/danger),
|
||||
// theme text on light bg variants (secondary). The CSS variables
|
||||
// flip between light/dark modes so the contrast stays good in both.
|
||||
if (variant() === "secondary")
|
||||
return { color: "var(--color-button-text-alt)" };
|
||||
return { color: "var(--color-button-text)" };
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
{...others}
|
||||
disabled={local.disabled || local.loading}
|
||||
class={`${baseClasses} ${variantClasses()} ${sizeClasses()} ${widthClass()} ${local.class || ""}`}
|
||||
style={buttonStyle()}
|
||||
>
|
||||
<Show
|
||||
when={local.loading}
|
||||
|
||||
@@ -23,6 +23,7 @@ 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,
|
||||
@@ -73,6 +74,9 @@ 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
|
||||
// ============================================================
|
||||
@@ -132,6 +136,22 @@ 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
|
||||
// ============================================================
|
||||
@@ -157,6 +177,41 @@ 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
|
||||
// ============================================================
|
||||
@@ -229,4 +284,22 @@ 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. */
|
||||
function resolveClientSite(): Site {
|
||||
export function resolveClientSite(): Site {
|
||||
if (typeof window === "undefined") return MAIN_SITE;
|
||||
const injected = window.__SITE__;
|
||||
if (injected && SITE_CONFIG[injected]) return SITE_CONFIG[injected];
|
||||
|
||||
@@ -53,7 +53,9 @@ 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"]);
|
||||
};
|
||||
|
||||
@@ -68,6 +70,7 @@ 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
Normal file
@@ -0,0 +1,160 @@
|
||||
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,12 +34,14 @@ 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.`
|
||||
@@ -47,10 +49,12 @@ 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;
|
||||
}
|
||||
@@ -98,29 +102,33 @@ 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("error loading dynamically imported module")
|
||||
event.message?.includes("Failed to fetch 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("error loading dynamically imported module")
|
||||
event.reason?.message?.includes(
|
||||
"Failed to fetch 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,7 +38,16 @@ export const validateClientEnv = (
|
||||
return envVars as unknown as ClientEnv;
|
||||
};
|
||||
|
||||
export const env = validateClientEnv(import.meta.env);
|
||||
const validateAndExportEnv = (): ClientEnv => {
|
||||
try {
|
||||
const validated = validateClientEnv(import.meta.env);
|
||||
return validated;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const env = validateAndExportEnv();
|
||||
|
||||
export const isMissingEnvVar = (varName: string): boolean => {
|
||||
return !import.meta.env[varName] || import.meta.env[varName]?.trim() === "";
|
||||
|
||||
8
src/env/server.ts
vendored
@@ -67,13 +67,7 @@ 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),
|
||||
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)
|
||||
TURNSTILE_SECRET_KEY: z.string().min(1)
|
||||
});
|
||||
|
||||
export type ServerEnv = z.infer<typeof serverEnvSchema>;
|
||||
|
||||
@@ -80,6 +80,7 @@ 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,6 +3,21 @@
|
||||
* 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
|
||||
|
||||
111
src/lib/cookies.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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,6 +81,7 @@ export function formatRelativeTime(
|
||||
return `${diffDay}d ago`;
|
||||
}
|
||||
} else {
|
||||
// style === "long"
|
||||
if (includeSeconds && diffSec < 60) {
|
||||
return `${diffSec} second${diffSec === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
|
||||
@@ -12,12 +12,14 @@ 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);
|
||||
@@ -37,6 +39,7 @@ 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"
|
||||
@@ -61,6 +64,7 @@ async function checkForNewVersion(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Store current ETag for future checks
|
||||
if (newEtag) {
|
||||
sessionStorage.setItem("app-etag", newEtag);
|
||||
}
|
||||
@@ -76,6 +80,7 @@ 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;
|
||||
}
|
||||
@@ -142,6 +147,7 @@ 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";
|
||||
@@ -156,9 +162,11 @@ 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) {
|
||||
@@ -166,6 +174,7 @@ 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();
|
||||
@@ -177,6 +186,7 @@ export function startDeploymentMonitoring(): void {
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
// Cleanup function
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).__cleanupDeploymentMonitoring = () => {
|
||||
clearInterval(intervalId);
|
||||
|
||||
@@ -16,17 +16,16 @@ import {
|
||||
} from "./nav-config";
|
||||
import { SITE_CONFIG, type SiteId } from "./site-context";
|
||||
|
||||
const ALL_SITES: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo", "nook"];
|
||||
const ALL_SITES: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo"];
|
||||
|
||||
describe("NAV_CONFIG — per-site link sets", () => {
|
||||
it("main → Home, Blog, Downloads, Resume, Contact, Privacy, GitHub, LinkedIn", () => {
|
||||
it("main → Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn", () => {
|
||||
expect(navLabelsFor("main")).toEqual([
|
||||
"Home",
|
||||
"Blog",
|
||||
"Downloads",
|
||||
"Resume",
|
||||
"Contact",
|
||||
"Privacy",
|
||||
"GitHub",
|
||||
"LinkedIn"
|
||||
]);
|
||||
@@ -81,7 +80,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", "nook"] as SiteId[]) {
|
||||
for (const id of ["nessa", "lineage", "gaze", "inputhalo"] 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);
|
||||
@@ -112,7 +111,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", "nook"] as SiteId[]) {
|
||||
for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) {
|
||||
for (const item of NAV_CONFIG[id]) {
|
||||
expect(item.showLoggedIn).toBeUndefined();
|
||||
expect(item.showLoggedOut).toBeUndefined();
|
||||
|
||||
@@ -80,7 +80,6 @@ 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/",
|
||||
@@ -117,10 +116,6 @@ 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,6 +26,7 @@ export function initPerformanceTracking() {
|
||||
|
||||
const supported = new Set(PerformanceObserver.supportedEntryTypes ?? []);
|
||||
|
||||
// Observe LCP
|
||||
if (supported.has("largest-contentful-paint")) {
|
||||
try {
|
||||
const lcpObserver = new PerformanceObserver((entryList) => {
|
||||
@@ -39,6 +40,7 @@ export function initPerformanceTracking() {
|
||||
}
|
||||
}
|
||||
|
||||
// Observe CLS
|
||||
if (supported.has("layout-shift")) {
|
||||
try {
|
||||
const clsObserver = new PerformanceObserver((entryList) => {
|
||||
@@ -57,6 +59,7 @@ export function initPerformanceTracking() {
|
||||
}
|
||||
}
|
||||
|
||||
// Observe FID
|
||||
if (supported.has("first-input")) {
|
||||
try {
|
||||
const fidObserver = new PerformanceObserver((entryList) => {
|
||||
@@ -71,6 +74,7 @@ export function initPerformanceTracking() {
|
||||
}
|
||||
}
|
||||
|
||||
// Observe INP (event timing)
|
||||
if (supported.has("event")) {
|
||||
try {
|
||||
const interactions: number[] = [];
|
||||
@@ -92,6 +96,7 @@ export function initPerformanceTracking() {
|
||||
}
|
||||
}
|
||||
|
||||
// Get navigation timing metrics
|
||||
window.addEventListener("load", () => {
|
||||
setTimeout(() => {
|
||||
const navTiming = performance.getEntriesByType(
|
||||
@@ -105,6 +110,7 @@ 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"
|
||||
@@ -129,6 +135,7 @@ export function initPerformanceTracking() {
|
||||
}
|
||||
|
||||
function sendMetrics() {
|
||||
// Only send if we have at least one metric
|
||||
if (Object.keys(metrics).length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -150,6 +157,7 @@ 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" },
|
||||
@@ -160,5 +168,6 @@ function sendMetrics() {
|
||||
);
|
||||
}
|
||||
|
||||
// Clear metrics after sending
|
||||
metrics = {};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, onMount, onCleanup, type Accessor } from "solid-js";
|
||||
import { createSignal, onMount, onCleanup, Accessor } from "solid-js";
|
||||
|
||||
export const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
@@ -8,9 +8,8 @@ export const MOBILE_BREAKPOINT = 768;
|
||||
* @returns Accessor for current window width
|
||||
*/
|
||||
export function createWindowWidth(debounceMs?: number): Accessor<number> {
|
||||
// Use a static default so SSR and initial client render agree; the
|
||||
// real value is set in onMount (after hydration) to avoid mismatches.
|
||||
const [width, setWidth] = createSignal(1024);
|
||||
const initialWidth = typeof window !== "undefined" ? window.innerWidth : 1024;
|
||||
const [width, setWidth] = createSignal(initialWidth);
|
||||
|
||||
onMount(() => {
|
||||
setWidth(window.innerWidth);
|
||||
|
||||
@@ -61,6 +61,7 @@ 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", "nook"] as SiteId[]) {
|
||||
for (const id of ["nessa", "lineage", "gaze", "inputhalo"] 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" | "nook";
|
||||
export type SiteId = "main" | "nessa" | "lineage" | "gaze" | "inputhalo";
|
||||
|
||||
export interface Site {
|
||||
/** Canonical id, also serialized into `<html data-site>` and `window.__SITE__`. */
|
||||
@@ -35,6 +35,8 @@ export interface Site {
|
||||
brandColor: string;
|
||||
/** Dark mode variant of the brand color (used when dark mode is active). */
|
||||
brandColorDark?: string;
|
||||
/** Whether the subdomain header should use a solid background (default: false for transparent). */
|
||||
headerOpaque?: boolean;
|
||||
/** Default OpenGraph image path (resolved against the site root). */
|
||||
ogDefaultImage: string;
|
||||
/** Favicon path for this site. */
|
||||
@@ -87,8 +89,9 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
|
||||
titleSuffix: " | Nessa",
|
||||
brandColor: "#527640",
|
||||
brandColorDark: "#6CA86C",
|
||||
headerOpaque: true,
|
||||
ogDefaultImage: "/nessa/og-default.png",
|
||||
faviconPath: "/nessa/favicon/favicon.ico"
|
||||
faviconPath: "/nessa/favicon.ico"
|
||||
},
|
||||
lineage: {
|
||||
id: "lineage",
|
||||
@@ -97,9 +100,9 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
|
||||
baseRoutePrefix: "/lineage",
|
||||
displayName: "Life and Lineage",
|
||||
titleSuffix: " | Life and Lineage",
|
||||
brandColor: "#a13536",
|
||||
brandColor: "#a6e3a1",
|
||||
ogDefaultImage: "/lineage/og-default.png",
|
||||
faviconPath: "/lineage/favicon/favicon.ico"
|
||||
faviconPath: "/lineage/favicon.ico"
|
||||
},
|
||||
gaze: {
|
||||
id: "gaze",
|
||||
@@ -108,9 +111,9 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
|
||||
baseRoutePrefix: "/gaze",
|
||||
displayName: "Gaze",
|
||||
titleSuffix: " | Gaze",
|
||||
brandColor: "#002cff",
|
||||
brandColor: "#f9e2af",
|
||||
ogDefaultImage: "/gaze/og-default.png",
|
||||
faviconPath: "/gaze/favicon/favicon.ico"
|
||||
faviconPath: "/gaze/favicon.ico"
|
||||
},
|
||||
inputhalo: {
|
||||
id: "inputhalo",
|
||||
@@ -119,21 +122,9 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
|
||||
baseRoutePrefix: "/inputhalo",
|
||||
displayName: "InputHalo",
|
||||
titleSuffix: " | InputHalo",
|
||||
brandColor: "#41a5ff",
|
||||
brandColor: "#f38ba8",
|
||||
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"
|
||||
faviconPath: "/inputhalo/favicon.ico"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -142,8 +133,7 @@ const SUBDOMAIN_SITES: ReadonlyArray<Site> = [
|
||||
SITE_CONFIG.nessa,
|
||||
SITE_CONFIG.lineage,
|
||||
SITE_CONFIG.gaze,
|
||||
SITE_CONFIG.inputhalo,
|
||||
SITE_CONFIG.nook
|
||||
SITE_CONFIG.inputhalo
|
||||
];
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
function urlElement(site: Site, entry: SitemapEntry): string {
|
||||
export function urlElement(site: Site, entry: SitemapEntry): string {
|
||||
const loc = `https://${site.domain}${entry.path}`;
|
||||
return ` <url>
|
||||
<loc>${xmlEscape(loc)}</loc>
|
||||
|
||||
@@ -28,11 +28,13 @@ 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");
|
||||
@@ -40,19 +42,21 @@ 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");
|
||||
|
||||
expect(locs.length).toBe(7);
|
||||
// Exactly 6 entries
|
||||
expect(locs.length).toBe(6);
|
||||
|
||||
// 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(7);
|
||||
expect(urlOpens).toBe(6);
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -69,6 +73,7 @@ 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");
|
||||
@@ -147,7 +152,7 @@ describe("SITEMAP_ROUTES validation", () => {
|
||||
});
|
||||
|
||||
it("each site has at least the home page entry", () => {
|
||||
const siteIds: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo", "nook"];
|
||||
const siteIds: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo"];
|
||||
for (const id of siteIds) {
|
||||
expect(SITEMAP_ROUTES[id].some((e) => e.path === "/")).toBe(true);
|
||||
}
|
||||
|
||||
@@ -47,8 +47,7 @@ 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: "/privacy-policy", changefreq: "yearly", priority: 0.4 }
|
||||
{ path: "/downloads", changefreq: "weekly", priority: 0.8 }
|
||||
],
|
||||
|
||||
// ── Subdomain sites ──────────────────────────────────────────────────
|
||||
@@ -78,11 +77,5 @@ 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,12 +45,15 @@ 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);
|
||||
};
|
||||
|
||||
@@ -61,6 +64,7 @@ export function useCountdown(options: UseCountdownOptions = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
onCleanup(() => {
|
||||
stopCountdown();
|
||||
});
|
||||
|
||||
102
src/middleware.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// @refresh reload
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/**
|
||||
* In-app host-based subdomain path rewrite.
|
||||
*
|
||||
* ## Why this exists
|
||||
*
|
||||
* `vercel.json` declares host `rewrites` that map each subdomain onto its
|
||||
* internal `src/routes/<prefix>/*` file route (e.g.
|
||||
* `lineage.freno.me/privacy` → `/lineage/privacy`). When Vercel builds with
|
||||
* the Nitro `vercel` preset, however, it emits a Build Output API
|
||||
* `.vercel/output/config.json` with a `routes` array — and per Vercel's
|
||||
* contract, **a `routes` array in `config.json` fully replaces
|
||||
* `vercel.json` `rewrites`/`redirects`/`headers`**. The host rewrites were
|
||||
* therefore silently never applied, which is why subdomain-only routes
|
||||
* (`/privacy`, `/deletion`) 404'd on production while routes that also
|
||||
* exist at the root (`/`, `/contact`, `/downloads`) appeared to work (they
|
||||
* were actually served by the root route, not the subdomain-branded one).
|
||||
*
|
||||
* ## What this does
|
||||
*
|
||||
* This middleware (registered via `app.config.ts` → `middleware`) runs as
|
||||
* an H3 `onRequest` hook BEFORE the SolidStart file router matches the path.
|
||||
* For a request whose `Host` resolves to a subdomain `Site`, it prefixes the
|
||||
* request URL with the site's `baseRoutePrefix` — e.g. `/privacy` on
|
||||
* `lineage.freno.me` becomes `/lineage/privacy` internally, exactly as the
|
||||
* `vercel.json` rewrite intended — so the file router resolves the correct
|
||||
* subdomain route file (`src/routes/lineage/privacy.tsx`).
|
||||
*
|
||||
* The browser URL is untouched (this is an internal rewrite, not a
|
||||
* redirect). `useLocation()` will report the prefixed internal path; the
|
||||
* canonical-URL derivation in `src/components/page-head-meta.ts` strips the
|
||||
* prefix so the public canonical stays `https://lineage.freno.me/privacy`.
|
||||
*
|
||||
* ## Scope / skip conditions
|
||||
*
|
||||
* - Main site / unknown host → no rewrite (no prefix to add).
|
||||
* - `/api/*` → shared API pool (already routed correctly on all hosts; the
|
||||
* `vercel.json` `/api/(.*)` rules are pure pass-throughs, so no prefix).
|
||||
* - Paths already carrying a subdomain prefix (dev path-based URLs like
|
||||
* `localhost:3000/lineage/privacy`, or a request already rewritten) → no
|
||||
* double-prefix.
|
||||
* - `/_build/*` build assets → served statically, not routed.
|
||||
*
|
||||
* ## Dev vs prod
|
||||
*
|
||||
* In dev, the Host is usually `localhost` (→ main → no rewrite); subdomain
|
||||
* pages are reached via their path prefix (`localhost:3000/lineage/privacy`)
|
||||
* which the skip-condition above leaves untouched. Dev subdomains reached
|
||||
* via `lineage.localhost:3000/privacy` (browsers resolve `*.localhost`) ARE
|
||||
* rewritten, keeping dev and prod behavior consistent.
|
||||
*/
|
||||
import { resolveSiteFromHost } from "~/lib/site-context";
|
||||
|
||||
/**
|
||||
* Minimal H3-event shape this middleware touches. Avoids importing
|
||||
* `vinxi/http` (a type-only subpath that some environments can't resolve) —
|
||||
* the real H3Event satisfies this structure at runtime.
|
||||
*/
|
||||
interface RewriteEvent {
|
||||
node?: {
|
||||
req?: {
|
||||
url?: string;
|
||||
originalUrl?: string;
|
||||
headers?: { host?: string };
|
||||
};
|
||||
};
|
||||
_path?: string;
|
||||
}
|
||||
|
||||
export default {
|
||||
onRequest(event: RewriteEvent) {
|
||||
const req = event?.node?.req;
|
||||
if (!req?.headers) return;
|
||||
|
||||
const site = resolveSiteFromHost(req.headers.host);
|
||||
// No prefix to add for the main site / unknown host.
|
||||
if (!site.baseRoutePrefix) return;
|
||||
|
||||
const url = req.url || "/";
|
||||
// Shared API pool — pass through unchanged on every host.
|
||||
if (url === "/api" || url.startsWith("/api/")) return;
|
||||
// Already prefixed (dev path-based URLs, or a request already rewritten).
|
||||
if (
|
||||
url === site.baseRoutePrefix ||
|
||||
url.startsWith(site.baseRoutePrefix + "/")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Static build assets are served as files, not via the router.
|
||||
if (url.startsWith("/_build/")) return;
|
||||
|
||||
// Internal rewrite: /privacy → /lineage/privacy (browser URL unchanged).
|
||||
// H3's toWebRequest derives the URL from `originalUrl ?? event.path`, so
|
||||
// we must set originalUrl too (not just _path/req.url) or the SolidStart
|
||||
// router keeps seeing the unprefixed browser path.
|
||||
const rewritten = site.baseRoutePrefix + url;
|
||||
req.originalUrl = rewritten;
|
||||
req.url = rewritten;
|
||||
event._path = rewritten;
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { HttpHeader, HttpStatusCode } from "@solidjs/start";
|
||||
import { HttpStatusCode } from "@solidjs/start";
|
||||
import { useLocation, useNavigate } from "@solidjs/router";
|
||||
import { createSignal, onCleanup, onMount, Show } from "solid-js";
|
||||
import { TerminalErrorPage } from "~/components/TerminalErrorPage";
|
||||
@@ -90,14 +90,6 @@ 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,6 +39,7 @@ export async function GET(_event: APIEvent) {
|
||||
});
|
||||
}
|
||||
|
||||
// Stream the XML content from S3
|
||||
const body = await response.Body.transformToString();
|
||||
|
||||
return new Response(body, {
|
||||
|
||||